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,51 @@
using System;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
namespace XGame.MiniGame
{
// 发布产物签名验证(设计 §7):用内置 RSA 公钥(modulus+exponent, base64)验 files.txt 的 digest 签名。
// 公钥未配置(空)时跳过验签(仅本地开发);正式包必须填入与发布方私钥配对的公钥。
// 用 RSAParameters+ImportParametersnetstandard2.0 起即有,Unity mono 普遍支持),避免 ImportFromPem(net5+)。
public static class ClientManifestVerifier
{
// TODO(发布前必填):从发布方 RSA 密钥对导出的公钥分量 base64。
// 例:RSAParameters p = rsa.ExportParameters(false); Modulus=Convert.ToBase64String(p.Modulus); Exponent=Convert.ToBase64String(p.Exponent)("AQAB")
private const string PublicKeyModulusB64 = "";
private const string PublicKeyExponentB64 = "";
public static bool IsConfigured =>
PublicKeyModulusB64.Length > 0 && PublicKeyExponentB64.Length > 0;
// 验 sig(对 digest 的 UTF8 字节做 RSA-SHA256-PKCS1 签名,与发布工具 ManifestSigner.Sign 一致)
public static bool Verify(string digest, byte[] signature)
{
if (!IsConfigured)
{
Debug.LogWarning("[ClientManifestVerifier] 未配置公钥,跳过验签(仅限开发)");
return true;
}
if (signature == null || signature.Length == 0) return false;
try
{
var p = new RSAParameters
{
Modulus = Convert.FromBase64String(PublicKeyModulusB64),
Exponent = Convert.FromBase64String(PublicKeyExponentB64),
};
using (var rsa = RSA.Create())
{
rsa.ImportParameters(p);
return rsa.VerifyData(Encoding.UTF8.GetBytes(digest), signature,
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
}
catch (Exception e)
{
Debug.LogError("[ClientManifestVerifier] 验签异常: " + e.Message);
return false;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e28d9299ac5c6ff4482881be5072577a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Threading;
using UnityEngine;
using XWorld.Framework.Protocol;
namespace XGame.MiniGame
{
public sealed class DevServerDiscovery
{
private readonly string _token;
private readonly string _extraProbeIp;
private readonly object _lock = new object();
private readonly List<DevDiscoveryReply> _results = new List<DevDiscoveryReply>();
private Thread _thread;
private volatile bool _running;
public DevServerDiscovery(string token)
{
_token = token;
// 跨网段联调:广播/定向广播不被路由器转发(RFC 2644),组播亦实测不通(路由器未开组播路由),
// 唯一可靠路径是向已知开发机 IP 单播。IP 来自 persistentDataPath/dev_server.txt——底包首启弹框
// 录入(见 Base 层 DevServerConfig/DevServerPrompt)。persistentDataPath 不能在后台线程访问,
// 故在主线程构造时读好存字段,Run(后台线程)只用字段。
_extraProbeIp = ReadConfiguredProbeIp();
}
public bool IsRunning => _running;
public void Begin(int durationMs = 1200)
{
if (_running) return;
_running = true;
_thread = new Thread(() => Run(durationMs)) { IsBackground = true };
_thread.Start();
}
private void Run(int durationMs)
{
UdpClient udp = null;
try
{
udp = new UdpClient(AddressFamily.InterNetwork);
udp.EnableBroadcast = true;
udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
udp.Client.Bind(new IPEndPoint(IPAddress.Any, 0));
udp.Client.ReceiveTimeout = 250;
uint nonce = unchecked((uint)Guid.NewGuid().GetHashCode());
byte[] query = DevDiscoveryCodec.EncodeQuery(new DevDiscoveryQuery
{
ProtoVersion = DevDiscovery.ProtoVersion,
Nonce = nonce,
Token = _token,
});
// 同机的 UDP 广播常收不到(取决于网卡是否回环广播),故同时向 127.0.0.1 单播;
// 并在窗口内周期重发,防响应器(网关)刚起来还没监听。
// 255.255.255.255(限制广播)在部分 Android WiFi 驱动上会被丢弃,故再按各网卡 IP|~mask 补发子网定向广播(如 192.168.1.255)。
List<IPEndPoint> targetList = new List<IPEndPoint>
{
new IPEndPoint(IPAddress.Broadcast, DevDiscovery.Port),
new IPEndPoint(IPAddress.Loopback, DevDiscovery.Port),
};
foreach (IPAddress bc in GetDirectedBroadcasts())
targetList.Add(new IPEndPoint(bc, DevDiscovery.Port));
if (!string.IsNullOrEmpty(_extraProbeIp) && IPAddress.TryParse(_extraProbeIp, out IPAddress extra))
targetList.Add(new IPEndPoint(extra, DevDiscovery.Port));
IPEndPoint[] targets = targetList.ToArray();
Debug.Log("[DevTest] targets(" + targets.Length + "): " + string.Join(", ", Array.ConvertAll(targets, t => t.ToString())));
DateTime deadline = DateTime.UtcNow.AddMilliseconds(durationMs);
DateTime nextSend = DateTime.MinValue;
while (_running && DateTime.UtcNow < deadline)
{
if (DateTime.UtcNow >= nextSend)
{
foreach (IPEndPoint t in targets)
{
try { udp.Send(query, query.Length, t); } catch { }
}
nextSend = DateTime.UtcNow.AddMilliseconds(300);
}
IPEndPoint from = new IPEndPoint(IPAddress.Any, 0);
byte[] data;
try { data = udp.Receive(ref from); }
catch (SocketException) { continue; }
if (!DevDiscoveryCodec.TryDecodeReply(data, out var reply)) continue;
if (reply.ProtoVersion != DevDiscovery.ProtoVersion) continue;
if (reply.Nonce != nonce) continue;
if (reply.Token != _token) continue;
lock (_lock)
{
if (!_results.Exists(r => r.GatewayUrl == reply.GatewayUrl))
{
_results.Add(reply);
}
}
}
}
catch (Exception e)
{
Debug.LogWarning("[DevTest] discovery thread error: " + e.Message);
}
finally
{
try { udp?.Close(); } catch { }
_running = false;
}
}
// 枚举本机所有 Up 状态 IPv4 网卡,按 broadcast = ip | ~mask 算出各自的子网定向广播地址。
// 关键:Android/Mono 下 IPv4Mask 常拿不到(返回 null/0.0.0.0 甚至抛异常),故对私有网段按 /24 兜底,
// 且逐地址 try/catch,任何一块网卡取掩码失败都不中断整体枚举。
private static List<IPAddress> GetDirectedBroadcasts()
{
List<IPAddress> list = new List<IPAddress>();
NetworkInterface[] nics;
try { nics = NetworkInterface.GetAllNetworkInterfaces(); }
catch (Exception e) { Debug.LogWarning("[DevTest] GetAllNetworkInterfaces failed: " + e.Message); return list; }
foreach (NetworkInterface ni in nics)
{
if (ni.OperationalStatus != OperationalStatus.Up) continue;
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
IPInterfaceProperties props;
try { props = ni.GetIPProperties(); }
catch { continue; }
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
{
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue; // 只处理 IPv4
byte[] ip = ua.Address.GetAddressBytes();
if (ip.Length != 4) continue;
byte[] mk = null;
try { IPAddress m = ua.IPv4Mask; if (m != null) mk = m.GetAddressBytes(); }
catch { mk = null; } // Android 上访问 IPv4Mask 可能抛,兜住
// 掩码不可用(null/长度不对/全 0)时,私有网段按 /24 兜底(最常见的家用/办公 WiFi),公网地址不猜。
bool maskBad = mk == null || mk.Length != 4 || (mk[0] == 0 && mk[1] == 0 && mk[2] == 0 && mk[3] == 0);
if (maskBad)
{
if (!IsPrivateV4(ip)) continue;
mk = new byte[] { 255, 255, 255, 0 };
}
byte[] bc = new byte[4];
for (int i = 0; i < 4; i++) bc[i] = (byte)(ip[i] | (~mk[i] & 0xFF));
IPAddress bcAddr = new IPAddress(bc);
list.Add(bcAddr);
Debug.Log("[DevTest] nic=" + ni.Name + " ip=" + ua.Address + " mask=" + string.Join(".", mk) + " bc=" + bcAddr);
}
}
return list;
}
private static bool IsPrivateV4(byte[] ip)
{
if (ip[0] == 10) return true; // 10.0.0.0/8
if (ip[0] == 192 && ip[1] == 168) return true; // 192.168.0.0/16
if (ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31) return true; // 172.16.0.0/12
return false;
}
public List<DevDiscoveryReply> TakeResults()
{
lock (_lock)
{
return new List<DevDiscoveryReply>(_results);
}
}
public void Stop()
{
_running = false;
}
// 读 dev_server.txt 首行并校验为 IPv4,失败一律 null。与 Base 层 DevServerConfig.Load() 语义一致——
// XWorld.Link 未引用 Base 程序集,按本项目“内联+两边同步”惯例复制最小实现,改格式时两边须同步。
private static string ReadConfiguredProbeIp()
{
try
{
string path = System.IO.Path.Combine(Application.persistentDataPath, "dev_server.txt");
if (!System.IO.File.Exists(path)) return null;
string line = System.IO.File.ReadAllText(path).Trim();
if (line.Length == 0) return null;
if (!IPAddress.TryParse(line, out IPAddress ip)) return null;
if (ip.AddressFamily != AddressFamily.InterNetwork) return null; // 只认 IPv4
return ip.ToString();
}
catch (Exception)
{
return null;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 42eb5dab97c9d9b4aa2540b4096eacd0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,146 @@
#if XW_DEVTEST
using System.Collections.Generic;
using UnityEngine;
using XGame;
using XWorld.Framework.Protocol;
namespace XGame.MiniGame
{
public sealed class DevServerSwitchUI : MonoBehaviour
{
public string Token = "xw-dev";
public bool DiscoverOnStart = true;
private DevServerDiscovery _discovery;
private List<DevDiscoveryReply> _found = new List<DevDiscoveryReply>();
private bool _prompting;
private void Start()
{
if (DiscoverOnStart && !MiniGameTestOverride.IsActive) BeginDiscovery();
}
private void OnDestroy()
{
_discovery?.Stop();
}
public void BeginDiscovery()
{
_found.Clear();
_prompting = false;
_discovery = new DevServerDiscovery(Token);
_discovery.Begin(1200);
}
private void Update()
{
if (_discovery != null && !_discovery.IsRunning && !_prompting)
{
_found = _discovery.TakeResults();
_discovery = null;
if (_found.Count > 0) _prompting = true;
}
}
private void OnGUI()
{
if (MiniGameTestOverride.IsActive)
{
GUI.color = new Color(1f, 0.85f, 0.2f);
GUILayout.BeginArea(new Rect(8, 8, 760, 30));
GUILayout.BeginHorizontal(GUI.skin.box);
GUILayout.Label($"DEV TEST -> {MiniGameTestOverride.ServerName} ({MiniGameTestOverride.GatewayUrl})");
if (GUILayout.Button("Reset", GUILayout.Width(90)))
{
MiniGameTestOverride.Clear();
GlobalData.CurNode = XData.GetString("CurNode");
GlobalData.CurCDN = XData.GetString("CurCDN");
CSharpClientApp.RestartCurrent();
}
GUILayout.EndHorizontal();
GUILayout.EndArea();
GUI.color = Color.white;
return;
}
if (!_prompting) return;
GUILayout.BeginArea(new Rect(8, 8, 760, 52 + _found.Count * 34), GUI.skin.box);
GUILayout.Label("LAN dev server found. Switch gateway and minigame resources?");
for (int i = 0; i < _found.Count; i++)
{
DevDiscoveryReply r = _found[i];
GUILayout.BeginHorizontal();
GUILayout.Label($"{r.ServerName} {r.GatewayUrl}");
if (GUILayout.Button("Switch", GUILayout.Width(80)))
{
MiniGameTestOverride.Apply(r.GatewayUrl, r.ResourceBaseUrl, r.ServerName);
GlobalData.CurNode = WithPid(r.GatewayUrl);
GlobalData.CurCDN = r.ResourceBaseUrl;
CSharpClientApp.RestartCurrent();
_prompting = false;
}
GUILayout.EndHorizontal();
}
if (GUILayout.Button("Ignore", GUILayout.Width(80))) _prompting = false;
GUILayout.EndArea();
}
private static string WithPid(string gatewayUrl)
{
if (string.IsNullOrWhiteSpace(gatewayUrl))
{
return gatewayUrl;
}
int pid = GetLocalPlayerId();
int queryStart = gatewayUrl.IndexOf('?');
if (queryStart < 0)
{
return gatewayUrl + "?pid=" + pid;
}
string head = gatewayUrl.Substring(0, queryStart);
string query = gatewayUrl.Substring(queryStart + 1);
string[] parts = query.Split('&');
bool replaced = false;
for (int i = 0; i < parts.Length; i++)
{
if (parts[i].StartsWith("pid=", System.StringComparison.OrdinalIgnoreCase))
{
parts[i] = "pid=" + pid;
replaced = true;
break;
}
}
return head + "?" + (replaced ? string.Join("&", parts) : query + "&pid=" + pid);
}
private static int GetLocalPlayerId()
{
const string key = "LocalClientId";
string id = XData.GetString(key, string.Empty);
if (string.IsNullOrEmpty(id))
{
id = SystemInfo.deviceUniqueIdentifier;
if (string.IsNullOrEmpty(id) || id == "unsupported")
{
id = System.Guid.NewGuid().ToString("N");
}
XData.SetString(key, id);
XData.Save();
}
unchecked
{
uint hash = 2166136261u;
for (int i = 0; i < id.Length; i++)
{
hash ^= id[i];
hash *= 16777619u;
}
return (int)(hash % 2000000000u) + 1;
}
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2cdf2cc29e809b04784da6c402184a82
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,36 @@
using System;
using UnityEngine;
using XWorld.Framework;
// Alias to resolve ambiguity between XWorld.Framework.ILogger and UnityEngine.ILogger
using IFwkLogger = XWorld.Framework.ILogger;
namespace XGame.MiniGame
{
// 注入给 IGameClient 的上下文:资源/日志/发送/退出
public sealed class GameClientCtx : IGameClientCtx
{
public IAssetLoader Assets { get; }
public IFwkLogger Logger { get; }
private readonly Action<NetMessage> _send;
private readonly Action _exit;
public GameClientCtx(IAssetLoader assets, IFwkLogger logger, Action<NetMessage> send, Action exit)
{
Assets = assets; Logger = logger; _send = send; _exit = exit;
}
public void Send(NetMessage message) => _send(message);
public void Exit() => _exit();
}
// 简单 Unity 日志器(实现框架 ILogger
public sealed class UnityLogger : IFwkLogger
{
private readonly string _prefix;
public UnityLogger(string prefix = "[minigame]") { _prefix = prefix; }
public void Info(string m) => Debug.Log($"{_prefix} {m}");
public void Warn(string m) => Debug.LogWarning($"{_prefix} {m}");
public void Error(string m) => Debug.LogError($"{_prefix} {m}");
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fc08e7a5bc4eb6140bcb9f5af3c33c15
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,68 @@
using System;
using System.IO;
using System.Reflection;
using UnityEngine;
using XWorld.Framework;
namespace XGame.MiniGame
{
// 从代码 ABmanifest.CodeAb)取出 Core/Client 热更 DLL 字节并加载,反射出 IGameClient 入口工厂
public sealed class MiniGameAssemblyLoader
{
public Func<IGameClient> CreateClient { get; private set; }
public string Error { get; private set; }
// localDir: MiniGameDownloader.LocalDirmanifest: 解析好的 game.json
public bool Load(string localDir, MiniGameManifest manifest)
{
string abPath = localDir + manifest.CodeAb;
if (!File.Exists(abPath))
{ Error = $"缺少代码 AB: {abPath}(需要 codeAb 统一 AB 格式,旧 .bytes 包不再支持)"; return false; }
AssetBundle ab = null;
try
{
ab = MiniGamePlatform.LoadAssetBundle(abPath);
if (ab == null) { Error = "代码 AB 打开失败: " + abPath; return false; }
byte[] coreBytes = LoadDllBytes(ab, manifest.CoreDll);
byte[] clientBytes = LoadDllBytes(ab, manifest.ClientDll);
if (coreBytes == null || clientBytes == null)
{
Error = $"代码 AB 内缺少 {manifest.CoreDll}/{manifest.ClientDll} 的 .bytesAB 内资产: {string.Join(", ", ab.GetAllAssetNames())}";
return false;
}
// Core 先于 Client 加载(Client 依赖 Core
Assembly.Load(coreBytes);
Assembly clientAsm = Assembly.Load(clientBytes);
Type entry = clientAsm.GetType(manifest.ClientEntryType);
if (entry == null) { Error = "找不到入口类型 " + manifest.ClientEntryType; return false; }
if (!typeof(IGameClient).IsAssignableFrom(entry))
{ Error = manifest.ClientEntryType + " 未实现 IGameClient"; return false; }
CreateClient = () => (IGameClient)Activator.CreateInstance(entry);
return true;
}
catch (Exception e)
{
Error = "加载小游戏程序集失败: " + e;
Debug.LogError("[MiniGameAssemblyLoader] " + Error);
return false;
}
finally
{
// DLL 字节已复制进托管内存,AB 本体可卸;false=不销毁已取出的对象
if (ab != null) ab.Unload(false);
}
}
// "X.dll.bytes" 导入后资产名为 "X.dll"Unity 剥最后一个扩展名);两种写法都试
private static byte[] LoadDllBytes(AssetBundle ab, string dllName)
{
TextAsset ta = ab.LoadAsset<TextAsset>(dllName) ?? ab.LoadAsset<TextAsset>(dllName + ".bytes");
return ta != null ? ta.bytes : null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a6ccf4c9865f0a34481f34cf3070e12c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using XWorld.Framework;
namespace XGame.MiniGame
{
// IAssetLoader over 已下载的小游戏资源 ABmanifest.Assets);首次 Load 时开全部包并缓存,退出统一卸载
public sealed class MiniGameAssetLoader : IAssetLoader
{
private readonly string _localDir;
private readonly List<string> _abNames;
private readonly List<AssetBundle> _opened = new List<AssetBundle>();
private bool _openedAll;
public MiniGameAssetLoader(string localDir, MiniGameManifest manifest)
{ _localDir = localDir; _abNames = manifest.Assets; }
// path: 打包时的资产路径(如 "Assets/MiniGames/RockPaperScissors/res/UI/Prefab/UI_RockPaperScissors.prefab"
// 或裸资产名("UI_RockPaperScissors");找不到回调 null。
public void Load(string path, Action<object> onLoaded)
{
EnsureOpened();
foreach (var ab in _opened)
{
UnityEngine.Object obj = ab.LoadAsset(path);
if (obj == null) obj = ab.LoadAsset(Path.GetFileNameWithoutExtension(path));
if (obj != null) { onLoaded?.Invoke(obj); return; }
}
Debug.LogError("[MiniGameAssetLoader] 资产未找到: " + path);
onLoaded?.Invoke(null);
}
// 小游戏退出时由 MiniGameHost 调用;true=连已实例化引用的资产一起卸
public void UnloadAll()
{
foreach (var ab in _opened) if (ab != null) ab.Unload(true);
_opened.Clear();
_openedAll = false;
}
private void EnsureOpened()
{
if (_openedAll) return;
_openedAll = true;
foreach (var name in _abNames)
{
string p = _localDir + name;
if (!File.Exists(p)) { Debug.LogError("[MiniGameAssetLoader] 资源 AB 缺失: " + p); continue; }
var ab = MiniGamePlatform.LoadAssetBundle(p);
if (ab == null) { Debug.LogError("[MiniGameAssetLoader] 资源 AB 打开失败: " + p); continue; }
_opened.Add(ab);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 21a3112de2a41a245a2a4cc7f082ee4f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,168 @@
using System;
using System.Collections;
using System.IO;
using UnityEngine;
using UnityEngine.Networking;
namespace XGame.MiniGame
{
// 下载并校验某小游戏版本的全部文件;产出本地目录 + 解析好的 manifest
public sealed class MiniGameDownloader
{
public string LocalDir { get; private set; }
public MiniGameManifest Manifest { get; private set; }
public string Error { get; private set; }
private readonly string _cdnBase; // 例如 http://.../minigame/
private readonly string _gameId;
private readonly int _version;
public MiniGameDownloader(string cdnBase, string gameId, int version)
{
_cdnBase = NormalizeCdnBase(cdnBase);
_gameId = gameId; _version = version;
LocalDir = $"{Application.persistentDataPath}/minigame/{gameId}/{version}/";
}
private string RemoteBase => $"{_cdnBase}{_gameId}/{_version}/{MiniGamePlatform.Name}/";
public IEnumerator Run(Action<bool> onDone)
{
Error = null;
Directory.CreateDirectory(LocalDir);
// 1) game.json(每次拉取,文件小)
string gameJson = null;
yield return GetText($"{RemoteBase}game.json", t => gameJson = t);
if (gameJson == null) { Fail("下载 game.json 失败"); onDone(false); yield break; }
try { Manifest = MiniGameManifest.ParseGameJson(gameJson); }
catch (Exception e) { Fail("game.json 解析失败: " + e.Message); onDone(false); yield break; }
if (Manifest.GameId != _gameId || Manifest.Version != _version)
{ Fail("game.json 与请求的 gameId/version 不符"); onDone(false); yield break; }
if (Manifest.MinFrameworkVersion > XWorld.Framework.FrameworkInfo.Version)
{ Fail($"小游戏要求最小框架版本 {Manifest.MinFrameworkVersion},当前框架 {XWorld.Framework.FrameworkInfo.Version}"); onDone(false); yield break; }
// 2) files.txtmd5 清单)
string filesTxt = null;
yield return GetText($"{RemoteBase}files.txt", t => filesTxt = t);
if (filesTxt == null) { Fail("下载 files.txt 失败"); onDone(false); yield break; }
Manifest.LoadFilesList(filesTxt);
// 验签(设计 §7):下载 files.txt.sig,校验清单签名后才信任 md5 列表
byte[] sig = null;
yield return GetBytes($"{RemoteBase}files.txt.sig", b => sig = b);
if (ClientManifestVerifier.IsConfigured)
{
if (sig == null) { Fail("缺少签名文件 files.txt.sig"); onDone(false); yield break; }
if (!ClientManifestVerifier.Verify(Manifest.Digest(), sig))
{ Fail("清单签名验证失败,拒绝加载"); onDone(false); yield break; }
}
// 3) 按清单逐个增量下载(md5 命名;本地已存在且 md5 匹配则跳过)
foreach (var kv in Manifest.Files)
{
string name = kv.Key; string md5 = kv.Value.Md5;
string localPath = LocalDir + name;
if (File.Exists(localPath) && Md5OfFile(localPath) == md5) continue;
string remote = $"{RemoteBase}{Md5Name(name, md5)}";
byte[] data = null;
yield return GetBytes(remote, b => data = b);
if (data == null) { Fail("下载失败: " + remote); onDone(false); yield break; }
if (Md5OfBytes(data) != md5) { Fail("md5 不符: " + name); onDone(false); yield break; }
Directory.CreateDirectory(Path.GetDirectoryName(localPath));
File.WriteAllBytes(localPath, data);
}
onDone(true);
}
private void Fail(string msg) { Error = msg; Debug.LogError("[MiniGameDownloader] " + msg); }
private static string NormalizeCdnBase(string cdnBase)
{
string url = string.IsNullOrWhiteSpace(cdnBase) ? "http://127.0.0.1:15081/" : cdnBase.Trim();
if (url.StartsWith("http://file://", StringComparison.OrdinalIgnoreCase))
{
url = url.Substring("http://".Length);
}
else if (url.StartsWith("https://file://", StringComparison.OrdinalIgnoreCase))
{
url = url.Substring("https://".Length);
}
if (url.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
{
url = NormalizeFileUrl(url);
}
else if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
url = "http://" + url;
}
return url.EndsWith("/") ? url : url + "/";
}
private static string NormalizeFileUrl(string url)
{
string rawPath = url.Substring("file://".Length).Replace('\\', '/');
while (rawPath.StartsWith("/") && rawPath.Length > 2 && rawPath[2] == ':')
{
rawPath = rawPath.Substring(1);
}
string localPath = Uri.UnescapeDataString(rawPath).Replace('/', Path.DirectorySeparatorChar);
try
{
return new Uri(localPath).AbsoluteUri;
}
catch
{
return "file:///" + rawPath.Replace("#", "%23");
}
}
// 与 LoadDll.GetFileMd5Name 同款:name.ext -> name_md5.ext
private static string Md5Name(string filename, string md5)
{
int pos = filename.LastIndexOf('.');
return pos >= 0 ? $"{filename.Substring(0, pos)}_{md5}{filename.Substring(pos)}"
: $"{filename}_{md5}";
}
private static string Md5OfBytes(byte[] data)
{
using (var md5 = System.Security.Cryptography.MD5.Create())
{
var hash = md5.ComputeHash(data);
var sb = new System.Text.StringBuilder(hash.Length * 2);
foreach (var b in hash) sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
private static string Md5OfFile(string path) => Md5OfBytes(File.ReadAllBytes(path));
private IEnumerator GetText(string url, Action<string> onText)
{
using (var uwr = UnityWebRequest.Get(url))
{
yield return uwr.SendWebRequest();
onText(uwr.result == UnityWebRequest.Result.Success ? uwr.downloadHandler.text : null);
if (uwr.result != UnityWebRequest.Result.Success)
Debug.LogError($"[MiniGameDownloader] GET 失败 {url}: {uwr.error}");
}
}
private IEnumerator GetBytes(string url, Action<byte[]> onBytes)
{
using (var uwr = UnityWebRequest.Get(url))
{
yield return uwr.SendWebRequest();
onBytes(uwr.result == UnityWebRequest.Result.Success ? uwr.downloadHandler.data : null);
if (uwr.result != UnityWebRequest.Result.Success)
Debug.LogError($"[MiniGameDownloader] GET 失败 {url}: {uwr.error}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 640fac2ffb7e60b49876a8afdd842f8f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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}"); }
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c9b0a79a6e2896646839f46e8d3e65b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
namespace XGame.MiniGame
{
// game.json 客户端视图(字段名大小写不敏感解析)
public sealed class MiniGameManifest
{
public string GameId;
public int Version;
public string ClientEntryType;
public string CoreDll;
public string ClientDll;
public string CodeAb; // 代码 AB 文件名(统一 ABCore/Client DLL 的 .bytes 都在里面)
public int MinFrameworkVersion;
public List<string> Assets = new List<string>();
// files.txt 解析出的 name->(md5,size),用于增量下载校验
public Dictionary<string, FileEntry> Files = new Dictionary<string, FileEntry>();
public sealed class FileEntry { public string Md5; public int Size; }
// 极简手写 JSON 解析(避免引入 Unity JsonUtility 的字段限制;只认本 schema 的扁平字段 + assets 字符串数组)
// 注意:生产可换成项目已用的 JSON 库;此实现仅解析本 schema,键大小写不敏感。
public static MiniGameManifest ParseGameJson(string json)
{
if (string.IsNullOrEmpty(json)) throw new FormatException("game.json 为空");
var m = new MiniGameManifest
{
GameId = JsonMini.GetString(json, "gameId"),
Version = JsonMini.GetInt(json, "version"),
ClientEntryType = JsonMini.GetString(json, "clientEntryType"),
CoreDll = JsonMini.GetString(json, "coreDll"),
ClientDll = JsonMini.GetString(json, "clientDll"),
CodeAb = JsonMini.GetString(json, "codeAb"),
MinFrameworkVersion = JsonMini.GetInt(json, "minFrameworkVersion"),
};
m.Assets = JsonMini.GetStringArray(json, "assets");
if (string.IsNullOrEmpty(m.GameId)) throw new FormatException("game.json 缺少 gameId");
if (string.IsNullOrEmpty(m.ClientEntryType)) throw new FormatException("game.json 缺少 clientEntryType");
if (string.IsNullOrEmpty(m.CoreDll) || string.IsNullOrEmpty(m.ClientDll)) throw new FormatException("game.json 缺少 coreDll/clientDll");
if (string.IsNullOrEmpty(m.CodeAb))
throw new FormatException("game.json 缺少 codeAb(统一 AB 新格式;旧 .bytes 直下包不再支持,请用 XWorld/生成小游戏更新 重新发布)");
if (m.Version <= 0) throw new FormatException("game.json version 必须为正");
return m;
}
// 与发布工具 FilesManifest.Digest() 完全一致:按 name 的 Ordinal 排序,拼 name=md5;
public string Digest()
{
var keys = new System.Collections.Generic.List<string>(Files.Keys);
keys.Sort(System.StringComparer.Ordinal);
var sb = new System.Text.StringBuilder();
foreach (var k in keys) sb.Append(k).Append('=').Append(Files[k].Md5).Append(';');
return sb.ToString();
}
// files.txt 行格式(沿用 dllfiles):{'name','md5',size},
public void LoadFilesList(string content)
{
Files.Clear();
if (string.IsNullOrEmpty(content)) return;
foreach (var raw in content.Split('\n'))
{
string line = raw.Trim();
if (line.Length < 5) continue;
// 去掉首 { 与尾 }, ,再去引号
int lb = line.IndexOf('{'); int rb = line.LastIndexOf('}');
if (lb < 0 || rb <= lb) continue;
string inner = line.Substring(lb + 1, rb - lb - 1).Replace("'", "");
string[] p = inner.Split(',');
if (p.Length < 3) continue;
int size; int.TryParse(p[2].Trim(), out size);
Files[p[0].Trim()] = new FileEntry { Md5 = p[1].Trim(), Size = size };
}
}
}
// 极小 JSON 取值器(仅供本 schema;非通用)
internal static class JsonMini
{
public static string GetString(string json, string key)
{
int i = FindKey(json, key); if (i < 0) return null;
int c = json.IndexOf(':', i); if (c < 0) return null;
int q1 = json.IndexOf('"', c + 1); if (q1 < 0) return null;
int q2 = json.IndexOf('"', q1 + 1); if (q2 < 0) return null;
return json.Substring(q1 + 1, q2 - q1 - 1);
}
public static int GetInt(string json, string key)
{
int i = FindKey(json, key); if (i < 0) return 0;
int c = json.IndexOf(':', i); if (c < 0) return 0;
int j = c + 1; while (j < json.Length && (json[j] == ' ' || json[j] == '\t')) j++;
int s = j; while (j < json.Length && (char.IsDigit(json[j]) || json[j] == '-')) j++;
int v; int.TryParse(json.Substring(s, j - s), out v); return v;
}
public static System.Collections.Generic.List<string> GetStringArray(string json, string key)
{
var list = new System.Collections.Generic.List<string>();
int i = FindKey(json, key); if (i < 0) return list;
int lb = json.IndexOf('[', i);
if (lb < 0) return list; // 键存在但无数组
int rb = json.IndexOf(']', lb);
if (rb <= lb) return list;
string inner = json.Substring(lb + 1, rb - lb - 1);
foreach (var part in inner.Split(','))
{
int q1 = part.IndexOf('"'); int q2 = part.LastIndexOf('"');
if (q1 >= 0 && q2 > q1) list.Add(part.Substring(q1 + 1, q2 - q1 - 1));
}
return list;
}
private static int FindKey(string json, string key)
{
string needle = "\"" + key + "\"";
int from = 0;
while (true)
{
int idx = json.IndexOf(needle, from, StringComparison.OrdinalIgnoreCase);
if (idx < 0) return -1;
int after = idx + needle.Length;
int j = after;
while (j < json.Length && (json[j] == ' ' || json[j] == '\t' || json[j] == '\r' || json[j] == '\n')) j++;
if (j < json.Length && json[j] == ':') return idx; // 确认是键
from = idx + 1; // 否则是值内嵌文本,继续找
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 36963803cf761dd41a7103ff14254909
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace XGame.MiniGame
{
// 客户端到服务端的帧通道。每帧轮询底层 socket,解码出的帧分发:
// Game 帧 -> OnGameMessage(NetMessage)(交给 IGameClient.OnNetMessage
// Framework 帧 -> OnFrameworkFrame(Frame)(宿主处理 MatchFound/RoomEnd 等)
public sealed class MiniGameNetChannel
{
private readonly XWebSocket _sock;
private readonly List<byte> _recvBuffer = new List<byte>(4096);
public Action<NetMessage> OnGameMessage;
public Action<Frame> OnFrameworkFrame;
public MiniGameNetChannel(XWebSocket sock) { _sock = sock; }
public bool IsConnected => _sock != null && _sock.IsConected();
// 业务输入:IGameClient 调 ctx.Send(NetMessage) -> 这里包成 Game 帧发出
public void SendGame(NetMessage msg)
{
byte[] frame = FrameCodec.Encode(new Frame(Channel.Game, msg.Opcode, msg.Payload));
SendRaw(frame);
}
public void SendFramework(FrameworkOpcode op, byte[] payload)
{
byte[] frame = FrameCodec.Encode(new Frame(Channel.Framework, (ushort)op, payload));
SendRaw(frame);
}
private void SendRaw(byte[] frame)
{
string err = null;
_sock.send(frame, null, ref err);
if (!string.IsNullOrEmpty(err)) Debug.LogError("[MiniGameNetChannel] send err: " + err);
}
// 每帧由宿主调用:把底层收到的字节解出并分发。
// XWebSocket 会把多条 WS 消息追加到同一个 bufferFrame 自带 payloadLen
// 所以这里按 FrameCodec 的线网格式从累计字节流中拆出 0..N 帧。
public void Pump()
{
if (_sock == null) return;
string err = null;
while (_sock.isDataRecv())
{
byte[] data = _sock.recv(ref err);
if (!string.IsNullOrEmpty(err)) { Debug.LogError("[MiniGameNetChannel] recv err: " + err); break; }
if (data == null || data.Length == 0) break;
_recvBuffer.AddRange(data);
}
while (TryPopFrame(out var frameBytes))
{
Frame f;
try { f = FrameCodec.Decode(frameBytes); }
catch (FormatException) { Debug.LogWarning("[MiniGameNetChannel] 畸形帧,丢弃"); continue; }
if (f.Channel == Channel.Game) OnGameMessage?.Invoke(new NetMessage(f.Opcode, f.Payload));
else OnFrameworkFrame?.Invoke(f);
}
}
private bool TryPopFrame(out byte[] frameBytes)
{
frameBytes = null;
if (_recvBuffer.Count < 4) return false; // channel + opcode + at least 1-byte payload len
int pos = 3;
uint payloadLen = 0;
int shift = 0;
while (true)
{
if (pos >= _recvBuffer.Count) return false;
if (shift > 28)
{
Debug.LogWarning("[MiniGameNetChannel] payloadLen varuint too long, clear buffer");
_recvBuffer.Clear();
return false;
}
byte b = _recvBuffer[pos++];
if (shift == 28 && (b & 0x70) != 0)
{
Debug.LogWarning("[MiniGameNetChannel] payloadLen overflow, clear buffer");
_recvBuffer.Clear();
return false;
}
payloadLen |= (uint)(b & 0x7F) << shift;
if ((b & 0x80) == 0) break;
shift += 7;
}
if (payloadLen > 1024 * 1024)
{
Debug.LogWarning("[MiniGameNetChannel] frame too large, clear buffer");
_recvBuffer.Clear();
return false;
}
int totalLen = pos + (int)payloadLen;
if (_recvBuffer.Count < totalLen) return false;
frameBytes = _recvBuffer.GetRange(0, totalLen).ToArray();
_recvBuffer.RemoveRange(0, totalLen);
return true;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6b4684221b289ce4aa15c841e44b355b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,29 @@
using System.IO;
using UnityEngine;
namespace XGame.MiniGame
{
// 小游戏 CDN 的平台段(与 LoadDll.sPlatform / CDN 目录命名一致),及平台相关的本地 AB 打开方式
public static class MiniGamePlatform
{
#if UNITY_ANDROID
public const string Name = "android";
#elif UNITY_IOS
public const string Name = "ios";
#elif UNITY_WEBGL
public const string Name = "webgl";
#else
public const string Name = "pc";
#endif
// WebGL 无同步文件系统 mmapLoadFromFile 不可用 → 读字节走 LoadFromMemory;其余平台 LoadFromFile 更省内存
public static AssetBundle LoadAssetBundle(string localPath)
{
#if UNITY_WEBGL
return AssetBundle.LoadFromMemory(File.ReadAllBytes(localPath));
#else
return AssetBundle.LoadFromFile(localPath);
#endif
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 62694f8496a2a0149a14149079e3ae93
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,39 @@
#if XW_DEVTEST
using UnityEngine;
namespace XGame.MiniGame
{
public static class MiniGameTestOverride
{
private const string KActive = "xw_devtest_active";
private const string KGateway = "xw_devtest_gateway";
private const string KCdn = "xw_devtest_cdn";
private const string KName = "xw_devtest_name";
public static bool IsActive => PlayerPrefs.GetInt(KActive, 0) == 1;
public static string GatewayUrl => PlayerPrefs.GetString(KGateway, "");
public static string CdnBase => PlayerPrefs.GetString(KCdn, "");
public static string ServerName => PlayerPrefs.GetString(KName, "");
public static void Apply(string gatewayUrl, string cdnBase, string serverName)
{
PlayerPrefs.SetInt(KActive, 1);
PlayerPrefs.SetString(KGateway, gatewayUrl ?? "");
PlayerPrefs.SetString(KCdn, cdnBase ?? "");
PlayerPrefs.SetString(KName, serverName ?? "");
PlayerPrefs.Save();
Debug.Log($"[DevTest] switched to LAN dev server {serverName}: {gatewayUrl}");
}
public static void Clear()
{
PlayerPrefs.DeleteKey(KActive);
PlayerPrefs.DeleteKey(KGateway);
PlayerPrefs.DeleteKey(KCdn);
PlayerPrefs.DeleteKey(KName);
PlayerPrefs.Save();
Debug.Log("[DevTest] reset to normal server config");
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 51a4bc69d945cbe458536264e75c64a4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,245 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace XGame.MiniGame
{
public sealed class PcLobbySmokeLauncher : MonoBehaviour
{
public string GatewayUrl = "ws://127.0.0.1:5005/ws?pid=1";
public string CdnBase = "";
public bool AutoStart;
private readonly List<GameInfoMsg> _games = new List<GameInfoMsg>();
private string _status = "idle";
private XWebSocket _socket;
private MiniGameNetChannel _lobbyNet;
private MiniGameHost _gameHost;
private bool _lobbyActive;
private bool _matching;
private float _matchElapsed;
private void Start()
{
#if XW_DEVTEST
if (GetComponent<DevServerSwitchUI>() == null) gameObject.AddComponent<DevServerSwitchUI>();
#endif
if (AutoStart) ConnectLobby();
}
private void OnDestroy()
{
_socket?.close();
_socket = null;
}
private void Update()
{
if (_lobbyActive) _lobbyNet?.Pump();
if (_matching) _matchElapsed += Time.deltaTime;
}
private void OnGUI()
{
if (!_lobbyActive && _matching) return;
GUILayout.BeginArea(new Rect(16, 16, 640, 260), GUI.skin.box);
GUILayout.Label("PC C# Lobby");
GUILayout.Label("Status: " + _status);
GUILayout.Label("Gateway URL");
GatewayUrl = GUILayout.TextField(GatewayUrl);
GUILayout.Label("CDN Base");
CdnBase = GUILayout.TextField(string.IsNullOrEmpty(CdnBase) ? DefaultCdnBase() : CdnBase);
GUILayout.BeginHorizontal();
if (GUILayout.Button(_socket == null ? "Enter Lobby" : "Refresh Games", GUILayout.Width(140)))
{
if (_socket == null) ConnectLobby();
else RequestGameList();
}
GUI.enabled = _socket != null && !_matching && _games.Count > 0;
if (GUILayout.Button("Random Match", GUILayout.Width(140))) RandomMatch();
GUI.enabled = true;
GUILayout.EndHorizontal();
if (_matching) GUILayout.Label("Matching: " + _matchElapsed.ToString("0.0") + "s / AI fill after server timeout");
GUILayout.Label("Discovered games:");
if (_games.Count == 0) GUILayout.Label("(none)");
for (int i = 0; i < _games.Count; i++)
{
var g = _games[i];
GUILayout.Label("- " + g.GameId + " v" + g.Version + " players " + g.MinPlayers + "-" + g.MaxPlayers);
}
GUILayout.EndArea();
}
public void ConnectLobby()
{
string url = ResolvedGatewayUrl();
_status = "connecting " + url;
_socket?.close();
_socket = new XWebSocket();
_socket.Connect(url, (sock, err) =>
{
if (!string.IsNullOrEmpty(err) || sock == null)
{
_status = "connect failed: " + err;
Debug.LogError("[PcLobby] " + _status);
return;
}
_status = "lobby connected";
_lobbyNet = new MiniGameNetChannel(sock);
_lobbyNet.OnFrameworkFrame = OnLobbyFrameworkFrame;
_lobbyActive = true;
_matching = false;
RequestGameList();
});
}
private void RequestGameList()
{
_status = "requesting game list";
_lobbyNet?.SendFramework(FrameworkOpcode.GameListRequest, Array.Empty<byte>());
}
private void RandomMatch()
{
_status = "matching random game";
_matching = true;
_matchElapsed = 0f;
_lobbyNet?.SendFramework(FrameworkOpcode.RandomMatchRequest, Array.Empty<byte>());
}
private void OnLobbyFrameworkFrame(Frame frame)
{
switch ((FrameworkOpcode)frame.Opcode)
{
case FrameworkOpcode.GameListResponse:
var list = GameListResponseMsg.Decode(frame.Payload);
_games.Clear();
_games.AddRange(list.Games);
_status = "games discovered: " + _games.Count;
break;
case FrameworkOpcode.MatchAssigned:
var assigned = MatchAssignedMsg.Decode(frame.Payload);
_status = "assigned " + assigned.GameId + " v" + assigned.Version + ", loading hot DLL";
EnterAssignedGame(assigned.GameId, assigned.Version);
break;
case FrameworkOpcode.Error:
var err = ErrorMsg.Decode(frame.Payload);
_status = "server error " + err.Code + ": " + err.Message;
_matching = false;
break;
}
}
private void EnterAssignedGame(string gameId, int version)
{
_lobbyActive = false;
_lobbyNet = null;
_gameHost = gameObject.GetComponent<MiniGameHost>();
if (_gameHost == null) _gameHost = gameObject.AddComponent<MiniGameHost>();
_gameHost.OnGameExited = () =>
{
_status = "returned to lobby";
_matching = false;
_lobbyNet = new MiniGameNetChannel(_socket);
_lobbyNet.OnFrameworkFrame = OnLobbyFrameworkFrame;
_lobbyActive = true;
RequestGameList();
};
_gameHost.EnterGame(ResolvedCdnBase(), gameId, version, _socket, false);
}
private string ResolvedCdnBase()
{
#if XW_DEVTEST
if (MiniGameTestOverride.IsActive && !string.IsNullOrEmpty(MiniGameTestOverride.CdnBase))
return MiniGameTestOverride.CdnBase;
#endif
return string.IsNullOrEmpty(CdnBase) ? DefaultCdnBase() : CdnBase;
}
private string ResolvedGatewayUrl()
{
#if XW_DEVTEST
if (MiniGameTestOverride.IsActive && !string.IsNullOrEmpty(MiniGameTestOverride.GatewayUrl))
{
string g = MiniGameTestOverride.GatewayUrl;
return WithLocalPid(g);
}
#endif
return WithLocalPid(GatewayUrl);
}
private static string WithLocalPid(string gatewayUrl)
{
if (string.IsNullOrWhiteSpace(gatewayUrl))
{
return gatewayUrl;
}
int pid = GetLocalPlayerId();
int queryStart = gatewayUrl.IndexOf('?');
if (queryStart < 0)
{
return gatewayUrl + "?pid=" + pid;
}
string head = gatewayUrl.Substring(0, queryStart);
string query = gatewayUrl.Substring(queryStart + 1);
string[] parts = query.Split('&');
bool replaced = false;
for (int i = 0; i < parts.Length; i++)
{
if (parts[i].StartsWith("pid=", StringComparison.OrdinalIgnoreCase))
{
parts[i] = "pid=" + pid;
replaced = true;
break;
}
}
return head + "?" + (replaced ? string.Join("&", parts) : query + "&pid=" + pid);
}
private static int GetLocalPlayerId()
{
const string key = "LocalClientId";
string id = XData.GetString(key, string.Empty);
if (string.IsNullOrEmpty(id))
{
id = SystemInfo.deviceUniqueIdentifier;
if (string.IsNullOrEmpty(id) || id == "unsupported")
{
id = Guid.NewGuid().ToString("N");
}
XData.SetString(key, id);
XData.Save();
}
unchecked
{
uint hash = 2166136261u;
for (int i = 0; i < id.Length; i++)
{
hash ^= id[i];
hash *= 16777619u;
}
return (int)(hash % 2000000000u) + 1;
}
}
private static string DefaultCdnBase()
{
// Editor: <repo>/Client/Assets -> <repo>/CDN/minigame/(由 XWorld/生成小游戏更新 菜单产出)
var assets = new DirectoryInfo(Application.dataPath);
var client = assets.Parent;
var repo = client?.Parent;
string root = repo != null ? repo.FullName : Directory.GetCurrentDirectory();
return "file:///" + root.Replace('\\', '/') + "/CDN/minigame/";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 234ccb579ede8ab48ad4f7dc368c851f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,146 @@
using System;
using System.IO;
using UnityEngine;
namespace XGame.MiniGame
{
// PC 本地端到端烟测入口:连接本地 Gateway,再用 MiniGameHost 下载/加载 rps 小游戏。
// 挂到任意场景对象后 Play;也可以运行时由 AddComponent 创建。
public sealed class PcMiniGameSmokeLauncher : MonoBehaviour
{
public string GatewayUrl = "ws://127.0.0.1:5005/ws?pid=1";
public string CdnBase = "";
public string GameId = "rps";
public int Version = 1;
public bool AutoStart;
private string _status = "idle";
private XWebSocket _socket;
private MiniGameHost _host;
private void Start()
{
if (AutoStart) StartSmoke();
}
private void OnDestroy()
{
_socket?.close();
_socket = null;
}
private void OnGUI()
{
GUILayout.BeginArea(new Rect(16, 16, 620, 230), GUI.skin.box);
GUILayout.Label("PC MiniGame Smoke");
GUILayout.Label("Status: " + _status);
GUILayout.Label("Gateway URL");
GatewayUrl = GUILayout.TextField(GatewayUrl);
GUILayout.Label("CDN Base");
CdnBase = GUILayout.TextField(string.IsNullOrEmpty(CdnBase) ? DefaultCdnBase() : CdnBase);
GUILayout.BeginHorizontal();
GUILayout.Label("Game", GUILayout.Width(48));
GameId = GUILayout.TextField(GameId, GUILayout.Width(120));
GUILayout.Label("Version", GUILayout.Width(56));
int.TryParse(GUILayout.TextField(Version.ToString(), GUILayout.Width(60)), out Version);
GUILayout.EndHorizontal();
if (GUILayout.Button("Start Smoke")) StartSmoke();
GUILayout.Label("Expected: download rps DLLs -> connect -> MatchFound(+AI) -> snapshots -> RoomEnd.");
GUILayout.EndArea();
}
public void StartSmoke()
{
string gatewayUrl = WithLocalPid(GatewayUrl);
_status = "connecting " + gatewayUrl;
_socket?.close();
_socket = new XWebSocket();
_socket.Connect(gatewayUrl, (sock, err) =>
{
if (!string.IsNullOrEmpty(err) || sock == null)
{
_status = "connect failed: " + err;
Debug.LogError("[PcMiniGameSmoke] " + _status);
return;
}
_status = "connected, entering " + GameId + " v" + Version;
Debug.Log("[PcMiniGameSmoke] " + _status);
_host = gameObject.GetComponent<MiniGameHost>();
if (_host == null) _host = gameObject.AddComponent<MiniGameHost>();
_host.EnterGame(ResolvedCdnBase(), GameId, Version, sock);
});
}
private static string WithLocalPid(string gatewayUrl)
{
if (string.IsNullOrWhiteSpace(gatewayUrl))
{
return gatewayUrl;
}
int pid = GetLocalPlayerId();
int queryStart = gatewayUrl.IndexOf('?');
if (queryStart < 0)
{
return gatewayUrl + "?pid=" + pid;
}
string head = gatewayUrl.Substring(0, queryStart);
string query = gatewayUrl.Substring(queryStart + 1);
string[] parts = query.Split('&');
bool replaced = false;
for (int i = 0; i < parts.Length; i++)
{
if (parts[i].StartsWith("pid=", StringComparison.OrdinalIgnoreCase))
{
parts[i] = "pid=" + pid;
replaced = true;
break;
}
}
return head + "?" + (replaced ? string.Join("&", parts) : query + "&pid=" + pid);
}
private static int GetLocalPlayerId()
{
const string key = "LocalClientId";
string id = XData.GetString(key, string.Empty);
if (string.IsNullOrEmpty(id))
{
id = SystemInfo.deviceUniqueIdentifier;
if (string.IsNullOrEmpty(id) || id == "unsupported")
{
id = Guid.NewGuid().ToString("N");
}
XData.SetString(key, id);
XData.Save();
}
unchecked
{
uint hash = 2166136261u;
for (int i = 0; i < id.Length; i++)
{
hash ^= id[i];
hash *= 16777619u;
}
return (int)(hash % 2000000000u) + 1;
}
}
private string ResolvedCdnBase()
{
return string.IsNullOrEmpty(CdnBase) ? DefaultCdnBase() : CdnBase;
}
private static string DefaultCdnBase()
{
// Editor: <repo>/Client/Assets -> <repo>/CDN/minigame/(由 XWorld/生成小游戏更新 菜单产出)
var assets = new DirectoryInfo(Application.dataPath);
var client = assets.Parent;
var repo = client?.Parent;
string root = repo != null ? repo.FullName : Directory.GetCurrentDirectory();
return "file:///" + root.Replace('\\', '/') + "/CDN/minigame/";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5465b3788fbc36e4997ac55a2f2ac387
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
using System;
using UnityEngine;
using XWorld.Framework;
namespace XGame.MiniGame
{
// IAssetLoader over XResLoader(异步按需从 CDN 下载/加载小游戏 AB 资源)
public sealed class UnityAssetLoader : IAssetLoader
{
// path: 形如 "Assets/Game/Art/UI/Prefab/UI_RPS.prefab"XResLoader 内部剥 "Assets/" 前缀并按目录解析 AB。
// 用回调版 LoadRes(内部自驱动协程),可在非协程上下文 fire-and-forget;失败回调 null。
public void Load(string path, Action<object> onLoaded)
{
XResLoader.LoadRes(path, typeof(UnityEngine.Object),
objs => onLoaded?.Invoke(objs != null && objs.Length > 0 ? objs[0] : null));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6c672c124aa90a347899d7a5132851ea
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: