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:
@@ -14,6 +14,8 @@ namespace AOT.Boot
|
||||
public ResourcePackage Package;
|
||||
public string HttpBase;
|
||||
public string WsUrlTemplate;
|
||||
/// <summary>CDN version.json 下发的维护/公告文案(可空)。</summary>
|
||||
public string Notice;
|
||||
|
||||
public string BuildWsUrl(string pid, string token = null)
|
||||
{
|
||||
|
||||
@@ -76,6 +76,7 @@ namespace AOT.Patch
|
||||
PackageName = _settings.PackageName,
|
||||
HttpBase = _settings.HttpBase,
|
||||
WsUrlTemplate = _settings.WsUrlTemplate,
|
||||
Notice = version.notice ?? string.Empty,
|
||||
};
|
||||
|
||||
#if UNITY_EDITOR
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f09133b5784dac24eaf495f342e7344e
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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:
|
||||
@@ -9,6 +9,7 @@ add_executable(aiserver
|
||||
src/db.cpp
|
||||
src/auth.cpp
|
||||
src/http_ws_server.cpp
|
||||
src/maintenance.cpp
|
||||
src/crypto.cpp
|
||||
third_party/sqlite3.c
|
||||
)
|
||||
|
||||
+3
-2
@@ -8,8 +8,9 @@ C++ 登录 / WebSocket 网关(SQLite),协议兼容客户端 `HotUpdate.Net
|
||||
| --- | --- | --- |
|
||||
| POST | `/register` | `{"username","password"}` → `{"token","pid"}` |
|
||||
| POST | `/login` | 同上 |
|
||||
| GET | `/health` | 健康检查 |
|
||||
| WS | `/ws?pid={pid}&token={token}` | 无 token → 401;支持 `hello`/`ping` 应用层应答 |
|
||||
| GET | `/health` | 健康检查(含 `maintenance`) |
|
||||
| GET | `/maintenance` | 维护开关状态 |
|
||||
| WS | `/ws?pid={pid}&token={token}` | 无 token → 401;支持 `hello`/`ping`;维护中下发 `maintenance` 后断开 |
|
||||
|
||||
本地默认:`http://127.0.0.1:8080`、`ws://127.0.0.1:8080/ws?...`
|
||||
生产由 Caddy TLS 反代到本进程(与 `game.xworld.ren` 部署方式一致)。
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"message": "服务器维护中,请稍后再试。"
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "auth.hpp"
|
||||
#include "crypto.hpp"
|
||||
#include "json.hpp"
|
||||
#include "maintenance.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
@@ -269,8 +270,8 @@ void ws_loop(socket_t s) {
|
||||
|
||||
} // namespace
|
||||
|
||||
HttpWsServer::HttpWsServer(db::Database& db, std::string host, uint16_t port)
|
||||
: db_(db), host_(std::move(host)), port_(port) {}
|
||||
HttpWsServer::HttpWsServer(db::Database& db, std::string host, uint16_t port, std::string maintenance_path)
|
||||
: db_(db), host_(std::move(host)), port_(port), maintenance_path_(std::move(maintenance_path)) {}
|
||||
|
||||
HttpWsServer::~HttpWsServer() { stop(); }
|
||||
|
||||
@@ -398,6 +399,18 @@ void HttpWsServer::handle_client(uintptr_t client_sock) {
|
||||
<< "\r\n";
|
||||
const auto resp = oss.str();
|
||||
if (!send_all(s, resp.data(), resp.size())) return;
|
||||
|
||||
// 维护模式:升级成功后下发 maintenance 并断开(客户端停重连)
|
||||
{
|
||||
const auto m = maintenance::load(maintenance_path_);
|
||||
if (m.enabled) {
|
||||
nlohmann::json body{{"op", "maintenance"}, {"message", m.message}, {"ok", false}};
|
||||
send_ws_text(s, body.dump());
|
||||
std::printf("[ws] maintenance reject pid=%lld\n", static_cast<long long>(pid));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
std::printf("[ws] open pid=%lld\n", static_cast<long long>(pid));
|
||||
ws_loop(s);
|
||||
std::printf("[ws] close pid=%lld\n", static_cast<long long>(pid));
|
||||
@@ -430,7 +443,18 @@ void HttpWsServer::handle_client(uintptr_t client_sock) {
|
||||
}
|
||||
|
||||
if (req.method == "GET" && req.path == "/health") {
|
||||
auto resp = http_response(200, "OK", "{\"ok\":true,\"service\":\"aiserver\"}");
|
||||
const auto m = maintenance::load(maintenance_path_);
|
||||
nlohmann::json body{{"ok", true}, {"service", "aiserver"}, {"maintenance", m.enabled}};
|
||||
if (m.enabled) body["message"] = m.message;
|
||||
auto resp = http_response(200, "OK", body.dump());
|
||||
send_all(s, resp.data(), resp.size());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method == "GET" && req.path == "/maintenance") {
|
||||
const auto m = maintenance::load(maintenance_path_);
|
||||
nlohmann::json body{{"enabled", m.enabled}, {"message", m.message}};
|
||||
auto resp = http_response(200, "OK", body.dump());
|
||||
send_all(s, resp.data(), resp.size());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
class HttpWsServer {
|
||||
public:
|
||||
HttpWsServer(db::Database& db, std::string host, uint16_t port);
|
||||
HttpWsServer(db::Database& db, std::string host, uint16_t port, std::string maintenance_path = {});
|
||||
~HttpWsServer();
|
||||
|
||||
bool start();
|
||||
@@ -22,6 +22,7 @@ class HttpWsServer {
|
||||
db::Database& db_;
|
||||
std::string host_;
|
||||
uint16_t port_;
|
||||
std::string maintenance_path_;
|
||||
std::atomic<bool> running_{false};
|
||||
uintptr_t listen_sock_ = static_cast<uintptr_t>(-1);
|
||||
std::thread accept_thread_;
|
||||
|
||||
+21
-8
@@ -10,22 +10,31 @@
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static void usage(const char* argv0) {
|
||||
std::printf("Usage: %s [--host 0.0.0.0] [--port 8080] [--db data/aiserver.db] [--daemon]\n", argv0);
|
||||
std::printf(
|
||||
"Usage: %s [--host 0.0.0.0] [--port 8080] [--db data/aiserver.db] "
|
||||
"[--maintenance data/maintenance.json] [--daemon]\n",
|
||||
argv0);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
std::string host = "0.0.0.0";
|
||||
uint16_t port = 8080;
|
||||
std::string db_path = "data/aiserver.db";
|
||||
|
||||
std::string maintenance_path;
|
||||
bool daemon = false;
|
||||
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string a = argv[i];
|
||||
if (a == "--host" && i + 1 < argc) host = argv[++i];
|
||||
else if (a == "--port" && i + 1 < argc) port = static_cast<uint16_t>(std::stoi(argv[++i]));
|
||||
else if (a == "--db" && i + 1 < argc) db_path = argv[++i];
|
||||
else if (a == "--daemon") daemon = true;
|
||||
if (a == "--host" && i + 1 < argc)
|
||||
host = argv[++i];
|
||||
else if (a == "--port" && i + 1 < argc)
|
||||
port = static_cast<uint16_t>(std::stoi(argv[++i]));
|
||||
else if (a == "--db" && i + 1 < argc)
|
||||
db_path = argv[++i];
|
||||
else if (a == "--maintenance" && i + 1 < argc)
|
||||
maintenance_path = argv[++i];
|
||||
else if (a == "--daemon")
|
||||
daemon = true;
|
||||
else if (a == "-h" || a == "--help") {
|
||||
usage(argv[0]);
|
||||
return 0;
|
||||
@@ -36,6 +45,9 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
}
|
||||
|
||||
if (maintenance_path.empty())
|
||||
maintenance_path = (fs::path(db_path).parent_path() / "maintenance.json").string();
|
||||
|
||||
try {
|
||||
fs::create_directories(fs::path(db_path).parent_path());
|
||||
} catch (...) {
|
||||
@@ -47,15 +59,16 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
HttpWsServer server(database, host, port);
|
||||
HttpWsServer server(database, host, port, maintenance_path);
|
||||
if (!server.start()) {
|
||||
std::printf("Listen failed on %s:%u\n", host.c_str(), static_cast<unsigned>(port));
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::printf("AIServer listening http://%s:%u (POST /register /login, WS /ws?pid=&token=)\n",
|
||||
std::printf("AIServer listening http://%s:%u (POST /register /login, GET /maintenance, WS /ws)\n",
|
||||
host.c_str(), static_cast<unsigned>(port));
|
||||
std::printf("DB=%s\n", db_path.c_str());
|
||||
std::printf("MaintenanceFile=%s\n", maintenance_path.c_str());
|
||||
if (daemon) {
|
||||
std::printf("daemon mode: running until process killed\n");
|
||||
while (server.running()) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#include "maintenance.hpp"
|
||||
|
||||
#include "json.hpp"
|
||||
|
||||
#include <fstream>
|
||||
|
||||
namespace maintenance {
|
||||
|
||||
State load(const std::string& path) {
|
||||
State s;
|
||||
if (path.empty()) return s;
|
||||
std::ifstream in(path);
|
||||
if (!in) return s;
|
||||
try {
|
||||
nlohmann::json j;
|
||||
in >> j;
|
||||
s.enabled = j.value("enabled", false);
|
||||
s.message = j.value("message", s.message);
|
||||
} catch (...) {
|
||||
// ignore malformed
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace maintenance
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
namespace maintenance {
|
||||
|
||||
struct State {
|
||||
bool enabled = false;
|
||||
std::string message = "服务器维护中,请稍后再试。";
|
||||
};
|
||||
|
||||
/// 读取 JSON:{"enabled":true,"message":"..."};文件不存在则 enabled=false。
|
||||
State load(const std::string& path);
|
||||
|
||||
} // namespace maintenance
|
||||
+16
-1
@@ -19,7 +19,8 @@ Base: `https://game.xworld.ren`(Caddy → 后端)
|
||||
| --- | --- | --- | --- | --- |
|
||||
| POST | `/register` | `{"username","password"}` | `{token,pid}` | `invalid_input` / `user_exists` |
|
||||
| POST | `/login` | `{"username","password"}` | `{token,pid}` | `invalid_credentials` |
|
||||
| GET | `/health` | — | `{ok,service}` | — |
|
||||
| GET | `/health` | — | `{ok,service,maintenance}` | — |
|
||||
| GET | `/maintenance` | — | `{enabled,message}` | — |
|
||||
|
||||
## WebSocket
|
||||
|
||||
@@ -29,8 +30,22 @@ Base: `https://game.xworld.ren`(Caddy → 后端)
|
||||
|
||||
- 无 token / token 无效 → HTTP 401
|
||||
- 应用层:`{"op":"hello"}` → `{"op":"hello_ack","ok":true}`;`{"op":"ping"}` → `{"op":"pong"}`
|
||||
- 维护:若 `data/maintenance.json` 中 `enabled=true`,升级后立刻下发 `{"op":"maintenance","message":"..."}` 并断开
|
||||
- 协议层 Ping/Pong 由服务端应答
|
||||
|
||||
## 维护开关
|
||||
|
||||
文件:`AIServer/data/maintenance.json`(与 `--db` 同目录,可用 `--maintenance` 覆盖)
|
||||
|
||||
```json
|
||||
{ "enabled": true, "message": "今晚 2:00-4:00 维护" }
|
||||
```
|
||||
|
||||
| GET | `/maintenance` | — | `{enabled,message}` |
|
||||
| GET | `/health` | — | 增加 `maintenance` 字段 |
|
||||
|
||||
CDN `version.json.notice` 也会在客户端启动时弹公告(不依赖游戏服)。
|
||||
|
||||
## 客户端
|
||||
|
||||
- `HotUpdate.Net.AuthApi` / `GameNet` / `WsClient`
|
||||
|
||||
@@ -16,3 +16,4 @@
|
||||
| `Doc/ui_design.jpg` | UI 视觉基准图;糖果休闲风控件参考(`Doc/Design/ui_design.jpg` 为同内容副本) |
|
||||
| `Doc/UI_AutoGen_Overview.md` | UI 自动生成方案总览(JSON→Prefab + 图集) |
|
||||
| `Doc/UIPrefabCreater/UI_ConfirmDialog.json` | 竖屏确认弹窗样例 JSON;对应 Prefab 路径 `Assets/Bundles/UI/Prefab/UI_ConfirmDialog.prefab` |
|
||||
| `Doc/UIPrefabCreater/UI_MainHUD.json` | M4 HUD(顶栏状态 + 断线重连条);Prefab `Assets/Bundles/UI/Prefab/UI_MainHUD.prefab` |
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"prefabName": "UI_MainHUD",
|
||||
"prefabPath": "Assets/Bundles/UI/Prefab/UI_MainHUD.prefab",
|
||||
"description": "HUD 层:顶栏状态 + 底部断线重连条。竖屏 1080x1920。运行时绑定 Txt_Status/Txt_Pid/Panel_Reconnect/Txt_Reconnect/Btn_Reconnect。",
|
||||
"canvas": {
|
||||
"canvasScaler": {
|
||||
"referenceResolution": [1080, 1920],
|
||||
"matchWidthOrHeight": 0.5
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"sprites": {
|
||||
"ui_panel_header": "Assets/Bundles/UI/Texture/Common/ui_panel_header.png",
|
||||
"toast_bg": "Assets/Bundles/UI/Texture/Common/toast_bg.png",
|
||||
"btn_primary_normal": "Assets/Bundles/UI/Texture/Common/btn_primary_normal.png",
|
||||
"btn_primary_pressed": "Assets/Bundles/UI/Texture/Common/btn_primary_pressed.png",
|
||||
"ico_refresh": "Assets/Bundles/UI/Texture/Common/ico_refresh.png"
|
||||
}
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"name": "UI_MainHUD",
|
||||
"parent": null,
|
||||
"active": true,
|
||||
"rect": {
|
||||
"anchorMin": [0, 0],
|
||||
"anchorMax": [1, 1],
|
||||
"offsetMin": [0, 0],
|
||||
"offsetMax": [0, 0]
|
||||
},
|
||||
"components": [
|
||||
{ "type": "Canvas", "sortingOrder": 100 },
|
||||
{
|
||||
"type": "CanvasScaler",
|
||||
"referenceResolution": [1080, 1920],
|
||||
"matchWidthOrHeight": 0.5
|
||||
},
|
||||
{ "type": "GraphicRaycaster" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Panel_Top",
|
||||
"parent": "UI_MainHUD",
|
||||
"active": true,
|
||||
"rect": {
|
||||
"anchorMin": [0, 1],
|
||||
"anchorMax": [1, 1],
|
||||
"pivot": [0.5, 1],
|
||||
"anchoredPosition": [0, -48],
|
||||
"sizeDelta": [-48, 120]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "Image",
|
||||
"sprite": "ui_panel_header",
|
||||
"imageType": "Sliced",
|
||||
"raycastTarget": false,
|
||||
"color": "#FFFFFFFF"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Txt_Status",
|
||||
"parent": "Panel_Top",
|
||||
"active": true,
|
||||
"rect": {
|
||||
"anchorMin": [0, 0],
|
||||
"anchorMax": [1, 1],
|
||||
"offsetMin": [32, 16],
|
||||
"offsetMax": [-32, -40]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "TextMeshProUGUI",
|
||||
"text": "连接中…",
|
||||
"fontSize": 30,
|
||||
"alignment": "MidlineLeft",
|
||||
"color": "#5A4632FF",
|
||||
"raycastTarget": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Txt_Pid",
|
||||
"parent": "Panel_Top",
|
||||
"active": true,
|
||||
"rect": {
|
||||
"anchorMin": [0, 0],
|
||||
"anchorMax": [1, 0],
|
||||
"pivot": [0, 0],
|
||||
"anchoredPosition": [32, 12],
|
||||
"sizeDelta": [-64, 36]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "TextMeshProUGUI",
|
||||
"text": "pid=-",
|
||||
"fontSize": 24,
|
||||
"alignment": "MidlineLeft",
|
||||
"color": "#6B7A94FF",
|
||||
"raycastTarget": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Panel_Reconnect",
|
||||
"parent": "UI_MainHUD",
|
||||
"active": false,
|
||||
"rect": {
|
||||
"anchorMin": [0.5, 0],
|
||||
"anchorMax": [0.5, 0],
|
||||
"pivot": [0.5, 0],
|
||||
"anchoredPosition": [0, 160],
|
||||
"sizeDelta": [920, 160]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "Image",
|
||||
"sprite": "toast_bg",
|
||||
"imageType": "Sliced",
|
||||
"raycastTarget": true,
|
||||
"color": "#FFFFFFFF"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Txt_Reconnect",
|
||||
"parent": "Panel_Reconnect",
|
||||
"active": true,
|
||||
"rect": {
|
||||
"anchorMin": [0, 0.45],
|
||||
"anchorMax": [1, 1],
|
||||
"offsetMin": [28, 8],
|
||||
"offsetMax": [-28, -16]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "TextMeshProUGUI",
|
||||
"text": "网络已断开,正在重连…",
|
||||
"fontSize": 28,
|
||||
"alignment": "Center",
|
||||
"color": "#33405EFF",
|
||||
"raycastTarget": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Btn_Reconnect",
|
||||
"parent": "Panel_Reconnect",
|
||||
"active": true,
|
||||
"rect": {
|
||||
"anchorMin": [0.5, 0],
|
||||
"anchorMax": [0.5, 0],
|
||||
"pivot": [0.5, 0],
|
||||
"anchoredPosition": [0, 20],
|
||||
"sizeDelta": [280, 72]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "Image",
|
||||
"sprite": "btn_primary_normal",
|
||||
"imageType": "Sliced",
|
||||
"raycastTarget": true,
|
||||
"color": "#FFFFFFFF"
|
||||
},
|
||||
{
|
||||
"type": "Button",
|
||||
"transition": "SpriteSwap",
|
||||
"spritePressed": "btn_primary_pressed",
|
||||
"onClick": "MainHUD.Reconnect"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Txt_ReconnectBtn",
|
||||
"parent": "Btn_Reconnect",
|
||||
"active": true,
|
||||
"rect": {
|
||||
"anchorMin": [0, 0],
|
||||
"anchorMax": [1, 1],
|
||||
"offsetMin": [0, 0],
|
||||
"offsetMax": [0, 0]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "TextMeshProUGUI",
|
||||
"text": "立即重连",
|
||||
"fontSize": 30,
|
||||
"fontStyle": "Bold",
|
||||
"alignment": "Center",
|
||||
"color": "#F6F8FFFF",
|
||||
"raycastTarget": false
|
||||
},
|
||||
{
|
||||
"type": "Outline",
|
||||
"effectColor": "#102033AA",
|
||||
"effectDistance": [1, -1]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Img_Refresh",
|
||||
"parent": "Btn_Reconnect",
|
||||
"active": true,
|
||||
"rect": {
|
||||
"anchorMin": [0, 0.5],
|
||||
"anchorMax": [0, 0.5],
|
||||
"pivot": [0, 0.5],
|
||||
"anchoredPosition": [18, 0],
|
||||
"sizeDelta": [40, 40]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "Image",
|
||||
"sprite": "ico_refresh",
|
||||
"imageType": "Simple",
|
||||
"raycastTarget": false,
|
||||
"color": "#FFFFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"bindings": ["Txt_Status", "Txt_Pid", "Panel_Reconnect", "Txt_Reconnect", "Btn_Reconnect"],
|
||||
"events": [
|
||||
{ "name": "MainHUD.Reconnect", "node": "Btn_Reconnect" }
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -361,7 +361,7 @@ BootContext(壳 → 热更)至少传递:
|
||||
| M1 | `version.json` + 上传脚本;微信开发者工具跑通更新条 |
|
||||
| M2 | WS 连通 `wss://game.xworld.ren/ws?pid=...`;登录握手 |
|
||||
| M3 | 资源流水线打通(图/模 → Bundles → CDN);示例角色进场景 |
|
||||
| M4 | UI / 玩法骨架;断线重连;维护公告 |
|
||||
| M4 | UI / 玩法骨架;断线重连;维护公告 | ✅ HUD+ConfirmDialog;Ws 指数退避重连;`version.notice` + `maintenance.json` |
|
||||
| M5 | 包体与性能优化;提审用壳版本策略固化 |
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user