Add MainHUD gameplay UI, net handshake updates, and server maintenance mode.

Introduce UiManager/MainHUD prefab flow, strengthen GameNet/WsClient, and add AIServer maintenance endpoint support.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 17:34:42 +08:00
co-authored by Cursor
parent 10d9b98637
commit c94b8fefeb
32 changed files with 1907 additions and 74 deletions
+6 -47
View File
@@ -1,7 +1,7 @@
using AOT.Boot;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using UnityEngine;
using UnityEngine.UI;
namespace HotUpdate
{
@@ -23,8 +23,6 @@ namespace HotUpdate
public sealed class HotUpdateBootstrap : MonoBehaviour
{
BootContext _ctx;
GameNet _net;
Text _hud;
public void Bind(BootContext ctx)
{
@@ -34,53 +32,14 @@ namespace HotUpdate
void Start()
{
Debug.Log($"[HotUpdate] Bootstrap ready. CDN={_ctx?.CdnRoot}");
EnsureHud();
// M3:示例角色(与网络并行,不依赖 WS)
var spawner = gameObject.AddComponent<CharacterSpawner>();
spawner.Bind(_ctx);
spawner.Spawn();
var net = gameObject.AddComponent<GameNet>();
net.Bind(_ctx);
_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} | MaleExplorer";
Debug.Log($"[HotUpdate] M2 WS ready pid={_net.Session?.Pid}");
};
_net.StartGuestSession();
}
var gameplay = gameObject.AddComponent<GameplayBootstrap>();
gameplay.Bind(_ctx, net);
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…";
net.StartGuestSession();
}
}
}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e3ffadd6f9ceffd42afecebdd7ac5f22
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,125 @@
using AOT.Boot;
using HotUpdate.Net;
using HotUpdate.UI;
using UnityEngine;
namespace HotUpdate.Gameplay
{
/// <summary>
/// M4 玩法骨架:挂 HUD、角色、网络状态;处理公告与断线重连提示。
/// </summary>
public sealed class GameplayBootstrap : MonoBehaviour
{
BootContext _ctx;
GameNet _net;
MainHudView _hud;
ConfirmDialogView _dialog;
bool _maintenanceLock;
public void Bind(BootContext ctx, GameNet net)
{
_ctx = ctx;
_net = net;
}
void Start()
{
EnsureUi();
WireNet();
MaybeShowBootNotice();
var spawner = gameObject.AddComponent<CharacterSpawner>();
spawner.Bind(_ctx);
spawner.Spawn();
}
void EnsureUi()
{
var uiRoot = new GameObject("UiRoot");
DontDestroyOnLoad(uiRoot);
var mgr = uiRoot.AddComponent<UiManager>();
mgr.Init(uiRoot.transform, new UiPrefabLoader());
var hudGo = mgr.Open(MainHudView.PrefabName);
_hud = hudGo != null ? hudGo.GetComponent<MainHudView>() : null;
if (_hud == null && hudGo != null)
_hud = hudGo.AddComponent<MainHudView>();
_hud?.Bind();
_hud?.SetStatus("启动中…");
if (_hud != null)
_hud.OnReconnectClicked += () => _net?.RequestReconnect();
var dlgGo = mgr.Open(ConfirmDialogView.PrefabName);
if (dlgGo != null)
{
dlgGo.SetActive(false);
_dialog = dlgGo.GetComponent<ConfirmDialogView>();
if (_dialog == null) _dialog = dlgGo.AddComponent<ConfirmDialogView>();
_dialog.Bind();
}
}
void WireNet()
{
if (_net == null) return;
_net.OnStatus += s => _hud?.SetStatus(s);
_net.OnAuthed += session => _hud?.SetPid(session != null ? session.Pid : 0);
_net.OnWsReady += () =>
{
if (_maintenanceLock) return;
_hud?.ShowReconnect(false);
_hud?.SetStatus("已连接");
};
_net.OnWsDisconnected += reason =>
{
if (_maintenanceLock) return;
_hud?.ShowReconnect(true, $"网络已断开({reason}),正在重连…");
};
_net.OnReconnecting += attempt =>
{
if (_maintenanceLock) return;
_hud?.ShowReconnect(true, $"正在重连…(第 {attempt} 次)");
};
_net.OnMaintenance += msg =>
{
_maintenanceLock = true;
_hud?.ShowReconnect(false);
_hud?.SetStatus("服务器维护中");
_dialog?.Show("维护公告", string.IsNullOrEmpty(msg) ? "服务器维护中,请稍后再试。" : msg);
};
_net.OnWsMessage += TryHandleProtocol;
}
void MaybeShowBootNotice()
{
if (_ctx == null || string.IsNullOrEmpty(_ctx.Notice)) return;
_dialog?.Show("公告", _ctx.Notice);
}
void TryHandleProtocol(string json)
{
if (string.IsNullOrEmpty(json)) return;
// 轻量解析,避免引入完整 JSON 库依赖
if (json.IndexOf("\"op\":\"maintenance\"", System.StringComparison.Ordinal) >= 0 ||
json.IndexOf("\"op\": \"maintenance\"", System.StringComparison.Ordinal) >= 0)
{
var msg = ExtractJsonString(json, "message");
_net?.NotifyMaintenance(msg);
}
}
static string ExtractJsonString(string json, string key)
{
var token = $"\"{key}\"";
var i = json.IndexOf(token, System.StringComparison.Ordinal);
if (i < 0) return null;
i = json.IndexOf(':', i);
if (i < 0) return null;
i = json.IndexOf('"', i + 1);
if (i < 0) return null;
var j = json.IndexOf('"', i + 1);
if (j < 0) return null;
return json.Substring(i + 1, j - i - 1);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ad4b87bb9c63c5c47a24a609988b37b6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -4,7 +4,8 @@
"references": [
"AOT.Runtime",
"YooAsset",
"UnityEngine.UI"
"UnityEngine.UI",
"Unity.TextMeshPro"
],
"includePlatforms": [],
"excludePlatforms": [],
@@ -6,7 +6,7 @@ using UnityEngine;
namespace HotUpdate.Net
{
/// <summary>
/// 登录 → WS 连接编排。
/// 登录 → WS 连接编排(含指数退避重连与维护公告)。
/// </summary>
public sealed class GameNet : MonoBehaviour
{
@@ -21,9 +21,14 @@ namespace HotUpdate.Net
public event Action OnWsReady;
public event Action<string> OnStatus;
public event Action<string> OnWsMessage;
public event Action<string> OnWsDisconnected;
public event Action<int> OnReconnecting;
public event Action<string> OnMaintenance;
BootContext _boot;
bool _started;
bool _maintenance;
bool _everConnected;
public void Bind(BootContext boot)
{
@@ -35,7 +40,6 @@ namespace HotUpdate.Net
if (!string.IsNullOrEmpty(_boot.WsUrlTemplate))
{
// 旧模板只有 pid 时补上 token
var tpl = _boot.WsUrlTemplate;
if (!tpl.Contains("{token}"))
{
@@ -54,11 +58,37 @@ namespace HotUpdate.Net
StartCoroutine(RunGuestFlow());
}
/// <summary>UI「立即重连」:重置退避并马上再连。</summary>
public void RequestReconnect()
{
if (_maintenance || Session == null || !Session.IsValid) return;
SetStatus("手动重连…");
if (Socket != null)
{
Socket.AutoReconnect = true;
Socket.ForceReconnect();
}
else
StartCoroutine(ConnectWs());
}
public void NotifyMaintenance(string message)
{
if (_maintenance) return;
_maintenance = true;
if (Socket != null)
{
Socket.AutoReconnect = false;
Socket.Close();
}
SetStatus("maintenance");
OnMaintenance?.Invoke(message ?? "服务器维护中");
}
IEnumerator RunGuestFlow()
{
SetStatus("auth…");
var deviceId = DeviceId.GetOrCreate();
// 服务器要求 username/password;编辑器自动账号
var username = $"guest_{deviceId}";
var password = $"pw_{deviceId}";
@@ -89,6 +119,7 @@ namespace HotUpdate.Net
IEnumerator ConnectWs()
{
if (_maintenance) yield break;
if (Session == null || !Session.IsValid)
{
SetStatus("no session");
@@ -99,14 +130,13 @@ namespace HotUpdate.Net
Socket?.Dispose();
Socket = new WsClient();
Socket.AutoReconnect = true;
Socket.OnConnected += () =>
Socket.OnConnected += HandleConnected;
Socket.OnDisconnected += HandleDisconnected;
Socket.OnReconnecting += attempt =>
{
SetStatus("ws connected");
OnWsReady?.Invoke();
// 应用层握手(服务端当前可无响应,保留扩展)
Socket.SendText($"{{\"op\":\"hello\",\"proto\":1,\"app\":\"{_boot?.AppVersion}\",\"res\":\"{_boot?.ResVersion}\",\"pid\":{Session.Pid}}}");
SetStatus($"ws reconnecting #{attempt}");
OnReconnecting?.Invoke(attempt);
};
Socket.OnDisconnected += reason => SetStatus($"ws down: {reason}");
Socket.OnMessage += msg =>
{
Debug.Log($"[GameNet] << {msg}");
@@ -116,7 +146,6 @@ namespace HotUpdate.Net
var url = Session.BuildWsUrl(WsUrlTemplate);
Socket.Connect(url);
// 等待最多 15s
float t = 0f;
while (t < 15f && Socket.State != WsState.Connected && Socket.State != WsState.Disconnected)
{
@@ -132,6 +161,28 @@ namespace HotUpdate.Net
SetStatus($"ws state={Socket.State}");
}
void HandleConnected()
{
if (_maintenance) return;
SetStatus(_everConnected ? "ws reconnected" : "ws connected");
_everConnected = true;
SendHello();
OnWsReady?.Invoke();
}
void HandleDisconnected(string reason)
{
SetStatus($"ws down: {reason}");
OnWsDisconnected?.Invoke(reason ?? "closed");
}
void SendHello()
{
if (Socket == null || Session == null) return;
Socket.SendText(
$"{{\"op\":\"hello\",\"proto\":1,\"app\":\"{_boot?.AppVersion}\",\"res\":\"{_boot?.ResVersion}\",\"pid\":{Session.Pid}}}");
}
void Update()
{
Socket?.Tick();
@@ -38,6 +38,7 @@ namespace HotUpdate.Net
public event Action OnConnected;
public event Action<string> OnDisconnected;
public event Action<string> OnMessage;
public event Action<int> OnReconnecting;
public bool AutoReconnect { get; set; } = true;
public float KeepAliveSeconds { get; set; } = 20f;
@@ -50,6 +51,20 @@ namespace HotUpdate.Net
_ = ConnectInternalAsync();
}
/// <summary>立刻断开并重新连接(重置退避计数)。</summary>
public void ForceReconnect()
{
if (string.IsNullOrEmpty(_url)) return;
_manualClose = false;
AutoReconnect = true;
_reconnectAttempt = 0;
SafeCancel();
try { _ws?.Dispose(); } catch { /* ignore */ }
_ws = null;
State = WsState.Disconnected;
_ = ConnectInternalAsync();
}
public void SendText(string text)
{
if (string.IsNullOrEmpty(text)) return;
@@ -119,6 +134,7 @@ namespace HotUpdate.Net
_reconnectAttempt++;
int delayMs = Math.Min(30000, 1000 * (1 << Math.Min(5, _reconnectAttempt)));
Debug.Log($"[WsClient] reconnect in {delayMs}ms attempt={_reconnectAttempt}");
OnReconnecting?.Invoke(_reconnectAttempt);
try
{
await Task.Delay(delayMs, CancellationToken.None);
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1a790887f85681a4a84ab4e02373106a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,77 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace HotUpdate.UI
{
/// <summary>
/// UI_ConfirmDialog:公告 / 维护 / 通用确认。
/// </summary>
public sealed class ConfirmDialogView : MonoBehaviour
{
public const string PrefabName = "UI_ConfirmDialog";
TextMeshProUGUI _title;
TextMeshProUGUI _message;
Button _ok;
Button _cancel;
Button _close;
Action _onOk;
Action _onCancel;
public void Bind()
{
_title = FindTmp("Txt_Title");
_message = FindTmp("Txt_Message");
_ok = FindBtn("Btn_Ok");
_cancel = FindBtn("Btn_Cancel");
_close = FindBtn("Btn_Close");
Wire(_ok, () => { _onOk?.Invoke(); Hide(); });
Wire(_cancel, () => { _onCancel?.Invoke(); Hide(); });
Wire(_close, () => { _onCancel?.Invoke(); Hide(); });
}
public void Show(
string title,
string message,
Action onOk = null,
Action onCancel = null,
bool showCancel = false)
{
if (_title != null) _title.text = title ?? string.Empty;
if (_message != null) _message.text = message ?? string.Empty;
_onOk = onOk;
_onCancel = onCancel;
if (_cancel != null) _cancel.gameObject.SetActive(showCancel);
gameObject.SetActive(true);
}
public void Hide()
{
gameObject.SetActive(false);
}
static void Wire(Button btn, Action action)
{
if (btn == null) return;
btn.onClick.RemoveAllListeners();
btn.onClick.AddListener(() => action?.Invoke());
}
TextMeshProUGUI FindTmp(string name)
{
foreach (var c in GetComponentsInChildren<TextMeshProUGUI>(true))
if (c.name == name) return c;
return null;
}
Button FindBtn(string name)
{
foreach (var c in GetComponentsInChildren<Button>(true))
if (c.name == name) return c;
return null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0eff42e367d11a7408adc8c9db3697c8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,71 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace HotUpdate.UI
{
/// <summary>
/// UI_MainHUD 运行时绑定。
/// </summary>
public sealed class MainHudView : MonoBehaviour
{
public const string PrefabName = "UI_MainHUD";
TextMeshProUGUI _status;
TextMeshProUGUI _pid;
GameObject _reconnectPanel;
TextMeshProUGUI _reconnectText;
Button _reconnectBtn;
public event Action OnReconnectClicked;
public void Bind()
{
_status = FindTmp("Txt_Status");
_pid = FindTmp("Txt_Pid");
var panel = transform.Find("Panel_Reconnect");
_reconnectPanel = panel != null ? panel.gameObject : null;
_reconnectText = FindTmp("Txt_Reconnect");
var btnTf = transform.Find("Panel_Reconnect/Btn_Reconnect");
_reconnectBtn = btnTf != null ? btnTf.GetComponent<Button>() : null;
if (_reconnectBtn != null)
{
_reconnectBtn.onClick.RemoveAllListeners();
_reconnectBtn.onClick.AddListener(() => OnReconnectClicked?.Invoke());
}
ShowReconnect(false);
}
public void SetStatus(string text)
{
if (_status != null) _status.text = text ?? string.Empty;
}
public void SetPid(long pid)
{
if (_pid != null) _pid.text = pid > 0 ? $"pid={pid}" : "pid=-";
}
public void ShowReconnect(bool visible, string message = null)
{
if (_reconnectPanel != null) _reconnectPanel.SetActive(visible);
if (visible && _reconnectText != null && !string.IsNullOrEmpty(message))
_reconnectText.text = message;
}
TextMeshProUGUI FindTmp(string path)
{
var t = transform.Find(path);
if (t == null)
{
// 深层查找
var all = GetComponentsInChildren<TextMeshProUGUI>(true);
foreach (var c in all)
if (c.name == path) return c;
return null;
}
return t.GetComponent<TextMeshProUGUI>();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 85b335bef198c764dac1b0c2792443a8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace HotUpdate.UI
{
/// <summary>
/// 竖屏 UI 页面栈:加载 Prefab、打开/关闭、按名查找。
/// </summary>
public sealed class UiManager : MonoBehaviour
{
public static UiManager Instance { get; private set; }
readonly Dictionary<string, GameObject> _opened = new Dictionary<string, GameObject>(StringComparer.Ordinal);
Transform _root;
UiPrefabLoader _loader;
public void Init(Transform root, UiPrefabLoader loader)
{
Instance = this;
_root = root;
_loader = loader ?? new UiPrefabLoader();
}
public GameObject Open(string prefabName, int siblingIndex = -1)
{
if (string.IsNullOrEmpty(prefabName)) return null;
if (_opened.TryGetValue(prefabName, out var existing) && existing != null)
{
existing.SetActive(true);
return existing;
}
var prefab = _loader.Load(prefabName);
if (prefab == null)
{
Debug.LogError($"[UiManager] prefab not found: {prefabName}");
return null;
}
var go = Instantiate(prefab, _root, false);
go.name = prefabName;
if (siblingIndex >= 0)
go.transform.SetSiblingIndex(siblingIndex);
_opened[prefabName] = go;
return go;
}
public T Open<T>(string prefabName) where T : Component
{
var go = Open(prefabName);
return go != null ? go.GetComponentInChildren<T>(true) : null;
}
public void Close(string prefabName, bool destroy = false)
{
if (!_opened.TryGetValue(prefabName, out var go) || go == null)
return;
if (destroy)
{
Destroy(go);
_opened.Remove(prefabName);
}
else go.SetActive(false);
}
public GameObject Get(string prefabName)
{
_opened.TryGetValue(prefabName, out var go);
return go;
}
void OnDestroy()
{
if (Instance == this) Instance = null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f9774ca39700a5042a7f4876e1f4e2cf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,39 @@
using UnityEngine;
using YooAsset;
namespace HotUpdate.UI
{
/// <summary>
/// UI Prefab 加载:编辑器 AssetDatabase;运行时优先 DefaultPackage 寻址。
/// </summary>
public sealed class UiPrefabLoader
{
public const string PrefabFolder = "Assets/Bundles/UI/Prefab";
public GameObject Load(string prefabName)
{
if (string.IsNullOrEmpty(prefabName)) return null;
if (YooAssets.IsInitialized)
{
// DefaultPackage 或任意已初始化包
var packages = new[] { "DefaultPackage", "Art" };
foreach (var name in packages)
{
if (!YooAssets.TryGetPackage(name, out var pkg)) continue;
if (pkg.InitializeStatus != EOperationStatus.Succeeded) continue;
var handle = pkg.LoadAssetSync<GameObject>(prefabName);
if (handle.Status == EOperationStatus.Succeeded)
return handle.GetAssetObject<GameObject>();
}
}
#if UNITY_EDITOR
var path = $"{PrefabFolder}/{prefabName}.prefab";
return UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>(path);
#else
return null;
#endif
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4e78d596353200e4fbb315f840e50f6e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: