文章 / Godot 技术实践(一):用场景树把游戏当成可组合的程序 / Godot 技术实践(二):用状态机把 2D 角色做成可维护的循环

Godot 技术实践(二):用状态机把 2D 角色做成可维护的循环

围绕 CharacterBody2D、碰撞层和有限状态机,把 2D 角色从“能移动”做成输入、物理、动画可分开维护的循环。

2026年9月1日12 min3339 words
Godot 技术实践(二):用状态机把 2D 角色做成可维护的循环
图 1-1 cover.webp

角色控制器是 Godot 项目里最先膨胀的脚本。左移、跳跃、冲刺、受伤无敌、爬梯子,如果都写进同一个 _physics_process(),不出两周就会出现“改跳跃却弄坏了落地动画”的情况。真正需要拆开的不是节点数量,而是每一帧里的职责:读输入、更新状态、写速度、播放动画。

系列导航:上一篇:用场景树把游戏当成可组合的程序|下一篇:用资源和组合把项目结构撑住

一、先选对物理体

Godot 4 的 2D 物理节点职责不同,选错会把后面所有逻辑带偏:

节点谁在驱动它适合做什么
CharacterBody2D你每帧写 velocity,再 move_and_slide()玩家、可精确控制的敌人
RigidBody2D物理引擎积分箱子、碎片、被炸飞的物体
Area2D不产生碰撞响应,只检测重叠伤害盒、拾取、触发器
StaticBody2D / AnimatableBody2D基本不动,或沿路径移动地面、平台

玩家用 CharacterBody2D 几乎是默认答案。需要“被爆炸推开”时,不要强行在 Character 上模拟刚体,单独做交互物体更清楚。碰撞形状用 CollisionShape2D 的胶囊或矩形,不要拿 Sprite 的可见轮廓当碰撞。

二、把一帧拆成四个步骤

一个可维护的角色循环,建议固定成:

读输入 → 决定状态 → 写 velocity / 调用 move_and_slide() → 更新动画

最小示例:

extends CharacterBody2D

@export var speed := 220.0
@export var jump_velocity := -400.0

func _physics_process(delta: float) -> void:
    var input_x := Input.get_axis("move_left", "move_right")

    if not is_on_floor():
        velocity += get_gravity() * delta

    if Input.is_action_just_pressed("jump") and is_on_floor():
        velocity.y = jump_velocity

    velocity.x = input_x * speed
    move_and_slide()
    _update_animation(input_x)

func _update_animation(input_x: float) -> void:
    if not is_on_floor():
        $AnimationPlayer.play("jump")
    elif absf(input_x) > 0.1:
        $AnimationPlayer.play("run")
        $Sprite2D.flip_h = input_x < 0
    else:
        $AnimationPlayer.play("idle")

注意三件容易被忽略的事:

  1. 重力乘 delta。 Godot 4 的 get_gravity() 返回加速度,不是每帧位移。
  2. 先改 velocity,再 move_and_slide()。 反过来写,当帧移动会对不上状态。
  3. 动画跟状态,不跟按键。 按着右键但撞墙时,应显示 idle 或 push,而不是 run。

碰撞层和掩码也要尽早分开:玩家、地面、敌人、伤害盒不要挤在 Layer 1。玩家扫描地面,伤害盒扫描受击盒,两者不要互为物理阻挡。细节见官方 CharacterBody2D 文档。

三、用状态机接住复杂度

当跳跃、冲刺、受伤开始互相抢 velocity 时,就把“当前是什么”显式化。不必上插件,一个枚举加一个状态脚本已经够用。

角色在待机、奔跑、跳跃、攻击之间切换

enum State { IDLE, RUN, JUMP, ATTACK }

var state: State = State.IDLE

func _physics_process(delta: float) -> void:
    var input_x := Input.get_axis("move_left", "move_right")
    state = _choose_state(input_x)

    match state:
        State.IDLE, State.RUN:
            _move_ground(input_x, delta)
        State.JUMP:
            _move_air(input_x, delta)
        State.ATTACK:
            _move_attack(delta)

    move_and_slide()
    _play_state_animation()

func _choose_state(input_x: float) -> State:
    if state == State.ATTACK and $AnimationPlayer.is_playing():
        return State.ATTACK
    if Input.is_action_just_pressed("attack") and is_on_floor():
        return State.ATTACK
    if not is_on_floor():
        return State.JUMP
    if absf(input_x) > 0.1:
        return State.RUN
    return State.IDLE

状态机要守三条规则,否则只是换了一种 if/else 写法:

  1. 每个状态自己写移动。 攻击时锁水平速度,不要让 run 的赋值漏进来。
  2. 切换条件集中在一处。 _choose_state() 是唯一入口,避免动画回调、受伤函数、输入函数各自改 state。
  3. 进入和退出有钩子。 进入 ATTACK 时播动画并开启 Hitbox;退出时关掉,避免挥空后伤害盒一直存在。

状态一旦超过五六个,就把每个状态拆成独立脚本,由角色持有 current 并转发 _physics_process。组合仍然优于让 Player 继承 AirPlayer 再继承 CombatPlayer。

四、动画和受击不要写进移动里

AnimationPlayer 适合片段播放,AnimationTree 适合混合和过渡。2D 像素角色通常前者就够。把 Hitbox 做成 Area2D 子节点,只在攻击动画的关键帧启用 monitoring,比在代码里 await get_tree().create_timer(0.12) 更稳定。

受击则建议单独状态,而不是在当前状态里减血并 return:

func take_damage(amount: int, knockback: Vector2) -> void:
    if state == State.HURT:
        return
    health -= amount
    velocity = knockback
    state = State.HURT
    hit.emit(amount)

无敌帧、闪烁、击退,都放在 HURT 的进入钩子里。这样冲刺能不能取消受击,是状态转换表上的一行,而不是散落在三个函数中的布尔值。

小结

2D 角色能跑起来不难,难的是输入、物理和动画不再互相踩脚。选对 CharacterBody2D,把一帧拆成固定步骤,再用状态机把“现在是什么”变成数据。下一篇会离开角色脚本,讨论 Resource、场景实例化和 Autoload:项目变大之后,真正撑住结构的不是更多状态,而是更清楚的数据边界。

下一篇:Godot 技术实践(三):用资源和组合把项目结构撑住

评论 (0)

加载评论中...
RELATED NOTES
03 entries
01Godot 技术实践(四):从可玩到可发布的调试、性能与导出阅读文章 →02Godot 技术实践(三):用资源和组合把项目结构撑住阅读文章 →03Godot 技术实践(一):用场景树把游戏当成可组合的程序阅读文章 →