Article

Unity 入门实战 11:关卡流程状态机(菜单/加载/战斗/结算一体化)

路线阶段:Unity 入门实战第 11 章。
本章目标:把关卡生命周期收敛为明确状态机,解决“流程写散后难维护”的核心问题。

学习目标

完成本章后,你应该能做到:

  1. 定义清晰的关卡流程状态与合法转移。
  2. 将加载、UI、输入开关、战斗系统启停纳入同一主控。
  3. 支持暂停/恢复/失败重开而不污染运行时状态。
  4. 用日志和事件还原每次状态变更轨迹。

背景问题

没有流程状态机时,项目通常会出现:

  1. UI 先进入结算,但战斗还在跑。
  2. 重开关卡后上次事件订阅没清理。
  3. 加载中仍可输入导致异常命令入队。
  4. 不同脚本都能切场景,顺序不可控。

状态定义

public enum LevelFlowState
{
    None = 0,
    Menu = 1,
    Loading = 2,
    Prewarm = 3,
    Battle = 4,
    Paused = 5,
    Result = 6,
    Unloading = 7
}

状态机接口

public interface ILevelFlowStateNode
{
    LevelFlowState State { get; }
    void Enter(LevelFlowContext ctx);
    void Tick(LevelFlowContext ctx, float dt);
    void Exit(LevelFlowContext ctx);
}
public sealed class LevelFlowContext
{
    public readonly EventBus EventBus;
    public readonly UIRootManager UI;
    public readonly InputGate InputGate;
    public readonly WaveDirector Wave;
    public readonly IAssetService Assets;
    public readonly SceneLoader Scenes;

    public bool BattleWin;
    public bool BattleLose;

    public LevelFlowContext(
        EventBus eventBus,
        UIRootManager ui,
        InputGate inputGate,
        WaveDirector wave,
        IAssetService assets,
        SceneLoader scenes)
    {
        EventBus = eventBus;
        UI = ui;
        InputGate = inputGate;
        Wave = wave;
        Assets = assets;
        Scenes = scenes;
    }
}

主控状态机

public sealed class LevelFlowMachine : IUpdatable
{
    public int Order { get { return 20; } }

    private readonly Dictionary<LevelFlowState, ILevelFlowStateNode> _nodes;
    private readonly LevelFlowContext _ctx;
    private ILevelFlowStateNode _current;

    public LevelFlowMachine(LevelFlowContext ctx, List<ILevelFlowStateNode> nodes)
    {
        _ctx = ctx;
        _nodes = new Dictionary<LevelFlowState, ILevelFlowStateNode>(nodes.Count);

        for (var i = 0; i < nodes.Count; i++)
        {
            _nodes[nodes[i].State] = nodes[i];
        }
    }

    public LevelFlowState Current
    {
        get { return _current == null ? LevelFlowState.None : _current.State; }
    }

    public void Start(LevelFlowState initial)
    {
        Change(initial, "boot");
    }

    public void Tick(float dt, float unscaledDt)
    {
        if (_current == null)
        {
            return;
        }

        _current.Tick(_ctx, dt);
    }

    public void Change(LevelFlowState next, string reason)
    {
        ILevelFlowStateNode node;
        if (!_nodes.TryGetValue(next, out node))
        {
            FoundationLog.Error("Flow", "state_missing " + next);
            return;
        }

        var from = _current == null ? LevelFlowState.None : _current.State;

        if (_current != null)
        {
            _current.Exit(_ctx);
        }

        _current = node;
        _current.Enter(_ctx);

        FoundationLog.Info("Flow", "change from=" + from + " to=" + next + " reason=" + reason);
        _ctx.EventBus.Publish("LevelFlowChanged", new FlowChangedPayload(from, next, reason));
    }
}

public struct FlowChangedPayload
{
    public LevelFlowState From;
    public LevelFlowState To;
    public string Reason;

    public FlowChangedPayload(LevelFlowState from, LevelFlowState to, string reason)
    {
        From = from;
        To = to;
        Reason = reason;
    }
}

核心状态实现

LoadingState

public sealed class LoadingState : ILevelFlowStateNode
{
    public LevelFlowState State { get { return LevelFlowState.Loading; } }

    private bool _done;

    public void Enter(LevelFlowContext ctx)
    {
        _done = false;
        ctx.InputGate.SetGameplayInput(false);
        ctx.UI.OpenLoading();

        CoroutineRunner.Instance.Run(LoadRoutine(ctx));
    }

    public void Tick(LevelFlowContext ctx, float dt)
    {
        if (_done)
        {
            ctx.EventBus.Publish("FlowRequest", LevelFlowState.Prewarm);
        }
    }

    public void Exit(LevelFlowContext ctx)
    {
        ctx.UI.CloseLoading();
    }

    private IEnumerator LoadRoutine(LevelFlowContext ctx)
    {
        yield return ctx.Assets.LoadGroupAsync("battle_core");
        yield return ctx.Scenes.LoadAsync("Main");
        _done = true;
    }
}

PrewarmState

public sealed class PrewarmState : ILevelFlowStateNode
{
    public LevelFlowState State { get { return LevelFlowState.Prewarm; } }

