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_confirm
ui_cancel
view_map
end_turn
select_card_1

这样做有三个直接好处:

游戏逻辑更稳定

游戏代码不关心玩家按的是 E 还是 Space,它只关心:

Input.is_action_just_pressed("end_turn")

UI 更容易显示

设置界面里显示的是:

  • 动作名称:结束回合
  • 当前键盘:E
  • 当前手柄:Y

而不是零散地管理很多键。

更适合扩展

同一个动作以后可以有:

  • 键盘键
  • 手柄按钮
  • 鼠标键
  • 备用键

动作本身不变,只是它的“事件集合”会扩展。

动作表的数据结构

动作表集中保存动作名称、分组和默认绑定。

建议放在:

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
}
]

为什么这个结构合理

每个动作都带 5 个信息:

字段说明
actionGodot 的 InputMap action 名
labelUI 显示文本
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) -> 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

注意这里的思想:

  • 键盘和手柄分开管理
  • 冲突也按设备分开判断
  • 不会出现“改一个设备把另一个设备删掉”的问题

一份完整可运行的 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 = 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

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

这就是这套架构最值钱的地方。

后续扩展

这套设计之所以值得做,不只是因为它能做按键页,而是因为它很容易横向复用。

扩展到鼠标绑定

在数据层新增:

default_mouse

在管理器新增:

get_mouse_event
set_mouse_binding
find_mouse_conflict

UI 层 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。