Unity UI 栈不该决定游戏流程:Root、Overlay 与状态机如何分权
状态先行
大厅从匹配返回主界面时,如果只是把 MainPanel 再次压栈,旧的 MatchingPanel 仍在历史里;下一次 Pop 的结果取决于玩家曾经打开过什么,而不是当前业务状态。真实实现先把流程收成五个状态,唯一写入口是 TryMove:
// client/Assets/HotUpdate/Game.Net/Lobby/LobbyFlow.cs · LobbyFlowStateMachine
public enum LobbyFlowState
{
Login,
Main,
Matching,
Battle,
Settle
}
public sealed class LobbyFlowStateMachine
{
public LobbyFlowState State { get; private set; } = LobbyFlowState.Login;
public bool TryMove(LobbyFlowState next)
{
if (!CanMove(State, next)) return false;
State = next;
return true;
}
static bool CanMove(LobbyFlowState current, LobbyFlowState next)
{
switch (current)
{
case LobbyFlowState.Login:
return next == LobbyFlowState.Main;
case LobbyFlowState.Main:
return next == LobbyFlowState.Matching || next == LobbyFlowState.Battle;
case LobbyFlowState.Matching:
return next == LobbyFlowState.Main || next == LobbyFlowState.Battle;
case LobbyFlowState.Battle:
return next == LobbyFlowState.Main || next == LobbyFlowState.Settle;
case LobbyFlowState.Settle:
return next == LobbyFlowState.Main;
default:
return false;
}
}
}

这里有两个不变量:Panel 不能绕过状态机宣布流程完成;非法迁移失败后,State 必须保持原值。状态机不持有 GameObject,也不解析网络连接,它只回答“这一步现在是否合法”。
页面分层
UIManager 管的是可见性。SetRoot 清空历史后建立新的流程根;Push/Pop 只留给设置页这类可逆局部导航;Overlay 独立显示,不改变业务状态。
// client/Assets/HotUpdate/Game.UI/UIManager.cs · SetRoot / Push / Pop / ShowOverlay
public T SetRoot<T>(object arg) where T : UiPanel
{
while (_stack.Count > 0)
{
UiPanel panel = _stack.Pop();
if (panel.gameObject.activeSelf)
{
panel.OnHide();
panel.gameObject.SetActive(false);
}
}
return Push<T>(arg);
}
public T ShowOverlay<T>(object arg) where T : UiPanel
{
T panel = (T)GetOrCreate(typeof(T).Name, typeof(T));
panel.gameObject.SetActive(true);
panel.transform.SetAsLastSibling();
panel.OnShow(arg);
return panel;
}
public void Pop()
{
if (_stack.Count == 0) return;
UiPanel top = _stack.Pop();
top.OnHide();
top.gameObject.SetActive(false);
if (_stack.Count == 0) return;
UiPanel next = _stack.Peek();
next.gameObject.SetActive(true);
next.OnShow(null);
}
把 Matching、Battle、Settle 都当作 Root,意味着取消匹配和结算返回不会暴露旧页面。设置页仍可 Push,因为关闭设置确实应该回到刚才的 Root。断线遮罩使用 ShowOverlay,因为断线时玩家仍处于 Battle,流程事实没有改变。
事件路由
网络层只产生事实,GameEntry 决定把事实交给哪个入口。match.found 最终调用 EnterBattle,而 EnterBattle 先迁移状态,再替换 Root;顺序反过来就会出现“画面已进战斗、流程仍在大厅”的半状态。
// GameEntry.cs::OnWsEvent + GameEntry.Battle.cs::EnterBattle
static void OnWsEvent(WsEvent evt)
{
if (evt.Event == "match.found")
{
BattleConnectionInfo info = BattleConnectionInfo.FromJson(evt.Data, true);
if (_matchingPanel != null)
_matchingPanel.CompleteMatch(info);
else
EnterBattle(info, false);
}
else if (evt.Event == "match.cancelled" && _matchingPanel != null)
{
_matchingPanel.StopFromServer("匹配已取消");
}
else if (evt.Event == "battle.settled")
{
ShowSettlement(SettleResult.FromJson(evt.Data));
}
}
static void EnterBattle(BattleConnectionInfo info, bool recovering)
{
string host; ushort port;
if (info == null || !info.TryGetEndpoint(out host, out port)) return;
if (!_flow.TryMove(LobbyFlowState.Battle)) return;
_matchingPanel = null;
_ui.SetRoot<BattleHud>(new BattleHudArgs());
// 随后才创建 BattleClient、重连控制器与逻辑输入。
}

