你在写 2D 游戏角色时一定遇到过这种情况:待机、跑步、跳跃、攻击这些功能拆开做都很简单,一旦组合进同一个角色,代码就开始失控。is_on_floor()的判断散落在输入响应、动画播放和碰撞处理里,只要多按两个键,角色就会一边播放攻击动画,一边在空中滑行,甚至卡在某个动画循环里出不来。
每次遇到这种问题,很多人第一反应是“继续加 if/else”,结果条件是越加越多,逻辑越来越难读。真正能从根本上把这一类问题理顺的,是一个很老但非常有效的模型:有限状态机(Finite State Machine)。
本文不想只讲概念,也不打算让你直接装一个状态机插件了事。我会从零手搓一个轻量级状态机,用它搭建玩家角色的完整架构。读完你会得到三样东西:一套自己可控的状态机核心代码、一套可扩展的角色状态组织方式,以及在实际项目中避开常见坑的经验。
1. 角色逻辑失控的典型症状
先说一个很多人都写过的反面示例,你大概率会觉得眼熟:
# 伪代码:多数新手项目的角色输入处理 func _physics_process(delta): if Input.is_action_just_pressed("attack"): animation.play("attack") attacking = true if is_on_floor(): if Input.is_action_pressed("move_left") or Input.is_action_pressed("move_right"): if not attacking: animation.play("run") velocity.x = direction * speed else: if not attacking: animation.play("idle") velocity.x = 0 if Input.is_action_just_pressed("jump") and is_on_floor(): velocity.y = jump_velocity animation.play("jump") if not is_on_floor(): velocity.y += gravity * delta if velocity.y > 0 and not attacking: animation.play("fall") move_and_slide()这段代码在最简单的情况下能跑,但问题非常明显:
- 攻击、移动、跳跃的状态互相交织,一个动作会覆盖另一个动作的动画。
- 攻击结束后想回到待机态,你需要额外判断“攻击动画是否播完”,于是又多出一个
attacking标志位。 - 跳跃和下落如果还要区分“上升段”和“下降段”,条件判断会继续膨胀。
- 未来加入冲刺、攀爬、滑铲、受击时,
if/else会变成难以维护的蜘蛛网。
当条件判断开始互相覆盖时,说明你已经不是在“写功能”,而是在“打补丁”。状态机解决的不是某一个 bug,而是整个角色行为的组织方式:它把“角色此刻处于什么状态”“什么条件下能去下一个状态”“进入状态后要做什么”拆成独立的小单元。
2. 状态机的核心概念与适用边界
2.1 四个基本概念
状态机听起来抽象,本质上只有四个东西:
| 概念 | 含义 | 在角色中的例子 |
|---|---|---|
| 状态 | 角色某一时刻所处的工作模式 | 待机、跑步、跳跃、攻击 |
| 转换 | 从一个状态切换到另一个状态 | 跑步状态下按下跳跃键,切换到跳跃状态 |
| 条件 | 触发转换的判断逻辑 | 按键输入、是否着地、动画是否播完 |
| 动作 | 进入、离开或停留在状态中执行的行为 | 进入跑步状态时播放 run 动画 |
“手搓状态机”要做的,就是把这四个东西变成代码里的“状态类 + 转换函数”。每个状态是一个独立脚本,它只负责三件事:
- 进入时初始化。
- 持续期间每帧更新。
- 离开时做清理。
所有状态之间的连接都统一交给一个 StateMachine 节点管理。这个节点不知道你的角色具体能做什么,它只负责“从状态 A 切到状态 B”,并保证切换过程按规范执行。
2.2 和 AnimationTree 状态机有什么区别
很多刚接触 Godot 的人会混淆两件事:逻辑状态机和动画状态机。
Godot 的 AnimationTree 里也有一个AnimationNodeStateMachine,它用来管理动画片段之间的过渡,比如从 idle 平滑过渡到 run,再过渡到 jump。这是“动画层的状态机”。
本文要实现的逻辑状态机,管理的是角色行为逻辑。它可以决定角色能不能攻击、能不能跳跃、速度应该朝哪个方向。你可以让 AnimationTree 播放动画,也可以像我示例里一样直接用AnimationPlayer.play()。
一个常见的错误是把行为逻辑塞进 AnimationTree 的 Transition 条件里。短时间能跑,但一旦逻辑复杂,你会发现“动画条件”和“真实逻辑”被绑死在了一起,既不好调试,也不好复用。更推荐的做法是:逻辑状态机做决策,动画层只负责表现。
2.3 状态机的适用边界
不是所有场景都适合状态机:
- 适合:状态数量有限、切换条件清晰的游戏角色,比如平台跳跃、格斗、Boss AI。
- 不适合:大量可叠加的 Buff 效果、技能连段组合数极高的系统、复杂 AI 决策。这类场景更适合用行为树、技能系统或状态叠加架构。
一句话判断标准:如果状态之间的排列组合让你想给状态机加几百个状态,说明你已经用错工具了。
3. 环境准备与项目结构设计
3.1 Godot 版本与项目配置
本文的代码基于 Godot 4.x 编写,以标题中的 4.6 为场景背景。如果你当前用的稳定版是 4.2、4.3 或 4.4,代码同样适用;如果未来正式版发布到 4.6,也无需调整。这篇文章重点讲解的是通用状态机架构,不依赖某个小版本的新增 API。
开始之前,先创建项目:
- 打开 Godot,新建一个空项目,主场景选择
CharacterBody2D。 - 项目设置中先配置输入映射,打开
项目设置 -> 输入映射,添加以下动作:
| 动作名 | 推荐按键 |
|---|---|
| move_left | A、左方向键 |
| move_right | D、右方向键 |
| jump | 空格 |
| attack | J |
- 准备 5 个动画片段,名称建议为:
idle、run、jump、fall、attack。如果没有美术素材,可以用 Sprite2D 配合 AnimationPlayer 做简单颜色变化或位移动画占位。
3.2 场景节点结构
搭建如下的节点树:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── AnimationPlayer ├── StateMachine (Node) │ ├── idle │ ├── run │ ├── jump │ ├── fall │ └── attack └── CanvasLayer └── StateLabel (Label)这里的核心设计是:StateMachine 是角色的子节点,角色是 Player。每个具体状态是 StateMachine 的子节点。这样在编辑器中可以直接看到角色有哪些状态,添加新状态就是加一个子节点,非常直观。
4. 手写核心:State 基类和 StateMachine
状态机核心只有两个文件:一个状态基类,一个状态机管理器。
4.1 State 基类
先定义所有状态的基础接口:
# scripts/state.gd class_name State extends Node var player: CharacterBody2D var state_machine: StateMachine func enter(msg: Dictionary = {}) -> void: pass func exit() -> void: pass func update(delta: float) -> void: pass func physics_update(delta: float) -> void: pass这段代码做的不是技术上的复杂事,而是定义一个协议:每个状态都必须有enter、exit、update、physics_update四个基础方法。
为什么不用_physics_process这样引擎内置的回调?
因为所有状态节点都挂在 StateMachine 下面,如果每个状态都实现一个_physics_process,一帧会被调用好几次,状态切换时还可能出现新旧状态同时更新的问题。所以基类定义的是普通方法,由 StateMachine 统一调度。
player和state_machine这两个变量由 StateMachine 在初始化时注入,状态脚本内部只需要直接使用。
4.2 StateMachine 管理器
# scripts/state_machine.gd class_name StateMachine extends Node signal state_changed(current_state: String) @export var initial_state: State var current_state: State var states: Dictionary = {} func _ready() -> void: for child in get_children(): if child is State: states[child.name.to_lower()] = child child.player = get_parent() child.state_machine = self if initial_state: change_state(initial_state.name.to_lower()) else: push_warning("StateMachine 没有设置 initial_state") func change_state(state_name: String, msg: Dictionary = {}) -> void: var next_state: State = states.get(state_name.to_lower()) if not next_state: push_warning("状态不存在: " + state_name) return if current_state: current_state.exit() current_state = next_state current_state.enter(msg) state_changed.emit(current_state.name) func physics_update(delta: float) -> void: if current_state: current_state.physics_update(delta) func update(delta: float) -> void: if current_state: current_state.update(delta)关键逻辑说明:
_ready()会把所有挂在 StateMachine 下的 State 子节点注册到字典里。字典的 key 是节点名的小写字符串,所以节点命名越规范,后面切换状态越省心。change_state()是状态切换的唯一入口。它先调用旧状态的exit(),再调用新状态的enter(msg),并发出state_changed信号。这样外层可以监听状态变化,做日志记录或 UI 更新。msg参数用于切换状态时传递上下文。比如从“跑步状态”切换到“跳跃状态”,可以传递当前速度;从“攻击状态”切换到下一个攻击状态,可以传攻击类型。
到这里,一个通用状态机核心已经完成了。它不依赖任何第三方插件,也没有绑定到“玩家”这个具体角色上,后面可以复用到敌人、NPC、UI 流程等。
5. 用状态机搭建玩家角色
核心写完后,开始实现玩家状态。假设你的角色是一个平台跳跃游戏的主角,需要支持:待机、跑步、跳跃、下落、攻击。
5.1 Player 脚本
# scripts/player.gd class_name Player extends CharacterBody2D @export var speed: float = 220.0 @export var jump_velocity: float = -420.0 @export var gravity: float = 980.0 @onready var anim_player: AnimationPlayer = $AnimationPlayer @onready var state_machine: StateMachine = $StateMachine func _ready() -> void: state_machine.state_changed.connect(_on_state_changed) func _physics_process(delta: float) -> void: state_machine.physics_update(delta) if not is_on_floor(): velocity.y += gravity * delta move_and_slide() func _on_state_changed(current_state: String) -> void: print("当前状态: ", current_state)Player 脚本只做几件事:
- 暴露调参需要的
speed、jump_velocity、gravity。 - 把每个物理帧转发给 StateMachine。
- 统一施加重力,避免每个状态重复写重力公式。
- 最后调用
move_and_slide()让角色真正移动。
这里体现了职责分离:状态机负责“决策”,Player 负责“物理执行”。
5.2 待机状态 IdleState
# scripts/states/idle_state.gd class_name IdleState extends State func enter(_msg: Dictionary = {}) -> void: player.velocity.x = 0 player.anim_player.play("idle") func physics_update(_delta: float) -> void: if not player.is_on_floor(): state_machine.change_state("fall") return var direction := Input.get_axis("move_left", "move_right") if direction != 0: state_machine.change_state("run") return if Input.is_action_just_pressed("jump"): state_machine.change_state("jump", {"jump": true}) return if Input.is_action_just_pressed("attack"): state_machine.change_state("attack", {"type": "normal"})注意两个细节:
- 移动检测用的是
Input.get_axis(),而不是is_action_just_pressed()。因为角色待机状态下,如果按住方向键,just_pressed只会触发一帧,很容易出现“明明按住方向键却不进入跑步状态”的体验问题。 - 进入待机状态时先把水平速度清零,避免上一个状态残留下速度导致角色滑步。
5.3 跑步状态 RunState
# scripts/states/run_state.gd class_name RunState extends State func enter(_msg: Dictionary = {}) -> void: player.anim_player.play("run") func physics_update(_delta: float) -> void: if not player.is_on_floor(): state_machine.change_state("fall") return var direction := Input.get_axis("move_left", "move_right") if direction == 0: state_machine.change_state("idle") return player.velocity.x = direction * player.speed if Input.is_action_just_pressed("jump"): state_machine.change_state("jump", {"jump": true}) return if Input.is_action_just_pressed("attack"): state_machine.change_state("attack", {"type": "normal"})跑步状态的职责是持续更新水平速度,并检测离开跑步状态的条件。
5.4 跳跃状态 JumpState
# scripts/states/jump_state.gd class_name JumpState extends State func enter(msg: Dictionary = {}) -> void: if msg.get("jump", false): player.velocity.y = player.jump_velocity player.anim_player.play("jump") func physics_update(_delta: float) -> void: if player.velocity.y > 0: state_machine.change_state("fall")跳跃状态做的事情比较少:进入时设置一次向上的初速度,之后每帧判断速度方向,一旦速度变成正数,说明角色已经开始下落,就切换到下落状态。
5.5 下落状态 FallState
# scripts/states/fall_state.gd class_name FallState extends State func enter(_msg: Dictionary = {}) -> void: player.anim_player.play("fall") func physics_update(_delta: float) -> void: if player.is_on_floor(): state_machine.change_state("idle") var direction := Input.get_axis("move_left", "move_right") if direction != 0: player.velocity.x = direction * player.speed下落状态除了播放 fall 动画,还允许玩家在空中左右移动,这样跳跃手感不至于僵硬。
5.6 攻击状态 AttackState
# scripts/states/attack_state.gd class_name AttackState extends State func enter(msg: Dictionary = {}) -> void: player.velocity.x = 0 player.anim_player.play("attack") if not player.anim_player.is_connected("animation_finished", _on_animation_finished): player.anim_player.animation_finished.connect(_on_animation_finished) func _on_animation_finished(anim_name: StringName) -> void: if anim_name == "attack": state_machine.change_state("idle")攻击状态是一个“动画驱动型”状态。它不像移动状态那样持续响应输入,而是在进入时触发攻击动画,等动画播放结束后回到待机状态。
这里最容易踩的坑是信号重复连接。每次进入攻击状态都调用connect(),会导致animation_finished信号累计绑定多个回调,动画播完后change_state("idle")被触发多次。所以在连接前用is_connected()做一次检查。
6. 运行效果与验证方法
6.1 运行步骤
- 在 Player 场景中把
StateMachine的initial_state设置为idle节点。 - 确保 Player 下有
CollisionShape2D,场景中有地面 StaticBody2D。 - 按 F5 运行项目。
预期表现:
- 角色初始播放
idle动画。 - 按住 A/D 或左右方向键,角色水平移动并播放
run动画。 - 松开方向键后,角色回到待机状态,水平速度归零。
- 地面状态按空格,角色向上跳起,播放
jump动画;速度方向改变后切换为fall动画。 - 落地后回到
idle状态。 - 按 J 键,角色播放
attack动画,动画结束后自动回到idle。
同时在 Godot 的 Output 面板中,你会看到状态切换日志:
当前状态: idle 当前状态: run 当前状态: jump 当前状态: fall 当前状态: idle6.2 更直观的调试方式
日志能看,但不够直观。你可以在场景里加一个 Label,实时显示状态名称:
# scripts/state_debug.gd extends Label @onready var player: Player = get_tree().get_first_node_in_group("player") func _process(_delta: float) -> void: if player: text = "State: " + str(player.state_machine.current_state.name)记得给 Player 节点添加到player组,或者在 Inspector 里手动拖引用。
6.3 如何判断成功
当你按下每个操作键,角色状态都能正确切换、播放对应动画、结束后回到合理状态时,核心流程就算跑通了。
如果运行失败,按照下面顺序排查:
- 看状态日志:状态有没有切换?如果一直停在某个状态,说明转换条件没有触发。
- 看动画效果:动画有没有播放?如果没有,检查动画名称是否匹配。
- 看物理表现:角色有没有移动?如果没有,检查 InputMap 和碰撞层配置。
7. 扩展:状态机如何保持可扩展
状态机的价值不只是解决当前几个状态,而是让后续扩展变得有序。
7.1 添加新状态的固定流程
假如你要加一个“冲刺”功能,步骤如下:
- 新建
dash_state.gd,继承 State。 - 在 Player 节点下挂一个名为
dash的新节点,绑定脚本。 - 在
run_state.gd或idle_state.gd中加入冲刺按键判断,调用change_state("dash")。 - 在
dash_state.gd中实现进入、持续、离开逻辑。
你会发现,核心 StateMachine 一行都不用改。新增状态是一次局部操作,不会影响其他状态。
冲刺状态示例:
# scripts/states/dash_state.gd class_name DashState extends State @export var dash_speed: float = 600.0 @export var dash_time: float = 0.15 var _elapsed: float = 0.0 func enter(msg: Dictionary = {}) -> void: var direction: float = msg.get("direction", 1.0) player.velocity.x = direction * dash_speed player.velocity.y = 0 _elapsed = 0.0 player.anim_player.play("dash") func physics_update(delta: float) -> void: _elapsed += delta if _elapsed >= dash_time: state_machine.change_state("run")这正体现了“状态机加节点”模式的扩展性。你在编辑器里加一个节点,再写一个脚本,功能就接入了。
7.2 复用同一套状态机做敌人 AI
StateMachine 并不绑定“玩家”。同一个核心,你也可以用来做敌人。
把敌人身上的状态节点换成patrol、chase、attack,各状态脚本读取自己的逻辑即可:
patrol_state.gd:左右巡逻,发现玩家范围内切换为chase。chase_state.gd:追踪玩家,距离足够近时切换为attack。attack_state.gd:播放攻击动画,动画结束后回到chase。
共用一套核心,状态脚本各自实现,这就是组件化的好处。
7.3 和 AnimationTree 结合
当角色动画越来越多,手动play()的写法会变得繁琐,过渡也不够平滑。这时可以引入 AnimationTree。
逻辑状态机依然负责决策,但在状态脚本的enter()里,不再是anim_player.play("run"),而是设置 AnimationTree 的过渡条件:
player.animation_tree.set("parameters/state_machine/transition_request", "run")这种组合的好处是:动画过渡由 AnimationTree 负责,逻辑过渡由逻辑状态机负责,各管一段,互不污染。
7.4 小心“状态爆炸”
如果你的技能系统允许几十种状态互相组合,比如“冲刺攻击”既要有冲刺的状态,又要有攻击的状态,那继续在一个状态机里增加状态会非常痛苦。
这时候有三个方向:
- 用“状态栈”或“叠加状态”处理可叠加行为。
- 把一套状态机嵌套进另一套,比如角色拥有移动状态机和武器状态机。
- 引入技能系统/行为树,让状态机只负责基础行为。
记住状态机的边界:它是用来组织离散行为的,不是用来穷举所有排列组合的。
8. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 角色不响应方向键 | InputMap 动作名与代码不一致 | 检查项目设置中的动作名 | 统一动作命名,确认代码中动作名与项目设置一致 |
| 状态一直停留在 idle | initial_state 未设置,或 change_state 名字不匹配 | 查看输出日志是否有 warning | 设置 initial_state,检查节点命名大小写 |
| 角色在 run 和 idle 之间快速抖动 | 移动判断用了 just_pressed | 检查状态切换条件 | 改用 get_axis() 判断方向输入 |
| 攻击动画播放后反复触发回待机逻辑 | animation_finished 信号重复连接 | 检查 is_connected 用法 | 连接前检查,或使用 CONNECT_ONE_SHOT |
| 动画不切换 | 动画名称不存在 | 查看 AnimationPlayer 的动画列表 | 统一动画命名,确保代码字符串一致 |
| is_on_floor() 一直返回 false | 缺少 CollisionShape2D,或地面图层不对 | 检查节点和碰撞层 | 添加碰撞形状,检查图层 Mask 配置 |
| 进入状态时角色瞬间位移 | 状态 enter 中直接修改了全局属性 | 检查 enter 方法 | 把物理运动逻辑放到 physics_update |
特别提醒一个新手常踩的坑:Godot 的is_on_floor()并不是“只要角色站地面上就立刻为 true”,它依赖上一帧move_and_slide()的结果。所以状态切换检测通常会有一帧延迟。平台跳跃游戏一般不会感知到这个延迟,但如果你写的是需要精确落地的玩法,就要考虑在move_and_slide()之后再处理落地判断。
9. 最佳实践与工程建议
状态机的代码量不大,但要长期维护好,必须建立一些团队约定。
9.1 状态切换只能走 StateMachine
不要在状态脚本里直接写current_state = xxx,也不要通过change_state()以外的方式改动状态。所有切换统一入口,才能加日志、加断点、加权限校验。
9.2 状态命名必须规范
StateMachine 用child.name.to_lower()作为字典 key,节点名用snake_case命名,比如idle_state、run_state。这比用IdleState这类 PascalCase 更利于传参和日志输出。
9.3 状态之间不要互相调用私有方法
如果你需要从攻击状态把数值传给下一个状态,用msg字典传递:
state_machine.change_state("attack", {"type": "heavy", "damage": 25})而不是在攻击状态里调用下一个状态的方法。这样状态之间保持低耦合,未来替换某个状态时不会牵连其他逻辑。
9.4 状态脚本不要膨胀
一个状态脚本只做一件事。如果run_state.gd里开始出现“攻击检测、冲刺检测、血量回复”等与跑步无关的逻辑,说明你已经把原来的 if/else 搬进了单个状态脚本,问题没有真正解决。
9.5 动画播放器信号注意生命周期
凡是依赖“动画播完”驱动的状态,连接信号前一定要做重复连接检查。更保险的做法是在exit()里断开信号:
func exit() -> void: if player.anim_player.is_connected("animation_finished", _on_animation_finished): player.anim_player.disconnect("animation_finished", _on_animation_finished)9.6 状态日志要有但不要太吵
开发过程中在state_changed信号里打印日志非常有用。发布版本时记得把日志关掉或换成 Debug 级别输出,避免控制台被刷爆。
9.7 把参数暴露在 Inspector
速度、跳跃力度、冲刺时间这些数值不要写死在状态脚本里。用@export暴露到 Inspector,策划和美术调整手感时不需要打开代码文件。
10. 总结与后续学习方向
这篇文章从“角色逻辑失控”这个最常见的痛点出发,先解释了状态机的四个核心概念,然后从零实现了 State 基类和 StateMachine 管理器,最后用玩家角色的待机、跑步、跳跃、下落、攻击五个状态跑通了完整流程。
你已经掌握的是一套不依赖第三方插件的状态机架构。它理解成本低、可扩展性好,也能复用到敌人 AI 和 UI 流程中。
接下来,如果你想继续深入,建议按顺序做这几件事:
- 给角色加一个冲刺状态,体验一下“新增状态不碰核心代码”的流程。
- 把状态机核心复用到敌人 AI 上,写一个简单的巡逻 / 追击 / 攻击敌人。
- 学习 AnimationTree 的动画状态机,把逻辑状态机和动画状态机组合起来,解决动画过渡问题。
- 写出状态转换图,放在项目文档里。团队协作时,状态转换图比代码注释更直观。
状态机不是游戏角色架构的唯一答案,但它是第一次让你脱离“if/else 泥潭”的架构工具。真正的手感仍然要靠参数调整和玩法设计,但至少下一次给角色加技能时,你不会再想着继续往条件判断里堆代码,而是会先回头看一眼状态转换图。建议收藏这份代码流程,等项目里的角色需要用上时,直接照着手搓一套。