ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Godot 4.6 轻量角色状态机开发实战:从零搭建可扩展架构

Godot 4.6 轻量角色状态机开发实战:从零搭建可扩展架构 很多 Godot 项目做到第三个角色动作状态管理就开始失控了。不是动作实现不了而是 if/else 嵌套和布尔变量组合越来越多每次加技能都要回去翻旧代码改一处还可能踩到另一处。这时候最需要的不是更复杂的架构而是一个能一眼看懂、能随手加状态的轻量状态机。这次我们用 Godot 4.6 从零手搓一个角色状态机不装任何第三方插件也不引入重量级框架。目标很明确把角色动作从“散落的 if 判断”收敛成“一组可插拔的状态”之后加新动作、调手感、做 NPC 或敌人 AI都能复用同一套模式。整篇文章按这个顺序走先给出状态机的规格和适用边界接着准备项目和输入映射然后手写状态基类、状态机宿主和五个基础状态最后做功能验证、扩展新状态并给出一套常见问题排查清单。如果你有一点在 C# 或 Java 里做状态机的经验或者玩过单片机里按键消抖用的状态机会发现这套思路放到 Godot 里完全一致可枚举的状态、明确的转移条件、集中管理的转移规则。要不要用状态机其实不用纠结。如果你的角色只有“左右移动”和一个跳跃那直接用节点承接就够了但只要你计划做攻击、连击、翻滚、攀爬、受击、死亡这些动作状态机的成本就值得先付掉。下面直接开始。1. Godot 4.6 状态机核心能力速览能力项说明项目类型角色控制架构 / 状态机框架目标引擎Godot 4.6 / 4.xGDScript向下兼容 4.3核心功能状态注册、状态切换、转移规则、动作输入抽象、动画联动组件构成1 个状态机宿主 1 个状态基类 N 个具体状态脚本外部依赖无插件无第三方库启动方式创建场景后直接 F5 运行硬件要求普通电脑即可无 GPU 门槛是否支持批量任务不适合批量任务适合游戏内高帧率循环接口能力状态类按固定接口实现宿主对外提供 change_to 与 can_transition扩展方式新建状态脚本 注册节点 在转移规则表中补一行适合读者想摆脱动画树思维、让角色逻辑完全可控的 Godot 开发者这里要说明两个概念。第一本文讨论的是“游戏逻辑状态机”不是 AnimationTree 里的动画状态机。AnimationTree 解决的是动画混合问题逻辑状态机解决的是“当前角色能不能跳、能不能攻击、该播放哪段动画”的问题。两者可以配合使用但不要混为一谈。第二状态机不是越高大上越好本文的状态机刻意保持轻量所有代码加在一起不到两百行目的是让你能随心所欲地改。2. 适用场景与使用边界状态机适合的场景非常明确。第一个是角色控制器。一个角色通常有空闲、移动、跳跃、坠落、攻击、受伤、死亡等动作动作之间有明确的转移条件。过去用布尔值组合比如is_jumping has_attacked is_grounded一旦条件超过四五个代码基本就不可读了。状态机把“条件判断”和“行为执行”分开每个状态只管自己进出场逻辑和转移时机复杂度被限制在一个小格子内。第二个是 NPC 和敌人 AI。巡逻、追击、攻击、回退这几个状态之间来回切换用状态机管理比用逐帧 if 判断清晰得多。第三个是 UI 流程和玩法阶段控制。菜单态、加载态、游玩态、暂停态本质上也是状态机只是载体不同。边界也很重要。如果你的项目只有一个开关型行为比如“开关门”用状态机属于过度设计。如果你的角色需要同时处于多个独立维度比如“移动方向”和“武器形态”一个单一状态机也容易写得混乱这时候更适合用“状态机叠状态机”或者“并发状态机”来拆解而不是把所有可能性塞进一个大矩阵。另外状态机只负责逻辑层不负责物理层和表现层。重力、碰撞、动画播放建议分别由角色脚本、动画播放器处理状态机只做编排。合规边界同样要提一句。本文所有代码和场景素材均为自建示例不涉及版权素材如果你后续把角色系统接入真实项目注意动画、音效、建模素材的授权范围尤其是商用项目。3. Godot 4.6 本地开发环境准备先准备开发环境。Godot 4.6 是标准编辑器分发不用安装解压即用。操作系统不限Windows / macOS / Linux 均可。硬件方面没有特殊门槛2GB 显存的集显也能跑 2D 场景真正吃资源的是后续 3D 大场景本文的角色状态机演示几乎可以忽略。具体检查清单如下下载 Godot 4.6 稳定版注意不要和 3.x 版的工程混淆。新建项目选择 2D 场景模板。渲染器选择默认即可2D 项目用 Forward 或 Mobile 都行。磁盘空间预留 1GB 以上Godot 本身很小但导入素材和缓存会占空间。编辑器版本确认顶部 Help - About能看到 Godot 4.6 版本号。项目目录建议如下res:// ├── scenes/ │ └── player/ │ └── player.tscn ├── scripts/ │ ├── character/ │ │ ├── player.gd │ │ ├── player_state_machine.gd │ └── states/ │ ├── state_base.gd │ ├── idle_state.gd │ ├── walk_state.gd │ ├── jump_state.gd │ ├── fall_state.gd │ └── attack_state.gd ├── assets/ │ └── sprites/ └── project.godot目录结构不是宗教可按团队习惯调整。但建议至少把状态脚本和角色脚本分开后面扩展时找代码更容易。项目创建完成后先不急着写状态机先把输入映射配好。4. 场景搭建与动作输入设计输入映射是一个经常被忽略的坑。如果直接在代码里写Input.is_key_pressed(KEY_A)后面想支持手柄或自定义按键就只能改代码。Godot 的 InputMap 可以把“按键”抽离成“动作名”状态机里只用动作名不管具体键位。打开 Project Settings - Input Map添加动作动作名推荐绑定move_leftA / 左方向键move_rightD / 右方向键move_upW / 上方向键move_downS / 下方向键jumpSpaceattackJ / 鼠标左键动作名可以任意起但不要用带空格的字符串后面在Input.get_vector里会不方便。配好后角色脚本里用一行代码就能拿到移动向量。# player.gd 内部使用的输入读取方法 func get_move_input() - Vector2: return Input.get_vector(move_left, move_right, move_up, move_down)get_vector会自动把四个方向合成一个单位向量横轴和纵轴都会归一化比手动获取四个键再叠加省事很多。2D 平台跳跃里横向移动我们只取input.x纵轴留给跳跃和重力。接下来搭建场景节点。创建一个 CharacterBody2D 作为玩家根节点脚本挂player.gd。子节点先加一个 CollisionShape2D再加一个 Sprite2D 或 AnimatedSprite2D最后加一个名为 StateMachine 的 Node挂player_state_machine.gd。结构如下Player (CharacterBody2D player.gd) ├── Sprite2D ├── CollisionShape2D └── StateMachine (Node player_state_machine.gd) ├── IdleState (Node idle_state.gd) ├── WalkState (Node walk_state.gd) ├── JumpState (Node jump_state.gd) ├── FallState (Node fall_state.gd) └── AttackState (Node attack_state.gd)StateMachine 节点的子节点就是各个状态。这样设计的好处是场景树即结构图打开任意一个角色场景一眼就能看到它支持哪些状态。后面加状态只需要在 StateMachine 下新建一个 Node挂对应脚本即可不需要改状态机宿主代码。需要注意一个细节StateMachine 是 Player 的子节点所以 StateMachine 的_ready一定比 Player 的_ready先执行。为了安全状态机统一提供一个set_player方法由 Player 在_ready里调用把角色引用注入给所有状态。5. 状态机基础实现从基类到注册表下面进入核心代码环节。我会按“基类 - 状态机宿主 - 角色脚本 - 具体状态 - 动画联动”的顺序写每个文件都给出完整代码和使用说明。5.1 状态基类状态基类负责定义状态的生命周期。每个状态在进入时调用enter退出时调用exit每帧调用process_update和physics_update。具体每个状态怎么实现完全由子类决定。# scripts/states/state_base.gd class_name StateBase extends Node signal state_exited(next_state_name: String) var player: Player var state_machine: PlayerStateMachine var state_name: String base func enter() - void: pass func exit() - void: pass func process_update(_delta: float) - void: pass func physics_update(_delta: float) - void: pass func _change_state(next_state_name: String) - bool: if state_machine.can_transition(state_name, next_state_name): state_machine.change_to(next_state_name) return true push_warning(非法状态转移: %s - %s % [state_name, next_state_name]) return false这里做了一件很重要的事状态不允许直接把自己切到任意状态而是先调用state_machine.can_transition检查转移规则。这个设计会在第 7 节体现出扩展价值。5.2 状态机宿主状态机宿主负责收集子节点里的所有状态维护当前状态并对外提供切换接口。状态收集采用注册表模式遍历所有子节点把state_name作为 key 存进字典。这样以后新增状态不需要改宿主代码。# scripts/character/player_state_machine.gd class_name PlayerStateMachine extends Node export var initial_state_name: String idle var player: Player var current_state: StateBase var states: Dictionary {} # 转移规则表从状态 A 允许转移到状态 B var transition_rules: Dictionary { idle: [walk, jump, attack], walk: [idle, jump, attack, fall], jump: [fall, attack], fall: [idle], attack: [idle, fall], } signal state_changed(old_state_name: String, new_state_name: String) func _ready() - void: _collect_states() _set_initial_state() func set_player(target: Player) - void: player target for state in states.values(): state.player target func _collect_states() - void: for child in get_children(): if child is StateBase: states[child.state_name] child child.state_machine self func _set_initial_state() - void: current_state states.get(initial_state_name) if current_state null: push_warning(初始状态未注册: %s % initial_state_name) return current_state.enter() func can_transition(from_state: String, to_state: String) - bool: var allowed: Array transition_rules.get(from_state, []) return allowed.has(to_state) func change_to(next_state_name: String) - void: if not states.has(next_state_name): push_warning(状态未注册: %s % next_state_name) return var next_state: StateBase states[next_state_name] if next_state current_state: return var old_name: String current_state.state_name if current_state else current_state.exit() current_state next_state current_state.enter() state_changed.emit(old_name, current_state.state_name) func _process(delta: float) - void: if current_state: current_state.process_update(delta) func _physics_process(delta: float) - void: if current_state: current_state.physics_update(delta)转移规则表放在状态机宿主脚本的最上方是一个集中的字典一眼能看到整个角色支持哪些转移边。比起把所有状态判断逻辑分散在几十个文件中这种写法在处理“非法状态”时特别快比如跌倒时攻击、攻击中跳跃都会被can_transition挡住并在控制台输出警告。5.3 角色脚本角色脚本是数据持有方。移动速度、重力、跳跃初速度这些参数放在这里状态只负责读取和修改velocity不直接处理碰撞。# scripts/character/player.gd class_name Player extends CharacterBody2D export var move_speed: float 180.0 export var jump_velocity: float -360.0 export var gravity: float 900.0 var state_machine: PlayerStateMachine var last_facing_direction: float 1.0 func _ready() - void: state_machine $StateMachine as PlayerStateMachine state_machine.set_player(self) state_machine.state_changed.connect(_on_state_changed) func get_move_input() - Vector2: return Input.get_vector(move_left, move_right, move_up, move_down) func _on_state_changed(old_state_name: String, new_state_name: String) - void: pass # 动画联动在这里做见 5.6 节这里把last_facing_direction放在角色脚本中用于记录面向。冲刺、受击等状态中玩家可能没有水平输入但动作仍然需要朝向这个变量就能用上。5.4 空闲与移动状态空闲状态是角色静止时的状态。进入时把横向速度清零物理更新时检查是否移动、跳跃、落地。# scripts/states/idle_state.gd class_name IdleState extends StateBase func _init() - void: state_name idle func enter() - void: player.velocity.x 0 func physics_update(delta: float) - void: var input_dir : player.get_move_input() if abs(input_dir.x) 0.05: _change_state(walk) return if Input.is_action_just_pressed(jump): _change_state(jump) return if not player.is_on_floor(): _change_state(fall)这里有一个工程经验_change_state之后要立刻return避免同一帧内重复判断。比如玩家同时按了移动和跳跃因为先进入 walk后面跳跃判断就不会再执行这符合大多数动作游戏的直觉移动键优先于跳跃键的情况不多。移动状态负责横向速度。当玩家按住方向键时速度线性设置不做加速曲线手感更直接方便后续调参数。# scripts/states/walk_state.gd class_name WalkState extends StateBase func _init() - void: state_name walk func physics_update(delta: float) - void: var input_dir : player.get_move_input() player.velocity.x input_dir.x * player.move_speed if input_dir.x ! 0.0: player.last_facing_direction sign(input_dir.x) player.sprite.scale.x player.last_facing_direction if abs(input_dir.x) 0.05: _change_state(idle) return if Input.is_action_just_pressed(jump): _change_state(jump) return if not player.is_on_floor(): _change_state(fall)注意player.sprite这个属性需要在 player.gd 里做一个 getteronready var sprite: Sprite2D $Sprite2D如果你用的是 AnimatedSprite2D就把 sprite 替换成动画节点。朝向翻转用scale.x -1是最简单的方式代价是碰撞体不受影响角色翻转后坐标轴不变适合 2D 横版游戏。5.5 跳跃、坠落与攻击状态跳跃状态负责给角色一个向上的初速度。Godot 的物理帧率默认是 60Hzvelocity.y gravity * delta这一行在_physics_process里写才稳定不能放在_process里。# scripts/states/jump_state.gd class_name JumpState extends StateBase func _init() - void: state_name jump func enter() - void: player.velocity.y player.jump_velocity func physics_update(delta: float) - void: var input_dir : player.get_move_input() player.velocity.x input_dir.x * player.move_speed player.velocity.y player.gravity * delta if player.velocity.y 0: _change_state(fall)坠落状态和跳跃状态很像唯一的区别是进入时不设初速度。落地检测需要注意一点is_on_floor()要在move_and_slide()之后才有效而move_and_slide()通常由角色脚本每帧调用。所以状态机物理更新里不直接调move_and_slide而是由角色在_physics_process中统一处理。# scripts/states/fall_state.gd class_name FallState extends StateBase func _init() - void: state_name fall func physics_update(delta: float) - void: var input_dir : player.get_move_input() player.velocity.x input_dir.x * player.move_speed player.velocity.y player.gravity * delta if player.is_on_floor() and player.velocity.y 0: _change_state(idle)那么谁来调move_and_slide应该在角色脚本player.gd中统一处理func _physics_process(delta: float) - void: if state_machine: state_machine._physics_process(delta) move_and_slide()严格来说这样写会让状态机和宿主顺序固定先让状态更新速度再执行物理移动。如果希望状态机也有机会在移动后感知落点可以把move_and_slide()放在状态机更新之前具体取决于项目需求。本文采用先状态后移动的顺序落地检测会延迟一帧但对大多数 2D 平台跳跃来说手感可以接受。攻击状态引入一个持续时间概念用计时器控制攻击帧。这是状态机里常用技巧状态内自带一个timer到点后自动退出。# scripts/states/attack_state.gd class_name AttackState extends StateBase export var duration: float 0.3 var timer: float 0.0 func _init() - void: state_name attack func enter() - void: timer 0.0 player.velocity.x 0 func physics_update(delta: float) - void: timer delta if timer duration: _change_state(idle if player.is_on_floor() else fall)这里把“按条件选目标状态”的表达式直接写在调用里简洁但可读性稍差。如果你后面扩展连击不建议用这种写法而是改成func _get_attack_recovery_state() - String: var next : idle if not player.is_on_floor(): next fall return next单独抽方法的好处是可以增加更多分支比如落到一半攻击、被敌人打断等场景。5.6 动画联动状态机与表现层解耦状态机不应该直接调用动画播放器。如果状态里写死player.animated_sprite.play(walk)那么状态机就和表现层耦合死了换动画资源、换渲染方式都要改逻辑代码。更好的做法是状态切发一个信号表现层自行订阅。角色脚本里这样实现# player.gd 中补全 _on_state_changed func _on_state_changed(old_state_name: String, new_state_name: String) - void: match new_state_name: idle: animated_sprite.play(idle) walk: animated_sprite.play(walk) jump: animated_sprite.play(jump_up) fall: animated_sprite.play(jump_fall) attack: animated_sprite.play(attack)如果每个状态名都能对应一个动画名甚至可以简化成animated_sprite.play(new_state_name)。代价是动画资源命名必须严格匹配状态名团队规范要求高。用match的好处是动画名可以灵活设计比如跳跃起跳和坠落分成两个动画但状态都叫 jump 和 fall。这里的状态机-动画联动方案本质上是“逻辑层的状态机”和“表现层的动画播放器”之间靠信号桥接。这也是很多人一开始不理解的点既然 AnimationTree 里有动画状态机为什么还要手写逻辑状态机因为 AnimationTree 的状态机只管混合权重和动画过渡它对“角色能不能跳”一无所知。逻辑状态机负责“能不能”动画状态机负责“怎么播”两者职责不同。6. 功能测试与效果验证代码写完直接在 Godot 里按 F5 运行。测试时建议先开两个面板Output 控制台和 Debugger 监视器。控制台能看到非法转移警告监视器能看到物理帧率和状态切换频率。下面是一套可直接执行的测试用例。测试动作操作预期状态序列验证点初始静止不按任何键idle角色无位移左右平移按住 Didle - walk横向速度趋近 move_speed停止移动松开 Dwalk - idlevelocity.x 清零地面跳跃按 Spaceidle - jump - fall - idle跳跃有滞空时间移动中跳跃按住 D Spacewalk - jump - fall - walk跳跃中保留水平速度空中攻击跳跃中按 Jjump - attack - fall攻击结束后回到坠落坠落落地从高处下落fall - idle落地瞬间无穿地非法转移测试手动错误调用控制台出现警告can_transition 拦截生效除了功能测试还需要一个可视化调试手段。在 Player 节点上加一个 Label 子节点动态显示当前状态名能在测试时清楚地看到状态序列。# 挂到任意 UI 层监听 player 的状态信号 extends Label var player: Player func _ready() - void: player get_parent().get_node(Player) as Player player.state_machine.state_changed.connect(_on_state_changed) text player.state_machine.current_state.state_name func _on_state_changed(old_state_name: String, new_state_name: String) - void: text new_state_name没有这个调试 Label 之前状态切换是否触发全靠肉眼观察动画非常不方便。加上之后按一下键就能在上角看到对应的状态名。另一个实用的调试方法是在enter()函数里加一行输出func enter() - void: print([%s] enter % state_name)测试完毕再删掉这些 print避免正式版本输出过多日志。完整验证的条件是所有状态能按转移规则表正常互切非法转移能输出警告角色物理移动不受影响动画能跟随状态切换。满足这四条基础状态机就通了。7. 可扩展性实操新增冲刺状态基础状态机最大的价值不是写出来能用而是后面加状态不用伤筋动骨。下面用新增一个“冲刺”状态来验证扩展性。加了冲刺之后角色按住移动键再按下 Shift 或 K会朝面向方向快速冲出一段距离。第一步新建状态脚本# scripts/states/dash_state.gd class_name DashState extends StateBase export var dash_speed: float 420.0 export var dash_time: float 0.12 var timer: float 0.0 var dash_direction: float 1.0 func _init() - void: state_name dash func enter() - void: timer 0.0 var input_dir : player.get_move_input() dash_direction input_dir.x if input_dir.x ! 0 else player.last_facing_direction player.velocity.x dash_direction * dash_speed player.velocity.y 0 func physics_update(delta: float) - void: timer delta player.sprite.scale.x dash_direction if timer dash_time: _change_state(fall if not player.is_on_floor() else idle)第二步在编辑器里选中 StateMachine 节点新建子节点挂上dash_state.gd脚本。这样状态机宿主会在_ready时自动收集 dash 状态无需改代码。第三步在转移规则表里补上冲刺相关的边。现在 dash 可以从 walk 和 idle 进入退出后回到 fall 或 idle# player_state_machine.gd 中修改后 var transition_rules: Dictionary { idle: [walk, jump, attack, dash], walk: [idle, jump, attack, fall, dash], jump: [fall, attack, dash], fall: [idle, dash], attack: [idle, fall], dash: [idle, fall], }第四步在 walk 状态里加入冲刺输入判断# walk_state.gd 中追加 if Input.is_action_just_pressed(dash): _change_state(dash) return第四步和第三步的顺序不要反。如果输入判断在没有转移规则时就触发_change_state会输出非法转移警告。这种“状态代码 规则表”双修改是刻意设计的状态代码负责“我想去”规则表负责“能不能去”。在不影响其他状态的情况下加新状态只需新增节点和补一行规则。这套模式特别适合团队协作。策划想要新动作你只需要在状态机下挂新脚本规则表加一行然后在对应状态里加输入判断。旧的五个状态文件不需要再来回改动。当然dash 要接入 InputMap在 Input Map 里添加dash动作绑定移动键附近的按键即可。配上之后角色就能实现短距离突进而且可以配合攻击形成“冲刺攻击”的连段因为你已经在 attack 状态的转移规则表里加了 dash 入口。8. 资源占用与性能观察很多项目引入状态机后担心性能。其实一个纯逻辑状态机的开销非常低每帧只是若干次函数调用和一次字典查找。本文这套实现里_physics_process调一次当前状态的physics_update_process调一次process_update客户端上的整体 CPU 开销处于微秒量级不需要刻意优化。但有几个确实会拖慢性能的写法需要注意。第一个是每帧遍历所有状态。有些设计会让状态机每帧轮询所有子节点判断哪个该激活。这种写法状态多了之后浪费明显而且状态之间的优先级管理会越来越难。本文采用注册表模式通过state_name直接定位状态不轮询。第二个是滥用信号。每个状态每帧都emit_signal会产生大量低效调用。本文只在状态切换时发一个state_changed信号频率低开销可忽略。第三个是过渡规则的字典查找频率。can_transition每次切换时调用一次切换频率远低于帧率字典查找的开销可以忽略。想观察运行成本可以在编辑器顶部 Debug - Debugger切到 Monitor 页面观察 Physics Process 的耗时。如果一次跳跃加攻击的过程中耗时曲线有明显尖峰多半不是状态机的锅而是碰撞检测或动画资源解码。用二分法禁用功能模块就能定位瓶颈。显存和内存的占用在 2D 角色场景里基本不用看真正要盯的是 3D 角色场景里的骨骼蒙皮和纹理缓存。9. 常见问题与排查方法状态机代码写完跑起来遇到问题是最正常的事。下面按常见程度列一份排查清单。问题现象可能原因排查方式解决方案角色完全不移动输入映射未配置或状态未触发Output 面板看是否有非法转移警告检查 Input Map 动作名是否和代码一致角色卡在 idle 无法进入 walk转移规则表没给 idle 添加 walk检查 transition_rules 里 idle 键补上 walk跳跃没反应jump 状态未注册或初始状态名错误检查 states 字典是否包含 jump确认 JumpState 节点挂在 StateMachine 下跳跃后直接穿地move_and_slide 未调用检查 player 脚本是否在状态更新后调用 move_and_slide补上 move_and_slide攻击状态无法打断转移规则表 attack 没配置出口查看是否出现非法转移警告按需求补 fall 或 idle动画和逻辑状态不同步状态切发和动画播放不在同一帧用调试 Label 观察状态名在 state_changed 信号槽统一播放动画冲刺方向反了last_facing_direction 未更新打断点看 dash_direction 值在 walk 移动时实时更新 last_facing_direction状态切换出现重复调用change_to 里未判等状态检查 next_state current_state 分支保留去重逻辑即可排查时第一件事不是看代码逻辑而是先看 Output 面板有没有push_warning打出来的警告。这套实现里所有非法转移和未注册状态都会走push_warning输出到控制台时是黄颜色。黄色警告基本指明问题方向比单靠脑补高效得多。如果运行时报脚本资源加载错误优先检查脚本文件名、类名和class_name声明是否一致。Godot 会把class_name注册为全局类型重名会导致解析冲突尤其是状态脚本多了以后StateBase、IdleState 这种名称很容易撞车。10. 最佳实践与架构建议状态机写完下面这些做法是长期维护的关键。第一每个状态脚本只干一件事。Idle 就管“停下”Walk 就管“移动”不要在 Idle 里写攻击逻辑的入口。如果发现自己在一个状态里加了一堆和状态名无关的代码说明状态拆得太粗了。第二把输入判断集中到physics_update。不要在enter()里使用Input.is_action_just_pressed因为just_pressed在物理帧内只发出一次进入状态后的下一帧可能仍然处于按下状态容易产生重复触发。统一放physics_update配合return短路可预测性最好。第三转移规则表是项目的“操作手册”。招聘一个不了解项目的 Godot 开发让他先看 transition_rules再进到每个状态看行为这对代码上手速度的帮助远超读十页文档。规则表不仅防非法状态也对截图时的状态构图有用一个状态能去哪些状态、不能去哪些一目了然。第四状态机不要直接持有定时器节点。在状态脚本里用var timer: float积累 delta 是最轻量的做法要打断一个动作就直接timer 0。如果每个状态都挂一个 Timer 节点场景树会越来越臃肿。第五注意命名一致性。状态名全小写下划线风格动画名也保持一致避免出现idle和Idle这种半大小写混用后面找 bug 找半天。第六涉及状态机的改动要跑回归测试。改 transition_rules 时把第 6 节的测试用例表重新跑一遍。关键改动最好提交在独立 commit方便回滚。11. 总结与下一步这套状态机最值得尝试的点是一次投入、长期受益。它解决的问题不是某个 bug而是角色控制代码的可维护性从零散的 if/else 变成可插拔状态从不可读的布尔组合变成集中管理的转移规则表。最先应该验证的是三个基础状态idle、walk、jump 的切换。这三个状态通了后面加 attack、dash、受击就只是重复劳动。最容易踩的坑有两处一是转移规则表忘补行导致状态想切但切不过去二是_change_state后忘了return造成同一帧多重判断。后续可以扩展的方向很多。第一加入土狼时间和跳跃缓冲手感会有质的提升第二把状态机从 Player 节点解耦成通用组件让敌人、NPC 共用同一套框架第三配合 AnimationTree 做动画混合逻辑状态机只管切片动画树负责过渡和混合这套架构可以撑起大多数 2D 动作游戏。第四如果你需要画状态机转移图网上能找到不少画状态机的工具照着 transition_rules 的字典结构画就行图一旦画清楚了代码结构和可解释性会同步提升。建议把本文代码作为基础模板收藏备用。实际项目里按自己的角色动作表把状态脚本替换掉规则表按需增删一套可扩展的角色架构就立起来了。遇到具体报错时回到第 9 节的排查表大多数问题都能定位。
返回列表