在 Godot 4 中做一套真正可用的键位绑定系统

从 InputMap 到可保存、可重绑、可扩展到 Xbox 手柄的完整架构设计

做游戏时,很多人第一次接触按键系统,都会直接打开 Godot 的 Project Settings -> Input Map,把 ui_acceptui_cancelmove_left 这些 action 填进去,然后就继续写业务逻辑了。

这一步没有错,但它只解决了“开发者预设默认按键”,并没有解决“玩家真的能改键”“设置界面能工作”“重启后还能记住”“后续能兼容 Xbox 手柄”这些正式项目一定会遇到的问题。

这篇文章不讲一个只能看不能跑的概念方案,而是从 Godot 项目里应该怎么设计输入系统 开始,循序渐进搭起一套可运行、可保存、可扩展、能复用到其他系统的键位绑定架构。


目录


一、为什么不能只靠 Project Settings 的 InputMap

Godot 自带的 Input Map 非常适合定义默认输入,但它的定位是:

项目默认输入表

它不是:

玩家自定义输入系统

这两个概念差很多。

1.1 只用 InputMap 会遇到什么问题

假设在项目设置里写好了:

  • end_turn -> E
  • ui_cancel -> Esc
  • view_deck -> D

那么游戏当然能跑。但很快会遇到这些真实问题:

玩家层面的问题

  • 玩家希望把 结束回合 改到 Space
  • 不同键盘布局下,默认键位手感不一样
  • 左手习惯、无障碍需求、单手操作需求都不同
  • 有人习惯键盘,有人习惯 Xbox 手柄

设置界面的问题

  • 需要一个“按键设置”页面
  • 点击某一行后,要进入“等待输入”状态
  • 玩家按下新按键后,要立即生效
  • 冲突时要提示,而不是静默覆盖
  • 退出设置页时,监听状态要自动取消

工程层面的问题

  • 重启游戏后,按键绑定要保留
  • 默认方案和用户方案要分开
  • 后续要支持手柄时,不能推翻重做
  • 游戏逻辑不能到处写 KEY_EKEY_M

所以,Project Settings 的 InputMap 只解决了“默认动作存在”这个起点,不解决输入系统本身。


二、正式项目里的输入系统到底要解决什么问题

一套正式的输入系统,至少要回答这几件事:

  1. 这个游戏有哪些“动作”?
  2. 每个动作默认绑定什么键盘键、什么手柄键?
  3. 玩家改键后,新的绑定存在哪里?
  4. 运行时怎样同步到 Godot 的 InputMap
  5. UI 怎么显示当前绑定?
  6. 冲突怎么处理?
  7. 以后加新动作时,能不能只改一处?

这七个问题,本质上是 数据、逻辑、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_confirm
ui_cancel
view_map
end_turn
select_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 RefCounted
class_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 个信息:

字段说明
actionGodot 的 InputMap action 名
labelUI 显示文本
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) -> InputEventKey
get_gamepad_event(action: String) -> InputEventJoypadButton
set_keyboard_binding(action: String, keycode: Key) -> void
set_gamepad_binding(action: String, button_index: JoyButton) -> void
clear_keyboard_binding(action: String) -> void
clear_gamepad_binding(action: String) -> void
find_keyboard_conflict(keycode: Key, exclude_action: String) -> String
find_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 = null
var _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
└─ KeybindPanel

14.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_event
set_mouse_binding
find_mouse_conflict

UI 层 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 加载顺序错误

建议顺序:

SettingsManager
InputConfigManager

否则配置可能还没准备好,输入系统就先初始化了。

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.tscnkeybind_panel.gd,动态生成列表。

第 6 步

接入 SettingsPopup,完成 Tab 切换和保存/重置。

第 7 步

在战斗逻辑里彻底只保留 action 调用,不再出现硬编码按键。

这套结构搭好以后,后面做:

  • 音频设置
  • 图像设置
  • 手柄提示
  • 快捷键提示
  • 双人输入
  • 触摸适配

都会轻松很多。