feat: isolate lobby during minigames
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
31afce6882
commit
6a5e534d00
@@ -614,7 +614,23 @@ Core 不引用 UnityEngine,不访问 UI,不访问 socket,不读写 Unity
|
|||||||
4. 本地 smoke 进入小游戏。
|
4. 本地 smoke 进入小游戏。
|
||||||
5. 验证异常路径:CDN 缺文件、版本不存在、资源缺失、服务端断开、重复退出。
|
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 缺失或结构不符 | 宿主回退代码构建简易结算窗。 |
|
| 结算 Prefab 缺失或结构不符 | 宿主回退代码构建简易结算窗。 |
|
||||||
| 玩家重复退出/重复结算 | 宿主通过 `_running`、`_waitingForResultConfirm` 防重入。 |
|
| 玩家重复退出/重复结算 | 宿主通过 `_running`、`_waitingForResultConfirm` 防重入。 |
|
||||||
|
|
||||||
## 13. 开发红线
|
## 14. 开发红线
|
||||||
|
|
||||||
1. 小游戏专属逻辑必须使用 C#,不要新增 Lua 小游戏逻辑。
|
1. 小游戏专属逻辑必须使用 C#,不要新增 Lua 小游戏逻辑。
|
||||||
2. 新资源加载必须异步,不要使用同步 `XResLoader.LoadRes` / `LoadResAB`。
|
2. 新资源加载必须异步,不要使用同步 `XResLoader.LoadRes` / `LoadResAB`。
|
||||||
@@ -644,7 +660,7 @@ Core 不引用 UnityEngine,不访问 UI,不访问 socket,不读写 Unity
|
|||||||
9. 发布旧版本时不要只覆盖部分平台,除非明确确认平台与服务端兼容。
|
9. 发布旧版本时不要只覆盖部分平台,除非明确确认平台与服务端兼容。
|
||||||
10. 签名、MD5、框架版本校验失败时不要降级加载旧包。
|
10. 签名、MD5、框架版本校验失败时不要降级加载旧包。
|
||||||
|
|
||||||
## 14. 验收清单
|
## 15. 验收清单
|
||||||
|
|
||||||
新增小游戏合入前至少验证:
|
新增小游戏合入前至少验证:
|
||||||
|
|
||||||
@@ -662,7 +678,7 @@ Core 不引用 UnityEngine,不访问 UI,不访问 socket,不读写 Unity
|
|||||||
- 客户端能显示结算并退出回大厅。
|
- 客户端能显示结算并退出回大厅。
|
||||||
- CDN 缺文件、入口类型错误、资源缺失等异常不会卡死。
|
- CDN 缺文件、入口类型错误、资源缺失等异常不会卡死。
|
||||||
|
|
||||||
## 15. 建议开发拆分
|
## 16. 建议开发拆分
|
||||||
|
|
||||||
第一阶段:最小闭环
|
第一阶段:最小闭环
|
||||||
|
|
||||||
|
|||||||
@@ -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<TestBehaviour>();
|
||||||
|
var disabledTick = _root.AddComponent<TestBehaviour>();
|
||||||
|
disabledTick.enabled = false;
|
||||||
|
|
||||||
|
var canvas = _root.AddComponent<CanvasGroup>();
|
||||||
|
canvas.interactable = true;
|
||||||
|
canvas.blocksRaycasts = true;
|
||||||
|
|
||||||
|
var isolation = _root.AddComponent<MiniGameStageIsolation>();
|
||||||
|
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<TestBehaviour>();
|
||||||
|
var isolation = _root.AddComponent<MiniGameStageIsolation>();
|
||||||
|
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<MiniGameStageIsolation>();
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0db89d5629e34042af0af9a432d1efb7
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -69,12 +69,19 @@ namespace XGame.MiniGame
|
|||||||
var dl = new MiniGameDownloader(cdnBase, gameId, version);
|
var dl = new MiniGameDownloader(cdnBase, gameId, version);
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
yield return dl.Run(r => ok = r);
|
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,反射入口
|
// 2) 加载 Core/Client DLL,反射入口
|
||||||
var asmLoader = new MiniGameAssemblyLoader();
|
var asmLoader = new MiniGameAssemblyLoader();
|
||||||
if (!asmLoader.Load(dl.LocalDir, dl.Manifest))
|
if (!asmLoader.Load(dl.LocalDir, dl.Manifest))
|
||||||
{ Debug.LogError("[MiniGameHost] 加载程序集失败: " + asmLoader.Error); yield break; }
|
{
|
||||||
|
FailEnterAndExit("加载程序集失败: " + asmLoader.Error);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
// 3) 网络通道
|
// 3) 网络通道
|
||||||
_net = new MiniGameNetChannel(sock);
|
_net = new MiniGameNetChannel(sock);
|
||||||
@@ -82,7 +89,16 @@ namespace XGame.MiniGame
|
|||||||
_net.OnFrameworkFrame = OnFrameworkFrame;
|
_net.OnFrameworkFrame = OnFrameworkFrame;
|
||||||
|
|
||||||
// 4) 反射实例化 IGameClient + 注入 ctx
|
// 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);
|
_assets = new MiniGameAssetLoader(dl.LocalDir, dl.Manifest);
|
||||||
if (_players == null)
|
if (_players == null)
|
||||||
{
|
{
|
||||||
@@ -103,7 +119,15 @@ namespace XGame.MiniGame
|
|||||||
|
|
||||||
// 6) OnEnter 接管
|
// 6) OnEnter 接管
|
||||||
// 注意:OnEnter 在 MatchFound 到达之前调用,房间可能尚未就绪;小游戏应等待后续网络消息再依赖房间状态
|
// 注意:OnEnter 在 MatchFound 到达之前调用,房间可能尚未就绪;小游戏应等待后续网络消息再依赖房间状态
|
||||||
SafeCall(() => _client.OnEnter(_ctx), "OnEnter");
|
try
|
||||||
|
{
|
||||||
|
_client.OnEnter(_ctx);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
FailEnterAndExit("OnEnter 抛异常: " + e.Message);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
_running = true;
|
_running = true;
|
||||||
Debug.Log("[MiniGameHost] 小游戏已进入: " + gameId);
|
Debug.Log("[MiniGameHost] 小游戏已进入: " + gameId);
|
||||||
}
|
}
|
||||||
@@ -157,6 +181,27 @@ namespace XGame.MiniGame
|
|||||||
// 返回大厅:交回 Lua game_module_runner 或大厅 UI(按现有大厅返回流程)
|
// 返回大厅:交回 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)
|
private void ShowResultDialog(RoomEndMsg roomEnd)
|
||||||
{
|
{
|
||||||
if (_waitingForResultConfirm)
|
if (_waitingForResultConfirm)
|
||||||
|
|||||||
@@ -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<GameObjectState> _gameObjectStates = new List<GameObjectState>();
|
||||||
|
private readonly List<BehaviourState> _behaviourStates = new List<BehaviourState>();
|
||||||
|
private readonly List<CanvasGroupState> _canvasGroupStates = new List<CanvasGroupState>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 51e84fcb0d45460891d9f0f8e60bf2a6
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -12,6 +12,7 @@ namespace XGame.MiniGame
|
|||||||
public string GatewayUrl = "ws://127.0.0.1:5005/ws?pid=1";
|
public string GatewayUrl = "ws://127.0.0.1:5005/ws?pid=1";
|
||||||
public string CdnBase = "";
|
public string CdnBase = "";
|
||||||
public bool AutoStart;
|
public bool AutoStart;
|
||||||
|
public MiniGameStageIsolation StageIsolation;
|
||||||
|
|
||||||
private readonly List<GameInfoMsg> _games = new List<GameInfoMsg>();
|
private readonly List<GameInfoMsg> _games = new List<GameInfoMsg>();
|
||||||
private string _status = "idle";
|
private string _status = "idle";
|
||||||
@@ -141,10 +142,12 @@ namespace XGame.MiniGame
|
|||||||
{
|
{
|
||||||
_lobbyActive = false;
|
_lobbyActive = false;
|
||||||
_lobbyNet = null;
|
_lobbyNet = null;
|
||||||
|
SuspendLobbyStage();
|
||||||
_gameHost = gameObject.GetComponent<MiniGameHost>();
|
_gameHost = gameObject.GetComponent<MiniGameHost>();
|
||||||
if (_gameHost == null) _gameHost = gameObject.AddComponent<MiniGameHost>();
|
if (_gameHost == null) _gameHost = gameObject.AddComponent<MiniGameHost>();
|
||||||
_gameHost.OnGameExited = () =>
|
_gameHost.OnGameExited = () =>
|
||||||
{
|
{
|
||||||
|
ResumeLobbyStage();
|
||||||
_status = "returned to lobby";
|
_status = "returned to lobby";
|
||||||
_matching = false;
|
_matching = false;
|
||||||
_lobbyNet = new MiniGameNetChannel(_socket);
|
_lobbyNet = new MiniGameNetChannel(_socket);
|
||||||
@@ -155,6 +158,35 @@ namespace XGame.MiniGame
|
|||||||
_gameHost.EnterGame(ResolvedCdnBase(), gameId, version, _socket, false);
|
_gameHost.EnterGame(ResolvedCdnBase(), gameId, version, _socket, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private MiniGameStageIsolation ResolveStageIsolation()
|
||||||
|
{
|
||||||
|
if (StageIsolation != null)
|
||||||
|
{
|
||||||
|
return StageIsolation;
|
||||||
|
}
|
||||||
|
|
||||||
|
StageIsolation = GetComponent<MiniGameStageIsolation>();
|
||||||
|
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()
|
private string ResolvedCdnBase()
|
||||||
{
|
{
|
||||||
#if XW_DEVTEST
|
#if XW_DEVTEST
|
||||||
|
|||||||
@@ -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<TestBehaviour>();
|
||||||
|
var disabledTick = _root.AddComponent<TestBehaviour>();
|
||||||
|
disabledTick.enabled = false;
|
||||||
|
|
||||||
|
var canvas = _root.AddComponent<CanvasGroup>();
|
||||||
|
canvas.interactable = true;
|
||||||
|
canvas.blocksRaycasts = true;
|
||||||
|
|
||||||
|
var isolation = _root.AddComponent<MiniGameStageIsolation>();
|
||||||
|
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<TestBehaviour>();
|
||||||
|
var isolation = _root.AddComponent<MiniGameStageIsolation>();
|
||||||
|
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<MiniGameStageIsolation>();
|
||||||
|
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<GameObjectState> _gameObjectStates = new List<GameObjectState>();
|
||||||
|
private readonly List<BehaviourState> _behaviourStates = new List<BehaviourState>();
|
||||||
|
private readonly List<CanvasGroupState> _canvasGroupStates = new List<CanvasGroupState>();
|
||||||
|
|
||||||
|
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<MiniGameStageIsolation>();
|
||||||
|
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 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
@@ -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` 是恢复大厅的统一时机。
|
||||||
|
- 新小游戏不得直接操作大厅对象。
|
||||||
Reference in New Issue
Block a user