FSM(状态机)

由你自行驱动的确定性逐对象状态机——命名状态、带守卫的转换与全局中断,纯 C# 实现。

给每个敌人、菜单和游戏阶段一组干净的命名状态——让声明式的守卫转换来完成切换。

接口IStateMachineService — 用于创建 IStateMachine<TContext> 实例的工厂
关闭开关NullStateMachineService
程序集CommonGameSystem.Core
启动启动时自动注册(23 个服务之一)——无需任何设置

它做什么

有限状态机(FSM)把行为组织成一组命名状态——例如敌人的 IdleChaseDead——并配上在状态之间迁移的规则。这个服务提供的是一个扁平的、headless 的 FSM:你把每个状态定义为一个小类,然后用声明式的守卫转换("当敌人看到玩家时,从 Idle 进入 Chase")或直接调用 ChangeState 来驱动状态机。

时钟由你的游戏掌控:你每帧对每台状态机 tick 一次,并亲自传入增量时间。没有内部 ticker,也没有 MonoBehaviour 的包袱。这台状态机为敌人 AI、菜单流程和游戏阶段循环而生——每次 tick 零分配、顺序确定、纯 C#,并且在 IL2CPP(Unity 用于玩家构建的提前编译器)下安全。

快速上手

只解析一次工厂,然后按对象创建状态机:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class MyStateMachineUser : MonoBehaviour
{
    private IStateMachine<MyStateMachineUser> _fsm;

    private void Awake()
    {
        // Via the factory (recommended — you get diagnostics and the one-line off switch):
        _fsm = ServiceLocator.Resolve<IStateMachineService>().Create<MyStateMachineUser>(this);

        // Or construct one directly (works standalone, but is not counted in diagnostics):
        // _fsm = new StateMachine<MyStateMachineUser>(this);
    }
}

如上所示,把状态机缓存在字段里。不要在 Update() 里解析服务。

API 参考

IStateMachine<TContext> — 每帧由你驱动的逐实例状态机

TContext 是你的各个状态读取和修改的对象——通常是拥有这台状态机的 MonoBehaviour 或实体。

成员签名说明
CurrentStateNamestring(属性)当前状态的名称;Start 之前为 null
IsRunningbool(属性)Start 之后为 trueDispose 之后为 false
Addvoid Add(string name, IState<TContext> state)注册一个状态。用 public const string 定义状态名,以便在编译期抓住拼写错误。
AddTransitionvoid AddTransition(string from, string to, Func<TContext, bool> guard)注册一个带守卫的转换。把 StateMachine<TContext>.AnyState 用作 from 即为全局中断;它们会被优先求值。守卫要保持廉价且无副作用——不做射线检测,不做修改。
Startvoid Start(string name)进入指定名称的状态(调用 OnEnter)。必须在 Tick 之前调用一次。不会触发 StateChanged
ChangeStatevoid ChangeState(string name)立即转换:旧状态执行 OnExit,新状态执行 OnEnter,然后触发 StateChanged。状态名未知时是安全的空操作。
Tickvoid Tick(float deltaTime)前进一步:先调用 OnUpdate(deltaTime),再对声明式转换求值。增量时间由你传入,所以时钟由你选。
StateChangedevent Action<string, string>在转换的 OnEnter 之后触发,参数为 (fromName, toName)。在 Start不会触发。
Disposevoid Dispose()干净地关停:对当前状态调用一次 OnExit,然后清空各表。重复调用是安全的。

IState<TContext> — 由你实现的接口

实现这个接口,或继承 StateBase<TContext> 并只重写你需要的部分:

成员签名说明
OnEntervoid OnEnter(TContext ctx)状态机进入该状态时调用一次。
OnUpdatevoid OnUpdate(TContext ctx, float deltaTime)该状态为当前状态期间每次 tick 调用。可以调用 ChangeState
OnExitvoid OnExit(TContext ctx)状态机离开该状态时调用一次。

IStateMachineService — 工厂

成员签名说明
Create<TContext>IStateMachine<TContext> Create<TContext>(TContext context, StateMachineOptions options = default)返回一台新的逐实例状态机。经工厂创建的状态机会被计入 ActiveMachineCount
ActiveMachineCountint(属性)尚未释放的工厂创建状态机数量。直接用 new 创建的状态机不被追踪。

示例

using CommonGameSystem.Core;
using UnityEngine;

