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

角色控制器是 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")
注意三件容易被忽略的事:
- 重力乘
delta。 Godot 4 的get_gravity()返回加速度,不是每帧位移。 - 先改
velocity,再move_and_slide()。 反过来写,当帧移动会对不上状态。 - 动画跟状态,不跟按键。 按着右键但撞墙时,应显示 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 写法:
- 每个状态自己写移动。 攻击时锁水平速度,不要让 run 的赋值漏进来。
- 切换条件集中在一处。
_choose_state()是唯一入口,避免动画回调、受伤函数、输入函数各自改state。 - 进入和退出有钩子。 进入
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:项目变大之后,真正撑住结构的不是更多状态,而是更清楚的数据边界。
评论 (0)