一、为什么要自定义Godot输入系统
很多用Godot做游戏的朋友,刚开始都会觉得自带的输入系统挺好用——按WASD走、按空格跳,点一下鼠标放技能,改个按键绑定就完事。但真到做跨设备的游戏时,麻烦就来了:玩家用键盘玩,用手柄玩,甚至用触屏玩,同样的操作逻辑(比如“攻击”),不同设备触发的方式完全不一样;更头疼的是,有时候玩家同时按了好几个键,Godot默认的输入逻辑会乱,比如同时按攻击和跳跃,系统只认一个,或者两个都触发但逻辑冲突。
自定义输入系统的核心,就是解决这两个问题:一是让不同设备的操作都能对应到同一个游戏逻辑(比如不管是按键盘的K、手柄的X键还是触屏的攻击按钮,都触发“攻击”这个逻辑);二是解决多输入同时触发时的冲突问题,比如同时按攻击和防御,系统知道该优先处理哪个。
二、自定义输入系统的基础逻辑设计
自定义输入系统的核心思路很简单,就是“两层映射”:第一层是把不同设备的原始输入(比如键盘W、手柄左摇杆上推)映射成“输入事件”(比如“向前移动”);第二层是把“输入事件”映射成“游戏逻辑”(比如“向前移动”事件触发角色前进)。这样不管是哪种设备的输入,最后都是走同一个游戏逻辑,不会出现不同设备逻辑不一致的问题。
2.1 输入事件的设计
输入事件是自定义系统的核心,它相当于一个“中间层”,把设备的差异都藏在这一层后面。我们可以把输入事件分成两类:一类是“状态型事件”(比如“向前移动”,是持续的状态),一类是“触发型事件”(比如“攻击”,是按一下就触发一次)。
比如我们可以定义这样的输入事件:
# 技术栈:Godot 4.x GDScript
# 输入事件枚举,所有游戏用到的输入都在这里定义
enum InputEvent {
MOVE_UP, # 向上移动(状态型)
MOVE_DOWN, # 向下移动(状态型)
MOVE_LEFT, # 向左移动(状态型)
MOVE_RIGHT, # 向右移动(状态型)
ATTACK, # 攻击(触发型)
JUMP, # 跳跃(触发型)
DODGE # 闪避(触发型)
}
2.2 设备输入到事件的映射
接下来就是把不同设备的输入映射到上面的输入事件。Godot自带的InputMap其实可以用来做这个,但自定义的话我们可以更灵活,比如给不同设备设置不同的映射规则,甚至支持玩家自定义按键。
比如我们可以写一个映射管理器,负责把原始输入转换成输入事件:
# 技术栈:Godot 4.x GDScript
# 输入映射管理器,继承自Node,放在场景树的根节点
extends Node
# 存储映射关系:key是原始输入的键值(比如键盘的KEY_W、手柄的BUTTON_X),value是对应的InputEvent
var input_map: Dictionary = {}
# 初始化映射关系,这里只举键盘和手柄的例子,触屏可以自己加
func _ready() -> void:
# 键盘映射:W对应MOVE_UP,S对应MOVE_DOWN,A对应MOVE_LEFT,D对应MOVE_RIGHT
input_map[KEY_W] = InputEvent.MOVE_UP
input_map[KEY_S] = InputEvent.MOVE_DOWN
input_map[KEY_A] = InputEvent.MOVE_LEFT
input_map[KEY_D] = InputEvent.MOVE_RIGHT
input_map[KEY_K] = InputEvent.ATTACK
input_map[KEY_SPACE] = InputEvent.JUMP
input_map[KEY_LSHIFT] = InputEvent.DODGE
# 手柄映射:左摇杆上推对应MOVE_UP,左摇杆下推对应MOVE_DOWN,X对应ATTACK,A对应JUMP,B对应DODGE
input_map[JOY_BUTTON_X] = InputEvent.ATTACK
input_map[JOY_BUTTON_A] = InputEvent.JUMP
input_map[JOY_BUTTON_B] = InputEvent.DODGE
# 左摇杆的轴输入,轴值小于-0.5时对应MOVE_UP(Y轴上推为负),大于0.5时对应MOVE_DOWN
input_map[JOY_AXIS_Y] = { "event": InputEvent.MOVE_UP, "condition": lambda axis: axis < -0.5 }
input_map[JOY_AXIS_Y] = { "event": InputEvent.MOVE_DOWN, "condition": lambda axis: axis > 0.5 }
# 左摇杆的X轴输入,小于-0.5对应MOVE_LEFT,大于0.5对应MOVE_RIGHT
input_map[JOY_AXIS_X] = { "event": InputEvent.MOVE_LEFT, "condition": lambda axis: axis < -0.5 }
input_map[JOY_AXIS_X] = { "event": InputEvent.MOVE_RIGHT, "condition": lambda axis: axis > 0.5 }
# 监听原始输入,把输入事件发送出去
func _input(event: InputEvent) -> void:
# 处理键盘输入
if event is InputEventKey:
var key = event.keycode
if key in input_map:
var input_event = input_map[key]
# 触发型事件:只有按下时才发送
if input_event in [InputEvent.ATTACK, InputEvent.JUMP, InputEvent.DODGE] and event.pressed:
_emit_input_event(input_event)
# 状态型事件:按下时发送激活,松开时发送停止
elif input_event in [InputEvent.MOVE_UP, InputEvent.MOVE_DOWN, InputEvent.MOVE_LEFT, InputEvent.MOVE_RIGHT]:
_emit_input_event(input_event, event.pressed)
# 处理手柄按钮输入
elif event is InputEventJoypadButton:
var button = event.button_index
if button in input_map:
var input_event = input_map[button]
if input_event in [InputEvent.ATTACK, InputEvent.JUMP, InputEvent.DODGE] and event.pressed:
_emit_input_event(input_event)
elif input_event in [InputEvent.MOVE_UP, InputEvent.MOVE_DOWN, InputEvent.MOVE_LEFT, InputEvent.MOVE_RIGHT]:
_emit_input_event(input_event, event.pressed)
# 处理手柄轴输入
elif event is InputEventJoypadMotion:
var axis = event.axis
var value = event.axis_value
if axis in input_map:
var axis_map = input_map[axis]
if axis_map["condition"](value):
var input_event = axis_map["event"]
_emit_input_event(input_event, true)
else:
# 轴值不满足条件时,发送停止状态
var stop_event = axis_map["event"]
_emit_input_event(stop_event, false)
# 发送输入事件的方法,其他节点可以监听这个信号
signal input_event_emitted(event: InputEvent, is_active: bool = true)
func _emit_input_event(event: InputEvent, is_active: bool = true) -> void:
input_event_emitted.emit(event, is_active)
2.3 输入事件到游戏逻辑的映射
最后一步,就是把输入事件映射到游戏逻辑。比如角色节点可以监听输入事件,然后执行对应的动作:
# 技术栈:Godot 4.x GDScript
# 角色节点,继承自CharacterBody2D
extends CharacterBody2D
# 速度变量,控制角色移动
var speed: float = 300.0
var velocity: Vector2 = Vector2.ZERO
func _ready() -> void:
# 监听输入映射管理器的输入事件
var input_manager = get_node("/root/InputManager")
input_manager.input_event_emitted.connect(_on_input_event)
# 处理输入事件的方法
func _on_input_event(event: InputEvent, is_active: bool) -> void:
# 处理移动事件
if event == InputEvent.MOVE_UP:
velocity.y = -speed if is_active else 0
elif event == InputEvent.MOVE_DOWN:
velocity.y = speed if is_active else 0
elif event == InputEvent.MOVE_LEFT:
velocity.x = -speed if is_active else 0
elif event == InputEvent.MOVE_RIGHT:
velocity.x = speed if is_active else 0
# 处理攻击事件
elif event == InputEvent.ATTACK and is_active:
_attack()
# 处理跳跃事件
elif event == InputEvent.JUMP and is_active:
_jump()
# 处理闪避事件
elif event == InputEvent.DODGE and is_active:
_dodge()
func _physics_process(delta: float) -> void:
# 更新角色速度
velocity = move_and_slide()
func _attack() -> void:
print("角色攻击!")
# 这里写攻击的逻辑,比如播放动画、生成子弹等
func _jump() -> void:
if is_on_floor():
velocity.y = -500
print("角色跳跃!")
func _dodge() -> void:
print("角色闪避!")
# 这里写闪避的逻辑,比如设置无敌状态、快速移动等
三、输入冲突的解决方法
自定义输入系统的另一个核心需求是解决输入冲突,比如玩家同时按攻击和跳跃,系统该怎么处理?或者同时按两个移动键,角色该往哪个方向走?
3.1 冲突的常见场景
常见的输入冲突有两种:一种是“多输入同时触发同一逻辑”(比如玩家同时按键盘的K和手柄的X,都触发攻击);另一种是“多输入同时触发不同逻辑”(比如同时按攻击和跳跃,或者同时按左和右)。
3.2 解决方法一:输入优先级
给不同的输入设置优先级,当多个输入冲突时,优先处理优先级高的输入。比如我们可以给攻击事件设置比跳跃更高的优先级,当同时按攻击和跳跃时,只触发攻击。
修改输入映射管理器,添加优先级处理:
# 技术栈:Godot 4.x GDScript
# 修改输入映射管理器,添加优先级
extends Node
# 输入事件的优先级,数字越大优先级越高
var input_priority: Dictionary = {
InputEvent.ATTACK: 3,
InputEvent.DODGE: 2,
InputEvent.JUMP: 1,
InputEvent.MOVE_UP: 0,
InputEvent.MOVE_DOWN: 0,
InputEvent.MOVE_LEFT: 0,
InputEvent.MOVE_RIGHT: 0
}
# 记录当前激活的输入事件及其优先级
var active_inputs: Dictionary = {}
# 修改_emit_input_event方法,添加优先级判断
func _emit_input_event(event: InputEvent, is_active: bool = true) -> void:
if is_active:
# 新输入激活,检查是否有冲突的输入
var new_priority = input_priority[event]
# 遍历当前激活的输入,找出同类型(状态型或触发型)的冲突输入
for active_event in active_inputs.keys():
# 同类型触发型事件,优先级低的被覆盖
if active_event in [InputEvent.ATTACK, InputEvent.JUMP, InputEvent.DODGE] and event in [InputEvent.ATTACK, InputEvent.JUMP, InputEvent.DODGE]:
if input_priority[active_event] < new_priority:
# 优先级低的输入被取消激活
active_inputs[active_event] = false
input_event_emitted.emit(active_event, false)
# 激活新输入
active_inputs[event] = true
input_event_emitted.emit(event, is_active)
else:
# 输入松开,取消激活
if event in active_inputs:
active_inputs[event] = false
input_event_emitted.emit(event, is_active)
3.3 解决方法二:输入过滤
对于状态型的输入,比如移动,我们可以设置规则,当多个移动键同时按下时,只保留一个。比如同时按左和右,只保留最后按下的那个;或者同时按上和左,角色往左上移动。
修改角色节点的移动逻辑,添加移动输入的过滤:
# 技术栈:Godot 4.x GDScript
# 修改角色节点的移动逻辑
extends CharacterBody2D
var speed: float = 300.0
var velocity: Vector2 = Vector2.ZERO
# 记录当前激活的移动方向
var active_moves: Array[InputEvent] = []
func _ready() -> void:
var input_manager = get_node("/root/InputManager")
input_manager.input_event_emitted.connect(_on_input_event)
func _on_input_event(event: InputEvent, is_active: bool) -> void:
# 处理移动事件
if event in [InputEvent.MOVE_UP, InputEvent.MOVE_DOWN, InputEvent.MOVE_LEFT, InputEvent.MOVE_RIGHT]:
if is_active:
# 激活的移动事件加入数组
active_moves.append(event)
else:
# 松开的移动事件从数组中移除
if event in active_moves:
active_moves.remove(event)
# 更新移动速度
_update_velocity()
# 其他事件处理不变
elif event == InputEvent.ATTACK and is_active:
_attack()
elif event == InputEvent.JUMP and is_active:
_jump()
elif event == InputEvent.DODGE and is_active:
_dodge()
func _update_velocity() -> void:
velocity = Vector2.ZERO
# 遍历激活的移动事件,更新速度
for move in active_moves:
if move == InputEvent.MOVE_UP:
velocity.y = -speed
elif move == InputEvent.MOVE_DOWN:
velocity.y = speed
elif move == InputEvent.MOVE_LEFT:
velocity.x = -speed
elif move == InputEvent.MOVE_RIGHT:
velocity.x = speed
# 限制速度的最大值,避免同时按两个方向时速度过快
if velocity.length() > speed:
velocity = velocity.normalized() * speed
func _physics_process(delta: float) -> void:
move_and_slide()
func _attack() -> void:
print("角色攻击!")
func _jump() -> void:
if is_on_floor():
velocity.y = -500
print("角色跳跃!")
func _dodge() -> void:
print("角色闪避!")
四、应用场景、优缺点和注意事项
4.1 应用场景
自定义输入系统最适合跨设备的游戏,比如同时支持键盘、手柄、触屏的PC游戏、主机游戏、手游;也适合需要复杂输入逻辑的游戏,比如格斗游戏(需要处理多按键组合)、动作游戏(需要处理输入冲突);另外,需要玩家自定义按键的游戏也适合用自定义输入系统,因为可以灵活地修改映射关系。
4.2 技术优缺点
优点:
- 适配多设备:可以把不同设备的输入统一映射到同一个游戏逻辑,避免不同设备逻辑不一致的问题。
- 灵活处理输入:可以自定义输入事件的逻辑,比如添加按键组合、输入延迟、输入优先级等。
- 解决输入冲突:可以通过优先级、过滤等方法解决多输入同时触发的问题。
- 支持玩家自定义:可以方便地修改输入映射,让玩家自定义按键。
缺点:
- 开发成本高:需要自己实现输入映射、冲突处理等逻辑,比用Godot自带的输入系统复杂。
- 维护难度大:如果游戏的输入逻辑比较复杂,自定义系统的代码会比较多,维护起来比较麻烦。
- 容易出bug:比如映射关系错误、冲突处理逻辑有问题,可能会导致输入异常。
4.3 注意事项
- 输入事件的设计要合理:输入事件要尽量简洁,不要定义太多冗余的事件,避免逻辑混乱。
- 映射关系要全面:要考虑到所有支持的设备的输入,比如键盘、手柄、触屏、鼠标等,避免某个设备的输入无法正常工作。
- 冲突处理要符合玩家习惯:比如攻击的优先级要比跳跃高,同时按攻击和跳跃时优先触发攻击,这符合大多数玩家的操作习惯。
- 要做输入测试:不同设备的输入逻辑可能不一样,要在不同设备上测试输入是否正常,比如手柄的轴输入是否准确,触屏的点击是否灵敏。
- 性能优化:自定义输入系统的代码要尽量高效,避免在输入处理逻辑中做太多复杂的计算,影响游戏的性能。
五、文章总结
Godot的输入系统是游戏开发的基础,自定义输入系统可以解决多设备适配和输入冲突的问题,让游戏的输入逻辑更灵活、更符合玩家的需求。自定义输入系统的核心是“两层映射”,即把设备输入映射到输入事件,再把输入事件映射到游戏逻辑;输入冲突的解决方法主要有输入优先级和输入过滤两种。
自定义输入系统虽然开发成本比较高,但对于跨设备、复杂输入逻辑的游戏来说,是非常必要的。在开发自定义输入系统时,要注意输入事件的设计、映射关系的全面性、冲突处理的合理性,同时要做好测试和性能优化,确保输入系统的稳定和高效。
评论
围绕“Godot输入系统自定义:适配多设备输入的逻辑设计与冲突解决”参与讨论