Godot 的 InputMap 保存运行中的动作与输入事件映射,也支持通过代码改键。要让玩家重绑后在下次启动时仍然生效,还需要保存和恢复用户配置。本文按动作表、配置管理器和设置界面介绍这条流程。
动作、配置与界面怎样配合
先看全局,再看细节。
┌──────────────────────────────────────────────────────┐│ 动作定义层 ││ action_id / 显示名 / 分组 / 默认键盘 / 默认手柄 │└──────────────────────┬───────────────────────────────┘ │ ▼┌──────────────────────────────────────────────────────┐│ InputConfigManager.gd ││ (AutoLoad 单例) ││ ││ 1. 读取默认动作表 ││ 2. 加载用户配置 ││ 3. 应用到 Godot InputMap ││ 4. 提供 set / get / reset / conflict 接口 ││ 5. 保存到 user://keybindings.cfg │└───────────────┬───────────────────────┬──────────────┘ │ │ ▼ ▼┌────────────────────────┐ ┌─────────────────────────┐│ KeybindPanel │ │ 游戏业务逻辑 ││ Scroll + ActionRow │ │ Input.is_action_pressed ││ │ │ 只认 action,不认具体键 │└────────────────────────┘ └─────────────────────────┘游戏逻辑通过动作名读取输入:
游戏逻辑使用 action,设置界面负责修改按键映射。
例如:
if Input.is_action_just_pressed("end_turn"): _end_turn()而不是:
if Input.is_key_pressed(KEY_E): _end_turn()前者使用当前动作绑定;后者固定检查 E 键,不会跟随动作重绑。
示例支持的功能
这篇文章的目标
下面的示例包含:
- 键盘绑定
- Xbox 手柄按钮绑定
- 设置页中重绑
- 冲突检测
- 本地保存
- 默认恢复
- 后续可扩展
本文暂不做的部分
为了让系统先稳定落地,下面这些功能先不做:
- 手柄摇杆轴映射
- 鼠标按键绑定
- 触摸手势
- 多套 profile 方案
- 本地双人多设备输入
- Steam Input 深度适配
这些都可以在这套架构上继续扩展,但不应该在第一版一起塞进去。
定义动作
正式项目里,输入系统的最小单位不应该是 KEY_E,而应该是一个“动作”。
例如:
ui_confirmui_cancelview_mapend_turnselect_card_1这样做有三个直接好处:
游戏逻辑更稳定
游戏代码不关心玩家按的是 E 还是 Space,它只关心:
Input.is_action_just_pressed("end_turn")UI 更容易显示
设置界面里显示的是:
- 动作名称:结束回合
- 当前键盘:E
- 当前手柄:Y
而不是零散地管理很多键。
更适合扩展
同一个动作以后可以有:
- 键盘键
- 手柄按钮
- 鼠标键
- 备用键
动作本身不变,只是它的“事件集合”会扩展。
动作表的数据结构
动作表集中保存动作名称、分组和默认绑定。
建议放在:
res://scripts/input/input_action_defs.gd内容如下:
extends RefCountedclass_name InputActionDefs
const ACTION_DEFS: Array[Dictionary] = [ # ── 通用 ───────────────────────── { "action": "ui_confirm", "label": "确认/选择", "group": "通用", "default_keyboard": KEY_ENTER, "default_gamepad": JOY_BUTTON_A }, { "action": "ui_cancel", "label": "取消/返回", "group": "通用", "default_keyboard": KEY_ESCAPE, "default_gamepad": JOY_BUTTON_B },
# ── 战斗 ───────────────────────── { "action": "end_turn", "label": "结束回合", "group": "战斗", "default_keyboard": KEY_E, "default_gamepad": JOY_BUTTON_Y }, { "action": "view_deck", "label": "查看牌组", "group": "战斗", "default_keyboard": KEY_D, "default_gamepad": JOY_BUTTON_X }, { "action": "view_map", "label": "查看地图", "group": "界面", "default_keyboard": KEY_M, "default_gamepad": JOY_BUTTON_LEFT_SHOULDER },
# ── 数字选牌 ────────────────────── { "action": "select_card_1", "label": "选择牌 #1", "group": "卡牌选择", "default_keyboard": KEY_1, "default_gamepad": JOY_BUTTON_INVALID }, { "action": "select_card_2", "label": "选择牌 #2", "group": "卡牌选择", "default_keyboard": KEY_2, "default_gamepad": JOY_BUTTON_INVALID }, { "action": "select_card_3", "label": "选择牌 #3", "group": "卡牌选择", "default_keyboard": KEY_3, "default_gamepad": JOY_BUTTON_INVALID }]为什么这个结构合理
每个动作都带 5 个信息:
| 字段 | 说明 |
|---|---|
| action | Godot 的 InputMap action 名 |
| label | UI 显示文本 |
| group | 用于设置页分组显示 |
| default_keyboard | 默认键盘绑定 |
| default_gamepad | 默认手柄绑定 |
它的好处是:
- 新增动作只加一行
- UI 可以自动按组生成
- 默认恢复不用到处写 if
- 管理器可以自动遍历这份表
这就是数据驱动。
配置管理器 InputConfigManager
输入管理器建议做成 AutoLoad,名字可以叫:
InputConfigManager
它的职责只有 4 类:
读取定义
从 ACTION_DEFS 读所有动作和默认值。
管理运行时输入
把当前绑定应用到 InputMap。
管理用户配置
把玩家改过的键保存到本地。
给 UI 提供查询和写入接口
比如:
- 获取某动作当前键盘绑定
- 获取某动作当前手柄绑定
- 设置键盘绑定
- 设置手柄绑定
- 检测冲突
- 重置默认
注意:
- 它不负责画界面。
- 它不负责按钮高亮。
- 它不负责监听某一行正在等待输入。
这些都是 UI 层的事。
把配置应用到 InputMap
这里是最容易设计错的地方。
很多人第一次做会写成:
InputMap.action_erase_events(action)InputMap.action_add_event(action, new_event)看起来没问题,但一旦以后同时支持:
- 键盘
- 手柄
会发现一个坑:
改键盘时把手柄事件也删了。
所以输入管理器一定要按“设备维度”来处理,而不是整 action 全清空。
推荐的输入管理器接口
get_keyboard_event(action: String) -> InputEventKeyget_gamepad_event(action: String) -> InputEventJoypadButton
set_keyboard_binding(action: String, keycode: Key) -> voidset_gamepad_binding(action: String, button_index: JoyButton) -> void
clear_keyboard_binding(action: String) -> voidclear_gamepad_binding(action: String) -> void
find_keyboard_conflict(keycode: Key, exclude_action: String) -> Stringfind_gamepad_conflict(button_index: JoyButton, exclude_action: String) -> String注意这里的思想:
- 键盘和手柄分开管理
- 冲突也按设备分开判断
- 不会出现“改一个设备把另一个设备删掉”的问题
一份完整可运行的 InputConfigManager.gd
extends Node
signal binding_changed(action: String, device: String)
const SAVE_PATH := "user://keybindings.cfg"
var _defs: Array[Dictionary] = []var _label_map: Dictionary = {}var _group_map: Dictionary = {}
# 运行时真实配置# 结构:# {# "ui_confirm": {# "keyboard": KEY_ENTER,# "gamepad": JOY_BUTTON_A# }# }var bindings: Dictionary = {}
func _ready() -> void: _defs = InputActionDefs.ACTION_DEFS _build_maps() _load_or_init_defaults() _apply_all_to_input_map()
func _build_maps() -> void: for def in _defs: var action: String = str(def["action"]) _label_map[action] = str(def["label"]) _group_map[action] = str(def["group"])
if not InputMap.has_action(action): InputMap.add_action(action)
func _load_or_init_defaults() -> void: bindings.clear()
# 先填默认值 for def in _defs: var action: String = str(def["action"]) bindings[action] = { "keyboard": int(def["default_keyboard"]), "gamepad": int(def["default_gamepad"]) }
# 再尝试读取用户配置 var cfg := ConfigFile.new() if cfg.load(SAVE_PATH) != OK: return
for def in _defs: var action: String = str(def["action"]) bindings[action]["keyboard"] = int( cfg.get_value("keybind_keyboard", action, bindings[action]["keyboard"]) ) bindings[action]["gamepad"] = int( cfg.get_value("keybind_gamepad", action, bindings[action]["gamepad"]) )
func save_all() -> void: var cfg := ConfigFile.new()
for action in bindings.keys(): cfg.set_value("keybind_keyboard", action, bindings[action]["keyboard"]) cfg.set_value("keybind_gamepad", action, bindings[action]["gamepad"])
var err := cfg.save(SAVE_PATH) if err != OK: push_error("保存键位失败:%s" % error_string(err))
func reset_all_to_default() -> void: for def in _defs: var action: String = str(def["action"]) bindings[action]["keyboard"] = int(def["default_keyboard"]) bindings[action]["gamepad"] = int(def["default_gamepad"])
_apply_all_to_input_map() save_all()
func reset_action_to_default(action: String) -> void: for def in _defs: if str(def["action"]) == action: bindings[action]["keyboard"] = int(def["default_keyboard"]) bindings[action]["gamepad"] = int(def["default_gamepad"]) _apply_action_to_input_map(action) save_all() binding_changed.emit(action, "keyboard") binding_changed.emit(action, "gamepad") return
func get_all_defs() -> Array[Dictionary]: return _defs
func get_label(action: String) -> String: return _label_map.get(action, action)
func get_group(action: String) -> String: return _group_map.get(action, "")
func get_keyboard_key(action: String) -> Key: if not bindings.has(action): return KEY_NONE return int(bindings[action]["keyboard"]) as Key
func get_gamepad_button(action: String) -> JoyButton: if not bindings.has(action): return JOY_BUTTON_INVALID return int(bindings[action]["gamepad"]) as JoyButton
func set_keyboard_binding(action: String, keycode: Key) -> void: if not bindings.has(action): return bindings[action]["keyboard"] = int(keycode) _apply_action_to_input_map(action) save_all() binding_changed.emit(action, "keyboard")
func set_gamepad_binding(action: String, button_index: JoyButton) -> void: if not bindings.has(action): return bindings[action]["gamepad"] = int(button_index) _apply_action_to_input_map(action) save_all() binding_changed.emit(action, "gamepad")
func clear_keyboard_binding(action: String) -> void: set_keyboard_binding(action, KEY_NONE)
func clear_gamepad_binding(action: String) -> void: set_gamepad_binding(action, JOY_BUTTON_INVALID)
func find_keyboard_conflict(keycode: Key, exclude_action: String = "") -> String: if keycode == KEY_NONE: return "" for action in bindings.keys(): if action == exclude_action: continue if int(bindings[action]["keyboard"]) == int(keycode): return action return ""
func find_gamepad_conflict(button_index: JoyButton, exclude_action: String = "") -> String: if button_index == JOY_BUTTON_INVALID: return "" for action in bindings.keys(): if action == exclude_action: continue if int(bindings[action]["gamepad"]) == int(button_index): return action return ""
func keyboard_to_text(keycode: Key) -> String: if keycode == KEY_NONE: return "— 未绑定 —" return OS.get_keycode_string(keycode)
func gamepad_to_text(button_index: JoyButton) -> String: match button_index: JOY_BUTTON_INVALID: return "— 未绑定 —" JOY_BUTTON_A: return "A" JOY_BUTTON_B: return "B" JOY_BUTTON_X: return "X" JOY_BUTTON_Y: return "Y" JOY_BUTTON_LEFT_SHOULDER: return "LB" JOY_BUTTON_RIGHT_SHOULDER: return "RB" JOY_BUTTON_BACK: return "Back" JOY_BUTTON_START: return "Start" _: return "Button %d" % button_index
func _apply_all_to_input_map() -> void: for action in bindings.keys(): _apply_action_to_input_map(action)
func _apply_action_to_input_map(action: String) -> void: if not InputMap.has_action(action): InputMap.add_action(action)
# 先清空 InputMap.action_erase_events(action)
# 再按当前配置重新写入 var keycode: Key = get_keyboard_key(action) if keycode != KEY_NONE: var key_event := InputEventKey.new() key_event.keycode = keycode InputMap.action_add_event(action, key_event)
var joy_button: JoyButton = get_gamepad_button(action) if joy_button != JOY_BUTTON_INVALID: var joy_event := InputEventJoypadButton.new() joy_event.button_index = joy_button InputMap.action_add_event(action, joy_event)组织设置界面
很多项目会把整个设置页写在一个脚本里,例如:
- 切页
- 音频
- 图像
- 按键
- 保存
- 重置
全都塞进 settings_popup.gd。
这在前期没问题,但按键设置是最容易变复杂的模块。 它至少包含:
- 列表生成
- 分组显示
- 监听某一项
- 冲突提示
- 键盘/手柄双列
- 保存/重置
所以最合理的拆法是:
SettingsPopup├─ Audio Page├─ Video Page└─ Keybind Page └─ KeybindPanel(独立脚本) └─ ActionBindRow × N(独立行)也就是:
SettingsPopup.gd:只管 Tab 切换和统一保存入口
KeybindPanel.gd:只管按键页逻辑
ActionBindRow.gd:只管单行交互
用状态表示重绑过程
按键重绑不是“按钮一按就结束”的普通交互,它至少有 3 种状态:
IDLE ↓ 点击键盘列LISTEN_KEYBOARD ↓ 按下新键成功 / 冲突 / 取消
IDLE ↓ 点击手柄列LISTEN_GAMEPAD ↓ 按下手柄按钮成功 / 冲突 / 取消如果不做状态机,很快会遇到:
- 两行同时处于等待输入
- Esc 既触发取消监听,又把设置页关掉
- 冲突提示显示后不能恢复
- 切换页面时旧监听没退出
推荐状态图
点击键盘按钮IDLE ─────────────────────────────► LISTEN_KEYBOARD ▲ │ │ │ 捕获键盘输入 │ ESC 取消 / 保存成功 / 关闭页面 ▼ └──────────────────────────────────────┘
点击手柄按钮IDLE ─────────────────────────────► LISTEN_GAMEPAD ▲ │ │ │ 捕获手柄按钮 │ ESC 取消 / 保存成功 / 关闭页面 ▼ └──────────────────────────────────────┘如果冲突,可以临时进入:
CONFLICT ↓ 显示提示 1.5 秒IDLE添加手柄按钮
这里最重要的是一个结论:
- 不要把“手柄支持”理解成以后再补一列 UI。
- 它必须从数据结构和管理接口一开始就考虑进去。
为什么不能“先只做键盘,手柄以后再说”
因为如果一开始把接口写成:
set_keybind(action, key)get_key(action)后面要加手柄,就要把整套:
- 数据结构
- 存储结构
- 冲突检测
- UI 接口
- InputMap 应用逻辑
全部重写一遍。
这就是典型的“看似先快,实际上后面更慢”。
推荐 UI 布局:仿《杀戮尖塔》的三列表格
键位设置页最稳的布局就是:
┌─────────────────────────────────────────────┐│ 动作名称 键盘 手柄 │├─────────────────────────────────────────────┤│ 确认/选择 Enter A ││ 取消/返回 Esc B ││ 查看地图 M LB ││ 结束回合 E Y │└─────────────────────────────────────────────┘它的优势很明显:
- 结构直观
- 键盘/手柄一眼可对照 -以后加图标也容易
- 很适合滚动列表
文件与节点结构
推荐目录:
res://├─ scenes/│ └─ menu/│ └─ ui/│ ├─ SettingsPopup.tscn│ ├─ KeybindPanel.tscn│ └─ ActionBindRow.tscn├─ scripts/│ ├─ input/│ │ ├─ input_action_defs.gd│ │ └─ input_config_manager.gd│ └─ menu/│ ├─ settings_popup.gd│ ├─ keybind_panel.gd│ └─ action_bind_row.gd这样分的好处是:
- 输入系统独立
- 菜单 UI 独立
- 以后在战斗内做“快捷键提示面板”时,也能复用输入系统
界面脚本示例
下面给出一个 可以直接按结构复刻 的 UI 方案。
ActionBindRow.tscn 推荐结构
ActionBindRow (HBoxContainer)├─ LabelActionName (Label)├─ BtnKeyboard (Button)└─ BtnGamepad (Button)设计说明
- 左列:动作名
- 中列:键盘按钮,点击后开始监听键盘
- 右列:手柄按钮,点击后开始监听手柄
action_bind_row.gd
extends HBoxContainer
signal request_rebind(action: String, device: String, row: Node)
enum RowState { IDLE, LISTEN_KEYBOARD, LISTEN_GAMEPAD, CONFLICT}
var action_name: String = ""var _state: RowState = RowState.IDLE
@onready var label_action_name: Label = $LabelActionName@onready var btn_keyboard: Button = $BtnKeyboard@onready var btn_gamepad: Button = $BtnGamepad
func setup(action: String) -> void: action_name = action label_action_name.text = InputConfigManager.get_label(action_name) refresh()
func _ready() -> void: btn_keyboard.pressed.connect(_on_keyboard_pressed) btn_gamepad.pressed.connect(_on_gamepad_pressed)
func refresh() -> void: btn_keyboard.text = InputConfigManager.keyboard_to_text( InputConfigManager.get_keyboard_key(action_name) ) btn_gamepad.text = InputConfigManager.gamepad_to_text( InputConfigManager.get_gamepad_button(action_name) )
func enter_listen_keyboard() -> void: _state = RowState.LISTEN_KEYBOARD btn_keyboard.text = "按下新按键..." set_process_unhandled_input(true)
func enter_listen_gamepad() -> void: _state = RowState.LISTEN_GAMEPAD btn_gamepad.text = "按下手柄按钮..." set_process_unhandled_input(true)
func exit_listening() -> void: _state = RowState.IDLE set_process_unhandled_input(false) refresh()
func show_conflict(device: String, conflict_action: String) -> void: _state = RowState.CONFLICT var text := "冲突:" + InputConfigManager.get_label(conflict_action)
if device == "keyboard": btn_keyboard.text = text else: btn_gamepad.text = text
get_tree().create_timer(1.5).timeout.connect(func(): exit_listening() )
func _on_keyboard_pressed() -> void: request_rebind.emit(action_name, "keyboard", self) enter_listen_keyboard()
func _on_gamepad_pressed() -> void: request_rebind.emit(action_name, "gamepad", self) enter_listen_gamepad()
func _unhandled_input(event: InputEvent) -> void: match _state: RowState.LISTEN_KEYBOARD: _handle_keyboard_input(event) RowState.LISTEN_GAMEPAD: _handle_gamepad_input(event)
func _handle_keyboard_input(event: InputEvent) -> void: if not (event is InputEventKey): return if not event.pressed: return
get_viewport().set_input_as_handled()
var key_event := event as InputEventKey var keycode: Key = key_event.keycode
if keycode == KEY_ESCAPE: exit_listening() return
if keycode == KEY_DELETE or keycode == KEY_BACKSPACE: InputConfigManager.clear_keyboard_binding(action_name) exit_listening() return
var conflict := InputConfigManager.find_keyboard_conflict(keycode, action_name) if conflict != "": show_conflict("keyboard", conflict) return
InputConfigManager.set_keyboard_binding(action_name, keycode) exit_listening()
func _handle_gamepad_input(event: InputEvent) -> void: if not (event is InputEventJoypadButton): return if not event.pressed: return
get_viewport().set_input_as_handled()
var joy_event := event as InputEventJoypadButton var button_index: JoyButton = joy_event.button_index
var conflict := InputConfigManager.find_gamepad_conflict(button_index, action_name) if conflict != "": show_conflict("gamepad", conflict) return
InputConfigManager.set_gamepad_binding(action_name, button_index) exit_listening()KeybindPanel.tscn 推荐结构
KeybindPanel (VBoxContainer)├─ HeaderRow (HBoxContainer)│ ├─ LabelActionHeader│ ├─ LabelKeyboardHeader│ └─ LabelGamepadHeader├─ ScrollContainer│ └─ ActionList (VBoxContainer)└─ BottomHint (Label)keybind_panel.gd
extends VBoxContainer
@onready var action_list: VBoxContainer = $ScrollContainer/ActionList
var _active_row: Node = nullvar _row_scene: PackedScene = preload("res://scenes/menu/ui/ActionBindRow.tscn")
func _ready() -> void: call_deferred("_build_rows")
if not InputConfigManager.binding_changed.is_connected(_on_binding_changed): InputConfigManager.binding_changed.connect(_on_binding_changed)
func _build_rows() -> void: for child in action_list.get_children(): child.queue_free()
var current_group := ""
for def in InputConfigManager.get_all_defs(): var group: String = str(def["group"]) var action: String = str(def["action"])
if group != current_group: current_group = group action_list.add_child(_make_group_header(group))
var row = _row_scene.instantiate() action_list.add_child(row) row.setup(action) row.request_rebind.connect(_on_request_rebind)
func _make_group_header(group_name: String) -> Control: var label := Label.new() label.text = "■ " + group_name label.add_theme_font_size_override("font_size", 15) return label
func _on_request_rebind(_action: String, _device: String, row: Node) -> void: if _active_row != null and _active_row != row and is_instance_valid(_active_row): if _active_row.has_method("exit_listening"): _active_row.exit_listening() _active_row = row
func _on_binding_changed(_action: String, _device: String) -> void: for child in action_list.get_children(): if child.has_method("refresh"): child.refresh()
func cancel_listening() -> void: if _active_row != null and is_instance_valid(_active_row): if _active_row.has_method("exit_listening"): _active_row.exit_listening() _active_row = null
func reset_all() -> void: InputConfigManager.reset_all_to_default() cancel_listening()接入 SettingsPopup
现在已经有:
- 音频页
- 图像页
- 按键设置页
最合适的接法是:
SettingsPopup└─ ContentPanel └─ Pages ├─ PageAudio ├─ PageVideo └─ PageKeybind └─ KeybindPanelsettings_popup.gd 里要做的事
只做 3 件事:
- 切换 Tab
- 保存时调用输入管理器保存
- 关闭或切页时取消按键监听
例如:
@onready var page_keybind: Control = $CenterContainer/PanelRoot/Root/VBox/ContentPanel/Pages/PageKeybind
func _switch_tab(tab_index: int) -> void: if page_keybind.has_method("cancel_listening"): page_keybind.cancel_listening()
page_audio.visible = tab_index == TAB_AUDIO page_video.visible = tab_index == TAB_VIDEO page_keybind.visible = tab_index == TAB_KEYBIND
func _on_save_pressed() -> void: # 音频 / 图像保存 ... InputConfigManager.save_all() close_popup()
func _on_reset_pressed() -> void: if _current_tab == TAB_KEYBIND: if page_keybind.has_method("reset_all"): page_keybind.reset_all()
func close_popup() -> void: if page_keybind.has_method("cancel_listening"): page_keybind.cancel_listening() hide()在游戏中读取动作
这一层反而最简单。
只要输入系统已经把配置应用到了 InputMap,业务逻辑里根本不用关心玩家怎么改键。
例如战斗里:
func _process(_delta: float) -> void: if Input.is_action_just_pressed("end_turn"): _end_turn()
if Input.is_action_just_pressed("view_deck"): _toggle_deck()
if Input.is_action_just_pressed("view_map"): _open_map()
if Input.is_action_just_pressed("select_card_1"): _select_card(0)
if Input.is_action_just_pressed("select_card_2"): _select_card(1)这一层永远不直接出现:
- KEY_E
- KEY_D
- JOY_BUTTON_A
这就是这套架构最值钱的地方。
后续扩展
这套设计之所以值得做,不只是因为它能做按键页,而是因为它很容易横向复用。
扩展到鼠标绑定
在数据层新增:
default_mouse在管理器新增:
get_mouse_eventset_mouse_bindingfind_mouse_conflictUI 层 ActionBindRow 新增一列 BtnMouse
状态机新增:
LISTEN_MOUSE
扩展到触摸方案
对于移动端,可以不再显示键盘列,而显示:
- 触摸区域
- 手势名
- 虚拟按钮
核心思想还是一样:
- 业务逻辑只认 action
- 触摸系统把触摸映射到 action
扩展到双人输入
未来如果做本地双人,只需要把 bindings 结构从:
bindings[action]["keyboard"]扩成:
bindings[player_id][action]["keyboard"]bindings[player_id][action]["gamepad"]也就是:
- 玩家 1 一套
- 玩家 2 一套
多玩家还需要在事件中区分设备,并分别处理配置和冲突。
扩展到 UI 快捷提示系统
很多游戏会在按钮旁边显示:
[E] 结束回合[M] 地图[A] 确认这时候只要从 InputConfigManager 查询当前 action 的显示文本,就能动态显示,不用写死。
排查改键问题
检查 AutoLoad 和保存文件
将输入管理脚本注册为名为 InputConfigManager 的 AutoLoad。本例使用独立的 user://keybindings.cfg,避免覆盖音量、分辨率等设置;保存失败时查看调试器报错。
坑二:Esc 同时关闭设置页和取消监听
解决方法:
- 在监听状态里,一旦吃掉输入,就调用:
get_viewport().set_input_as_handled()这会阻止后续的未处理输入回调。_unhandled_input() 只接收尚未被 GUI 等阶段消费的事件;若按钮先消费了按键,应调整焦点或在 _input() 阶段拦截重绑输入。
坑三:动态生成行后没显示
如果 _ready() 时节点树还没完全就绪,建议:
call_deferred("_build_rows")坑四:改了键,UI 没刷新
不要手动到处调用 refresh()。
更稳的做法是监听:
InputConfigManager.binding_changed只要绑定改了,相关 UI 就刷新。
坑五:改一个设备,把另一个设备清掉
这是最常见的设计错误。
原因是用了:
- InputMap.action_erase_events(action)
但没有重新把所有当前配置写回去。
正确做法是:
每次改动后,根据当前完整配置,重新构建该 action 的全部事件。
接口说明:Godot InputMap。
评论