    private bool _done;

    public void Enter(LevelFlowContext ctx)
    {
        _done = false;
        CoroutineRunner.Instance.Run(PrewarmRoutine(ctx));
    }

    public void Tick(LevelFlowContext ctx, float dt)
    {
        if (_done)
        {
            ctx.EventBus.Publish("FlowRequest", LevelFlowState.Battle);
        }
    }

    public void Exit(LevelFlowContext ctx) { }

    private IEnumerator PrewarmRoutine(LevelFlowContext ctx)
    {
        yield return ctx.Assets.PrewarmAsync("battle_core");
        _done = true;
    }
}

BattleState

public sealed class BattleState : ILevelFlowStateNode
{
    public LevelFlowState State { get { return LevelFlowState.Battle; } }

    public void Enter(LevelFlowContext ctx)
    {
        ctx.BattleWin = false;
        ctx.BattleLose = false;

        ctx.InputGate.SetGameplayInput(true);
        ctx.UI.OpenBattleHud();
        ctx.Wave.StartBattle();
    }

    public void Tick(LevelFlowContext ctx, float dt)
    {
        if (ctx.BattleWin)
        {
            ctx.EventBus.Publish("FlowRequest", LevelFlowState.Result);
            return;
        }

        if (ctx.BattleLose)
        {
            ctx.EventBus.Publish("FlowRequest", LevelFlowState.Result);
            return;
        }
    }

    public void Exit(LevelFlowContext ctx)
    {
        ctx.InputGate.SetGameplayInput(false);
        ctx.UI.CloseBattleHud();
    }
}

PausedState

public sealed class PausedState : ILevelFlowStateNode
{
    public LevelFlowState State { get { return LevelFlowState.Paused; } }

    public void Enter(LevelFlowContext ctx)
    {
        Time.timeScale = 0f;
        ctx.UI.OpenPause();
    }

    public void Tick(LevelFlowContext ctx, float dt) { }

    public void Exit(LevelFlowContext ctx)
    {
        ctx.UI.ClosePause();
        Time.timeScale = 1f;
    }
}

ResultState

public sealed class ResultState : ILevelFlowStateNode
{
    public LevelFlowState State { get { return LevelFlowState.Result; } }

    public void Enter(LevelFlowContext ctx)
    {
        ctx.UI.OpenResult(ctx.BattleWin);
        ctx.EventBus.Publish("ResultShown", ctx.BattleWin);
    }

    public void Tick(LevelFlowContext ctx, float dt) { }

    public void Exit(LevelFlowContext ctx)
    {
        ctx.UI.CloseResult();
    }
}

FlowRequest 机制

避免任何模块直接拿 LevelFlowMachine.Change

public sealed class FlowRequestBridge : IDisposable
{
    private readonly EventBus _eventBus;
    private readonly LevelFlowMachine _flow;

    public FlowRequestBridge(EventBus eventBus, LevelFlowMachine flow)
    {
        _eventBus = eventBus;
        _flow = flow;

        _eventBus.Subscribe("FlowRequest", OnFlowRequest);
    }

    public void Dispose()
    {
        _eventBus.Unsubscribe("FlowRequest", OnFlowRequest);
    }

    private void OnFlowRequest(object payload)
    {
        var target = (LevelFlowState)payload;
        _flow.Change(target, "event_request");
    }
}

输入门控

public sealed class InputGate
{
    public bool GameplayEnabled { get; private set; }
    public bool UIEnabled { get; private set; }

    public void SetGameplayInput(bool enabled)
    {
        GameplayEnabled = enabled;
    }

    public void SetUIInput(bool enabled)
    {
        UIEnabled = enabled;
    }
}

在控制器中统一判断:

if (!inputGate.GameplayEnabled) return;

与前面章节联动

  1. 资源加载章节:Loading/Prewarm 状态复用 IAssetService
  2. UI章节:不同流程状态激活不同面板。
  3. 波次系统:Battle 状态进入时启动,退出时停用。
  4. 存档系统:Result 状态写入进度。

WebGL 兼容注意点

  1. 状态切换中尽量避免阻塞主线程。
  2. 加载页必须能处理浏览器焦点丢失/恢复。
  3. Pause 在 WebGL 下可由页面失焦自动触发。

验收清单

  1. 从菜单到战斗到结算完整闭环可重复执行。
  2. 暂停恢复后战斗逻辑与输入门控正确。
  3. 重开关卡不会残留上轮事件与对象状态。
  4. Flow 日志可完整还原状态变化链路。

常见坑

坑 1:状态间共享临时变量未重置

会导致第二次进入状态出现脏数据。每个 Enter 需显式初始化。

坑 2:允许任意状态跳转到任意状态

流程会失控。应在桥接层限制合法转移。

坑 3:UI 打开关闭放在多个脚本

最终难以维护。统一由状态节点控制。

本月作业

实现“失败重开与继续挑战”双路径:

  1. 失败后可立即重开当前关卡。
  2. 胜利后可进入下一关并保留必要进度。
  3. 两条路径都通过状态机事件流转完成。

下一章进入 Unity 入门实战 12:敌人 AI 行为树化(从简单 FSM 过渡到可配置行为节点)。