Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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+ImportParameters(netstandard2.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
|
||||
{
|
||||
// 从代码 AB(manifest.CodeAb)取出 Core/Client 热更 DLL 字节并加载,反射出 IGameClient 入口工厂
|
||||
public sealed class MiniGameAssemblyLoader
|
||||
{
|
||||
public Func<IGameClient> CreateClient { get; private set; }
|
||||
public string Error { get; private set; }
|
||||
|
||||
// localDir: MiniGameDownloader.LocalDir;manifest: 解析好的 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} 的 .bytes(AB 内资产: {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 已下载的小游戏资源 AB(manifest.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.txt(md5 清单)
|
||||
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 文件名(统一 AB:Core/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 消息追加到同一个 buffer;Frame 自带 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 无同步文件系统 mmap,LoadFromFile 不可用 → 读字节走 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:
|
||||
Reference in New Issue
Block a user