feat: isolate lobby during minigames

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ud18010
2026-07-29 15:26:54 +08:00
co-authored by Claude Opus 4.8
parent 31afce6882
commit 6a5e534d00
9 changed files with 1408 additions and 8 deletions
+20 -4
View File
@@ -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. 建议开发拆分
第一阶段:最小闭环
@@ -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);
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)
@@ -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 CdnBase = "";
public bool AutoStart;
public MiniGameStageIsolation StageIsolation;
private readonly List<GameInfoMsg> _games = new List<GameInfoMsg>();
private string _status = "idle";
@@ -141,10 +142,12 @@ namespace XGame.MiniGame
{
_lobbyActive = false;
_lobbyNet = null;
SuspendLobbyStage();
_gameHost = gameObject.GetComponent<MiniGameHost>();
if (_gameHost == null) _gameHost = gameObject.AddComponent<MiniGameHost>();
_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<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()
{
#if XW_DEVTEST