☰
从零手搓有限状态机:用FSM架构理顺2D游戏角色行为
2026/10/8 11:04:23 网站建设 项目流程

你在写 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 动画

“手搓状态机”要做的,就是把这四个东西变成代码里的“状态类 + 转换函数”。每个状态是一个独立脚本,它只负责三件事:

  1. 进入时初始化。
  2. 持续期间每帧更新。
  3. 离开时做清理。

所有状态之间的连接都统一交给一个 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。

开始之前,先创建项目:

  1. 打开 Godot,新建一个空项目,主场景选择CharacterBody2D。
  2. 项目设置中先配置输入映射,打开项目设置 -> 输入映射,添加以下动作:
动作名推荐按键
move_leftA、左方向键
move_rightD、右方向键
jump空格
attackJ
  1. 准备 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 运行步骤

  1. 在 Player 场景中把StateMachine的initial_state设置为idle节点。
  2. 确保 Player 下有CollisionShape2D,场景中有地面 StaticBody2D。
  3. 按 F5 运行项目。

预期表现:

  • 角色初始播放idle动画。
  • 按住 A/D 或左右方向键,角色水平移动并播放run动画。
  • 松开方向键后,角色回到待机状态,水平速度归零。
  • 地面状态按空格,角色向上跳起,播放jump动画;速度方向改变后切换为fall动画。
  • 落地后回到idle状态。
  • 按 J 键,角色播放attack动画,动画结束后自动回到idle。

同时在 Godot 的 Output 面板中,你会看到状态切换日志:

当前状态: idle 当前状态: run 当前状态: jump 当前状态: fall 当前状态: idle

6.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 如何判断成功

当你按下每个操作键,角色状态都能正确切换、播放对应动画、结束后回到合理状态时,核心流程就算跑通了。

如果运行失败,按照下面顺序排查:

  1. 看状态日志:状态有没有切换?如果一直停在某个状态,说明转换条件没有触发。
  2. 看动画效果:动画有没有播放?如果没有,检查动画名称是否匹配。
  3. 看物理表现:角色有没有移动?如果没有,检查 InputMap 和碰撞层配置。

7. 扩展:状态机如何保持可扩展

状态机的价值不只是解决当前几个状态,而是让后续扩展变得有序。

7.1 添加新状态的固定流程

假如你要加一个“冲刺”功能,步骤如下:

  1. 新建dash_state.gd,继承 State。
  2. 在 Player 节点下挂一个名为dash的新节点,绑定脚本。
  3. 在run_state.gd或idle_state.gd中加入冲刺按键判断,调用change_state("dash")。
  4. 在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 动作名与代码不一致检查项目设置中的动作名统一动作命名,确认代码中动作名与项目设置一致
状态一直停留在 idleinitial_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 流程中。

接下来,如果你想继续深入,建议按顺序做这几件事:

  1. 给角色加一个冲刺状态,体验一下“新增状态不碰核心代码”的流程。
  2. 把状态机核心复用到敌人 AI 上,写一个简单的巡逻 / 追击 / 攻击敌人。
  3. 学习 AnimationTree 的动画状态机,把逻辑状态机和动画状态机组合起来,解决动画过渡问题。
  4. 写出状态转换图,放在项目文档里。团队协作时,状态转换图比代码注释更直观。

状态机不是游戏角色架构的唯一答案,但它是第一次让你脱离“if/else 泥潭”的架构工具。真正的手感仍然要靠参数调整和玩法设计,但至少下一次给角色加技能时,你不会再想着继续往条件判断里堆代码,而是会先回头看一眼状态转换图。建议收藏这份代码流程,等项目里的角色需要用上时,直接照着手搓一套。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询