Godot 手柄测试与映射工具实战:基于 godot-demo-projects 的 Joypads Demo 源码解析

原创2026-10-02 12:03:052,000 阅读
文章标签:示例工程

Godot 手柄测试与映射工具实战:基于 godot-demo-projects 的 Joypads Demo 源码解析

导读

本文以 Godot 官方演示仓库 godot-demo-projects 中的 misc/joypads 项目为蓝本,系统讲解如何在 Godot 中构建一个完整的手柄(游戏手柄/手柄控制器)输入测试工具:它不仅能在运行时实时读取手柄的设备名、GUID、各个轴(Axis)与按键(Button)的状态,还内置了一个"控制器映射字符串生成向导",可以把任意手柄的物理输入逐步重映射为标准 SDL 映射字符串并立即注入引擎。读完本文,你将掌握 Input 单例在手柄方向的核心 API 用法、死区(deadzone)处理技巧、振动控制、以及"录制映射 → 生成字符串 → 运行时注入"的完整链路,可直接复用到自己的游戏项目中。


项目概览:这个 Demo 是什么

misc/joypads 目录中的 README.md 将该工具定位为:

  • 一个用于测试 joypad input(手柄输入) 的工具;
  • 一个用于生成控制器映射字符串(controller mapping strings) 的工具;
  • 语言:GDScript;
  • 渲染器:Compatibility(对应项目配置中的 renderer/rendering_method="gl_compatibility")。

从源码注释(joypads.gd)看,它由 Dana Olson 编写,受 jstest-gtk 启发,本质上是一个"手柄状态监视 + 映射生成"的调试工具,非常适合在开发跨平台游戏时排查手柄兼容性问题。

项目的核心文件布局如下:

文件 作用
joypads.gd 主界面逻辑:轮询轴/按键状态、设备切换、振动控制
joypads.tscn 主场景,project.godot 中 run/main_scene="res://joypads.tscn" 指定它为启动场景
joypad_diagram.tscn 手柄示意图(Sprite2D 组装的手柄正面/侧面图,用于可视化高亮当前触发的部件)
remap/remap_wizard.gd 映射录制向导逻辑:逐键录制、生成映射字符串、运行时注入
remap/joy_mapping.gd 映射条目数据类:把一次录制结果格式化为 SDL 风格的映射片段
remap/remap_wizard.tscn 向导窗口界面(含 Start / Mapping / MapWindow 三个 Window)

项目配置还开启了 run/low_processor_mode=true,即使窗口不活跃也能以较低刷新率持续采样手柄输入,窗口固定为 600x560,拉伸模式为 canvas_items。


界面布局与运行效果

主场景 joypads.tscn 由五个区域组成:

  1. DeviceInfo(顶部设备信息栏):包含一个 SpinBox(设备编号选择器,最大值 16)和一个 RichTextLabel(显示手柄名称与 GUID)。
  2. Axes(左侧轴列表):垂直排列 Axis 0 ~ Axis 9 共 10 条进度条,每条都有对应的实时数值标签。
  3. JoypadDiagram(右侧手柄示意图):实例化 joypad_diagram.tscn,以 Sprite2D 精灵叠加的方式绘制手柄外形、按键点位和方向指示箭头。
  4. Buttons(右下按钮网格):GridContainer 8 列,显示按钮 0~20 的按下状态。
  5. Vibration(左下振动控制):弱/强马达强度 SpinBox(范围 0~1,步长 0.05)与时长 SpinBox(范围 0~10 秒,步长 0.1),以及"Start Vibration / Stop Vibration"按钮。
  6. 底部另有 Set Raw Mapping / Remap / Show 三个按钮,用于清除映射、启动重映射向导、查看当前映射字符串。

Joypads Demo 运行界面,顶部显示设备号与手柄名称 GUID,左侧为 Axis 0-9 进度条与数值,右下为按钮网格,左下为振动控制区,右侧为带高亮标记的手柄示意图