// The context: the object your states read and modify.
public class Enemy : MonoBehaviour
{
    public bool SeesPlayer;
    public float Health = 100f;
}

// Define state names as public const strings to avoid typos.
public static class EnemyStates
{
    public const string Idle = "Idle";
    public const string Chase = "Chase";
    public const string Dead = "Dead";
}

// Minimal states — extend StateBase and override only what you need.
public class IdleState : StateBase<Enemy> { }
public class ChaseState : StateBase<Enemy> { }
public class DeadState : StateBase<Enemy> { }

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class EnemyController : MonoBehaviour
{
    private IStateMachine<Enemy> _fsm;
    private ITimeService _time;

    private void Awake()
    {
        _time = ServiceLocator.Resolve<ITimeService>();
        var service = ServiceLocator.Resolve<IStateMachineService>();
        _fsm = service.Create<Enemy>(GetComponent<Enemy>());

        _fsm.Add(EnemyStates.Idle, new IdleState());
        _fsm.Add(EnemyStates.Chase, new ChaseState());
        _fsm.Add(EnemyStates.Dead, new DeadState());

        // Declarative transitions (preferred).
        _fsm.AddTransition(EnemyStates.Idle, EnemyStates.Chase, enemy => enemy.SeesPlayer);
        _fsm.AddTransition(EnemyStates.Chase, EnemyStates.Idle, enemy => !enemy.SeesPlayer);

        // Global interrupt: any state can go to Dead.
        _fsm.AddTransition(StateMachine<Enemy>.AnyState, EnemyStates.Dead, enemy => enemy.Health <= 0);

        _fsm.Start(EnemyStates.Idle);
    }

    private void Update()
    {
        // The machine reads no clock — you pass the delta, so you pick the clock.
        _fsm.Tick(_time.DeltaTime(Clock.Gameplay));
    }

    private void OnDestroy() => _fsm.Dispose();
}

关闭它

ServiceLocator.Replace<IStateMachineService>(new NullStateMachineService());

这会静默所有状态机:TickChangeStateStart 变成空操作,CurrentStateName 恒为 nullStateChanged 永不触发,ActiveMachineCount 为 0。适合在测试期间关闭 AI 或菜单流程而无需改动调用代码。空实现服务在构造时记录一条警告;之后的每次 Create<T> 调用保持静默。

常见陷阱

  • 仅限主线程。 TickChangeStateStart 都必须在主线程上运行。工作线程的调用在编辑器中抛出 InvalidOperationException(这些检查在 IL2CPP 发布构建中会被剥离)。让状态机留在帧循环里。
  • 时钟由你选。 状态机不读取任何时钟——deltaTime 由你传给 Tick。想要随游戏一起暂停的行为,传 timeService.DeltaTime(Clock.Gameplay)。想在暂停或慢动作中继续运转,传 timeService.UnscaledDeltaTime(Clock.Gameplay)。不存在单独的"unscaled"时钟:时钟只有 GameplayUIBackground 三个,每个都同时提供缩放与未缩放两种读数——见时间
  • 守卫要保持廉价。 转换守卫对每台状态机的每次 tick 都会运行。守卫里的昂贵操作——射线检测、寻路、内存分配——会按状态机数 × 转换数放大。请在调用 Tick 之前,把这类结果计算并缓存在你的上下文对象上。
  • 结构体上下文不会保留修改。 如果 TContextstruct,在 OnUpdate 里做的修改只作用于一份局部副本并会丢失。请使用引用类型——通常是拥有状态机的 MonoBehaviour 或实体——作为可变的共享上下文。
  • 结构体上下文与 IL2CPP。 如果 TContext 是值类型,请在项目中添加 link.xml 条目,使泛型实例化 StateMachine<YourStruct> 能在 IL2CPP 代码裁剪中幸存。类类型上下文不需要条目。

行为保证

  • 在状态钩子内部请求的 ChangeState 会在当前钩子结束后才生效。若请求了多次,以最后一次为准。状态机用循环而非递归来处理它们——转换链不可能造成栈溢出。
  • AnyState 全局中断总是先于当前状态自己的转换求值。
  • default(StateMachineOptions) 就是安全配置:警告开启、状态异常被兜住、转换到当前状态是空操作。
  • 这是一台扁平 FSM——没有内建的层级或状态堆叠。需要可堆叠、可恢复的游戏状态,见下推栈。需要回退栈式的菜单导航,请用 UI 框架中的 UI 面板栈。

相关页面