Add hot-update boot pipeline, UI bundles/atlas assets, and design docs.

Ignore AIClient/Library and AIClient/HybridCLRData permanently; include HybridCLR/YooAsset project setup and UI tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 16:02:58 +08:00
co-authored by Cursor
parent f3f66ab063
commit b7a0bbcb89
279 changed files with 27332 additions and 14 deletions
@@ -0,0 +1,81 @@
using AOT.Boot;
using HotUpdate.Net;
using UnityEngine;
using UnityEngine.UI;
namespace HotUpdate
{
/// <summary>
/// 热更稳定入口(壳通过反射/Assembly.Load 调用,签名勿改)。
/// </summary>
public static class Entry
{
public static void Start(BootContext ctx)
{
Debug.Log($"[HotUpdate.Entry] Start app={ctx?.AppVersion} res={ctx?.ResVersion}");
var root = new GameObject("HotUpdateRoot");
Object.DontDestroyOnLoad(root);
root.AddComponent<HotUpdateBootstrap>().Bind(ctx);
}
}
public sealed class HotUpdateBootstrap : MonoBehaviour
{
BootContext _ctx;
GameNet _net;
Text _hud;
public void Bind(BootContext ctx)
{
_ctx = ctx;
}
void Start()
{
Debug.Log($"[HotUpdate] Bootstrap ready. CDN={_ctx?.CdnRoot}");
EnsureHud();
_net = gameObject.AddComponent<GameNet>();
_net.Bind(_ctx);
_net.OnStatus += s =>
{
if (_hud != null) _hud.text = s;
};
_net.OnWsReady += () =>
{
if (_hud != null)
_hud.text = $"WS OK pid={_net.Session?.Pid}";
Debug.Log($"[HotUpdate] M2 WS ready pid={_net.Session?.Pid}");
};
_net.StartGuestSession();
}
void EnsureHud()
{
var canvasGo = new GameObject("NetHud");
canvasGo.transform.SetParent(transform, false);
var canvas = canvasGo.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 200;
var scaler = canvasGo.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1080, 1920);
canvasGo.AddComponent<GraphicRaycaster>();
var textGo = new GameObject("Status");
textGo.transform.SetParent(canvasGo.transform, false);
_hud = textGo.AddComponent<Text>();
_hud.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
_hud.fontSize = 28;
_hud.color = Color.white;
_hud.alignment = TextAnchor.LowerCenter;
var rt = textGo.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0.05f, 0.08f);
rt.anchorMax = new Vector2(0.95f, 0.16f);
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
_hud.text = "net…";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d880c1fc6caec5243875d833d4027a53
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
{
"name": "HotUpdate.Runtime",
"rootNamespace": "HotUpdate",
"references": [
"AOT.Runtime",
"YooAsset",
"UnityEngine.UI"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: a80bdfe5054d1ec45bb33f18fc116bc6
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 81bd14963b08a964ea0227cb6a8bfb01
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,124 @@
using System;
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
namespace HotUpdate.Net
{
/// <summary>
/// 游戏服 HTTP 鉴权:POST /register、POST /login → {token,pid}
/// </summary>
public static class AuthApi
{
public const string DefaultHttpBase = "https://game.xworld.ren";
[Serializable]
class AuthBody
{
public string username;
public string password;
}
public static IEnumerator Register(string httpBase, string username, string password, Action<AuthResponse, string> onDone)
{
yield return PostJson(httpBase, "/register", username, password, onDone);
}
public static IEnumerator Login(string httpBase, string username, string password, Action<AuthResponse, string> onDone)
{
yield return PostJson(httpBase, "/login", username, password, onDone);
}
/// <summary>
/// 优先登录;失败则注册再登录(编辑器/游客自动账号)。
/// </summary>
public static IEnumerator EnsureAccount(string httpBase, string username, string password, Action<AuthResponse, string> onDone)
{
AuthResponse loginOk = null;
string loginErr = null;
yield return Login(httpBase, username, password, (r, e) => { loginOk = r; loginErr = e; });
if (loginOk != null && !string.IsNullOrEmpty(loginOk.token))
{
onDone?.Invoke(loginOk, null);
yield break;
}
AuthResponse regOk = null;
string regErr = null;
yield return Register(httpBase, username, password, (r, e) => { regOk = r; regErr = e; });
if (regOk != null && !string.IsNullOrEmpty(regOk.token))
{
onDone?.Invoke(regOk, null);
yield break;
}
// 注册失败(例如已存在)再登录一次
AuthResponse login2 = null;
string login2Err = null;
yield return Login(httpBase, username, password, (r, e) => { login2 = r; login2Err = e; });
if (login2 != null && !string.IsNullOrEmpty(login2.token))
{
onDone?.Invoke(login2, null);
yield break;
}
onDone?.Invoke(null, $"auth failed login={loginErr}|{login2Err} register={regErr}");
}
static IEnumerator PostJson(string httpBase, string path, string username, string password, Action<AuthResponse, string> onDone)
{
var url = (httpBase ?? DefaultHttpBase).TrimEnd('/') + path;
var body = JsonUtility.ToJson(new AuthBody { username = username, password = password });
var bytes = Encoding.UTF8.GetBytes(body);
using (var req = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST))
{
req.uploadHandler = new UploadHandlerRaw(bytes);
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
req.timeout = 15;
yield return req.SendWebRequest();
#if UNITY_2020_2_OR_NEWER
bool failed = req.result != UnityWebRequest.Result.Success;
#else
bool failed = req.isNetworkError || req.isHttpError;
#endif
var text = req.downloadHandler?.text;
if (failed)
{
string err = req.error;
if (!string.IsNullOrEmpty(text))
{
try
{
var ae = JsonUtility.FromJson<AuthError>(text);
if (!string.IsNullOrEmpty(ae?.error))
err = ae.error;
}
catch { /* ignore */ }
}
onDone?.Invoke(null, $"{(int)req.responseCode} {err}");
yield break;
}
AuthResponse resp = null;
try { resp = JsonUtility.FromJson<AuthResponse>(text); }
catch (Exception e)
{
onDone?.Invoke(null, $"parse auth: {e.Message}");
yield break;
}
if (resp == null || string.IsNullOrEmpty(resp.token))
{
onDone?.Invoke(null, "auth response missing token");
yield break;
}
onDone?.Invoke(resp, null);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f9ee3063c5b47e948a30d132e0bd3d81
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,22 @@
using System;
using UnityEngine;
namespace HotUpdate.Net
{
public static class DeviceId
{
const string PrefKey = "aigame.device_id";
public static string GetOrCreate()
{
var id = PlayerPrefs.GetString(PrefKey, string.Empty);
if (!string.IsNullOrEmpty(id))
return id;
id = $"ed_{Guid.NewGuid():N}".Substring(0, 18);
PlayerPrefs.SetString(PrefKey, id);
PlayerPrefs.Save();
return id;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d6d92d71b8ab13e4496a9f7a74770338
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,148 @@
using System;
using System.Collections;
using AOT.Boot;
using UnityEngine;
namespace HotUpdate.Net
{
/// <summary>
/// 登录 → WS 连接编排。
/// </summary>
public sealed class GameNet : MonoBehaviour
{
public string HttpBase = AuthApi.DefaultHttpBase;
public string WsUrlTemplate = "wss://game.xworld.ren/ws?pid={pid}&token={token}";
public SessionData Session { get; private set; }
public WsClient Socket { get; private set; }
public string Status { get; private set; } = "idle";
public event Action<SessionData> OnAuthed;
public event Action OnWsReady;
public event Action<string> OnStatus;
public event Action<string> OnWsMessage;
BootContext _boot;
bool _started;
public void Bind(BootContext boot)
{
_boot = boot;
if (_boot != null && !string.IsNullOrEmpty(_boot.WsUrlTemplate))
{
// 旧模板只有 pid 时补上 token
var tpl = _boot.WsUrlTemplate;
if (!tpl.Contains("{token}"))
{
tpl = tpl.Contains("?")
? tpl + (tpl.EndsWith("?") || tpl.EndsWith("&") ? "token={token}" : "&token={token}")
: tpl + "?pid={pid}&token={token}";
}
WsUrlTemplate = tpl;
}
}
public void StartGuestSession()
{
if (_started) return;
_started = true;
StartCoroutine(RunGuestFlow());
}
IEnumerator RunGuestFlow()
{
SetStatus("auth…");
var deviceId = DeviceId.GetOrCreate();
// 服务器要求 username/password;编辑器自动账号
var username = $"guest_{deviceId}";
var password = $"pw_{deviceId}";
AuthResponse auth = null;
string err = null;
yield return AuthApi.EnsureAccount(HttpBase, username, password, (r, e) => { auth = r; err = e; });
if (auth == null || string.IsNullOrEmpty(auth.token))
{
SetStatus($"auth fail: {err}");
Debug.LogError($"[GameNet] auth failed: {err}");
yield break;
}
Session = new SessionData
{
Username = username,
Token = auth.token,
Pid = auth.pid,
DeviceId = deviceId,
};
Debug.Log($"[GameNet] authed pid={Session.Pid} user={username}");
OnAuthed?.Invoke(Session);
SetStatus($"authed pid={Session.Pid}");
yield return ConnectWs();
}
IEnumerator ConnectWs()
{
if (Session == null || !Session.IsValid)
{
SetStatus("no session");
yield break;
}
SetStatus("ws connecting…");
Socket?.Dispose();
Socket = new WsClient();
Socket.AutoReconnect = true;
Socket.OnConnected += () =>
{
SetStatus("ws connected");
OnWsReady?.Invoke();
// 应用层握手(服务端当前可无响应,保留扩展)
Socket.SendText($"{{\"op\":\"hello\",\"proto\":1,\"app\":\"{_boot?.AppVersion}\",\"res\":\"{_boot?.ResVersion}\",\"pid\":{Session.Pid}}}");
};
Socket.OnDisconnected += reason => SetStatus($"ws down: {reason}");
Socket.OnMessage += msg =>
{
Debug.Log($"[GameNet] << {msg}");
OnWsMessage?.Invoke(msg);
};
var url = Session.BuildWsUrl(WsUrlTemplate);
Socket.Connect(url);
// 等待最多 15s
float t = 0f;
while (t < 15f && Socket.State != WsState.Connected && Socket.State != WsState.Disconnected)
{
t += Time.unscaledDeltaTime;
yield return null;
}
if (Socket.State == WsState.Connected)
SetStatus("ws connected");
else if (Socket.State == WsState.Disconnected && !string.IsNullOrEmpty(Socket.LastError))
SetStatus($"ws fail: {Socket.LastError}");
else if (Socket.State != WsState.Connected)
SetStatus($"ws state={Socket.State}");
}
void Update()
{
Socket?.Tick();
}
void OnDestroy()
{
Socket?.Dispose();
Socket = null;
}
void SetStatus(string s)
{
Status = s;
Debug.Log($"[GameNet] {s}");
OnStatus?.Invoke(s);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a0a9138507b5d7746b089d754e55d525
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,37 @@
using System;
namespace HotUpdate.Net
{
[Serializable]
public class AuthResponse
{
public string token;
public long pid;
}
[Serializable]
public class AuthError
{
public string error;
}
public sealed class SessionData
{
public string Username;
public string Token;
public long Pid;
public string DeviceId;
public bool IsValid => !string.IsNullOrEmpty(Token) && Pid > 0;
public string BuildWsUrl(string template = null)
{
var tpl = string.IsNullOrEmpty(template)
? "wss://game.xworld.ren/ws?pid={pid}&token={token}"
: template;
return tpl
.Replace("{pid}", Pid.ToString())
.Replace("{token}", Uri.EscapeDataString(Token ?? string.Empty));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b1b2fe654c190a84dabea37193cb0ef7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,184 @@
using System;
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace HotUpdate.Net
{
public enum WsState
{
Disconnected,
Connecting,
Connected,
Reconnecting,
}
/// <summary>
/// 基于 ClientWebSocket 的传输(Editor / Standalone)。
/// WebGL/微信需另接平台桥,本类在 WebGL 下不可用。
/// </summary>
public sealed class WsClient : IDisposable
{
readonly ConcurrentQueue<string> _inbox = new ConcurrentQueue<string>();
readonly ConcurrentQueue<string> _outbox = new ConcurrentQueue<string>();
ClientWebSocket _ws;
CancellationTokenSource _cts;
Task _recvTask;
Task _sendTask;
string _url;
int _reconnectAttempt;
bool _manualClose;
public WsState State { get; private set; } = WsState.Disconnected;
public string LastError { get; private set; }
public event Action OnConnected;
public event Action<string> OnDisconnected;
public event Action<string> OnMessage;
public bool AutoReconnect { get; set; } = true;
public float KeepAliveSeconds { get; set; } = 20f;
public void Connect(string url)
{
_url = url;
_manualClose = false;
_reconnectAttempt = 0;
_ = ConnectInternalAsync();
}
public void SendText(string text)
{
if (string.IsNullOrEmpty(text)) return;
_outbox.Enqueue(text);
}
public void Close()
{
_manualClose = true;
AutoReconnect = false;
SafeCancel();
try { _ws?.Dispose(); } catch { /* ignore */ }
_ws = null;
State = WsState.Disconnected;
}
public void Tick()
{
while (_inbox.TryDequeue(out var msg))
OnMessage?.Invoke(msg);
}
async Task ConnectInternalAsync()
{
#if UNITY_WEBGL && !UNITY_EDITOR
LastError = "ClientWebSocket not supported on WebGL; need WeChat bridge";
State = WsState.Disconnected;
OnDisconnected?.Invoke(LastError);
return;
#else
SafeCancel();
_cts = new CancellationTokenSource();
State = _reconnectAttempt > 0 ? WsState.Reconnecting : WsState.Connecting;
LastError = null;
try
{
_ws = new ClientWebSocket();
_ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(Mathf.Max(5f, KeepAliveSeconds));
Debug.Log($"[WsClient] connecting {_url}");
await _ws.ConnectAsync(new Uri(_url), _cts.Token);
State = WsState.Connected;
_reconnectAttempt = 0;
Debug.Log("[WsClient] connected");
OnConnected?.Invoke();
_recvTask = RecvLoopAsync(_cts.Token);
_sendTask = SendLoopAsync(_cts.Token);
await Task.WhenAny(_recvTask, _sendTask);
}
catch (Exception e)
{
LastError = e.Message;
Debug.LogWarning($"[WsClient] connect/loop error: {e.Message}");
}
finally
{
var wasConnected = State == WsState.Connected || State == WsState.Reconnecting || State == WsState.Connecting;
State = WsState.Disconnected;
try { _ws?.Dispose(); } catch { /* ignore */ }
_ws = null;
if (wasConnected)
OnDisconnected?.Invoke(LastError ?? "closed");
if (!_manualClose && AutoReconnect)
{
_reconnectAttempt++;
int delayMs = Math.Min(30000, 1000 * (1 << Math.Min(5, _reconnectAttempt)));
Debug.Log($"[WsClient] reconnect in {delayMs}ms attempt={_reconnectAttempt}");
try
{
await Task.Delay(delayMs, CancellationToken.None);
if (!_manualClose && AutoReconnect)
_ = ConnectInternalAsync();
}
catch { /* ignore */ }
}
}
#endif
}
async Task RecvLoopAsync(CancellationToken ct)
{
var buffer = new byte[64 * 1024];
var sb = new StringBuilder();
while (!ct.IsCancellationRequested && _ws != null && _ws.State == WebSocketState.Open)
{
sb.Clear();
WebSocketReceiveResult result;
do
{
result = await _ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
if (result.MessageType == WebSocketMessageType.Close)
{
LastError = $"server close {_ws.CloseStatus} {_ws.CloseStatusDescription}";
return;
}
if (result.MessageType == WebSocketMessageType.Text)
sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
} while (!result.EndOfMessage);
if (sb.Length > 0)
_inbox.Enqueue(sb.ToString());
}
}
async Task SendLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested && _ws != null && _ws.State == WebSocketState.Open)
{
if (_outbox.TryDequeue(out var text))
{
var bytes = Encoding.UTF8.GetBytes(text);
await _ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, ct);
}
else
{
await Task.Delay(30, ct);
}
}
}
void SafeCancel()
{
try { _cts?.Cancel(); } catch { /* ignore */ }
try { _cts?.Dispose(); } catch { /* ignore */ }
_cts = null;
}
public void Dispose() => Close();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 82d0b8191ea4ed947a9ca3dd092fa9b3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: