From 6a5e534d00e9b9a7eb819b8261ae9028ec1dcd01 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Wed, 29 Jul 2026 15:26:54 +0800 Subject: [PATCH] feat: isolate lobby during minigames Co-Authored-By: Claude Opus 4.8 --- .../Assets/Doc/MiniGameDevelopmentDesign.md | 24 +- .../EditMode/MiniGameStageIsolationTests.cs | 113 +++ .../MiniGameStageIsolationTests.cs.meta | 11 + .../Script/xmain/MiniGame/MiniGameHost.cs | 53 +- .../xmain/MiniGame/MiniGameStageIsolation.cs | 210 +++++ .../MiniGame/MiniGameStageIsolation.cs.meta | 11 + .../xmain/MiniGame/PcLobbySmokeLauncher.cs | 32 + ...26-07-29-minigame-lobby-stage-isolation.md | 768 ++++++++++++++++++ ...9-minigame-lobby-stage-isolation-design.md | 194 +++++ 9 files changed, 1408 insertions(+), 8 deletions(-) create mode 100644 Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs create mode 100644 Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta create mode 100644 Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs create mode 100644 Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta create mode 100644 docs/superpowers/plans/2026-07-29-minigame-lobby-stage-isolation.md create mode 100644 docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md diff --git a/Client/Assets/Doc/MiniGameDevelopmentDesign.md b/Client/Assets/Doc/MiniGameDevelopmentDesign.md index 9caf9c0e..9ab168a5 100644 --- a/Client/Assets/Doc/MiniGameDevelopmentDesign.md +++ b/Client/Assets/Doc/MiniGameDevelopmentDesign.md @@ -614,7 +614,23 @@ Core 不引用 UnityEngine,不访问 UI,不访问 socket,不读写 Unity 4. 本地 smoke 进入小游戏。 5. 验证异常路径:CDN 缺文件、版本不存在、资源缺失、服务端断开、重复退出。 -## 12. 异常处理与兜底 +## 12. 大厅与小游戏阶段隔离 + +进入小游戏后,大厅必须暂停并隐藏,直到小游戏退出后再恢复。大厅与小游戏是互斥阶段,不允许大厅 UI、输入、场景单位渲染与小游戏同时处于可操作状态。 + +隔离由 `MiniGameStageIsolation` 负责,调用方在进入小游戏前调用 `SuspendLobbyForMiniGame()`,在 `MiniGameHost.OnGameExited` 中调用 `ResumeLobbyAfterMiniGame()`。`MiniGameHost` 只负责小游戏生命周期,不直接依赖大厅对象。 + +隔离范围包括: + +- 大厅操作 UI 根节点:隐藏并在恢复时还原原始 active 状态。 +- 大厅场景/单位/特效根节点:隐藏并停止渲染。 +- 大厅输入脚本:禁用,避免小游戏期间响应大厅点击、摇杆或快捷键。 +- 大厅 Tick/表现脚本:禁用,避免后台继续驱动大厅单位。 +- 需要保持 active 的 CanvasGroup:关闭交互和射线阻挡。 + +进入失败也必须恢复大厅。下载失败、校验失败、DLL 加载失败或热更客户端 `OnEnter` 抛异常时,`MiniGameHost` 会触发 `OnGameExited`,调用方应统一在该回调中恢复大厅。 + +## 13. 异常处理与兜底 | 场景 | 处理策略 | |---|---| @@ -631,7 +647,7 @@ Core 不引用 UnityEngine,不访问 UI,不访问 socket,不读写 Unity | 结算 Prefab 缺失或结构不符 | 宿主回退代码构建简易结算窗。 | | 玩家重复退出/重复结算 | 宿主通过 `_running`、`_waitingForResultConfirm` 防重入。 | -## 13. 开发红线 +## 14. 开发红线 1. 小游戏专属逻辑必须使用 C#,不要新增 Lua 小游戏逻辑。 2. 新资源加载必须异步,不要使用同步 `XResLoader.LoadRes` / `LoadResAB`。 @@ -644,7 +660,7 @@ Core 不引用 UnityEngine,不访问 UI,不访问 socket,不读写 Unity 9. 发布旧版本时不要只覆盖部分平台,除非明确确认平台与服务端兼容。 10. 签名、MD5、框架版本校验失败时不要降级加载旧包。 -## 14. 验收清单 +## 15. 验收清单 新增小游戏合入前至少验证: @@ -662,7 +678,7 @@ Core 不引用 UnityEngine,不访问 UI,不访问 socket,不读写 Unity - 客户端能显示结算并退出回大厅。 - CDN 缺文件、入口类型错误、资源缺失等异常不会卡死。 -## 15. 建议开发拆分 +## 16. 建议开发拆分 第一阶段:最小闭环 diff --git a/Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs b/Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs new file mode 100644 index 00000000..d3d68eb6 --- /dev/null +++ b/Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs @@ -0,0 +1,113 @@ +using NUnit.Framework; +using UnityEngine; +using XGame.MiniGame; + +public sealed class MiniGameStageIsolationTests +{ + private GameObject _root; + + private sealed class TestBehaviour : MonoBehaviour + { + } + + [TearDown] + public void TearDown() + { + if (_root != null) + { + Object.DestroyImmediate(_root); + _root = null; + } + } + + [Test] + public void SuspendAndResume_RestoresOriginalActiveAndEnabledStates() + { + _root = new GameObject("root"); + GameObject uiActive = new GameObject("ui-active"); + GameObject uiInactive = new GameObject("ui-inactive"); + GameObject sceneRoot = new GameObject("scene-root"); + uiActive.transform.SetParent(_root.transform); + uiInactive.transform.SetParent(_root.transform); + sceneRoot.transform.SetParent(_root.transform); + uiInactive.SetActive(false); + + var enabledInput = _root.AddComponent(); + var disabledTick = _root.AddComponent(); + disabledTick.enabled = false; + + var canvas = _root.AddComponent(); + canvas.interactable = true; + canvas.blocksRaycasts = true; + + var isolation = _root.AddComponent(); + isolation.LobbyUiRoots = new[] { uiActive, uiInactive }; + isolation.LobbySceneRoots = new[] { sceneRoot }; + isolation.LobbyInputBehaviours = new Behaviour[] { enabledInput }; + isolation.LobbyTickBehaviours = new Behaviour[] { disabledTick }; + isolation.LobbyCanvasGroups = new[] { canvas }; + + isolation.SuspendLobbyForMiniGame(); + + Assert.IsTrue(isolation.IsSuspended); + Assert.IsFalse(uiActive.activeSelf); + Assert.IsFalse(uiInactive.activeSelf); + Assert.IsFalse(sceneRoot.activeSelf); + Assert.IsFalse(enabledInput.enabled); + Assert.IsFalse(disabledTick.enabled); + Assert.IsFalse(canvas.interactable); + Assert.IsFalse(canvas.blocksRaycasts); + + isolation.ResumeLobbyAfterMiniGame(); + + Assert.IsFalse(isolation.IsSuspended); + Assert.IsTrue(uiActive.activeSelf); + Assert.IsFalse(uiInactive.activeSelf); + Assert.IsTrue(sceneRoot.activeSelf); + Assert.IsTrue(enabledInput.enabled); + Assert.IsFalse(disabledTick.enabled); + Assert.IsTrue(canvas.interactable); + Assert.IsTrue(canvas.blocksRaycasts); + } + + [Test] + public void SuspendAndResume_AreIdempotent() + { + _root = new GameObject("root"); + GameObject ui = new GameObject("ui"); + ui.transform.SetParent(_root.transform); + var behaviour = _root.AddComponent(); + var isolation = _root.AddComponent(); + isolation.LobbyUiRoots = new[] { ui }; + isolation.LobbyInputBehaviours = new Behaviour[] { behaviour }; + + isolation.SuspendLobbyForMiniGame(); + ui.SetActive(true); + behaviour.enabled = true; + isolation.SuspendLobbyForMiniGame(); + + Assert.IsFalse(ui.activeSelf); + Assert.IsFalse(behaviour.enabled); + + isolation.ResumeLobbyAfterMiniGame(); + isolation.ResumeLobbyAfterMiniGame(); + + Assert.IsTrue(ui.activeSelf); + Assert.IsTrue(behaviour.enabled); + } + + [Test] + public void SuspendAndResume_IgnoreNullEntries() + { + _root = new GameObject("root"); + var isolation = _root.AddComponent(); + isolation.LobbyUiRoots = new GameObject[] { null }; + isolation.LobbySceneRoots = new GameObject[] { null }; + isolation.LobbyInputBehaviours = new Behaviour[] { null }; + isolation.LobbyTickBehaviours = new Behaviour[] { null }; + isolation.LobbyCanvasGroups = new CanvasGroup[] { null }; + + Assert.DoesNotThrow(() => isolation.SuspendLobbyForMiniGame()); + Assert.DoesNotThrow(() => isolation.ResumeLobbyAfterMiniGame()); + } +} diff --git a/Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta b/Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta new file mode 100644 index 00000000..cf1989e0 --- /dev/null +++ b/Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0db89d5629e34042af0af9a432d1efb7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs b/Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs index 190099cb..dfd15bf9 100644 --- a/Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs +++ b/Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs @@ -69,12 +69,19 @@ namespace XGame.MiniGame var dl = new MiniGameDownloader(cdnBase, gameId, version); bool ok = false; yield return dl.Run(r => ok = r); - if (!ok) { Debug.LogError("[MiniGameHost] 下载失败: " + dl.Error); yield break; } + if (!ok) + { + FailEnterAndExit("下载失败: " + dl.Error); + yield break; + } // 2) 加载 Core/Client DLL,反射入口 var asmLoader = new MiniGameAssemblyLoader(); if (!asmLoader.Load(dl.LocalDir, dl.Manifest)) - { Debug.LogError("[MiniGameHost] 加载程序集失败: " + asmLoader.Error); yield break; } + { + FailEnterAndExit("加载程序集失败: " + asmLoader.Error); + yield break; + } // 3) 网络通道 _net = new MiniGameNetChannel(sock); @@ -82,7 +89,16 @@ namespace XGame.MiniGame _net.OnFrameworkFrame = OnFrameworkFrame; // 4) 反射实例化 IGameClient + 注入 ctx - _client = asmLoader.CreateClient(); + try + { + _client = asmLoader.CreateClient(); + } + catch (Exception e) + { + FailEnterAndExit("创建客户端失败: " + e.Message); + yield break; + } + _assets = new MiniGameAssetLoader(dl.LocalDir, dl.Manifest); if (_players == null) { @@ -103,7 +119,15 @@ namespace XGame.MiniGame // 6) OnEnter 接管 // 注意:OnEnter 在 MatchFound 到达之前调用,房间可能尚未就绪;小游戏应等待后续网络消息再依赖房间状态 - SafeCall(() => _client.OnEnter(_ctx), "OnEnter"); + try + { + _client.OnEnter(_ctx); + } + catch (Exception e) + { + FailEnterAndExit("OnEnter 抛异常: " + e.Message); + yield break; + } _running = true; Debug.Log("[MiniGameHost] 小游戏已进入: " + gameId); } @@ -157,6 +181,27 @@ namespace XGame.MiniGame // 返回大厅:交回 Lua game_module_runner 或大厅 UI(按现有大厅返回流程) } + private void FailEnterAndExit(string reason) + { + DestroyResultDialog(); + _waitingForResultConfirm = false; + _running = false; + _client = null; + _net = null; + _ctx = null; + if (_players != null) + { + _players.SetRoomPlayers(null); + } + if (_assets != null) + { + _assets.UnloadAll(); + _assets = null; + } + Debug.LogError("[MiniGameHost] 进入小游戏失败: " + reason); + OnGameExited?.Invoke(); + } + private void ShowResultDialog(RoomEndMsg roomEnd) { if (_waitingForResultConfirm) diff --git a/Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs b/Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs new file mode 100644 index 00000000..89b0fad9 --- /dev/null +++ b/Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs @@ -0,0 +1,210 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace XGame.MiniGame +{ + public sealed class MiniGameStageIsolation : MonoBehaviour + { + public GameObject[] LobbyUiRoots; + public GameObject[] LobbySceneRoots; + public Behaviour[] LobbyInputBehaviours; + public Behaviour[] LobbyTickBehaviours; + public CanvasGroup[] LobbyCanvasGroups; + + private readonly List _gameObjectStates = new List(); + private readonly List _behaviourStates = new List(); + private readonly List _canvasGroupStates = new List(); + + public bool IsSuspended { get; private set; } + + public void SuspendLobbyForMiniGame() + { + if (IsSuspended) + { + ApplySuspendedState(); + return; + } + + CaptureState(); + IsSuspended = true; + ApplySuspendedState(); + } + + public void ResumeLobbyAfterMiniGame() + { + if (!IsSuspended) + { + return; + } + + for (int i = 0; i < _gameObjectStates.Count; i++) + { + GameObjectState state = _gameObjectStates[i]; + if (state.Target != null) + { + state.Target.SetActive(state.ActiveSelf); + } + } + + for (int i = 0; i < _behaviourStates.Count; i++) + { + BehaviourState state = _behaviourStates[i]; + if (state.Target != null) + { + state.Target.enabled = state.Enabled; + } + } + + for (int i = 0; i < _canvasGroupStates.Count; i++) + { + CanvasGroupState state = _canvasGroupStates[i]; + if (state.Target != null) + { + state.Target.interactable = state.Interactable; + state.Target.blocksRaycasts = state.BlocksRaycasts; + } + } + + _gameObjectStates.Clear(); + _behaviourStates.Clear(); + _canvasGroupStates.Clear(); + IsSuspended = false; + } + + private void CaptureState() + { + _gameObjectStates.Clear(); + _behaviourStates.Clear(); + _canvasGroupStates.Clear(); + CaptureGameObjects(LobbyUiRoots); + CaptureGameObjects(LobbySceneRoots); + CaptureBehaviours(LobbyInputBehaviours); + CaptureBehaviours(LobbyTickBehaviours); + CaptureCanvasGroups(LobbyCanvasGroups); + } + + private void ApplySuspendedState() + { + SetGameObjectsActive(LobbyUiRoots, false); + SetGameObjectsActive(LobbySceneRoots, false); + SetBehavioursEnabled(LobbyInputBehaviours, false); + SetBehavioursEnabled(LobbyTickBehaviours, false); + SetCanvasGroupsBlocked(LobbyCanvasGroups, false, false); + } + + private void CaptureGameObjects(GameObject[] targets) + { + if (targets == null) return; + for (int i = 0; i < targets.Length; i++) + { + GameObject target = targets[i]; + if (target != null) + { + _gameObjectStates.Add(new GameObjectState(target, target.activeSelf)); + } + } + } + + private void CaptureBehaviours(Behaviour[] targets) + { + if (targets == null) return; + for (int i = 0; i < targets.Length; i++) + { + Behaviour target = targets[i]; + if (target != null) + { + _behaviourStates.Add(new BehaviourState(target, target.enabled)); + } + } + } + + private void CaptureCanvasGroups(CanvasGroup[] targets) + { + if (targets == null) return; + for (int i = 0; i < targets.Length; i++) + { + CanvasGroup target = targets[i]; + if (target != null) + { + _canvasGroupStates.Add(new CanvasGroupState(target, target.interactable, target.blocksRaycasts)); + } + } + } + + private static void SetGameObjectsActive(GameObject[] targets, bool active) + { + if (targets == null) return; + for (int i = 0; i < targets.Length; i++) + { + if (targets[i] != null) + { + targets[i].SetActive(active); + } + } + } + + private static void SetBehavioursEnabled(Behaviour[] targets, bool enabled) + { + if (targets == null) return; + for (int i = 0; i < targets.Length; i++) + { + if (targets[i] != null) + { + targets[i].enabled = enabled; + } + } + } + + private static void SetCanvasGroupsBlocked(CanvasGroup[] targets, bool interactable, bool blocksRaycasts) + { + if (targets == null) return; + for (int i = 0; i < targets.Length; i++) + { + CanvasGroup target = targets[i]; + if (target != null) + { + target.interactable = interactable; + target.blocksRaycasts = blocksRaycasts; + } + } + } + + private readonly struct GameObjectState + { + public readonly GameObject Target; + public readonly bool ActiveSelf; + + public GameObjectState(GameObject target, bool activeSelf) + { + Target = target; + ActiveSelf = activeSelf; + } + } + + private readonly struct BehaviourState + { + public readonly Behaviour Target; + public readonly bool Enabled; + + public BehaviourState(Behaviour target, bool enabled) + { + Target = target; + Enabled = enabled; + } + } + + private readonly struct CanvasGroupState + { + public readonly CanvasGroup Target; + public readonly bool Interactable; + public readonly bool BlocksRaycasts; + + public CanvasGroupState(CanvasGroup target, bool interactable, bool blocksRaycasts) + { + Target = target; + Interactable = interactable; + BlocksRaycasts = blocksRaycasts; + } + } + } +} diff --git a/Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta b/Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta new file mode 100644 index 00000000..400b06fa --- /dev/null +++ b/Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 51e84fcb0d45460891d9f0f8e60bf2a6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs b/Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs index a95072cf..9078c6d8 100644 --- a/Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs +++ b/Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs @@ -12,6 +12,7 @@ namespace XGame.MiniGame public string GatewayUrl = "ws://127.0.0.1:5005/ws?pid=1"; public string CdnBase = ""; public bool AutoStart; + public MiniGameStageIsolation StageIsolation; private readonly List _games = new List(); private string _status = "idle"; @@ -141,10 +142,12 @@ namespace XGame.MiniGame { _lobbyActive = false; _lobbyNet = null; + SuspendLobbyStage(); _gameHost = gameObject.GetComponent(); if (_gameHost == null) _gameHost = gameObject.AddComponent(); _gameHost.OnGameExited = () => { + ResumeLobbyStage(); _status = "returned to lobby"; _matching = false; _lobbyNet = new MiniGameNetChannel(_socket); @@ -155,6 +158,35 @@ namespace XGame.MiniGame _gameHost.EnterGame(ResolvedCdnBase(), gameId, version, _socket, false); } + private MiniGameStageIsolation ResolveStageIsolation() + { + if (StageIsolation != null) + { + return StageIsolation; + } + + StageIsolation = GetComponent(); + return StageIsolation; + } + + private void SuspendLobbyStage() + { + MiniGameStageIsolation isolation = ResolveStageIsolation(); + if (isolation != null) + { + isolation.SuspendLobbyForMiniGame(); + } + } + + private void ResumeLobbyStage() + { + MiniGameStageIsolation isolation = ResolveStageIsolation(); + if (isolation != null) + { + isolation.ResumeLobbyAfterMiniGame(); + } + } + private string ResolvedCdnBase() { #if XW_DEVTEST diff --git a/docs/superpowers/plans/2026-07-29-minigame-lobby-stage-isolation.md b/docs/superpowers/plans/2026-07-29-minigame-lobby-stage-isolation.md new file mode 100644 index 00000000..02f6b993 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-minigame-lobby-stage-isolation.md @@ -0,0 +1,768 @@ +# MiniGame Lobby Stage Isolation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the lobby and minigame mutually exclusive stages by suspending and hiding lobby UI/render/input/tick while a minigame runs, then restoring the lobby after minigame exit or enter failure. + +**Architecture:** Add a reusable `MiniGameStageIsolation` MonoBehaviour that snapshots configured lobby object state, suspends it for minigame, and restores it exactly. Keep `MiniGameHost` focused on minigame lifecycle, but guarantee `OnGameExited` fires on enter failure so the caller can restore lobby. Wire `PcLobbySmokeLauncher` to call isolation before `MiniGameHost.EnterGame` and resume it from `OnGameExited`. + +**Tech Stack:** Unity 2022.3 C#, NUnit EditMode tests, existing `MiniGameRuntime.Verify` dotnet build project, `XGame.MiniGame` runtime code. + +--- + +## File Map + +- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs` + - Runtime component that snapshots and restores lobby UI roots, scene roots, input behaviours, tick behaviours, and canvas groups. +- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta` + - Unity meta for the new runtime script. +- Create: `Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs` + - EditMode tests for suspend/resume, original-state restore, idempotency, and null safety. +- Create: `Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta` + - Unity meta for the new test script. +- Modify: `Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs` + - Call stage isolation before entering a minigame and resume it when `MiniGameHost.OnGameExited` fires. +- Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs` + - Call `OnGameExited` on minigame enter failure so suspended lobby always resumes. +- Modify: `Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj` + - Already compiles `Assets/Script/xmain/MiniGame/**/*.cs`; only verify no extra changes needed. +- Modify: `Client/Assets/Doc/MiniGameDevelopmentDesign.md` + - Add a “大厅与小游戏阶段隔离” section matching the implementation. +- Modify: `docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md` + - Keep final spec aligned with implementation if any naming changes occur. + +--- + +## Task 1: Add `MiniGameStageIsolation` With EditMode Tests + +**Files:** +- Create: `Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs` +- Create: `Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta` +- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs` +- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta` + +- [ ] **Step 1: Write failing EditMode tests** + +Create `Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs`: + +```csharp +using NUnit.Framework; +using UnityEngine; +using XGame.MiniGame; + +public sealed class MiniGameStageIsolationTests +{ + private GameObject _root; + + private sealed class TestBehaviour : MonoBehaviour + { + } + + [TearDown] + public void TearDown() + { + if (_root != null) + { + Object.DestroyImmediate(_root); + _root = null; + } + } + + [Test] + public void SuspendAndResume_RestoresOriginalActiveAndEnabledStates() + { + _root = new GameObject("root"); + GameObject uiActive = new GameObject("ui-active"); + GameObject uiInactive = new GameObject("ui-inactive"); + GameObject sceneRoot = new GameObject("scene-root"); + uiActive.transform.SetParent(_root.transform); + uiInactive.transform.SetParent(_root.transform); + sceneRoot.transform.SetParent(_root.transform); + uiInactive.SetActive(false); + + var enabledInput = _root.AddComponent(); + var disabledTick = _root.AddComponent(); + disabledTick.enabled = false; + + var canvas = _root.AddComponent(); + canvas.interactable = true; + canvas.blocksRaycasts = true; + + var isolation = _root.AddComponent(); + isolation.LobbyUiRoots = new[] { uiActive, uiInactive }; + isolation.LobbySceneRoots = new[] { sceneRoot }; + isolation.LobbyInputBehaviours = new Behaviour[] { enabledInput }; + isolation.LobbyTickBehaviours = new Behaviour[] { disabledTick }; + isolation.LobbyCanvasGroups = new[] { canvas }; + + isolation.SuspendLobbyForMiniGame(); + + Assert.IsTrue(isolation.IsSuspended); + Assert.IsFalse(uiActive.activeSelf); + Assert.IsFalse(uiInactive.activeSelf); + Assert.IsFalse(sceneRoot.activeSelf); + Assert.IsFalse(enabledInput.enabled); + Assert.IsFalse(disabledTick.enabled); + Assert.IsFalse(canvas.interactable); + Assert.IsFalse(canvas.blocksRaycasts); + + isolation.ResumeLobbyAfterMiniGame(); + + Assert.IsFalse(isolation.IsSuspended); + Assert.IsTrue(uiActive.activeSelf); + Assert.IsFalse(uiInactive.activeSelf); + Assert.IsTrue(sceneRoot.activeSelf); + Assert.IsTrue(enabledInput.enabled); + Assert.IsFalse(disabledTick.enabled); + Assert.IsTrue(canvas.interactable); + Assert.IsTrue(canvas.blocksRaycasts); + } + + [Test] + public void SuspendAndResume_AreIdempotent() + { + _root = new GameObject("root"); + GameObject ui = new GameObject("ui"); + ui.transform.SetParent(_root.transform); + var behaviour = _root.AddComponent(); + var isolation = _root.AddComponent(); + isolation.LobbyUiRoots = new[] { ui }; + isolation.LobbyInputBehaviours = new Behaviour[] { behaviour }; + + isolation.SuspendLobbyForMiniGame(); + ui.SetActive(true); + behaviour.enabled = true; + isolation.SuspendLobbyForMiniGame(); + + Assert.IsFalse(ui.activeSelf); + Assert.IsFalse(behaviour.enabled); + + isolation.ResumeLobbyAfterMiniGame(); + isolation.ResumeLobbyAfterMiniGame(); + + Assert.IsTrue(ui.activeSelf); + Assert.IsTrue(behaviour.enabled); + } + + [Test] + public void SuspendAndResume_IgnoreNullEntries() + { + _root = new GameObject("root"); + var isolation = _root.AddComponent(); + isolation.LobbyUiRoots = new GameObject[] { null }; + isolation.LobbySceneRoots = new GameObject[] { null }; + isolation.LobbyInputBehaviours = new Behaviour[] { null }; + isolation.LobbyTickBehaviours = new Behaviour[] { null }; + isolation.LobbyCanvasGroups = new CanvasGroup[] { null }; + + Assert.DoesNotThrow(() => isolation.SuspendLobbyForMiniGame()); + Assert.DoesNotThrow(() => isolation.ResumeLobbyAfterMiniGame()); + } +} +``` + +- [ ] **Step 2: Create test meta file** + +Create `Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta`: + +```text +fileFormatVersion: 2 +guid: 0db89d5629e34042af0af9a432d1efb7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +``` + +- [ ] **Step 3: Run tests to verify RED** + +Run Unity EditMode test command: + +```bash +"D:/Program Files/Unity 2022.3.62f3/Editor/Unity.exe" -batchmode -projectPath "D:/UD/AI/AIC#Project/Client" -runTests -testPlatform EditMode -testResults "D:/UD/AI/AIC#Project/Temp/minigame-stage-isolation-editmode.xml" -quit +``` + +Expected: FAIL because `XGame.MiniGame.MiniGameStageIsolation` does not exist. + +- [ ] **Step 4: Implement `MiniGameStageIsolation`** + +Create `Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs`: + +```csharp +using System.Collections.Generic; +using UnityEngine; + +namespace XGame.MiniGame +{ + public sealed class MiniGameStageIsolation : MonoBehaviour + { + public GameObject[] LobbyUiRoots; + public GameObject[] LobbySceneRoots; + public Behaviour[] LobbyInputBehaviours; + public Behaviour[] LobbyTickBehaviours; + public CanvasGroup[] LobbyCanvasGroups; + + private readonly List _gameObjectStates = new List(); + private readonly List _behaviourStates = new List(); + private readonly List _canvasGroupStates = new List(); + + public bool IsSuspended { get; private set; } + + public void SuspendLobbyForMiniGame() + { + if (IsSuspended) + { + ApplySuspendedState(); + return; + } + + CaptureState(); + IsSuspended = true; + ApplySuspendedState(); + } + + public void ResumeLobbyAfterMiniGame() + { + if (!IsSuspended) + { + return; + } + + for (int i = 0; i < _gameObjectStates.Count; i++) + { + GameObjectState state = _gameObjectStates[i]; + if (state.Target != null) + { + state.Target.SetActive(state.ActiveSelf); + } + } + + for (int i = 0; i < _behaviourStates.Count; i++) + { + BehaviourState state = _behaviourStates[i]; + if (state.Target != null) + { + state.Target.enabled = state.Enabled; + } + } + + for (int i = 0; i < _canvasGroupStates.Count; i++) + { + CanvasGroupState state = _canvasGroupStates[i]; + if (state.Target != null) + { + state.Target.interactable = state.Interactable; + state.Target.blocksRaycasts = state.BlocksRaycasts; + } + } + + _gameObjectStates.Clear(); + _behaviourStates.Clear(); + _canvasGroupStates.Clear(); + IsSuspended = false; + } + + private void CaptureState() + { + _gameObjectStates.Clear(); + _behaviourStates.Clear(); + _canvasGroupStates.Clear(); + CaptureGameObjects(LobbyUiRoots); + CaptureGameObjects(LobbySceneRoots); + CaptureBehaviours(LobbyInputBehaviours); + CaptureBehaviours(LobbyTickBehaviours); + CaptureCanvasGroups(LobbyCanvasGroups); + } + + private void ApplySuspendedState() + { + SetGameObjectsActive(LobbyUiRoots, false); + SetGameObjectsActive(LobbySceneRoots, false); + SetBehavioursEnabled(LobbyInputBehaviours, false); + SetBehavioursEnabled(LobbyTickBehaviours, false); + SetCanvasGroupsBlocked(LobbyCanvasGroups, false, false); + } + + private void CaptureGameObjects(GameObject[] targets) + { + if (targets == null) return; + for (int i = 0; i < targets.Length; i++) + { + GameObject target = targets[i]; + if (target != null) + { + _gameObjectStates.Add(new GameObjectState(target, target.activeSelf)); + } + } + } + + private void CaptureBehaviours(Behaviour[] targets) + { + if (targets == null) return; + for (int i = 0; i < targets.Length; i++) + { + Behaviour target = targets[i]; + if (target != null) + { + _behaviourStates.Add(new BehaviourState(target, target.enabled)); + } + } + } + + private void CaptureCanvasGroups(CanvasGroup[] targets) + { + if (targets == null) return; + for (int i = 0; i < targets.Length; i++) + { + CanvasGroup target = targets[i]; + if (target != null) + { + _canvasGroupStates.Add(new CanvasGroupState(target, target.interactable, target.blocksRaycasts)); + } + } + } + + private static void SetGameObjectsActive(GameObject[] targets, bool active) + { + if (targets == null) return; + for (int i = 0; i < targets.Length; i++) + { + if (targets[i] != null) + { + targets[i].SetActive(active); + } + } + } + + private static void SetBehavioursEnabled(Behaviour[] targets, bool enabled) + { + if (targets == null) return; + for (int i = 0; i < targets.Length; i++) + { + if (targets[i] != null) + { + targets[i].enabled = enabled; + } + } + } + + private static void SetCanvasGroupsBlocked(CanvasGroup[] targets, bool interactable, bool blocksRaycasts) + { + if (targets == null) return; + for (int i = 0; i < targets.Length; i++) + { + CanvasGroup target = targets[i]; + if (target != null) + { + target.interactable = interactable; + target.blocksRaycasts = blocksRaycasts; + } + } + } + + private readonly struct GameObjectState + { + public readonly GameObject Target; + public readonly bool ActiveSelf; + + public GameObjectState(GameObject target, bool activeSelf) + { + Target = target; + ActiveSelf = activeSelf; + } + } + + private readonly struct BehaviourState + { + public readonly Behaviour Target; + public readonly bool Enabled; + + public BehaviourState(Behaviour target, bool enabled) + { + Target = target; + Enabled = enabled; + } + } + + private readonly struct CanvasGroupState + { + public readonly CanvasGroup Target; + public readonly bool Interactable; + public readonly bool BlocksRaycasts; + + public CanvasGroupState(CanvasGroup target, bool interactable, bool blocksRaycasts) + { + Target = target; + Interactable = interactable; + BlocksRaycasts = blocksRaycasts; + } + } + } +} +``` + +- [ ] **Step 5: Create runtime meta file** + +Create `Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta`: + +```text +fileFormatVersion: 2 +guid: 51e84fcb0d45460891d9f0f8e60bf2a6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +``` + +- [ ] **Step 6: Run tests to verify GREEN** + +Run: + +```bash +"D:/Program Files/Unity 2022.3.62f3/Editor/Unity.exe" -batchmode -projectPath "D:/UD/AI/AIC#Project/Client" -runTests -testPlatform EditMode -testResults "D:/UD/AI/AIC#Project/Temp/minigame-stage-isolation-editmode.xml" -quit +``` + +Expected: `MiniGameStageIsolationTests` pass. + +--- + +## Task 2: Wire Isolation Into `PcLobbySmokeLauncher` + +**Files:** +- Modify: `Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs` + +- [ ] **Step 1: Add isolation field and helper methods** + +In `PcLobbySmokeLauncher`, add a field near `AutoStart`: + +```csharp +public MiniGameStageIsolation StageIsolation; +``` + +Add these helper methods near `EnterAssignedGame`: + +```csharp +private MiniGameStageIsolation ResolveStageIsolation() +{ + if (StageIsolation != null) + { + return StageIsolation; + } + + StageIsolation = GetComponent(); + return StageIsolation; +} + +private void SuspendLobbyStage() +{ + MiniGameStageIsolation isolation = ResolveStageIsolation(); + if (isolation != null) + { + isolation.SuspendLobbyForMiniGame(); + } +} + +private void ResumeLobbyStage() +{ + MiniGameStageIsolation isolation = ResolveStageIsolation(); + if (isolation != null) + { + isolation.ResumeLobbyAfterMiniGame(); + } +} +``` + +- [ ] **Step 2: Suspend before entering minigame** + +In `EnterAssignedGame`, immediately after `_lobbyNet = null;`, add: + +```csharp +SuspendLobbyStage(); +``` + +- [ ] **Step 3: Resume from `OnGameExited` before restoring lobby network/UI state** + +In the `_gameHost.OnGameExited = () =>` callback, add `ResumeLobbyStage();` as the first statement: + +```csharp +_gameHost.OnGameExited = () => +{ + ResumeLobbyStage(); + _status = "returned to lobby"; + _matching = false; + _lobbyNet = new MiniGameNetChannel(_socket); + _lobbyNet.OnFrameworkFrame = OnLobbyFrameworkFrame; + _lobbyActive = true; + RequestGameList(); +}; +``` + +- [ ] **Step 4: Build runtime verify project** + +Run: + +```bash +dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj -c Release -nologo +``` + +Expected: build succeeds with 0 warnings and 0 errors. + +--- + +## Task 3: Ensure Enter Failure Restores Lobby + +**Files:** +- Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs` + +- [ ] **Step 1: Add failure exit helper** + +In `MiniGameHost`, add this method near `ExitGame()`: + +```csharp +private void FailEnterAndExit(string reason) +{ + DestroyResultDialog(); + _waitingForResultConfirm = false; + _running = false; + _client = null; + _net = null; + _ctx = null; + if (_players != null) + { + _players.SetRoomPlayers(null); + } + if (_assets != null) + { + _assets.UnloadAll(); + _assets = null; + } + Debug.LogError("[MiniGameHost] 进入小游戏失败: " + reason); + OnGameExited?.Invoke(); +} +``` + +- [ ] **Step 2: Use helper on download failure** + +Replace: + +```csharp +if (!ok) { Debug.LogError("[MiniGameHost] 下载失败: " + dl.Error); yield break; } +``` + +with: + +```csharp +if (!ok) +{ + FailEnterAndExit("下载失败: " + dl.Error); + yield break; +} +``` + +- [ ] **Step 3: Use helper on assembly load failure** + +Replace: + +```csharp +if (!asmLoader.Load(dl.LocalDir, dl.Manifest)) +{ Debug.LogError("[MiniGameHost] 加载程序集失败: " + asmLoader.Error); yield break; } +``` + +with: + +```csharp +if (!asmLoader.Load(dl.LocalDir, dl.Manifest)) +{ + FailEnterAndExit("加载程序集失败: " + asmLoader.Error); + yield break; +} +``` + +- [ ] **Step 4: Wrap client creation and OnEnter failures** + +Replace the block from `_client = asmLoader.CreateClient();` through `SafeCall(() => _client.OnEnter(_ctx), "OnEnter");` with: + +```csharp +try +{ + _client = asmLoader.CreateClient(); +} +catch (Exception e) +{ + FailEnterAndExit("创建客户端失败: " + e.Message); + yield break; +} + +_assets = new MiniGameAssetLoader(dl.LocalDir, dl.Manifest); +if (_players == null) +{ + _players = new RoomPlayerService(); +} +_ctx = new GameClientCtx( + _assets, + new UnityLogger($"[{gameId}]"), + _players, + msg => { if (_net != null) _net.SendGame(msg); }, + () => ExitGame()); + +if (sendMatchRequest) +{ + _net.SendFramework(FrameworkOpcode.MatchRequest, + new MatchRequestMsg { GameId = gameId, Version = version }.Encode()); +} + +try +{ + _client.OnEnter(_ctx); +} +catch (Exception e) +{ + FailEnterAndExit("OnEnter 抛异常: " + e.Message); + yield break; +} +``` + +- [ ] **Step 5: Keep success path unchanged** + +After the `try/catch`, keep: + +```csharp +_running = true; +Debug.Log("[MiniGameHost] 小游戏已进入: " + gameId); +``` + +- [ ] **Step 6: Build runtime verify project** + +Run: + +```bash +dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj -c Release -nologo +``` + +Expected: build succeeds with 0 warnings and 0 errors. + +--- + +## Task 4: Sync Documentation + +**Files:** +- Modify: `Client/Assets/Doc/MiniGameDevelopmentDesign.md` +- Modify: `docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md` + +- [ ] **Step 1: Add stage isolation section to `MiniGameDevelopmentDesign.md`** + +Append a section before “异常处理与兜底”: + +```markdown +## 大厅与小游戏阶段隔离 + +进入小游戏后,大厅必须暂停并隐藏,直到小游戏退出后再恢复。大厅与小游戏是互斥阶段,不允许大厅 UI、输入、场景单位渲染与小游戏同时处于可操作状态。 + +隔离由 `MiniGameStageIsolation` 负责,调用方在进入小游戏前调用 `SuspendLobbyForMiniGame()`,在 `MiniGameHost.OnGameExited` 中调用 `ResumeLobbyAfterMiniGame()`。`MiniGameHost` 只负责小游戏生命周期,不直接依赖大厅对象。 + +隔离范围包括: + +- 大厅操作 UI 根节点:隐藏并在恢复时还原原始 active 状态。 +- 大厅场景/单位/特效根节点:隐藏并停止渲染。 +- 大厅输入脚本:禁用,避免小游戏期间响应大厅点击、摇杆或快捷键。 +- 大厅 Tick/表现脚本:禁用,避免后台继续驱动大厅单位。 +- 需要保持 active 的 CanvasGroup:关闭交互和射线阻挡。 + +进入失败也必须恢复大厅。下载失败、校验失败、DLL 加载失败或热更客户端 `OnEnter` 抛异常时,`MiniGameHost` 会触发 `OnGameExited`,调用方应统一在该回调中恢复大厅。 +``` + +- [ ] **Step 2: Ensure spec matches final method names** + +In `docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md`, ensure it references: + +```text +MiniGameStageIsolation.SuspendLobbyForMiniGame() +MiniGameStageIsolation.ResumeLobbyAfterMiniGame() +MiniGameHost.OnGameExited +``` + +- [ ] **Step 3: Scan docs for placeholders** + +Run: + +```bash +rg -n "TBD|TODO|FIXME|待定|占位" Client/Assets/Doc/MiniGameDevelopmentDesign.md docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md +``` + +Expected: no matches. + +--- + +## Task 5: Final Verification And Commit + +**Files:** +- All files from Tasks 1-4. + +- [ ] **Step 1: Run EditMode isolation tests** + +Run: + +```bash +"D:/Program Files/Unity 2022.3.62f3/Editor/Unity.exe" -batchmode -projectPath "D:/UD/AI/AIC#Project/Client" -runTests -testPlatform EditMode -testResults "D:/UD/AI/AIC#Project/Temp/minigame-stage-isolation-editmode.xml" -quit +``` + +Expected: `MiniGameStageIsolationTests` pass. If unrelated pre-existing EditMode tests fail, report them separately and still verify the XML contains passing `MiniGameStageIsolationTests`. + +- [ ] **Step 2: Build runtime verify project** + +Run: + +```bash +dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj -c Release -nologo +``` + +Expected: build succeeds with 0 warnings and 0 errors. + +- [ ] **Step 3: Run shared framework tests to ensure previous room player work stays green** + +Run: + +```bash +dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --no-restore +``` + +Expected: 57 tests pass, 0 fail. + +- [ ] **Step 4: Inspect relevant diff only** + +Run: + +```bash +git diff -- Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs Client/Assets/Doc/MiniGameDevelopmentDesign.md docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md docs/superpowers/plans/2026-07-29-minigame-lobby-stage-isolation.md +``` + +Expected: diff only contains stage isolation feature and related docs/plan. + +- [ ] **Step 5: Commit only relevant files** + +Run: + +```bash +git add Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs Client/Assets/Doc/MiniGameDevelopmentDesign.md docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md docs/superpowers/plans/2026-07-29-minigame-lobby-stage-isolation.md +git commit -m "feat: isolate lobby during minigames" -m "Co-Authored-By: Claude Opus 4.8 " +``` + +Expected: commit succeeds on the current feature branch without staging unrelated art/animation changes. + +--- + +## Self-Review + +- Spec coverage: Tasks cover a reusable isolation component, lobby smoke integration, enter-failure recovery through `OnGameExited`, docs, EditMode tests, and runtime build verification. +- Placeholder scan: No implementation placeholders are left in this plan. +- Type consistency: The plan consistently uses `MiniGameStageIsolation`, `SuspendLobbyForMiniGame`, `ResumeLobbyAfterMiniGame`, `StageIsolation`, and `MiniGameHost.OnGameExited`. diff --git a/docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md b/docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md new file mode 100644 index 00000000..8293b86a --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md @@ -0,0 +1,194 @@ +# MiniGame Lobby Stage Isolation Design + +## 目标 + +进入小游戏后,大厅必须进入暂停并隐藏状态:大厅操作界面关闭或不可交互,大厅场景和单位不再渲染,大厅输入和大厅 Tick 停止,直到小游戏完全退出后再恢复大厅。这样大厅和小游戏成为两个互斥阶段,避免大厅 UI、输入、场景渲染和小游戏同时存在导致遮挡、误操作或性能浪费。 + +本设计采用“暂停并隐藏大厅”,不销毁大厅对象。原因是当前大厅/小游戏链路复用同一 `XWebSocket`,小游戏结束后要快速回到大厅;销毁重建大厅会引入额外状态恢复和网络重连复杂度。 + +## 范围 + +本设计覆盖: + +- 进入小游戏前暂停大厅 UI、输入、Tick 和渲染。 +- 小游戏运行期间保持大厅不可见、不可操作。 +- 小游戏退出或进入失败时恢复大厅。 +- 保持 `MiniGameHost` 只负责小游戏生命周期,不让它直接依赖大厅对象。 +- 先支持 `PcLobbySmokeLauncher` 链路,后续正式大厅入口复用同一隔离组件。 + +不覆盖: + +- 销毁并重新加载完整大厅场景。 +- 大厅业务状态持久化重建。 +- 小游戏内部 UI 设计。 + +## 架构 + +新增通用组件 `MiniGameStageIsolation`,放在 `Client/Assets/Script/xmain/MiniGame/`。它是大厅阶段隔离控制器,只负责保存大厅对象原始状态、暂停大厅、恢复大厅,不包含具体小游戏逻辑。 + +`MiniGameHost` 继续只处理:下载、校验、加载 DLL、创建 `IGameClient`、网络 Pump、结算、退出清理。它不直接关闭大厅 UI 或大厅场景。 + +调用方负责把隔离控制器串到流程里。当前 `PcLobbySmokeLauncher` 在收到 `MatchAssigned` 后调用: + +```text +EnterAssignedGame(gameId, version) + ↓ +MiniGameStageIsolation.SuspendLobbyForMiniGame() + ↓ +MiniGameHost.EnterGame(...) + ↓ +MiniGameHost.OnGameExited + ↓ +MiniGameStageIsolation.ResumeLobbyAfterMiniGame() + ↓ +恢复大厅网络 Pump 和大厅操作界面 +``` + +## `MiniGameStageIsolation` 职责 + +建议接口: + +```csharp +public sealed class MiniGameStageIsolation : MonoBehaviour +{ + public GameObject[] LobbyUiRoots; + public GameObject[] LobbySceneRoots; + public Behaviour[] LobbyInputBehaviours; + public Behaviour[] LobbyTickBehaviours; + public CanvasGroup[] LobbyCanvasGroups; + + public bool IsSuspended { get; } + + public void SuspendLobbyForMiniGame(); + public void ResumeLobbyAfterMiniGame(); +} +``` + +字段语义: + +- `LobbyUiRoots`:大厅普通操作界面根节点。Suspend 时 `SetActive(false)`,Resume 时恢复原始 active 状态。 +- `LobbySceneRoots`:大厅 3D 场景、单位、特效、地图等渲染根节点。Suspend 时 `SetActive(false)`,Resume 时恢复原始 active 状态。 +- `LobbyInputBehaviours`:大厅输入、摇杆、点击、快捷键等脚本。Suspend 时 `enabled = false`,Resume 时恢复原始 enabled 状态。 +- `LobbyTickBehaviours`:大厅逻辑 Tick、AI 展示、场景表现控制等脚本。Suspend 时 `enabled = false`,Resume 时恢复原始 enabled 状态。 +- `LobbyCanvasGroups`:需要保留 active 但禁止交互的 UI 根。Suspend 时 `interactable=false`、`blocksRaycasts=false`,Resume 时恢复原状态。 + +实现要求: + +1. 防重入:重复调用 `SuspendLobbyForMiniGame()` 不重复覆盖原始状态;重复调用 `ResumeLobbyAfterMiniGame()` 不报错。 +2. 恢复原状:Resume 恢复的是 Suspend 前的原始状态,不是简单全部打开。 +3. 空引用安全:数组里存在 null 时跳过并打印 warning 或静默跳过。 +4. 不销毁对象:只隐藏或禁用,避免破坏大厅状态。 +5. 不管理小游戏对象:小游戏 UI/资源由 `MiniGameHost` 和热更客户端清理。 + +## 大厅链路接入 + +`PcLobbySmokeLauncher` 当前已经有 `_lobbyActive` 控制大厅网络 Pump,有 `_matching` 控制匹配状态,有 `OnGameExited` 恢复大厅网络和刷新游戏列表。 + +接入后: + +- `EnterAssignedGame()` 中,在 `MiniGameHost.EnterGame()` 前调用隔离器 Suspend。 +- `OnGameExited` 中,先 Resume 大厅,再恢复 `_lobbyNet`、`_lobbyActive`、`_matching`,最后 `RequestGameList()`。 +- 如果 `MiniGameHost.EnterGame()` 下载失败或加载失败,也必须触发恢复,避免大厅被隐藏后卡死。 + +建议 `MiniGameHost` 在进入失败路径调用一个统一失败退出函数,确保 `OnGameExited` 被调用。例如: + +```text +CoEnter 下载失败 / DLL 加载失败 / CreateClient 失败 + ↓ +FailEnterAndExit(reason) + ↓ +清理已创建资源 + ↓ +OnGameExited?.Invoke() +``` + +这样大厅隔离恢复不需要知道失败原因,只要监听 `OnGameExited`。 + +## 生命周期 + +正常进入: + +```text +大厅收到 MatchAssigned + ↓ +暂停大厅网络 Pump(_lobbyActive=false) + ↓ +MiniGameStageIsolation.SuspendLobbyForMiniGame() + ↓ +MiniGameHost.EnterGame(...) + ↓ +小游戏下载、加载、OnEnter + ↓ +小游戏运行,期间大厅不可见不可操作 +``` + +正常退出: + +```text +小游戏 RoomEnd / 玩家确认结算 / 主动退出 + ↓ +MiniGameHost.ExitGame() + ↓ +热更客户端 OnExit + 资源卸载 + ↓ +OnGameExited + ↓ +MiniGameStageIsolation.ResumeLobbyAfterMiniGame() + ↓ +恢复大厅网络 Pump 和大厅 UI + ↓ +刷新大厅列表或大厅状态 +``` + +失败恢复: + +```text +小游戏下载失败 / 校验失败 / DLL 加载失败 / 入口类型错误 + ↓ +MiniGameHost 清理部分初始化状态 + ↓ +OnGameExited + ↓ +MiniGameStageIsolation.ResumeLobbyAfterMiniGame() + ↓ +大厅恢复可见和可操作,并显示错误日志或状态 +``` + +## 错误处理 + +- Suspend 后如果 EnterGame 失败,必须恢复大厅。 +- Resume 前如果部分对象已被外部销毁,跳过该对象,不抛异常。 +- 小游戏结算弹窗显示期间大厅仍保持隐藏,避免结算窗和大厅 UI 叠加。 +- 玩家重复点击进入小游戏时,如果 `IsSuspended == true`,不再次进入或不再次保存状态。 +- 如果大厅本身某些 UI 在进入前就是关闭状态,退出小游戏后仍保持关闭。 + +## 测试策略 + +编辑器/纯 C# 可验证: + +1. `MiniGameStageIsolation` Suspend 后: + - 配置的 UI root inactive。 + - 配置的 scene root inactive。 + - 输入 Behaviour disabled。 + - Tick Behaviour disabled。 + - CanvasGroup 不可交互且不挡射线。 +2. Resume 后恢复 Suspend 前状态。 +3. 重复 Suspend/Resume 不破坏状态。 +4. null 配置不抛异常。 + +运行时 smoke 验证: + +1. 进入小游戏后,大厅操作 GUI 不再显示或不可操作。 +2. 大厅 3D/单位根节点不可见。 +3. 小游戏结算弹窗期间大厅仍隐藏。 +4. 点击结算确认退出小游戏后,大厅恢复可见可操作。 +5. CDN 缺包或 DLL 加载失败时,大厅也能恢复。 + +## 文档同步 + +`Client/Assets/Doc/MiniGameDevelopmentDesign.md` 应补充“大厅与小游戏阶段隔离”章节,说明: + +- 小游戏期间大厅必须暂停并隐藏。 +- 隔离职责属于调用方/隔离组件,不属于具体小游戏。 +- `MiniGameHost.OnGameExited` 是恢复大厅的统一时机。 +- 新小游戏不得直接操作大厅对象。