状态机
状态机适合处理“对象会在几个状态之间切换”的逻辑。比如建筑从关机到待机、工作、收尾;植物从生长到结果、枯萎、收获;生物从闲逛到进食、睡觉、逃跑。
如果只是每秒检查一次数值,用 ISim1000ms 就够了。只有当逻辑开始出现“当前阶段不同,能做的事也不同”时,状态机才值得上。
这一章用建筑控制器作为主例,并对照原版 AirFilter、ActiveController 这类常见写法。
两种常见写法
第一种是组件自带状态机:
public class MyMachine : StateMachineComponent<MyMachine.StatesInstance>
{
}这种最常用。组件挂到建筑、物品、植物或生物上,OnSpawn() 里启动,OnCleanUp() 里停止。
第二种是独立控制器:
public class MyController : GameStateMachine<MyController, MyController.Instance>
{
}原版 ActiveController 就是这种。它更像一个可复用控制器,常用于“只根据 Operational 播放动画”这类通用逻辑。
入门建议先用第一种,因为它能直接访问 master 组件,写起来直观。
基础骨架
先做一个建筑状态机:没电时关闭,有电时待机,满足条件后工作,工作结束再回到待机。
using KSerialization;
using UnityEngine;
namespace MyFirstStateMachine
{
[SerializationConfig(MemberSerialization.OptIn)]
public class MyBuildingController
: StateMachineComponent<MyBuildingController.StatesInstance>
{
[MyCmpGet]
private Operational operational;
[MyCmpGet]
private EnergyConsumer energyConsumer;
[Serialize]
private bool hasWork;
protected override void OnSpawn()
{
base.OnSpawn();
smi.StartSM();
}
protected override void OnCleanUp()
{
smi.StopSM("OnCleanUp");
base.OnCleanUp();
}
public bool HasWork()
{
return hasWork;
}
}
}StateMachineComponent<T> 会帮你保存一个 smi。它不是随便起的变量名,可以理解成“这个对象上的状态机实例”。
写 States
状态本身写在内部类 States 里:
public class States : GameStateMachine<States, StatesInstance, MyBuildingController>
{
public State off;
public ReadyStates on;
public override void InitializeStates(out StateMachine.BaseState defaultState)
{
defaultState = off;
root.DoNothing();
off
.PlayAnim("off")
.Enter(smi => smi.master.operational.SetActive(false))
.Transition(
on,
smi => smi.master.energyConsumer.IsPowered,
UpdateRate.SIM_200ms);
on
.DefaultState(on.idle)
.Transition(
off,
smi => !smi.master.energyConsumer.IsPowered,
UpdateRate.SIM_200ms);
on.idle
.PlayAnim("on")
.Enter(smi => smi.master.operational.SetActive(false))
.Update((smi, dt) => smi.FindWork(), UpdateRate.SIM_1000ms)
.Transition(
on.working,
smi => smi.master.HasWork(),
UpdateRate.SIM_200ms);
on.working
.PlayAnim("working_loop", KAnim.PlayMode.Loop)
.Enter(smi => smi.master.operational.SetActive(true))
.ScheduleGoTo(3f, on.finish);
on.finish
.PlayAnim("working_pst")
.Enter(smi => smi.FinishWork())
.OnAnimQueueComplete(on.idle);
}
public class ReadyStates : State
{
public State idle;
public State working;
public State finish;
}
}这段代码里有几个核心点:
defaultState = off:状态机启动后先进哪个状态。off、on、on.idle:这些字段不是普通变量,而是状态节点。DefaultState(on.idle):进入on这个父状态后,默认进入子状态idle。Transition():按条件跳转,适合轮询电力、储存、温度这种状态。ScheduleGoTo():停留一段时间后跳转,适合播放动作或等待加工完成。OnAnimQueueComplete():动画播完后跳转,适合pre、pst动画。
写 StatesInstance
StatesInstance 放运行时数据和辅助方法。不要把大堆业务逻辑挤在 InitializeStates() 里。
public class StatesInstance
: GameStateMachine<States, StatesInstance, MyBuildingController, object>.GameInstance
{
private float nextScanTime;
public StatesInstance(MyBuildingController master)
: base(master)
{
nextScanTime = Time.time;
}
public void FindWork()
{
if (Time.time < nextScanTime)
{
return;
}
nextScanTime = Time.time + 1f;
// 在这里写扫描逻辑。
// 比如检查附近植物、检查储存、检查目标温度。
}
public void FinishWork()
{
// 在这里写工作完成后的结算。
// 比如生成物品、扣材料、刷新 UI。
}
}master 指向挂载状态机的组件,也就是外层的 MyBuildingController。要拿同一个对象上的组件,可以用 master.GetComponent<T>(),也可以在外层用 [MyCmpGet] 先拿好。
事件跳转
有些状态不适合一直轮询。比如储存变化、开关变化、建筑可用性变化,原版常用 EventTransition()。
AirFilter.cs 里就有类似写法:过滤介质变化或建筑可用性变化时,再判断能不能进入工作状态。
waiting
.EventTransition(
GameHashes.OnStorageChange,
hasFilter,
smi => smi.master.HasFilter() && smi.master.operational.IsOperational)
.EventTransition(
GameHashes.OperationalChanged,
hasFilter,
smi => smi.master.HasFilter() && smi.master.operational.IsOperational);如果条件只有在某个事件发生后才需要检查,用 EventTransition() 会比 Transition(..., UpdateRate.SIM_200ms) 更干净。
Enter 和 Exit
Enter() 是进入状态时做一次,Exit() 是离开状态时做一次。最常见的用途是开关消耗、UI 状态、动画、标签。
on.working
.Enter(smi => smi.master.operational.SetActive(true))
.Exit(smi => smi.master.operational.SetActive(false));原版 AirFilter 在进入有滤芯状态时启用消耗,离开时关闭消耗:
hasFilter
.Enter(smi => smi.master.elementConsumer.EnableConsumption(true))
.Exit(smi => smi.master.elementConsumer.EnableConsumption(false));这类资源开关一定要成对写。只在 Enter() 打开,却忘了在 Exit() 关闭,很容易出现建筑停了但还在耗资源的问题。
状态机接到建筑上
状态机组件要在建筑配置里加进去:
public override void ConfigureBuildingTemplate(GameObject go, Tag prefabTag)
{
go.AddOrGet<MyBuildingController>();
}
public override void DoPostConfigureComplete(GameObject go)
{
go.AddOrGet<Operational>();
go.AddOrGet<EnergyConsumer>();
}如果状态机里用了 [MyCmpGet] private EnergyConsumer energyConsumer;,那这个组件必须真的存在。缺组件时,游戏通常会在加载或生成对象时报错。
参数和复杂状态
状态机可以保存参数,比如 BoolParameter、IntParameter、TargetParameter。原版的医生站、攻击工作、门控制器都大量使用参数。
入门时不用急着上参数。能用外层组件字段表达的,就先放在组件里:
[Serialize]
private bool hasWork;等逻辑变复杂,比如多个外部系统都要推动状态变化,再考虑状态机参数:
public StateMachine<States, StatesInstance, MyBuildingController, object>.BoolParameter hasTarget;参数的好处是可以用 ParamTransition() 精确响应变化;坏处是代码可读性会下降。别为了“看起来像原版”就把简单逻辑写复杂。
调试方法
状态机问题一般看三件事:
- 有没有启动:
OnSpawn()是否调用了smi.StartSM()。 - 有没有停掉:
OnCleanUp()是否调用了smi.StopSM("OnCleanUp")。 - 条件会不会变化:
Transition()的条件是不是永远 false。
调试时可以先在 Enter() 里打日志:
off.Enter(smi => Debug.Log("[MyBuildingController] enter off"));
on.idle.Enter(smi => Debug.Log("[MyBuildingController] enter idle"));
on.working.Enter(smi => Debug.Log("[MyBuildingController] enter working"));确认状态跳转没问题以后,再删掉日志。状态机本身不难,难的是条件太多以后不知道卡在哪一步。
常见坑
不要在 InitializeStates() 里直接访问场景对象。这个方法是在定义状态图,不是在运行某个建筑实例。需要访问对象时,用 smi.master。
不要在 Update() 里做很重的扫描。附近实体搜索、全图查找、路径判断都要限频,可以用 nextScanTime 之类的字段控制扫描间隔。
不要忘记 Operational.SetActive(false)。很多建筑的动画、耗电、状态灯都依赖它。
不要把状态写得太碎。off -> idle -> working -> finish 已经能覆盖大多数入门建筑。等逻辑真的分叉了,再加父状态或子状态。