路线阶段:Unity 入门实战第 11 章。
本章目标:把关卡生命周期收敛为明确状态机,解决“流程写散后难维护”的核心问题。
学习目标
完成本章后,你应该能做到:
- 定义清晰的关卡流程状态与合法转移。
- 将加载、UI、输入开关、战斗系统启停纳入同一主控。
- 支持暂停/恢复/失败重开而不污染运行时状态。
- 用日志和事件还原每次状态变更轨迹。
背景问题
没有流程状态机时,项目通常会出现:
- UI 先进入结算,但战斗还在跑。
- 重开关卡后上次事件订阅没清理。
- 加载中仍可输入导致异常命令入队。
- 不同脚本都能切场景,顺序不可控。
状态定义
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;
与前面章节联动
- 资源加载章节:Loading/Prewarm 状态复用
IAssetService。 - UI章节:不同流程状态激活不同面板。
- 波次系统:Battle 状态进入时启动,退出时停用。
- 存档系统:Result 状态写入进度。
WebGL 兼容注意点
- 状态切换中尽量避免阻塞主线程。
- 加载页必须能处理浏览器焦点丢失/恢复。
- Pause 在 WebGL 下可由页面失焦自动触发。
验收清单
- 从菜单到战斗到结算完整闭环可重复执行。
- 暂停恢复后战斗逻辑与输入门控正确。
- 重开关卡不会残留上轮事件与对象状态。
- Flow 日志可完整还原状态变化链路。
常见坑
坑 1:状态间共享临时变量未重置
会导致第二次进入状态出现脏数据。每个 Enter 需显式初始化。
坑 2:允许任意状态跳转到任意状态
流程会失控。应在桥接层限制合法转移。
坑 3:UI 打开关闭放在多个脚本
最终难以维护。统一由状态节点控制。
本月作业
实现“失败重开与继续挑战”双路径:
- 失败后可立即重开当前关卡。
- 胜利后可进入下一关并保留必要进度。
- 两条路径都通过状态机事件流转完成。
下一章进入 Unity 入门实战 12:敌人 AI 行为树化(从简单 FSM 过渡到可配置行为节点)。