前言
MetroidvaniaLike 是我用 Unity 6 做的一个 2D 横版动作 RPG 练习项目。玩家从最初的移动和跳跃,慢慢加到了二段跳、墙滑、蹬墙跳、冲刺、攻击、受伤和死亡。动作多起来以后,Player.Update() 里同时处理输入、动画和速度会越来越乱,于是项目给每个动作单独写了状态类。
这篇先记录状态机目前的结构,以及我重新检查代码时发现的切换约束问题。
项目源码:qian488/MetroidvaniaLike。
这个系列
- 本文:玩家状态机骨架与边界
- 土狼时间、输入缓冲与动作打断
- 敌人 AI、属性与技能系统
动作增加后出现的问题
一个简单角色控制器可能只有:
ReadInput();
Move();
Jump();
加入动作后,约束开始变多:
- 地面才能普通跳,空中可能允许二段跳;
- 贴墙下落时进入墙滑,但输入离开墙体后退出;
- 冲刺期间重力、速度和受击响应可能不同;
- 攻击连段由动画关键帧决定能否衔接;
- 受伤和死亡应当覆盖普通移动;
- 某些攻击可被冲刺取消,某些不可。
这些规则全部写入 Player.Update() 后,同一帧会有多个分支读写速度和动画参数,状态切换顺序也变得难以追踪。
StateMachine 的实现
项目的 StateMachine 保持简单:
public class StateMachine
{
public EntityState CurrentState { get; private set; }
public void Initialize(EntityState initialState)
{
CurrentState = initialState;
CurrentState.Enter();
}
public void ChangeState(EntityState nextState)
{
CurrentState.Exit();
CurrentState = nextState;
CurrentState.Enter();
}
}
它只维护当前状态、初始化和切换。具体“何时从 Jump 进入 Fall”不属于状态机本体,而属于当前状态的业务判断。
状态基类提供共享生命周期:
Enter → 初始化计时器、打开动画状态、注册临时事件
Update → 读取当前状态需要的输入、检测切换条件
Exit → 关闭动画状态、清理临时标记和事件
如果需要稳定处理 Rigidbody2D,也可以把物理写入 FixedUpdate 对应入口,但要避免一部分状态在 Update 改速度、另一部分在 FixedUpdate 改速度而没有统一约定。
玩家目前有哪些状态
项目中包含 Idle、Move、Jump、Fall、DoubleJump、WallSlide、WallJump、Dash、BasicAttack、HeavyAttack、AirAttack、CounterAttack、Hurt 和 Dead 等状态。
项目按照以下行为差异拆分状态:
- 输入解释不同;
- 物理规则不同;
- 动画和结束条件不同;
- 可打断关系不同;
- 生命周期中需要独立清理。
例如 Jump 和 Fall 都在空中,但 Jump 需要处理上升初速度、提前松键短跳;Fall 更关心落地、墙体和土狼时间窗口。把它们分开后,每个类的判断更聚焦。
只有动画表现不同、行为规则相同的情况,可以继续共用状态。这个判断能控制状态类数量。
为什么项目最后用了状态类
状态较少时,enum + switch 可以直接表达分支:
switch (state)
{
case PlayerState.Idle: UpdateIdle(); break;
case PlayerState.Move: UpdateMove(); break;
}
当状态持续增加后,每个状态的 Enter、Update、Exit、动画和切换条件会分散在多个 switch 中。添加一种攻击可能要同时修改五个位置。
类状态机的主要收益是局部性:与 Dash 有关的输入、计时、速度和退出条件尽量放在 PlayerDashState。代价则是类和构造代码变多,跨状态共享逻辑需要抽象。
本项目的玩家动作已经包含十余种状态,类状态机能让各动作的代码集中在对应文件中,因此采用了这一方案。
状态实例在哪里创建
玩家在初始化时创建所需状态,运行中只切换引用。这能避免每次切换都 new 状态对象,也让状态持续持有 Player、StateMachine 和动画参数。
但所有构造集中在 Player.Awake() 后会出现样板代码:
Player
├── idleState
├── moveState
├── jumpState
├── ...十余种状态
└── Awake 中逐个 new
当状态继续增加,可以引入 PlayerStateContext 收拢共享依赖,再由状态工厂创建。这里不建议一开始就做通用依赖注入容器;先让状态边界稳定,再消除重复装配。
攻击动画怎样通知当前状态
攻击命中帧、连段窗口和动画结束通常由 Animation Event 触发。合理链路是:
Animation Event
↓
PlayerAnimationTriggers
↓
当前状态处理 AttackTrigger / AnimationFinished
动画触发器只报告“命中帧到了”或“动画结束了”。当前状态收到事件后,再决定造成伤害或切换到哪个状态。这样可以在状态代码中追踪完整的切换条件。
还要防止旧状态的延迟回调在切换后生效。协程、Tween 和事件订阅应在 Exit() 中停止或校验当前状态。
当前最明显的问题:切换约束太弱
最小 ChangeState() 可以从任何状态切到任何状态。这使它易懂,却无法阻止:
- Dead 被 Move 打断;
- Hurt 被普通输入立即覆盖;
- 攻击前摇在不允许时被 Jump 取消;
- 同一帧连续发生多次切换。
进一步的状态机应显式表达:
bool CanTransitionTo(EntityState next);
int Priority { get; }
bool CanBeInterrupted { get; }
也可以由 Player 层统一裁决高优先级状态。优先实现死亡、受伤和攻击硬直等关键不变量,再逐步补充其他转换限制。
还需要补哪些检查
状态机可以做针对性测试:
- 初始化时只调用一次初始状态 Enter;
- ChangeState 顺序必须是旧 Exit → 新 Enter;
- Dead/Hurt 的优先级不会被普通输入覆盖;
- 状态退出后不再响应旧协程和事件;
- 快速交替输入不会让动画参数同时保留互斥状态。
在游戏内还应提供调试显示:当前状态、上一状态、最近切换原因和停留时间。动作问题通常发生得很快,只看画面很难区分是输入没读到、检测失败还是状态被另一条规则覆盖。
写在最后
- 状态机明确了当前由哪个动作解释输入并控制角色。
- 状态按照输入、物理、动画结束条件和打断规则拆分。
- 状态实例提前创建减少运行时分配,但装配代码会随规模增长。
- 动画事件只表达事实,状态负责 gameplay 决策。
- 当前最需要补充的是切换约束、调试信息和测试。
下一篇继续看跳跃手感。项目里已经加入土狼时间和跳跃缓冲,还需要把它们和墙滑、冲刺、攻击打断放在一起检查。