截图所示为该工具连接 PS5 Controller 时的实际运行状态:顶部显示设备 0 与手柄 GUID,Axis 0/1 等摇杆轴有非零输入并在示意图上以绿色方向箭头高亮,按钮 9、10 被按下时在网格和示意图上同步高亮。


设备发现与连接状态监听

启动时枚举已连接手柄

在 joypads.gd 的 _ready() 中,先连接 Input.joy_connection_changed 信号,再遍历当前已连接的手柄并打印信息:

func _ready() -> void:
	Input.joy_connection_changed.connect(_on_joy_connection_changed)

	for joypad in Input.get_connected_joypads():
		print_rich("Found joypad #%d: [b]%s[/b] - %s" % [joypad, Input.get_joy_name(joypad), Input.get_joy_guid(joypad)])

这里用到了三个关键的 Input API:

  • Input.get_connected_joypads():返回当前所有已连接手柄的设备号数组;
  • Input.get_joy_name(device_id):返回设备名(如 "PS5 Controller");
  • Input.get_joy_guid(device_id):返回设备的 GUID 字符串(截图中的 030000004c050000e60c000011810000 即为一例),GUID 是手柄映射数据库中的核心标识。

热插拔事件回调

_on_joy_connection_changed(device_id, connected)(joypads.gd)在手柄插拔时被触发:接入时以绿色 + 打印新设备名与 GUID,拔出时以红色 - 打印断开提示;若断开/接入的正是当前选中的设备号,还会同步刷新顶部名称标签。

设备切换与名称显示

_process() 中每一帧从 SpinBox 读取设备号(joy_num),当编号变化时查询 Input.get_joy_name(joy_num):若存在则通过 set_joypad_name() 显示名称,并把 GUID 渲染成可点击链接(点击后 OS.shell_open 打开 Godot 的 gamecontrollerdb 便于对照查询,见 joypads.gd);若无设备则显示 "No controller detected at ID %d.",并将整个操作界面以 modulate.a = 0.5 置灰(joypads.gd)。


轴(Axis)实时监控与死区可视化

轴轮询与进度条更新

主循环对前 10 个轴(min(JOY_AXIS_MAX, 10))逐一调用 Input.get_joy_axis(joy_num, axis) 取得归一化的轴值(范围约 -1.0 ~ 1.0),并同步到进度条与数值标签:

for axis in range(int(min(JOY_AXIS_MAX, 10))):
	axis_value = Input.get_joy_axis(joy_num, axis)
	axes.get_node("Axis" + str(axis) + "/ProgressBar").set_value(100 * axis_value)
	axes.get_node("Axis" + str(axis) + "/ProgressBar/Value").set_text(
		"[center][fade start=2 length=16]%s[/fade][/center]" % axis_value)

进度条在场景中以 min_value = -100.0、step = 0.0001、show_percentage = false 配置(见 joypads.tscn),数值标签使用 BBCode 的 [fade] 让数字渐隐,避免视觉噪点。

死区(Deadzone)处理

源码顶部定义了 const DEADZONE = 0.2(joypads.gd)。当轴值绝对值小于死区时,方向指示箭头隐藏;大于死区时显示对应方向的箭头,并按"有效范围"而非原始值计算透明度,让摇杆越推越明显:

var scaled_alpha_value: float = (abs(axis_value) - DEADZONE) / (1.0 - DEADZONE)

同时,轴标签(Axis Label)的字体颜色会在 abs(axis_value) >= DEADZONE 时从半透明白切换为绿色高亮(FONT_COLOR_ACTIVE),形成"处于有效输入范围"的视觉反馈(joypads.gd)。这也是游戏开发中处理摇杆"漂移/抖动"的典型做法:小于死区的输入应视为无效。

扳机轴的特殊处理

对于左右扳机(JOY_AXIS_TRIGGER_LEFT / JOY_AXIS_TRIGGER_RIGHT),因为它们是单向轴(只有正值有意义),代码单独处理:axis_value <= DEADZONE 时隐藏,否则显示并调节透明度(joypads.gd)。


