ARTICLE DETAIL

资讯详情

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

Godot 4.6手写角色状态机:从零搭建可扩展控制架构

Godot 4.6手写角色状态机:从零搭建可扩展控制架构 这次我们来看一个 Godot 4.6 的项目从零手搓一个可扩展的状态机并把它装进角色控制架构里。如果你写过 Godot 里的角色控制大概率会遇到这类问题_physics_process()里堆满if分支跳跃状态、下落状态、攻击状态纠缠在一起改一个动作要小心牵连另一个动作。项目小的时候还能忍角色技能一多、状态一长代码就会迅速变成一团乱麻。这篇文章会直接从 Godot 4.6 编辑器开始一步步写一个轻量状态机。它不依赖 AnimationTree 自带的状态机工具全手写逻辑透明方便后续扩展。核心思路是“类即状态”每个状态一个脚本角色本身只负责管理状态切换不写业务逻辑。这样加新技能、新动作、新动画只需要新增一个脚本不用反复改动角色主脚本。文章会覆盖三块内容状态机的设计思路、GDScript 代码实现、以及接入角色后的功能测试与排查方法。适合已经会 Godot 基础操作、准备开始写角色系统的读者如果你正在做动作游戏、平台跳跃或者需要多技能切换的项目这篇可以直接当作参考架构来用。1. 核心能力速览能力项说明项目类型Godot 4.6 本地脚本方案纯 GDScript不依赖第三方插件核心功能待机、移动、跳跃、下落、攻击等状态切换支持动画播放、物理移动扩展方式新增一个状态脚本即可接入角色不需要修改角色主脚本运行环境Godot 4.6 编辑器部分 API 依赖 4.x 版本如_physics_process(delta)、move_and_slide()编程语言GDScript依赖项无外部依赖编辑器自带标准节点即可架构特性状态栈、状态注册表、状态基类、条件切换方法适合场景动作游戏角色、平台跳跃角色、NPC 行为切换、任何需要多状态切换的游戏实体兼容性状态机模式通用结构可迁移到 C#、Java 等项目先给结论这套方案不要求显卡、不要求插件包只要一个能打开 Godot 4.6 的电脑就够了。它的优势不在可视化而在代码结构。读完最好自己敲一遍才能真正理解状态切换的节奏。2. 为什么不用 AnimationTree 自带状态机Godot 自带 AnimationTree里面确实有状态机节点。做动画切换很方便节点连线、进入条件、自动切换可视化操作适合美术和策划协作。但是它有几个问题在角色逻辑越来越复杂的时候会暴露状态散落在动画层角色逻辑写在代码里动画状态画在编辑器里两边要维护两套状态定义。动作一多容易不同步。逻辑代码还是得自己写AnimationTree 解决的是动画过渡问题不能帮你管理跳跃高度、攻击判定、无敌帧、位移速度。这些逻辑最后还是要回到代码里而且很可能会再次堆积在_physics_process()中。层级切换不明显当角色倒地、眩晕、被打飞时动画状态和逻辑状态经常不一样。动画可以继续播但逻辑已经切走了用可视化状态机表达这种关系比较麻烦。手搓状态机的方案正相反状态就是类类里包含动画播放、物理逻辑、进入条件和退出条件。动画只是状态的一部分而不是状态的容器。代码中可以看到完整的行为闭环排查问题的时候只需要打开一个脚本。好接下来直接进入实现环节。3. 状态机设计三种方案思路与选型在动手写代码前先明确状态机是什么。状态机是一种让对象在不同“状态”之间切换的技术常见实现有 if-else 枚举、内置状态机、类状态机三种。这三种其实也不是游戏行业独有在 51 单片机开发、C# 状态机、Java 状态机等场景里都能看到类似思路。if-else 枚举方案用枚举变量表示状态_physics_process()里写大量分支判断。优点是直观缺点是可扩展性差新增一个状态要改动核心判断逻辑。内置状态机如 AnimationTree可视化编辑动画过渡方便但逻辑能力有限。类状态方案每个状态是一个脚本状态机主体通过一个引用指向当前状态类切换状态就是替换引用。好处是新增状态不碰旧代码功能隔离最彻底。本文选择类状态方案。它跟 C#、Java 里的状态模式设计思路一致但用 GDScript 实现语法轻、上手快、效果好。为了在写代码前理清关系建议先画一张最简单的状态转移图类似这样Idle → Move → Jump → Fall → Idle这表示角色先待机然后移动跳跃后进入下落落地后回到待机。画图的时候只需要把每个状态写成方框把触发条件标在连线上就行例如“按下方向键移动”和“到达地面”。先画图再写代码能减少很多不必要的返工。4. 从零实现状态基类与状态注册先建一个项目然后创建脚本state.gd作为所有状态类的公共基础。# state.gd # 所有角色状态的基类 class_name State extends RefCounted # 状态持有者引用方便状态内部访问角色节点 var entity: CharacterBody2D # 状态帧动画引用若不需要播放动画可忽略 var anim: AnimationPlayer # 进入状态时调用 func enter(prev_state: String) - void: pass # 退出状态时调用 func exit(next_state: String) - void: pass # 每帧物理更新 func physics_update(delta: float) - void: pass # 每帧调用 func update(delta: float) - void: pass # 处理输入事件若状态不想响应输入返回 false 交给状态机 func handle_input(event: InputEvent) - bool: return false这个基类不写业务逻辑只定义接口。entity用来访问角色节点anim用来播放动画。如果某个状态不需要动画就不填anim完全解耦。接着创建角色主体脚本player.gd挂在一个CharacterBody2D节点上。角色节点下还需要一个AnimationPlayer子节点用来播放动画。# player.gd extends CharacterBody2D enum StateKey { IDLE, MOVE, JUMP, FALL, ATTACK } # 状态名称到类名的映射表新增状态只要在这里加一行 const STATE_CLASS_PATHS : { StateKey.IDLE: res://states/idle_state.gd, StateKey.MOVE: res://states/move_state.gd, StateKey.JUMP: res://states/jump_state.gd, StateKey.FALL: res://states/fall_state.gd, StateKey.ATTACK: res://states/attack_state.gd } # 当前状态引用 var current_state: State var current_state_key: int -1 onready var anim: AnimationPlayer $AnimationPlayer func _ready() - void: change_state(StateKey.IDLE) func _physics_process(delta: float) - void: if current_state ! null: current_state.physics_update(delta) move_and_slide() func _unhandled_input(event: InputEvent) - void: if current_state ! null: if not current_state.handle_input(event): return func change_state(new_key: int) - void: if current_state ! null: current_state.exit(_state_key_to_str(new_key)) var state_script: Script load(STATE_CLASS_PATHS[new_key]) current_state state_script.new() current_state.entity self current_state.anim anim current_state_key new_key current_state.enter(_state_key_to_str(new_key)) func _state_key_to_str(key: int) - String: for k in STATE_CLASS_PATHS: if k key: return String(k) return UNKNOWNchange_state()是核心方法。它会先让旧状态退出再创建新状态脚本设置引用最后调用新状态的enter()。这样状态切换时不直接写在角色主脚本里而是由每个状态内部按条件请求切换。代码中定义了一个STATE_CLASS_PATHS映射表。以后新增状态只需要在StateKey枚举中加一个值然后在映射表中指到一个新脚本即可。这就是“可扩展角色架构”的具体体现。5. 编写四个基础状态上面的代码框架需要一个“状态能请求切换”的机制但本身还没实现“如何触发切换”。这里最简单的做法是每个状态脚本内部通过entity.change_state(StateKey.XXX)来申请切换。先写idle_state.gd待机状态逻辑是如果按下了左方向键或右方向键就转为移动如果按下跳跃键则立即进入跳跃状态。# idle_state.gd extends State func enter(prev_state: String) - void: anim.play(idle) # 待机时让速度减速到 0 entity.velocity Vector2.ZERO func physics_update(delta: float) - void: var move_dir : Input.get_axis(ui_left, ui_right) if move_dir ! 0.0: entity.change_state(StateKey.MOVE) elif Input.is_action_just_pressed(ui_accept): entity.change_state(StateKey.JUMP)注意enter()中直接重置速度为 0这样从任何状态回到待机时角色会稳定停下来。接着写move_state.gd移动状态# move_state.gd extends State var move_speed : 200.0 func enter(prev_state: String) - void: anim.play(run) func physics_update(delta: float) - void: var move_dir : Input.get_axis(ui_left, ui_right) entity.velocity.x move_dir * move_speed if move_dir 0.0: entity.change_state(StateKey.IDLE) elif Input.is_action_just_pressed(ui_accept): entity.change_state(StateKey.JUMP) elif Input.is_action_just_pressed(ui_attack): entity.change_state(StateKey.ATTACK)这里只有水平方向移动垂直方向交给重力。如果按跳跃键就从移动状态切到跳跃状态。这就形成“移动中跳跃”的自然衔接。然后写jump_state.gd跳跃状态。跳跃的要点是向上给速度进入后播放跳跃动画再根据垂直速度判断是否进入下落状态。# jump_state.gd extends State var jump_velocity : -300.0 func enter(prev_state: String) - void: anim.play(jump) entity.velocity.y jump_velocity func physics_update(delta: float) - void: entity.velocity.y 980.0 * delta # 当上升速度转为下降切换到下落状态 if entity.velocity.y 0.0: entity.change_state(StateKey.FALL)这里用到的重力加速度980.0是大约值实际项目需按角色手感调整。跳起来后一旦垂直速度变为正数说明角色已经过抛物线顶点此时切到下落状态。接着写fall_state.gd下落状态# fall_state.gd extends State func enter(prev_state: String) - void: anim.play(fall) func physics_update(delta: float) - void: entity.velocity.y 980.0 * delta # 落地后回到待机 if entity.is_on_floor(): entity.change_state(StateKey.IDLE)下落状态的重点是检测是否落地。用is_on_floor()判断角色是否碰撞到地面一旦落地立刻切回待机。到这里一个角色的“待机 → 移动 → 跳跃 → 下落”循环已经闭环。6. 攻击状态与状态阻断攻击状态是一个很典型的“不可打断又需要自然退出”的状态。它不能在半空中突然切回待机。正确的做法是进入攻击后播完攻击动画再退出。为了实现这个“播完才退”的机制可以在状态里监听动画播放完毕的信号。先写攻击输入逻辑把它放到移动状态中# 在 move_state.gd 中 elif Input.is_action_just_pressed(ui_attack): entity.change_state(StateKey.ATTACK)然后在角色player.gd的_ready()中连接动画信号# 在 player.gd 中 func _ready() - void: change_state(StateKey.IDLE) anim.animation_finished.connect(_on_animation_finished) func _on_animation_finished(anim_name: StringName) - void: if current_state_key StateKey.ATTACK: change_state(StateKey.IDLE)攻击状态脚本# attack_state.gd extends State func enter(prev_state: String) - void: # 攻击位移方向根据角色朝向决定 var dir : 1.0 if entity.scale.x 0 else -1.0 entity.velocity.x dir * 60.0 anim.play(attack) func physics_update(delta: float) - void: # 攻击中保持水平速度 entity.velocity.x lerp(entity.velocity.x, 0.0, 0.1) entity.velocity.y 980.0 * delta这样攻击状态播完后_on_animation_finished()会把状态切回待机。如果你想做“攻击后必须一小段硬直”可以在此基础上加一个attack_timer做延迟切换。这里有一个关键设计——攻击状态里没写“按攻击键再次攻击”所以每次攻击都是播完一个动画才回到待机不会出现无限连打的乱切。后期如果你要做连击可以在攻击动画的不同帧加计数然后根据输入切换到下一段攻击状态但基础架构不变。7. 状态切换与事件去重状态机一个常见的坑同一个输入在多帧中重复触发。GDScript 的_unhandled_input()是每帧事件回调Input.is_action_just_pressed()只在按下瞬间返回 true。状态切换后角色可能在同一帧收到跳跃输入又在下一帧收到起跳请求导致连跳。这个问题的原因通常是跳跃触发条件写在了每一帧都会执行的update()中而不是只在“按键按下瞬间”判断。解决办法跳跃这类“瞬间动作”使用Input.is_action_just_pressed()不要使用Input.is_action_pressed()。在enter()里做状态进入准备时不重复处理已经消耗的输入事件。如果游戏有输入缓冲需求才考虑使用输入缓冲队列否则一律即时触发。你可以通过加一个调试print()输出当前状态切换日志每次跳跃只应该打印一条“IDLE → JUMP”的记录。如果打印了两条说明同一帧内切换了两次状态需要检查_physics_process()里是否在切换状态之后又调用了当前状态的physics_update()。这里补充一个细节change_state()切换时旧状态的exit()先执行然后新状态创建enter()再执行。如果enter()里又调用了change_state()会造成连续切换。建议在change_state()开头加一个保护判断新旧状态是否相同func change_state(new_key: int) - void: if new_key current_state_key: return # ... 后续切换逻辑这样能避免同一个状态反复进入退出。8. 功能测试与效果验证写完状态机以后建议按下面的步骤跑一遍验证不要直接堆复杂技能。测试环境建议一个空的 2D 场景添加CharacterBody2D挂上CollisionShape2D再加一个静态StaticBody2D作为地面。角色材质随便用ColorRect或Sprite2D代替。按顺序验证测试用例操作方式预期结果待机状态不进行任何输入角色播放 idle 动画速度保持为 0待机切移动按住右方向键角色向右移动播放 run 动画移动停止松开方向键角色停止移动切回 idle起跳在地面时按跳跃键角色向上运动播放 jump 动画下落切换跳起后等待上升速度变为负播放 fall 动画角色下落落地回待机落地瞬间播放 idle 动画状态切回 IDLE攻击衔接移动中按攻击键播放 attack 动画攻击结束后切回待机状态日志观察输出窗口每个状态切换一条日志无重复切换如果发现跳跃高度不对优先检查两处jump_velocity是否设置合理、physics_update()中的重力加速度是否每帧都执行。如果发现落地后状态没切回去检查is_on_floor()是否生效——它依赖碰撞体和地面层是否正确配置单纯挂上 CollisionShape2D 但没有实际接触地面不会返回 true。攻击动画播放完后如果没回到待机检查动画名是否与anim.play(attack)中的字符串完全一致包括大小写。9. 常见问题与排查方法问题现象可能原因排查方式解决方案状态切换后动画不变动画名称写错或 AnimationPlayer 没有绑定目标节点打印anim.current_animation检查动画名与anim.play()参数一致跳跃后直接进入下落重力加速度在跳跃帧也执行了查看jump_state.gd的enter()是否先设置速度在enter()中先设置velocity.y再累加重力落地后没有回到待机is_on_floor()未生效碰撞层不匹配检查角色和地面的碰撞层确认两者有共同碰撞层攻击播完不回待机animation_finished信号未连接打印信号回调在_ready()中连接信号同一帧切换两次状态change_state()被重复调用打印切换前后状态值在change_state()加相同状态保护角色移动时抖动输入读取在物理帧和普通帧同时执行把所有移动逻辑放进physics_update()不要在_process()写物理相关逻辑继续前进时角色不能转向没有处理方向翻转逻辑检查move_state.gd是否设置scale.x翻转根据移动方向更新entity.scale.x重点关注一个原则状态机的错误大多数不是“状态切错了”而是“切换时机没控制好”。跳跃逻辑写在普通帧里、物理移动写在物理帧里两者节奏不一致就会出现跳不高、切不回来、抖动等问题。因此凡是跟物理移动、碰撞检测、速度计算相关的逻辑都统一放到_physics_process()中跟 UI、动画播放暂停相关的放到_process()中。10. 扩展方向子状态机、动画联动与条件切换表这套手搓状态机最大的价值在于扩展空间。下面几个方向都能在现有框架上加子状态机比如攻击动作有两种轻攻击和重攻击它们共享“攻击中不可移动”的规则。你可以让ATTACK状态内部再套一个小状态机用attack_sub_state管理轻击、重击、连击。实现方式是把State基类扩展成两层或在attack_state.gd里再写一个局部分支。从角色主状态机来看仍然只需要一个ATTACK状态不会污染主逻辑。动画联动跳跃状态和下落状态可以共用一套物理逻辑只有动画不同。你可以再做一层air_state.gd把跳跃和下落当作它的两个子状态或者把公共的重力代码提取到一个air_base.gd中这样两个状态都不重复写重力代码。条件切换表当状态变多以后与其在每个状态里写一堆Input.is_action_just_pressed()不如维护一张状态切换条件表。表格里记录“当前状态、目标状态、切换条件、优先级”用数据驱动的方式统一判断。这个表在动作游戏里特别有用可以做到精确控制哪些动作能够打断哪些动作。上面这些扩展核心都没有离开最初设计的两点状态是类、切换由状态机管理。只要这两点不变加新状态、加新动作永远都是新增文件而不是修改旧代码。11. 最佳实践与使用建议最后总结一些围绕状态机工程化的建议项目越往后写这些建议越有用先跑通状态切换再调动画手感。不要一开始就急着调跳跃高度和攻击帧。先把状态机的“管道”跑通看到日志输出里状态能按预期顺序切换再慢慢调动画参数。保留一份基础四状态备份。待机、移动、跳跃、下落这四态是整个角色系统的地基。无论后续做多少技能这四态都应该保持稳定。改动它们的时候重点检查会不会影响其他状态。统一使用change_state()作为唯一切换入口。不要在状态脚本里直接改current_state_key会导致状态机的引用和标记不同步。代码里输出日志来辅助校验。在change_state()中加入调试输出比如切换到哪个状态以及来源状态。发布前再注释掉。状态内职责单一。一个状态只管理自己的动画、移动、输入触发。公共逻辑比如重力提取到公共基类或工具函数中。注意多角色复用。这套状态机不绑定具体角色脚本其他角色也可以挂载同样的player.gd只需要修改状态脚本里的动画名称和参数即可。处理好撞击、眩晕等打断性状态。这类状态通常需要暂停当前状态的所有操作建议在状态机里增加一个“优先级”字段让高优先级状态可以强制切换低优先级状态不能随意打断当前状态。12. 总结从零手搓状态机核心并不在于设计多复杂而在于状态切换的“时机”和“引用关系”清晰。Godot 4.6 的 GDScript 提供了一个非常轻量的类结构完全够我们在本地搭一套稳定的角色状态机架构一个基类定义生命周期一个角色主脚本管理状态注册和切换每个技能状态独占一个脚本。这个方案的可扩展性来自于“新增状态 新增脚本 注册表加一行”这个简单规则。建议先做四个基础状态跑通这个项目确认切换逻辑稳定以后再慢慢加攻击、翻滚、受击、二段跳这类复杂状态。等熟练以后你会发现状态机的架构不仅适用于平台跳跃角色也适用于 NPC、敌人 AI、甚至 UI 界面的行为切换。这套东西最难的不是代码而是让每个状态“知道自己在做什么、什么时候该退出”。把这套机制搭好角色架构自然不会走弯路。
返回列表