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;
        }
    }
}

大厅 Root、局部页面和 Overlay 的分层主视觉

这里有两个不变量: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、重连控制器与逻辑输入。
}

GameEntry、LobbyFlowStateMachine 与 UIManager 的所有权和时序

失败窗口

现有测试锁住了正常闭环和非法迁移:完整路径最终回到 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 只负责把真实事件接到两者之间。页面栈可以记住局部导航历史,但不能成为游戏流程的数据库。