按键(Button)实时监控

按键轮询循环覆盖前 21 个按钮(min(JOY_BUTTON_SDL_MAX, 21),与界面上 0~20 的网格一致):

for button in range(int(min(JOY_BUTTON_SDL_MAX, 21))):
	if Input.is_joy_button_pressed(joy_num, button):
		button_grid.get_child(button).add_theme_color_override(&"font_color", FONT_COLOR_ACTIVE)
		if button <= JOY_BUTTON_MISC1:
			joypad_buttons.get_child(button).show()
	else:
		button_grid.get_child(button).add_theme_color_override(&"font_color", FONT_COLOR_DEFAULT)
		if button <= JOY_BUTTON_MISC1:
			joypad_buttons.get_child(button).hide()

按下时按钮数字高亮为绿色,同时手柄示意图中对应位置的精灵(Sprite2D)显示出来;松开则恢复。JOY_BUTTON_MISC1 之前的按钮(即标准 SDL 按钮集,含 ABXY、肩键、摇杆按压、方向键等)都参与了示意图联动,之后的扩展按钮只在网格中显示。


振动控制(Rumble)

振动区三个参数由界面 SpinBox 提供,调用两个 Input API:

func _on_start_vibration_pressed() -> void:
	var weak: float = $Vibration/Weak/Value.get_value()
	var strong: float = $Vibration/Strong/Value.get_value()
	var duration: float = $Vibration/Duration/Value.get_value()
	Input.start_joy_vibration(cur_joy, weak, strong, duration)

func _on_stop_vibration_pressed() -> void:
	Input.stop_joy_vibration(cur_joy)
  • Input.start_joy_vibration(device, weak_motor, strong_motor, duration):以 0~1 的强度启动双马达振动(弱马达高频、强马达低频),时长为秒;
  • Input.stop_joy_vibration(device):立即停止振动。

这一功能非常适合在游戏中用于打击反馈、UI 确认或手柄联动效果验证。


重映射向导:从录制到映射字符串

这是本 Demo 最具实战价值的部分,位于 remap/remap_wizard.gd 与 remap/joy_mapping.gd。

录制流程

点击主界面的 Remap 按钮会调用 _on_Remap_pressed() → $RemapWizard.start(cur_joy)(joypads.gd)。向导 start() 获取目标设备的 GUID 与名称,并针对 Web 平台与桌面平台分流:

  • 在 Web(OS.has_feature("web"))上,先弹出"试用常见映射"窗口(可直接套用 Xbox / Xbox OSX 预设);
  • 在桌面平台上直接打开 Mapping 窗口开始逐步录制(remap_wizard.gd)。

录制基于 _input() 事件回调(remap_wizard.gd),要点如下:

func _input(input_event: InputEvent) -> void:
	if cur_step == -1:
		return
	if input_event is not InputEventJoypadButton and input_event is not InputEventJoypadMotion:
		return
	if input_event.device != joy_index:
		return
  • 只处理 InputEventJoypadButton 与 InputEventJoypadMotion;
  • 只接受当前正在重映射的设备的事件(input_event.device != joy_index 直接返回),以此过滤误触其他手柄和模拟摇杆漂移;
  • 对摇杆事件,先 get_viewport().set_input_as_handled() 防止继续传播,再判断 abs(motion.axis_value) > DEADZONE(向导的死区为 0.3),按"全轴 / 正半轴 / 负半轴"三种模式记录,并可根据界面上的 Full axis / Invert Axis 复选框决定 JoyMapping.Axis 与 inverted;
  • 对按键事件,记录 btn.button_index 为一个 BTN 类型映射。

映射条目数据类 JoyMapping

joy_mapping.gd 定义了一个 RefCounted 数据类,把一次录制结果格式化为 SDL 风格片段:

enum Type { NONE, BTN, AXIS }
enum Axis { FULL, HALF_PLUS, HALF_MINUS }

