LOADING

加载过慢请开启缓存 浏览器默认开启

MetroidvaniaLike 系列一:用类状态机组织 Unity 动作角色

前言

MetroidvaniaLike 是我用 Unity 6 做的一个 2D 横版动作 RPG 练习项目。玩家从最初的移动和跳跃,慢慢加到了二段跳、墙滑、蹬墙跳、冲刺、攻击、受伤和死亡。动作多起来以后,Player.Update() 里同时处理输入、动画和速度会越来越乱,于是项目给每个动作单独写了状态类。

这篇先记录状态机目前的结构,以及我重新检查代码时发现的切换约束问题。

项目源码:qian488/MetroidvaniaLike

这个系列

  1. 本文:玩家状态机骨架与边界
  2. 土狼时间、输入缓冲与动作打断
  3. 敌人 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 决策。
  • 当前最需要补充的是切换约束、调试信息和测试。

下一篇继续看跳跃手感。项目里已经加入土狼时间和跳跃缓冲,还需要把它们和墙滑、冲刺、攻击打断放在一起检查。