Skip to content

状态机

状态机适合处理“对象会在几个状态之间切换”的逻辑。比如建筑从关机到待机、工作、收尾;植物从生长到结果、枯萎、收获;生物从闲逛到进食、睡觉、逃跑。

如果只是每秒检查一次数值,用 ISim1000ms 就够了。只有当逻辑开始出现“当前阶段不同,能做的事也不同”时,状态机才值得上。

这一章用建筑控制器作为主例,并对照原版 AirFilterActiveController 这类常见写法。

两种常见写法

第一种是组件自带状态机:

csharp
public class MyMachine : StateMachineComponent<MyMachine.StatesInstance>
{
}

这种最常用。组件挂到建筑、物品、植物或生物上,OnSpawn() 里启动,OnCleanUp() 里停止。

第二种是独立控制器:

csharp
public class MyController : GameStateMachine<MyController, MyController.Instance>
{
}

原版 ActiveController 就是这种。它更像一个可复用控制器,常用于“只根据 Operational 播放动画”这类通用逻辑。

入门建议先用第一种,因为它能直接访问 master 组件,写起来直观。

基础骨架

先做一个建筑状态机:没电时关闭,有电时待机,满足条件后工作,工作结束再回到待机。

csharp
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 里:

csharp
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:状态机启动后先进哪个状态。
  • offonon.idle:这些字段不是普通变量,而是状态节点。
  • DefaultState(on.idle):进入 on 这个父状态后,默认进入子状态 idle
  • Transition():按条件跳转,适合轮询电力、储存、温度这种状态。
  • ScheduleGoTo():停留一段时间后跳转,适合播放动作或等待加工完成。
  • OnAnimQueueComplete():动画播完后跳转,适合 prepst 动画。

写 StatesInstance

StatesInstance 放运行时数据和辅助方法。不要把大堆业务逻辑挤在 InitializeStates() 里。

csharp
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 里就有类似写法:过滤介质变化或建筑可用性变化时,再判断能不能进入工作状态。

csharp
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 状态、动画、标签。

csharp
on.working
    .Enter(smi => smi.master.operational.SetActive(true))
    .Exit(smi => smi.master.operational.SetActive(false));

原版 AirFilter 在进入有滤芯状态时启用消耗,离开时关闭消耗:

csharp
hasFilter
    .Enter(smi => smi.master.elementConsumer.EnableConsumption(true))
    .Exit(smi => smi.master.elementConsumer.EnableConsumption(false));

这类资源开关一定要成对写。只在 Enter() 打开,却忘了在 Exit() 关闭,很容易出现建筑停了但还在耗资源的问题。

状态机接到建筑上

状态机组件要在建筑配置里加进去:

csharp
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;,那这个组件必须真的存在。缺组件时,游戏通常会在加载或生成对象时报错。

参数和复杂状态

状态机可以保存参数,比如 BoolParameterIntParameterTargetParameter。原版的医生站、攻击工作、门控制器都大量使用参数。

入门时不用急着上参数。能用外层组件字段表达的,就先放在组件里:

csharp
[Serialize]
private bool hasWork;

等逻辑变复杂,比如多个外部系统都要推动状态变化,再考虑状态机参数:

csharp
public StateMachine<States, StatesInstance, MyBuildingController, object>.BoolParameter hasTarget;

参数的好处是可以用 ParamTransition() 精确响应变化;坏处是代码可读性会下降。别为了“看起来像原版”就把简单逻辑写复杂。

调试方法

状态机问题一般看三件事:

  • 有没有启动:OnSpawn() 是否调用了 smi.StartSM()
  • 有没有停掉:OnCleanUp() 是否调用了 smi.StopSM("OnCleanUp")
  • 条件会不会变化:Transition() 的条件是不是永远 false。

调试时可以先在 Enter() 里打日志:

csharp
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 已经能覆盖大多数入门建筑。等逻辑真的分叉了,再加父状态或子状态。