379 lines
16 KiB
C#
379 lines
16 KiB
C#
using System;
|
||
using System.Collections;
|
||
using System.Text;
|
||
using TMPro;
|
||
using UnityEngine;
|
||
using UnityEngine.EventSystems;
|
||
using UnityEngine.UI;
|
||
using XWorld.Framework;
|
||
using XWorld.Framework.Protocol;
|
||
|
||
namespace XGame.MiniGame
|
||
{
|
||
// 一次小游戏会话的宿主。EnterGame 启动整条链路;每帧驱动;ExitGame 收尾。
|
||
public sealed class MiniGameHost : MonoBehaviour
|
||
{
|
||
// 标准 UGUI 结算弹窗预设(由 Doc/UIPrefabCreater/UI_MiniGameResult.json 经管线生成,进 game_art_ui_prefab AB)
|
||
private const string ResultDialogPrefabPath = "Assets/Game/Art/UI/Prefab/UI_MiniGameResult.prefab";
|
||
|
||
private IGameClient _client;
|
||
private MiniGameNetChannel _net;
|
||
private GameClientCtx _ctx;
|
||
private MiniGameAssetLoader _assets;
|
||
private bool _running;
|
||
private bool _waitingForResultConfirm;
|
||
private string _gameId;
|
||
private int _version;
|
||
private string _roomId;
|
||
private int _selfPlayerId;
|
||
private GameObject _resultDialog;
|
||
public Action OnGameExited;
|
||
|
||
// 入口:cdnBase 小游戏 CDN 根;sock 已连接的 XWebSocket(来自大厅/或本宿主新建)
|
||
public void EnterGame(string cdnBase, string gameId, int version, XWebSocket sock)
|
||
=> EnterGame(cdnBase, gameId, version, sock, true);
|
||
|
||
public void EnterGame(string cdnBase, string gameId, int version, XWebSocket sock, bool sendMatchRequest)
|
||
{
|
||
_gameId = gameId; _version = version;
|
||
// FIX: XWCoroutine.StartCo is a static method (not Instance.StartCo)
|
||
XWCoroutine.StartCo(CoEnter(cdnBase, gameId, version, sock, sendMatchRequest));
|
||
}
|
||
|
||
// 大厅链路:MatchFound 由大厅通道消费(sendMatchRequest=false 时宿主收不到),
|
||
// 由外部把房间信息注入,否则结算弹框无法判断胜负(_selfPlayerId 恒 0)。
|
||
public void SetMatchInfo(string roomId, int selfPlayerId)
|
||
{
|
||
_roomId = roomId;
|
||
_selfPlayerId = selfPlayerId;
|
||
}
|
||
|
||
private IEnumerator CoEnter(string cdnBase, string gameId, int version, XWebSocket sock, bool sendMatchRequest)
|
||
{
|
||
// 1) 下载 + 校验
|
||
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; }
|
||
|
||
// 2) 加载 Core/Client DLL,反射入口
|
||
var asmLoader = new MiniGameAssemblyLoader();
|
||
if (!asmLoader.Load(dl.LocalDir, dl.Manifest))
|
||
{ Debug.LogError("[MiniGameHost] 加载程序集失败: " + asmLoader.Error); yield break; }
|
||
|
||
// 3) 网络通道
|
||
_net = new MiniGameNetChannel(sock);
|
||
_net.OnGameMessage = msg => { if (_client != null) SafeCall(() => _client.OnNetMessage(msg), "OnNetMessage"); };
|
||
_net.OnFrameworkFrame = OnFrameworkFrame;
|
||
|
||
// 4) 反射实例化 IGameClient + 注入 ctx
|
||
_client = asmLoader.CreateClient();
|
||
_assets = new MiniGameAssetLoader(dl.LocalDir, dl.Manifest);
|
||
_ctx = new GameClientCtx(
|
||
_assets,
|
||
new UnityLogger($"[{gameId}]"),
|
||
msg => { if (_net != null) _net.SendGame(msg); },
|
||
() => ExitGame());
|
||
|
||
if (sendMatchRequest)
|
||
{
|
||
_net.SendFramework(FrameworkOpcode.MatchRequest,
|
||
new MatchRequestMsg { GameId = gameId, Version = version }.Encode());
|
||
}
|
||
|
||
// 6) OnEnter 接管
|
||
// 注意:OnEnter 在 MatchFound 到达之前调用,房间可能尚未就绪;小游戏应等待后续网络消息再依赖房间状态
|
||
SafeCall(() => _client.OnEnter(_ctx), "OnEnter");
|
||
_running = true;
|
||
Debug.Log("[MiniGameHost] 小游戏已进入: " + gameId);
|
||
}
|
||
|
||
private void OnFrameworkFrame(Frame f)
|
||
{
|
||
switch ((FrameworkOpcode)f.Opcode)
|
||
{
|
||
case FrameworkOpcode.MatchFound:
|
||
MatchFoundMsg found = MatchFoundMsg.Decode(f.Payload);
|
||
_roomId = found.RoomId;
|
||
_selfPlayerId = found.SelfPlayerId;
|
||
Debug.Log("[MiniGameHost] 房间就绪: " + _roomId);
|
||
break;
|
||
case FrameworkOpcode.RoomEnd:
|
||
Debug.Log("[MiniGameHost] 房间结束,弹结算框");
|
||
ShowResultDialog(RoomEndMsg.Decode(f.Payload));
|
||
break;
|
||
// Snapshot/Heartbeat 等:本步骤交给小游戏自行通过 Game 帧处理;如需框架级快照可在此扩展
|
||
}
|
||
}
|
||
|
||
private void Update()
|
||
{
|
||
if (!_running) return;
|
||
_net?.Pump();
|
||
if (_client != null) SafeCall(() => _client.OnUpdate(Time.deltaTime), "OnUpdate");
|
||
}
|
||
|
||
public void ExitGame()
|
||
{
|
||
if (!_running && _client == null) return;
|
||
DestroyResultDialog();
|
||
_waitingForResultConfirm = false;
|
||
_running = false;
|
||
if (_client != null) { SafeCall(() => _client.OnExit(), "OnExit"); _client = null; }
|
||
_net = null;
|
||
_ctx = null;
|
||
if (_assets != null) { _assets.UnloadAll(); _assets = null; } // 卸载本小游戏资源 AB
|
||
Debug.Log("[MiniGameHost] 已退出小游戏: " + _gameId);
|
||
OnGameExited?.Invoke();
|
||
// 返回大厅:交回 Lua game_module_runner 或大厅 UI(按现有大厅返回流程)
|
||
}
|
||
|
||
private void ShowResultDialog(RoomEndMsg roomEnd)
|
||
{
|
||
if (_waitingForResultConfirm)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_waitingForResultConfirm = true;
|
||
_running = false;
|
||
DestroyResultDialog();
|
||
EnsureEventSystem();
|
||
|
||
string resultText = DecodeResultBlob(roomEnd.ResultBlob);
|
||
string title = ResultTitle(roomEnd, resultText);
|
||
string message = ResultMessage(resultText);
|
||
// 项目规则:资源一律异步加载(同步只读本地不下载);预设不可用时回退代码构建——结算窗口宁丑不缺
|
||
XWCoroutine.StartCo(CoShowResultDialog(title, message));
|
||
}
|
||
|
||
private IEnumerator CoShowResultDialog(string title, string message)
|
||
{
|
||
GameObject prefab = null;
|
||
yield return XResLoader.coLoadRes(ResultDialogPrefabPath, typeof(GameObject), obj => prefab = obj as GameObject);
|
||
if (!_waitingForResultConfirm)
|
||
{
|
||
yield break; // 加载期间用户已退出(ExitGame),不再弹结算
|
||
}
|
||
if (prefab == null || !TryShowResultDialogFromPrefab(prefab, title, message))
|
||
{
|
||
Debug.LogWarning("[MiniGameHost] 结算预设不可用,回退代码构建: " + ResultDialogPrefabPath);
|
||
ShowResultDialogFallback(title, message);
|
||
}
|
||
}
|
||
|
||
// 标准 UGUI 结算预设(Doc/UIPrefabCreater/UI_MiniGameResult.json 生成),按名绑定节点
|
||
private bool TryShowResultDialogFromPrefab(GameObject prefab, string title, string message)
|
||
{
|
||
GameObject instance = Instantiate(prefab);
|
||
instance.name = "MiniGameResultDialog";
|
||
TextMeshProUGUI titleText = FindChildComponent<TextMeshProUGUI>(instance.transform, "Txt_Title");
|
||
TextMeshProUGUI messageText = FindChildComponent<TextMeshProUGUI>(instance.transform, "Txt_Message");
|
||
Button okButton = FindChildComponent<Button>(instance.transform, "Btn_Ok");
|
||
if (titleText == null || messageText == null || okButton == null)
|
||
{
|
||
Destroy(instance); // 预设结构不符(旧资源包),回退代码构建
|
||
return false;
|
||
}
|
||
|
||
titleText.text = title;
|
||
messageText.text = message;
|
||
okButton.onClick.AddListener(ExitGame);
|
||
_resultDialog = instance;
|
||
return true;
|
||
}
|
||
|
||
private static T FindChildComponent<T>(Transform root, string name) where T : Component
|
||
{
|
||
Transform[] transforms = root.GetComponentsInChildren<Transform>(true);
|
||
for (int i = 0; i < transforms.Length; i++)
|
||
{
|
||
if (transforms[i].name == name)
|
||
{
|
||
return transforms[i].GetComponent<T>();
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 回退路径:预设缺失/结构不符时代码构建(旧底包兼容),样式从简
|
||
private void ShowResultDialogFallback(string title, string message)
|
||
{
|
||
_resultDialog = new GameObject("MiniGameResultDialog");
|
||
Canvas canvas = _resultDialog.AddComponent<Canvas>();
|
||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||
canvas.sortingOrder = 30000;
|
||
_resultDialog.AddComponent<CanvasScaler>().uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||
_resultDialog.AddComponent<GraphicRaycaster>();
|
||
|
||
GameObject mask = CreateUiObject("Mask", _resultDialog.transform);
|
||
Image maskImage = mask.AddComponent<Image>();
|
||
maskImage.color = new Color(0f, 0f, 0f, 0.58f);
|
||
Stretch(mask.GetComponent<RectTransform>());
|
||
|
||
GameObject panel = CreateUiObject("Panel", mask.transform);
|
||
Image panelImage = panel.AddComponent<Image>();
|
||
panelImage.color = new Color(0.08f, 0.09f, 0.11f, 0.96f);
|
||
RectTransform panelRect = panel.GetComponent<RectTransform>();
|
||
panelRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||
panelRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||
panelRect.pivot = new Vector2(0.5f, 0.5f);
|
||
panelRect.sizeDelta = new Vector2(520f, 300f);
|
||
panelRect.anchoredPosition = Vector2.zero;
|
||
|
||
Text titleText = CreateText("Title", panel.transform, title, 34, FontStyle.Bold, TextAnchor.MiddleCenter);
|
||
RectTransform titleRect = titleText.GetComponent<RectTransform>();
|
||
titleRect.anchorMin = new Vector2(0f, 1f);
|
||
titleRect.anchorMax = new Vector2(1f, 1f);
|
||
titleRect.pivot = new Vector2(0.5f, 1f);
|
||
titleRect.offsetMin = new Vector2(32f, -92f);
|
||
titleRect.offsetMax = new Vector2(-32f, -28f);
|
||
|
||
Text messageText = CreateText("Message", panel.transform, message, 22, FontStyle.Normal, TextAnchor.MiddleCenter);
|
||
RectTransform messageRect = messageText.GetComponent<RectTransform>();
|
||
messageRect.anchorMin = new Vector2(0f, 0f);
|
||
messageRect.anchorMax = new Vector2(1f, 1f);
|
||
messageRect.offsetMin = new Vector2(42f, 98f);
|
||
messageRect.offsetMax = new Vector2(-42f, -106f);
|
||
|
||
Button okButton = CreateButton(panel.transform, "确定");
|
||
RectTransform buttonRect = okButton.GetComponent<RectTransform>();
|
||
buttonRect.anchorMin = new Vector2(0.5f, 0f);
|
||
buttonRect.anchorMax = new Vector2(0.5f, 0f);
|
||
buttonRect.pivot = new Vector2(0.5f, 0f);
|
||
buttonRect.sizeDelta = new Vector2(180f, 56f);
|
||
buttonRect.anchoredPosition = new Vector2(0f, 34f);
|
||
okButton.onClick.AddListener(ExitGame);
|
||
}
|
||
|
||
private string ResultTitle(RoomEndMsg roomEnd, string resultText)
|
||
{
|
||
if (roomEnd.WinnerPlayerId == 0)
|
||
{
|
||
// 有结算文本 = 游戏明确上报了"无胜者"→ 平局;无文本 = 旧模块/异常结束,保持中性标题(见 RoomEndResult 字段约定)
|
||
return string.IsNullOrEmpty(resultText) ? "游戏结束" : "平局";
|
||
}
|
||
if (_selfPlayerId == 0)
|
||
{
|
||
// 身份未知(未收到 MatchFound 也未注入):不猜胜负,给中性标题
|
||
return "游戏结束";
|
||
}
|
||
if (roomEnd.WinnerPlayerId == _selfPlayerId)
|
||
{
|
||
return "胜利";
|
||
}
|
||
return "失败";
|
||
}
|
||
|
||
private string ResultMessage(string resultText)
|
||
{
|
||
// 游戏上报的结算文本(如"最终比分 2 : 1")优先;裸 pid 对玩家无意义,不再显示
|
||
if (!string.IsNullOrEmpty(resultText))
|
||
{
|
||
return resultText;
|
||
}
|
||
return string.IsNullOrEmpty(_roomId) ? $"{_gameId}@{_version}" : $"房间 {_roomId}";
|
||
}
|
||
|
||
private static string DecodeResultBlob(byte[] resultBlob)
|
||
{
|
||
if (resultBlob == null || resultBlob.Length == 0)
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
if (resultBlob.Length > 4096)
|
||
{
|
||
return string.Empty; // 结算文本不应这么大,视为异常数据
|
||
}
|
||
|
||
try
|
||
{
|
||
string text = Encoding.UTF8.GetString(resultBlob);
|
||
for (int i = 0; i < text.Length; i++)
|
||
{
|
||
if (char.IsControl(text[i]) && text[i] != '\n' && text[i] != '\r' && text[i] != '\t')
|
||
{
|
||
return string.Empty;
|
||
}
|
||
}
|
||
return text;
|
||
}
|
||
catch
|
||
{
|
||
return string.Empty;
|
||
}
|
||
}
|
||
|
||
private void DestroyResultDialog()
|
||
{
|
||
if (_resultDialog != null)
|
||
{
|
||
Destroy(_resultDialog);
|
||
_resultDialog = null;
|
||
}
|
||
}
|
||
|
||
private static void EnsureEventSystem()
|
||
{
|
||
if (EventSystem.current != null)
|
||
{
|
||
return;
|
||
}
|
||
GameObject eventSystem = new GameObject("EventSystem");
|
||
eventSystem.AddComponent<EventSystem>();
|
||
eventSystem.AddComponent<StandaloneInputModule>();
|
||
}
|
||
|
||
private static GameObject CreateUiObject(string name, Transform parent)
|
||
{
|
||
GameObject obj = new GameObject(name);
|
||
obj.transform.SetParent(parent, false);
|
||
obj.AddComponent<RectTransform>();
|
||
return obj;
|
||
}
|
||
|
||
private static void Stretch(RectTransform rect)
|
||
{
|
||
rect.anchorMin = Vector2.zero;
|
||
rect.anchorMax = Vector2.one;
|
||
rect.offsetMin = Vector2.zero;
|
||
rect.offsetMax = Vector2.zero;
|
||
}
|
||
|
||
private static Text CreateText(string name, Transform parent, string content, int fontSize, FontStyle style, TextAnchor alignment)
|
||
{
|
||
GameObject obj = CreateUiObject(name, parent);
|
||
Text text = obj.AddComponent<Text>();
|
||
text.text = content;
|
||
// Unity 2022.2+ 移除了内置 Arial 字体,必须用 LegacyRuntime.ttf,否则抛 ArgumentException
|
||
text.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
|
||
text.fontSize = fontSize;
|
||
text.fontStyle = style;
|
||
text.alignment = alignment;
|
||
text.color = Color.white;
|
||
text.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||
text.verticalOverflow = VerticalWrapMode.Truncate;
|
||
return text;
|
||
}
|
||
|
||
private static Button CreateButton(Transform parent, string label)
|
||
{
|
||
GameObject obj = CreateUiObject("ButtonOk", parent);
|
||
Image image = obj.AddComponent<Image>();
|
||
image.color = new Color(0.2f, 0.55f, 1f, 1f);
|
||
Button button = obj.AddComponent<Button>();
|
||
|
||
Text text = CreateText("Text", obj.transform, label, 24, FontStyle.Bold, TextAnchor.MiddleCenter);
|
||
Stretch(text.GetComponent<RectTransform>());
|
||
return button;
|
||
}
|
||
|
||
private static void SafeCall(Action a, string where)
|
||
{
|
||
try { a(); }
|
||
catch (Exception e) { Debug.LogError($"[MiniGameHost] 小游戏 {where} 抛异常: {e}"); }
|
||
}
|
||
}
|
||
}
|