_to_string() 生成诸如 b0、a1、+a6、-a7~ 这样的片段:b/a 前缀区分按键与轴,+/- 表示半轴方向,~ 后缀表示反向(inverted):

func _to_string() -> String:
	if type == Type.NONE:
		return ""
	var ts: String = "b" if type == Type.BTN else "a"
	var prefix: String = ""
	var suffix: String = "~" if inverted else ""
	match axis:
		Axis.HALF_PLUS: prefix = "+"
		Axis.HALF_MINUS: prefix = "-"
	return "%s%s%d%s" % [prefix, ts, idx, suffix]

同时提供 to_human_string(),输出人类可读的 "Button 5" / "Axis 0 (+)" 之类文本,用于向导界面实时反馈。

标准键位表与平台映射

JoyMapping.BASE 定义了 20 个标准键位(a、b、y、x、start、back、左右摇杆按压、左右肩键、方向键四项、左右摇杆 XY、左右扳机),并映射到 Godot 的 JOY_BUTTON_* 与 JOY_AXIS_* 常量——这就是录制向导一步步提示用户按下的全部键位集合(joy_mapping.gd)。

另外还内置了两套 Xbox 预设:

  • XBOX:基于 Linux/Windows 常见的 b0/a0 布局,方向键用 -a7、+a7、-a6、+a6 表示(joy_mapping.gd);
  • XBOX_OSX:macOS 下 Xbox 手柄的按钮编号不同(如 A 为 b11、B 为 b12),方向键是 b0~b3(joy_mapping.gd)。

PLATFORMS 字典则把 Godot 的 OS.get_name() 输出映射为 SDL 映射字符串所需的平台标签(Windows、Mac OS X、Linux、Android、iOS、Javascript、UWP 等,含 4.x 的 macOS/BSD 兼容写法)。

生成完整映射字符串

录制完所有步骤后,create_mapping_string() 把结果拼成标准 SDL 映射字符串(remap_wizard.gd):

func create_mapping_string(mapping: Dictionary) -> String:
	var string: String = "%s,%s," % [joy_guid, joy_name]
	for k: String in mapping:
		var m: Variant = mapping[k]
		if typeof(m) == TYPE_OBJECT and m.type == JoyMapping.Type.NONE:
			continue
		string += "%s:%s," % [k, str(m)]
	var platform: String = "Unknown"
	if JoyMapping.PLATFORMS.keys().has(OS.get_name()):
		platform = JoyMapping.PLATFORMS[OS.get_name()]
	return string + "platform:" + platform

生成的字符串形如:

GUID,Controller Name,a:b0,b:b1,leftx:a0,lefty:a1,platform:Linux,

这与 SDL GameControllerDB / Godot gamecontrollerdb.txt 中使用的映射格式一致:以 GUID 和名称开头,随后是 键位:物理输入 的逗号分隔列表,最后以 platform: 结尾。

运行时注入与清除

remap_and_close() 完成两个关键动作(remap_wizard.gd):

func remap_and_close(mapping: Dictionary) -> void:
	last_mapping = create_mapping_string(mapping)
	Input.add_joy_mapping(last_mapping, true)
	reset()
	show_map()
  • Input.add_joy_mapping(mapping_string, update_existing):把映射字符串注入引擎运行时的手柄映射数据库(update_existing=true 表示覆盖已有 GUID 的映射),无需重启即可立即生效;
  • show_map():桌面端弹出 MapWindow 用 TextEdit 展示最终字符串,Web 端则通过 JavaScriptBridge.eval("window.prompt(...)") 弹出提示框复制结果(remap_wizard.gd)。

对应的清除操作是主界面的 Set Raw Mapping 按钮:Input.remove_joy_mapping(guid),把该 GUID 的运行时映射移除,恢复引擎默认行为(joypads.gd)。

录制过程中用户还可以 Previous / Next 前后翻步、Skip 跳过当前键位(跳过会从映射字典中删除已录条目)、通过 Full axis / Invert Axis 复选框调整当前轴映射的语义。

Web 平台的特别处理

