一、为什么要自定义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 技术优缺点

优点:

  1. 适配多设备:可以把不同设备的输入统一映射到同一个游戏逻辑,避免不同设备逻辑不一致的问题。
  2. 灵活处理输入:可以自定义输入事件的逻辑,比如添加按键组合、输入延迟、输入优先级等。
  3. 解决输入冲突:可以通过优先级、过滤等方法解决多输入同时触发的问题。
  4. 支持玩家自定义:可以方便地修改输入映射,让玩家自定义按键。

缺点:

  1. 开发成本高:需要自己实现输入映射、冲突处理等逻辑,比用Godot自带的输入系统复杂。
  2. 维护难度大:如果游戏的输入逻辑比较复杂,自定义系统的代码会比较多,维护起来比较麻烦。
  3. 容易出bug:比如映射关系错误、冲突处理逻辑有问题,可能会导致输入异常。

4.3 注意事项

  1. 输入事件的设计要合理:输入事件要尽量简洁,不要定义太多冗余的事件,避免逻辑混乱。
  2. 映射关系要全面:要考虑到所有支持的设备的输入,比如键盘、手柄、触屏、鼠标等,避免某个设备的输入无法正常工作。
  3. 冲突处理要符合玩家习惯:比如攻击的优先级要比跳跃高,同时按攻击和跳跃时优先触发攻击,这符合大多数玩家的操作习惯。
  4. 要做输入测试:不同设备的输入逻辑可能不一样,要在不同设备上测试输入是否正常,比如手柄的轴输入是否准确,触屏的点击是否灵敏。
  5. 性能优化:自定义输入系统的代码要尽量高效,避免在输入处理逻辑中做太多复杂的计算,影响游戏的性能。

五、文章总结

Godot的输入系统是游戏开发的基础,自定义输入系统可以解决多设备适配和输入冲突的问题,让游戏的输入逻辑更灵活、更符合玩家的需求。自定义输入系统的核心是“两层映射”,即把设备输入映射到输入事件,再把输入事件映射到游戏逻辑;输入冲突的解决方法主要有输入优先级和输入过滤两种。

自定义输入系统虽然开发成本比较高,但对于跨设备、复杂输入逻辑的游戏来说,是非常必要的。在开发自定义输入系统时,要注意输入事件的设计、映射关系的全面性、冲突处理的合理性,同时要做好测试和性能优化,确保输入系统的稳定和高效。