失败窗口
现有测试锁住了正常闭环和非法迁移:完整路径最终回到 Main;Login → Battle 返回 false 且仍停在 Login。
// shared/GameNet.Tests/LobbyFlowTests.cs
[Test]
public void FullLobbyFlowReturnsToMain()
{
var flow = new LobbyFlowStateMachine();
Assert.That(flow.TryMove(LobbyFlowState.Main), Is.True);
Assert.That(flow.TryMove(LobbyFlowState.Matching), Is.True);
Assert.That(flow.TryMove(LobbyFlowState.Battle), Is.True);
Assert.That(flow.TryMove(LobbyFlowState.Settle), Is.True);
Assert.That(flow.TryMove(LobbyFlowState.Main), Is.True);
Assert.That(flow.State, Is.EqualTo(LobbyFlowState.Main));
}
[Test]
public void InvalidTransitionKeepsCurrentState()
{
var flow = new LobbyFlowStateMachine();
Assert.That(flow.TryMove(LobbyFlowState.Battle), Is.False);
Assert.That(flow.State, Is.EqualTo(LobbyFlowState.Login));
}
但状态机没有解决事件身份。代码允许 Main → Battle,这是登录后恢复已有对局所需;同一条边也会接受“取消匹配后迟到的 match.found”。当 _matchingPanel 已清空时,事件直接进入 EnterBattle。因此当前边界只能证明迁移合法,不能证明事件属于本轮匹配。恢复路径与实时匹配需要 room_id、匹配请求代次或独立恢复状态来消除歧义。
实测结果
当天执行使用当前测试程序集和当前编译产物。过滤参数被自定义 NUnitLite Runner 忽略,因此实际跑了整个 GameNet.Tests;输出按真实总数记录,没有把 49/49 写成仅 11 个大厅测试。
cd /Volumes/T7/proj/game/TankPlay
/usr/bin/time -p dotnet test shared/GameNet.Tests/GameNet.Tests.csproj \
--filter FullyQualifiedName~LobbyFlowTests --no-restore
/usr/bin/time -p bash tools/compilecheck/run.sh
dotnet run --project /Volumes/T7/write/blog.moyuta.com/articel/.tmp/ui-flow-probe/ui-flow-probe.csproj \
--no-restore --nologo
GameNet.Tests
Total tests: 49. Passed: 49. Failed: 0.
exit=0 real=0.62s
compilecheck
HybridCLR.Runtime PASS 0 YooAsset PASS 0
Launcher PASS 0 Game.Core PASS 0
Game.Proto PASS 0 Game.Play PASS 0
Game.Net PASS 0 Game.View PASS 0
Game.UI PASS 0 Game.Main PASS 0
exit=0 real=7.15s
normal
Login -> Main accepted=True state=Main
Main -> Matching accepted=True state=Matching
Matching -> Battle accepted=True state=Battle
Battle -> Settle accepted=True state=Settle
Settle -> Main accepted=True state=Main
cancel
Login -> Main accepted=True state=Main
Main -> Matching accepted=True state=Matching
Matching -> Main accepted=True state=Main
restore-or-late-event
Login -> Main accepted=True state=Main
Main -> Battle accepted=True state=Battle
invalid
Login -> Battle accepted=False state=Login
exit=0 real=2.30s
这些时间只证明命令在本机完成,不是性能基准。compilecheck 证明十个托管程序集在 Unity 2022.3 API 表面下为 0 error;它不证明场景序列化、资源加载、IL2CPP 或真机行为。
架构取舍
| 方案 | 得到什么 | 失去什么 |
|---|---|---|
| 所有页面统一 Push | 实现最少 | Back 由历史偶然决定,Root 无法收敛 |
| 状态机 + SetRoot | 迁移可测,流程页无残留 | 必须维护状态与页面映射 |
| Overlay 独立 | 断线、加载不污染流程 | Overlay 自己需要互斥和关闭纪律 |
这套实现没有引入通用导航框架,代价是 GameEntry 仍承担路由。当前只有一个大厅主流程时,这是可接受的集中点;为了“解耦”再加事件总线,只会把一次可追踪调用拆成多次隐式订阅。
演进条件
当日志出现取消后仍进入旧房间、跨场景返回错误 Root,或同时存在组队、匹配、活动三条并行流程时,单状态枚举才到上限。下一步不是扩大页面栈,而是给网络事实增加代次身份,把恢复战斗拆成显式事件,并按流程域建立独立状态机。
最终判断很简单:状态机决定“现在是什么”,UIManager 决定“现在看见什么”,GameEntry 只负责把真实事件接到两者之间。页面栈可以记住局部导航历史,但不能成为游戏流程的数据库。