系列定位:Unity 老版本兼容(.NET 3.5 / C# 4)+ 项目驱动。
本章目标:把散落在Update的行为分支改造成有限状态机(FSM),降低耦合并建立清晰的行为边界。
学习目标
完成本章后,你应该能做到:
- 识别“if/else 行为泥球”导致的不可维护问题。
- 设计状态接口、状态上下文、状态切换守卫三个核心抽象。
- 落地“巡逻-追击-攻击-受击-死亡”五状态角色行为。
- 通过日志与回放数据验证状态机是否按预期流转。
为什么必须上 FSM
在中小型项目早期,常见写法是一个 PlayerController.Update() 堆满分支:
if (hp <= 0) { ... }
else if (isHit) { ... }
else if (targetInRange) { ... }
else if (targetDetected) { ... }
else { ... }
这类代码最初很快,但到第 3~5 个需求就会崩:
- 条件顺序耦合:改一个
if顺序,行为全变。 - 状态不封闭:任何地方都能改共享字段,导致“幽灵状态”。
- 测试困难:无法稳定复现实例,回归成本高。
- 扩展困难:新增一个“眩晕”状态会改动大量旧逻辑。
FSM 的价值是:把行为切换从“隐式条件”改成“显式状态图”。
设计约束(兼容旧 Unity)
- 不依赖复杂泛型魔法与表达式树。
- 每帧不分配临时对象。
- 切换行为必须可记录(from/to/reason/time)。
- 状态对象可复用,禁止在
Tick里new。
核心抽象
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 章的汇合点:
- Logging:每次状态切换都打结构化日志,线上可追踪。
- Assertions:在
ChangeState前验证next != null,避免空状态崩溃。 - EventBus:状态变化事件(
StateChanged)广播给 UI、音效、特效系统。 - Scheduler:受击恢复、技能前摇、攻击冷却都可统一交给调度器。
- Command:玩家操作依旧走命令系统,FSM 只负责“行为策略”。
测试与验收
单元测试建议
至少覆盖这 4 条:
Patrol -> Chase:当IsPlayerVisible=true时正确切换。Chase -> Attack:进入攻击范围后切换。Any -> Hit:被击中时优先进入硬直。Any -> Dead:Hp<=0时不可再离开死亡状态。
回归验收
- 连续运行 10 分钟,状态切换路径无异常抖动。
Profiler中状态机路径无明显 GC 峰值。- 日志中能按时间顺序还原一个守卫完整行为链路。
常见坑
坑 1:状态对象内部直接拿全局单例
会让状态难测试、难复用。坚持走 StateContext 注入。
坑 2:状态切换在多个地方并发触发
最后会出现“同帧切换两次”问题。把守卫逻辑放在统一入口(如 GuardBrain)。
坑 3:状态内偷偷改不该改的数据
例如 AttackState 里直接改导航目标,会污染职责边界。状态只做当前职责。
本月作业
实现“精英守卫”扩展:
- 新增
EnrageState(低血量狂暴)。 - 保证不改动现有状态签名,只新增状态和切换规则。
- 输出状态图(文本或图均可)并附 5 条关键切换日志。
完成这个作业后,你的行为系统就具备了可持续扩展能力。下一章我们会进入“配置驱动技能系统”,让状态与技能参数彻底解耦。