在 Godot 4 中做一套真正可用的键位绑定系统
从 InputMap 到可保存、可重绑、可扩展到 Xbox 手柄的完整架构设计
做游戏时,很多人第一次接触按键系统,都会直接打开 Godot 的
Project Settings -> Input Map,把ui_accept、ui_cancel、move_left这些 action 填进去,然后就继续写业务逻辑了。这一步没有错,但它只解决了“开发者预设默认按键”,并没有解决“玩家真的能改键”“设置界面能工作”“重启后还能记住”“后续能兼容 Xbox 手柄”这些正式项目一定会遇到的问题。
这篇文章不讲一个只能看不能跑的概念方案,而是从 Godot 项目里应该怎么设计输入系统 开始,循序渐进搭起一套可运行、可保存、可扩展、能复用到其他系统的键位绑定架构。
目录
- 一、为什么不能只靠 Project Settings 的 InputMap
- 二、正式项目里的输入系统到底要解决什么问题
- 三、整体架构设计图
- 四、设计目标与边界
- 五、第一步:定义动作,而不是定义按键
- 六、第二步:设计一份真正可维护的数据结构
- 七、第三步:InputConfigManager 的职责边界
- 八、第四步:运行时如何把配置应用到 Godot 的 InputMap
- 九、第五步:设置界面为什么一定要拆层
- 十、第六步:按键重绑为什么必须做状态机
- 十一、第七步:支持 Xbox 手柄时应该怎么设计
- 十二、第八步:完整文件结构建议
- 十三、第九步:完整可运行示例
- 十四、第十步:如何接到 SettingsPopup 里
- 十五、第十一步:如何在游戏逻辑里使用
- 十六、第十二步:如何扩展到其他系统
- 十七、常见坑与调试建议
- 十八、结语:这套架构为什么值得做一次
一、为什么不能只靠 Project Settings 的 InputMap
Godot 自带的 Input Map 非常适合定义默认输入,但它的定位是:
项目默认输入表
它不是:
玩家自定义输入系统
这两个概念差很多。
1.1 只用 InputMap 会遇到什么问题
假设在项目设置里写好了:
end_turn -> Eui_cancel -> Escview_deck -> D
那么游戏当然能跑。但很快会遇到这些真实问题:
玩家层面的问题
- 玩家希望把
结束回合改到Space - 不同键盘布局下,默认键位手感不一样
- 左手习惯、无障碍需求、单手操作需求都不同
- 有人习惯键盘,有人习惯 Xbox 手柄
设置界面的问题
- 需要一个“按键设置”页面
- 点击某一行后,要进入“等待输入”状态
- 玩家按下新按键后,要立即生效
- 冲突时要提示,而不是静默覆盖
- 退出设置页时,监听状态要自动取消
工程层面的问题
- 重启游戏后,按键绑定要保留
- 默认方案和用户方案要分开
- 后续要支持手柄时,不能推翻重做
- 游戏逻辑不能到处写
KEY_E、KEY_M
所以,Project Settings 的 InputMap 只解决了“默认动作存在”这个起点,不解决输入系统本身。
二、正式项目里的输入系统到底要解决什么问题
一套正式的输入系统,至少要回答这几件事:
- 这个游戏有哪些“动作”?
- 每个动作默认绑定什么键盘键、什么手柄键?
- 玩家改键后,新的绑定存在哪里?
- 运行时怎样同步到 Godot 的
InputMap? - UI 怎么显示当前绑定?
- 冲突怎么处理?
- 以后加新动作时,能不能只改一处?
这七个问题,本质上是 数据、逻辑、UI、存储 四类问题,不应该混在一个脚本里。
三、整体架构设计图
先看全局,再看细节。
┌──────────────────────────────────────────────────────┐│ 动作定义层 ││ action_id / 显示名 / 分组 / 默认键盘 / 默认手柄 │└──────────────────────┬───────────────────────────────┘ │ ▼┌──────────────────────────────────────────────────────┐│ InputConfigManager.gd ││ (AutoLoad 单例) ││ ││ 1. 读取默认动作表 ││ 2. 加载用户配置 ││ 3. 应用到 Godot InputMap ││ 4. 提供 set / get / reset / conflict 接口 ││ 5. 保存到 user://settings.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()前者可以随时改键,后者一旦写进业务逻辑,后面所有重绑都会很痛苦。
四、设计目标与边界
在开始写代码前,先把边界说清楚。
4.1 这篇文章的目标
实现一套 MVP 到可发布级别之间 的输入系统,支持:
- 键盘绑定
- Xbox 手柄按钮绑定
- 设置页中重绑
- 冲突检测
- 本地保存
- 默认恢复
- 后续可扩展
4.2 本文暂不做的部分
为了让系统先稳定落地,下面这些功能先不做:
- 手柄摇杆轴映射
- 鼠标按键绑定
- 触摸手势
- 多套 profile 方案
- 本地双人多设备输入
- Steam Input 深度适配
这些都可以在这套架构上继续扩展,但不应该在第一版一起塞进去。
五、第一步:定义动作,而不是定义按键
正式项目里,输入系统的最小单位不应该是 KEY_E,而应该是一个“动作”。
例如:
ui_confirmui_cancelview_mapend_turnselect_card_1这样做有三个直接好处:
5.1 游戏逻辑更稳定
游戏代码不关心玩家按的是 E 还是 Space,它只关心:
Input.is_action_just_pressed("end_turn")5.2 UI 更容易显示
设置界面里显示的是:
- 动作名称:结束回合
- 当前键盘:E
- 当前手柄:Y
而不是零散地管理很多键。
5.3 更适合扩展
同一个动作以后可以有:
- 键盘键
- 手柄按钮
- 鼠标键
- 备用键
动作本身不变,只是它的“事件集合”会扩展。
六、第二步:设计一份真正可维护的数据结构
这一层是整个系统的唯一真相来源。
建议放在:
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 }]6.1 为什么这个结构合理
每个动作都带 5 个信息:
| 字段 | 说明 |
|---|---|
| action | Godot 的 InputMap action 名 |
| label | UI 显示文本 |
| group | 用于设置页分组显示 |
| default_keyboard | 默认键盘绑定 |
| default_gamepad | 默认手柄绑定 |
它的好处是:
- 新增动作只加一行
- UI 可以自动按组生成
- 默认恢复不用到处写 if
- 管理器可以自动遍历这份表
这就是数据驱动。
七、第三步:InputConfigManager 的职责边界
输入管理器建议做成 AutoLoad,名字可以叫:
InputConfigManager
它的职责只有 4 类:
7.1 读取定义
从 ACTION_DEFS 读所有动作和默认值。
7.2 管理运行时输入
把当前绑定应用到 InputMap。
7.3 管理用户配置
把玩家改过的键保存到本地。
7.4 给 UI 提供查询和写入接口
比如:
- 获取某动作当前键盘绑定
- 获取某动作当前手柄绑定
- 设置键盘绑定
- 设置手柄绑定
- 检测冲突
- 重置默认
注意:
- 它不负责画界面。
- 它不负责按钮高亮。
- 它不负责监听某一行正在等待输入。
这些都是 UI 层的事。
八、第四步:运行时如何把配置应用到 Godot 的 InputMap
这里是最容易设计错的地方。
很多人第一次做会写成:
InputMap.action_erase_events(action)InputMap.action_add_event(action, new_event)看起来没问题,但一旦以后同时支持:
- 键盘
- 手柄
会发现一个坑:
改键盘时把手柄事件也删了。
所以输入管理器一定要按“设备维度”来处理,而不是整 action 全清空。
8.1 推荐的输入管理器接口
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注意这里的思想:
- 键盘和手柄分开管理
- 冲突也按设备分开判断
- 不会出现“改一个设备把另一个设备删掉”的问题
8.2 一份完整可运行的 InputConfigManager.gd
extends Node
signal binding_changed(action: String, device: String)
const SAVE_PATH := "user://settings.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"])
cfg.save(SAVE_PATH)
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 既触发取消监听,又把设置页关掉
- 冲突提示显示后不能恢复
- 切换页面时旧监听没退出
10.1 推荐状态图
点击键盘按钮IDLE ─────────────────────────────► LISTEN_KEYBOARD ▲ │ │ │ 捕获键盘输入 │ ESC 取消 / 保存成功 / 关闭页面 ▼ └──────────────────────────────────────┘
点击手柄按钮IDLE ─────────────────────────────► LISTEN_GAMEPAD ▲ │ │ │ 捕获手柄按钮 │ ESC 取消 / 保存成功 / 关闭页面 ▼ └──────────────────────────────────────┘如果冲突,可以临时进入:
CONFLICT ↓ 显示提示 1.5 秒IDLE十一、第七步:支持 Xbox 手柄时应该怎么设计
这里最重要的是一个结论:
- 不要把“手柄支持”理解成以后再补一列 UI。
- 它必须从数据结构和管理接口一开始就考虑进去。
11.1 为什么不能“先只做键盘,手柄以后再说”
因为如果一开始把接口写成:
set_keybind(action, key)get_key(action)后面要加手柄,就要把整套:
- 数据结构
- 存储结构
- 冲突检测
- UI 接口
- InputMap 应用逻辑
全部重写一遍。
这就是典型的“看似先快,实际上后面更慢”。
11.2 推荐 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 方案。
13.1 ActionBindRow.tscn 推荐结构
ActionBindRow (HBoxContainer)├─ LabelActionName (Label)├─ BtnKeyboard (Button)└─ BtnGamepad (Button)设计说明
- 左列:动作名
- 中列:键盘按钮,点击后开始监听键盘
- 右列:手柄按钮,点击后开始监听手柄
13.2 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()13.3 KeybindPanel.tscn 推荐结构
KeybindPanel (VBoxContainer)├─ HeaderRow (HBoxContainer)│ ├─ LabelActionHeader│ ├─ LabelKeyboardHeader│ └─ LabelGamepadHeader├─ ScrollContainer│ └─ ActionList (VBoxContainer)└─ BottomHint (Label)13.4 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 └─ KeybindPanel14.1 settings_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
这就是这套架构最值钱的地方。
十六、第十二步:如何扩展到其他系统
这套设计之所以值得做,不只是因为它能做按键页,而是因为它很容易横向复用。
16.1 扩展到鼠标绑定
在数据层新增:
default_mouse在管理器新增:
get_mouse_eventset_mouse_bindingfind_mouse_conflictUI 层 ActionBindRow 新增一列 BtnMouse
状态机新增:
LISTEN_MOUSE
16.2 扩展到触摸方案
对于移动端,可以不再显示键盘列,而显示:
- 触摸区域
- 手势名
- 虚拟按钮
核心思想还是一样:
- 业务逻辑只认 action
- 触摸系统把触摸映射到 action
16.3 扩展到双人输入
未来如果做本地双人,只需要把 bindings 结构从:
bindings[action]["keyboard"]扩成:
bindings[player_id][action]["keyboard"]bindings[player_id][action]["gamepad"]也就是:
- 玩家 1 一套
- 玩家 2 一套
核心架构不用推翻。
16.4 扩展到 UI 快捷提示系统
很多游戏会在按钮旁边显示:
[E] 结束回合[M] 地图[A] 确认这时候只要从 InputConfigManager 查询当前 action 的显示文本,就能动态显示,不用写死。
十七、常见坑与调试建议
这一部分很重要,是真实开发里最容易踩的坑。
17.1 坑一:AutoLoad 加载顺序错误
建议顺序:
SettingsManagerInputConfigManager否则配置可能还没准备好,输入系统就先初始化了。
17.2 坑二:Esc 同时关闭设置页和取消监听
解决方法:
- 在监听状态里,一旦吃掉输入,就调用:
get_viewport().set_input_as_handled()这样事件不会继续往上传递到 SettingsPopup。
17.3 坑三:动态生成行后没显示
如果 _ready() 时节点树还没完全就绪,建议:
call_deferred("_build_rows")17.4 坑四:改了键,UI 没刷新
不要手动到处调用 refresh()。
更稳的做法是监听:
InputConfigManager.binding_changed只要绑定改了,相关 UI 就刷新。
17.5 坑五:改一个设备,把另一个设备清掉
这是最常见的设计错误。
原因是用了:
- InputMap.action_erase_events(action)
但没有重新把所有当前配置写回去。
正确做法是:
每次改动后,根据当前完整配置,重新构建该 action 的全部事件。
十八、结语:这套架构为什么值得做一次
很多系统看起来“项目设置里点一点就能用”,输入系统就是其中之一。 但一旦项目进入正式开发阶段,就会发现:
- 玩家需要改键
- 设置页需要可交互
- 键盘和手柄要同时支持
- 新动作越来越多
- 提示文本要跟着变
- 以后还想复用到别的项目
这时候,一套输入系统不再是“一个小功能”,而是项目基础设施的一部分。
这篇文章最终想表达的,其实不是“Godot 里怎么重绑按键”,而是这句话:
正式项目里,输入系统的核心不是某个按键,而是 action。 默认键只是起点,真正的系统要能管理、展示、重绑、保存、扩展。
只要把这层想明白,后面的音频设置、图像设置、UI 缩放、手柄提示、快捷栏显示,都会变得更容易设计。
附:最推荐的实现顺序
最后给一个真正适合开发时照着走的顺序。
第 1 步
先写 input_action_defs.gd,把动作表列清楚。
第 2 步
写 InputConfigManager.gd,实现默认加载、保存、应用 InputMap。
第 3 步
用最简单的打印测试:
print(InputConfigManager.keyboard_to_text( InputConfigManager.get_keyboard_key("end_turn")))确保管理器能正常工作。
第 4 步
做 ActionBindRow.tscn 和 action_bind_row.gd。第 5 步
做 KeybindPanel.tscn 和 keybind_panel.gd,动态生成列表。
第 6 步
接入 SettingsPopup,完成 Tab 切换和保存/重置。
第 7 步
在战斗逻辑里彻底只保留 action 调用,不再出现硬编码按键。
这套结构搭好以后,后面做:
- 音频设置
- 图像设置
- 手柄提示
- 快捷键提示
- 双人输入
- 触摸适配
都会轻松很多。
评论