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:
@@ -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:
|
||||
Reference in New Issue
Block a user