FSM(状态机)
由你自行驱动的确定性逐对象状态机——命名状态、带守卫的转换与全局中断,纯 C# 实现。
给每个敌人、菜单和游戏阶段一组干净的命名状态——让声明式的守卫转换来完成切换。
| 接口 | IStateMachineService — 用于创建 IStateMachine<TContext> 实例的工厂 |
| 关闭开关 | NullStateMachineService |
| 程序集 | CommonGameSystem.Core |
| 启动 | 启动时自动注册(23 个服务之一)——无需任何设置 |
它做什么
有限状态机(FSM)把行为组织成一组命名状态——例如敌人的 Idle、Chase 和 Dead——并配上在状态之间迁移的规则。这个服务提供的是一个扁平的、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 或实体。
| 成员 | 签名 | 说明 |
|---|---|---|
CurrentStateName | string(属性) | 当前状态的名称;Start 之前为 null。 |
IsRunning | bool(属性) | Start 之后为 true,Dispose 之后为 false。 |
Add | void Add(string name, IState<TContext> state) | 注册一个状态。用 public const string 定义状态名,以便在编译期抓住拼写错误。 |
AddTransition | void AddTransition(string from, string to, Func<TContext, bool> guard) | 注册一个带守卫的转换。把 StateMachine<TContext>.AnyState 用作 from 即为全局中断;它们会被优先求值。守卫要保持廉价且无副作用——不做射线检测,不做修改。 |
Start | void Start(string name) | 进入指定名称的状态(调用 OnEnter)。必须在 Tick 之前调用一次。不会触发 StateChanged。 |
ChangeState | void ChangeState(string name) | 立即转换:旧状态执行 OnExit,新状态执行 OnEnter,然后触发 StateChanged。状态名未知时是安全的空操作。 |
Tick | void Tick(float deltaTime) | 前进一步:先调用 OnUpdate(deltaTime),再对声明式转换求值。增量时间由你传入,所以时钟由你选。 |
StateChanged | event Action<string, string> | 在转换的 OnEnter 之后触发,参数为 (fromName, toName)。在 Start 时不会触发。 |
Dispose | void Dispose() | 干净地关停:对当前状态调用一次 OnExit,然后清空各表。重复调用是安全的。 |
IState<TContext> — 由你实现的接口
实现这个接口,或继承 StateBase<TContext> 并只重写你需要的部分:
| 成员 | 签名 | 说明 |
|---|---|---|
OnEnter | void OnEnter(TContext ctx) | 状态机进入该状态时调用一次。 |
OnUpdate | void OnUpdate(TContext ctx, float deltaTime) | 该状态为当前状态期间每次 tick 调用。可以调用 ChangeState。 |
OnExit | void OnExit(TContext ctx) | 状态机离开该状态时调用一次。 |
IStateMachineService — 工厂
| 成员 | 签名 | 说明 |
|---|---|---|
Create<TContext> | IStateMachine<TContext> Create<TContext>(TContext context, StateMachineOptions options = default) | 返回一台新的逐实例状态机。经工厂创建的状态机会被计入 ActiveMachineCount。 |
ActiveMachineCount | int(属性) | 尚未释放的工厂创建状态机数量。直接用 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());
这会静默所有状态机:Tick、ChangeState 和 Start 变成空操作,CurrentStateName 恒为 null,StateChanged 永不触发,ActiveMachineCount 为 0。适合在测试期间关闭 AI 或菜单流程而无需改动调用代码。空实现服务在构造时记录一条警告;之后的每次 Create<T> 调用保持静默。
常见陷阱
- 仅限主线程。
Tick、ChangeState和Start都必须在主线程上运行。工作线程的调用在编辑器中抛出InvalidOperationException(这些检查在 IL2CPP 发布构建中会被剥离)。让状态机留在帧循环里。 - 时钟由你选。 状态机不读取任何时钟——
deltaTime由你传给Tick。想要随游戏一起暂停的行为,传timeService.DeltaTime(Clock.Gameplay)。想在暂停或慢动作中继续运转,传timeService.UnscaledDeltaTime(Clock.Gameplay)。不存在单独的"unscaled"时钟:时钟只有Gameplay、UI和Background三个,每个都同时提供缩放与未缩放两种读数——见时间。 - 守卫要保持廉价。 转换守卫对每台状态机的每次 tick 都会运行。守卫里的昂贵操作——射线检测、寻路、内存分配——会按状态机数 × 转换数放大。请在调用
Tick之前,把这类结果计算并缓存在你的上下文对象上。 - 结构体上下文不会保留修改。 如果
TContext是struct,在OnUpdate里做的修改只作用于一份局部副本并会丢失。请使用引用类型——通常是拥有状态机的 MonoBehaviour 或实体——作为可变的共享上下文。 - 结构体上下文与 IL2CPP。 如果
TContext是值类型,请在项目中添加link.xml条目,使泛型实例化StateMachine<YourStruct>能在 IL2CPP 代码裁剪中幸存。类类型上下文不需要条目。
行为保证
- 在状态钩子内部请求的
ChangeState会在当前钩子结束后才生效。若请求了多次,以最后一次为准。状态机用循环而非递归来处理它们——转换链不可能造成栈溢出。 AnyState全局中断总是先于当前状态自己的转换求值。default(StateMachineOptions)就是安全配置:警告开启、状态异常被兜住、转换到当前状态是空操作。- 这是一台扁平 FSM——没有内建的层级或状态堆叠。需要可堆叠、可恢复的游戏状态,见下推栈。需要回退栈式的菜单导航,请用 UI 框架中的 UI 面板栈。