向导对 Web 平台做了专门适配:

  • 进入向导前先显示"尝试常见映射"窗口(Xbox / Xbox OSX 预设按钮,见 remap_wizard.tscn),因为浏览器中难以做逐键录制;
  • 映射窗口(Mapping Window)显式连接了 window_input 信号到 _input(),并在 remap_wizard.tscn 中把 SubViewport 的 handle_input_locally = false,确保子窗口获得焦点时仍能收到手柄事件;
  • 结果展示用 window.prompt 而不是桌面弹窗。

手柄示意图的联动实现

joypad_diagram.tscn 用一张 diagram.png 作为手柄底图(Sprite2D),叠加两组精灵:

  • Buttons 节点:21 个 Sprite2D,使用 indicators.png 的 Atlas 区域(region_rect)截取圆形/箭头图形,按手柄物理位置摆放(如 0~3 对应正面 ABXY、4~6 对应肩键区、9/10 为扳机横条、11~14 为方向键四向箭头);
  • Axes 节点:为每个摇杆轴放置 0- / 0+ 之类的成对箭头精灵,扳机则用单个横向条(如节点 4、5)。

主脚本中直接按节点名取用这些精灵并 show/hide + 调 self_modulate.a 透明度(joypads.gd),实现"按哪个键,示意图上哪个位置亮起"的直观反馈。在重映射向导中,同一个 joypad_diagram.tscn 被放进 SubViewport 复用(见 remap_wizard.tscn),_update_step() 会根据当前要录制的键位只高亮对应的部件(remap_wizard.gd),引导用户操作。


运行与使用方式

  1. 使用 Godot 4.x(项目 config/features=PackedStringArray("4.7"),见 project.godot)打开 misc/joypads/project.godot;
  2. F5 运行项目(主场景为 res://joypads.tscn);
  3. 连接任意支持的手柄,顶部 Device 栏用 SpinBox 切换设备编号,左侧观察 10 条轴的实时数值与进度条,右下观察按钮高亮,右侧示意图同步反馈触发位置;
  4. 在振动区调节弱/强马达强度(0~1)与时长(秒)后点击 Start Vibration 测试手感;
  5. 点击 Remap 打开映射向导,按示意图高亮提示依次拨动摇杆、按下按键,配合 Full axis / Invert Axis 调整语义,完成后将弹出生成的映射字符串;
  6. 可点击 Show 再次查看最近生成的映射字符串,点击 Set Raw Mapping 清除当前设备的运行时映射。

小结:可以复用到项目中的核心要点

从 misc/joypads 这个 Demo 中,可以直接迁移到游戏项目里的能力包括:

  • 输入轮询范式:用 Input.get_joy_axis() / Input.is_joy_button_pressed() 在 _process() 中做连续状态读取,配合 DEADZONE 阈值过滤摇杆漂移,并用"有效范围归一化"((abs - deadzone) / (1 - deadzone))做渐变反馈;
  • 设备生命周期:通过 Input.joy_connection_changed 信号 + Input.get_connected_joypads() 实现热插拔与多设备管理;
  • 振动 API:Input.start_joy_vibration() / Input.stop_joy_vibration() 的强弱双马达与时长控制;
  • 映射体系:理解 GUID、SDL 映射字符串格式(GUID,Name,a:b0,...,platform:Linux)、Input.add_joy_mapping() 运行时注入与 Input.remove_joy_mapping() 清除——这在处理"无默认映射的小众手柄"或调试 Web 端手柄兼容性时非常关键;
  • UI 可视化联动:把输入状态映射到 Sprite2D 高亮与 Label 主题色覆盖,是调试工具类界面的经典做法。

对希望深入理解 Godot 手柄系统底层映射数据库的读者,还可以对照 JoyMapping.BASE 中的键位表与 PLATFORMS 平台映射表,阅读 Godot 引擎的 gamecontrollerdb 格式约定,从而在自定义手柄支持、控制器数据库贡献等场景中游刃有余。

登录后查看全文
godot-demo-projects