Article

C# 实战课 08:有限状态机 FSM(让角色行为可控、可测、可扩展)

系列定位:Unity 老版本兼容(.NET 3.5 / C# 4)+ 项目驱动。
本章目标:把散落在 Update 的行为分支改造成有限状态机(FSM),降低耦合并建立清晰的行为边界。

学习目标

完成本章后,你应该能做到:

  1. 识别“if/else 行为泥球”导致的不可维护问题。
  2. 设计状态接口、状态上下文、状态切换守卫三个核心抽象。
  3. 落地“巡逻-追击-攻击-受击-死亡”五状态角色行为。
  4. 通过日志与回放数据验证状态机是否按预期流转。

为什么必须上 FSM

在中小型项目早期,常见写法是一个 PlayerController.Update() 堆满分支:

if (hp <= 0) { ... }
else if (isHit) { ... }
else if (targetInRange) { ... }
else if (targetDetected) { ... }
else { ... }

这类代码最初很快,但到第 3~5 个需求就会崩:

  1. 条件顺序耦合:改一个 if 顺序,行为全变。
  2. 状态不封闭:任何地方都能改共享字段,导致“幽灵状态”。
  3. 测试困难:无法稳定复现实例,回归成本高。
  4. 扩展困难:新增一个“眩晕”状态会改动大量旧逻辑。

FSM 的价值是:把行为切换从“隐式条件”改成“显式状态图”

设计约束(兼容旧 Unity)

  • 不依赖复杂泛型魔法与表达式树。
  • 每帧不分配临时对象。
  • 切换行为必须可记录(from/to/reason/time)。
  • 状态对象可复用,禁止在 Ticknew

核心抽象

1. 状态接口

public interface IState
{
    string Name { get; }
    void Enter(StateContext context);
    void Tick(StateContext context, float dt);
    void Exit(StateContext context);
}

2. 状态上下文

public sealed class StateContext
{
    public readonly Actor Actor;
    public readonly Scheduler Scheduler;
    public readonly EventBus EventBus;

    public StateContext(Actor actor, Scheduler scheduler, EventBus eventBus)
    {
        Actor = actor;
        Scheduler = scheduler;
        EventBus = eventBus;
    }
}

上下文只放“状态需要的能力”,不要把全项目服务都塞进来。

3. 状态机主体

public sealed class StateMachine
{
    private readonly StateContext _context;
    private IState _current;

    public StateMachine(StateContext context)
    {
        _context = context;
    }

    public string CurrentName
    {
        get { return _current == null ? "None" : _current.Name; }
    }

    public void Start(IState initial)
    {
        if (initial == null)
        {
            throw new ArgumentNullException("initial");
        }

        _current = initial;
        _current.Enter(_context);
        FoundationLog.Info("FSM", "Start state=" + _current.Name);
    }

    public void Tick(float dt)
    {
        if (_current == null)
        {
            return;
        }

        _current.Tick(_context, dt);
    }

    public void ChangeState(IState next, string reason)
    {
        if (next == null)
        {
            throw new ArgumentNullException("next");
        }

        if (_current == next)
        {
            return;
        }

        var from = _current == null ? "None" : _current.Name;
        if (_current != null)
        {
            _current.Exit(_context);
        }

        _current = next;
        _current.Enter(_context);

        FoundationLog.Info("FSM", "Transition from=" + from + " to=" + _current.Name + " reason=" + reason);
    }
}

项目驱动:做一个“守卫 AI 行为系统”

本章直接做可落地小目标:

  • 守卫默认巡逻。
  • 发现玩家后追击。
  • 进入攻击距离后攻击。
  • 被击中时短暂硬直,再回到原流程。
  • 生命值归零进入死亡状态并停止行为。

1. 角色基础数据

public sealed class Actor
{
    public float Hp;
    public float MaxHp;
    public float MoveSpeed;

    public bool IsPlayerVisible;
    public bool IsTargetInAttackRange;
    public bool IsHit;

    public void MoveToTarget(float dt) { }
    public void Patrol(float dt) { }
    public void Attack() { }
}

2. 各状态实现(关键逻辑)

PatrolState

public sealed class PatrolState : IState
{
    private readonly StateMachine _fsm;
    private readonly IState _chase;

    public PatrolState(StateMachine fsm, IState chase)
    {
        _fsm = fsm;
        _chase = chase;
    }

    public string Name { get { return "Patrol"; } }

    public void Enter(StateContext context)
    {
        FoundationLog.Debug("FSM", "Enter Patrol");
    }

    public void Tick(StateContext context, float dt)
    {
        if (context.Actor.Hp <= 0f)
        {
            return;
        }

        context.Actor.Patrol(dt);

        if (context.Actor.IsPlayerVisible)
        {
            _fsm.ChangeState(_chase, "player_visible");
        }
    }

    public void Exit(StateContext context) { }
}

ChaseState

public sealed class ChaseState : IState
{
    private readonly StateMachine _fsm;
    private readonly IState _attack;
    private readonly IState _patrol;

    public ChaseState(StateMachine fsm, IState attack, IState patrol)
    {
        _fsm = fsm;
        _attack = attack;
        _patrol = patrol;
    }

    public string Name { get { return "Chase"; } }

    public void Enter(StateContext context) { }

    public void Tick(StateContext context, float dt)
    {
        if (!context.Actor.IsPlayerVisible)
        {
            _fsm.ChangeState(_patrol, "lost_target");
            return;
        }

        if (context.Actor.IsTargetInAttackRange)
        {
            _fsm.ChangeState(_attack, "in_attack_range");
            return;
        }

        context.Actor.MoveToTarget(dt);
    }

    public void Exit(StateContext context) { }
}

AttackState(含冷却)

public sealed class AttackState : IState
{
    private readonly StateMachine _fsm;
    private readonly IState _chase;
    private float _cooldown;

    public AttackState(StateMachine fsm, IState chase)
    {
        _fsm = fsm;
        _chase = chase;
    }

    public string Name { get { return "Attack"; } }

    public void Enter(StateContext context)
    {
        _cooldown = 0f;
    }

    public void Tick(StateContext context, float dt)
    {
        if (!context.Actor.IsTargetInAttackRange)
        {
            _fsm.ChangeState(_chase, "target_out_of_range");
            return;
        }

        _cooldown -= dt;
        if (_cooldown <= 0f)
        {
            context.Actor.Attack();
            _cooldown = 0.8f;
        }
    }

    public void Exit(StateContext context) { }
}

HitState(受击硬直)

public sealed class HitState : IState
{
    private readonly StateMachine _fsm;
    private readonly IState _resume;
    private float _timer;

    public HitState(StateMachine fsm, IState resume)
    {
        _fsm = fsm;
        _resume = resume;
    }

    public string Name { get { return "Hit"; } }

    public void Enter(StateContext context)
    {
        _timer = 0.25f;
        context.Actor.IsHit = false;
    }

    public void Tick(StateContext context, float dt)
    {
        _timer -= dt;
        if (_timer <= 0f)
        {
            _fsm.ChangeState(_resume, "hit_recovered");
        }
    }

    public void Exit(StateContext context) { }
}

DeadState

public sealed class DeadState : IState
{
    public string Name { get { return "Dead"; } }

    public void Enter(StateContext context)
    {
        FoundationLog.Warn("FSM", "Actor dead");
    }

    public void Tick(StateContext context, float dt)
    {
        // 死亡状态不做任何行为
    }

    public void Exit(StateContext context) { }
}

3. 切换守卫与统一入口

把状态切换决策收敛到一处,避免状态互相乱跳:

public sealed class GuardBrain
{
    private readonly StateMachine _fsm;
    private readonly Actor _actor;
    private readonly IState _dead;
    private readonly IState _hit;

    public GuardBrain(StateMachine fsm, Actor actor, IState dead, IState hit)
    {
        _fsm = fsm;
        _actor = actor;
        _dead = dead;
        _hit = hit;
    }

    public void Tick(float dt)
    {
        if (_actor.Hp <= 0f)
        {
            _fsm.ChangeState(_dead, "hp_zero");
            return;
        }

        if (_actor.IsHit)
        {
            _fsm.ChangeState(_hit, "got_hit");
            return;
        }

        _fsm.Tick(dt);
    }
}

与前面 Foundation 章节串联

本章是前 1~7 章的汇合点:

  1. Logging:每次状态切换都打结构化日志,线上可追踪。
  2. Assertions:在 ChangeState 前验证 next != null,避免空状态崩溃。
  3. EventBus:状态变化事件(StateChanged)广播给 UI、音效、特效系统。
  4. Scheduler:受击恢复、技能前摇、攻击冷却都可统一交给调度器。
  5. Command:玩家操作依旧走命令系统,FSM 只负责“行为策略”。

测试与验收

单元测试建议

至少覆盖这 4 条:

  1. Patrol -> Chase:当 IsPlayerVisible=true 时正确切换。
  2. Chase -> Attack:进入攻击范围后切换。
  3. Any -> Hit:被击中时优先进入硬直。
  4. Any -> DeadHp<=0 时不可再离开死亡状态。

回归验收

  • 连续运行 10 分钟,状态切换路径无异常抖动。
  • Profiler 中状态机路径无明显 GC 峰值。
  • 日志中能按时间顺序还原一个守卫完整行为链路。

常见坑

坑 1:状态对象内部直接拿全局单例

会让状态难测试、难复用。坚持走 StateContext 注入。

坑 2:状态切换在多个地方并发触发

最后会出现“同帧切换两次”问题。把守卫逻辑放在统一入口(如 GuardBrain)。

坑 3:状态内偷偷改不该改的数据

例如 AttackState 里直接改导航目标,会污染职责边界。状态只做当前职责。

本月作业

实现“精英守卫”扩展:

  1. 新增 EnrageState(低血量狂暴)。
  2. 保证不改动现有状态签名,只新增状态和切换规则。
  3. 输出状态图(文本或图均可)并附 5 条关键切换日志。

完成这个作业后,你的行为系统就具备了可持续扩展能力。下一章我们会进入“配置驱动技能系统”,让状态与技能参数彻底解耦。