Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
@@ -0,0 +1,378 @@
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}"); }
}
}
}