Ship the WeChat hot-update catalogs and the camp, TideWhale T2, UI atlas, and animation-lab work.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace HotUpdate.Gameplay
|
||||
{
|
||||
/// <summary>Plays Idle or Walk on the camp guide from how far the root moved.</summary>
|
||||
public sealed class ExplorerLocomotion : MonoBehaviour
|
||||
{
|
||||
Animator _animator;
|
||||
Vector3 _last;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_animator = GetComponentInChildren<Animator>();
|
||||
if (_animator != null) _animator.applyRootMotion = false;
|
||||
_last = transform.position;
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
if (_animator == null) return;
|
||||
Vector3 delta = transform.position - _last;
|
||||
_last = transform.position;
|
||||
float speed = new Vector2(delta.x, delta.z).magnitude / Mathf.Max(Time.deltaTime, 0.0001f);
|
||||
_animator.SetInteger("Motion", speed > 0.04f ? 1 : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6fc4bdf17f036674c8192f73149401bd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -31,18 +31,6 @@ namespace HotUpdate.Gameplay
|
||||
{
|
||||
WireNet();
|
||||
StartCoroutine(EnsureUi());
|
||||
|
||||
var plaza = gameObject.AddComponent<TownSquareSpawner>();
|
||||
plaza.Bind(_ctx);
|
||||
StartCoroutine(SpawnWorld(plaza));
|
||||
}
|
||||
|
||||
System.Collections.IEnumerator SpawnWorld(TownSquareSpawner plaza)
|
||||
{
|
||||
yield return plaza.SpawnCo();
|
||||
var spawner = gameObject.AddComponent<CharacterSpawner>();
|
||||
spawner.Bind(_ctx);
|
||||
spawner.Spawn();
|
||||
}
|
||||
|
||||
System.Collections.IEnumerator EnsureUi()
|
||||
@@ -57,6 +45,11 @@ namespace HotUpdate.Gameplay
|
||||
_hud = hudGo != null ? hudGo.GetComponent<MainHudView>() : null;
|
||||
if (_hud == null && hudGo != null)
|
||||
_hud = hudGo.AddComponent<MainHudView>();
|
||||
if (_hud == null)
|
||||
{
|
||||
if (_ctx != null) _ctx.MainUiError = "主界面资源加载失败,请检查 Art 资源包";
|
||||
yield break;
|
||||
}
|
||||
_hud?.Bind();
|
||||
_hud?.SetStatus(_maintenanceLock ? "服务器维护中" : FriendlyStatus(_net?.Status));
|
||||
_hud?.SetPid(_net?.Session?.Pid ?? 0);
|
||||
@@ -66,6 +59,8 @@ namespace HotUpdate.Gameplay
|
||||
_hud.OnReconnectClicked += () => _net?.RequestReconnect();
|
||||
_hud.OnHomeActionClicked += ShowHomeFeature;
|
||||
}
|
||||
if (_ctx != null) _ctx.MainUiReady = true;
|
||||
Debug.Log("[UI] Main HUD ready");
|
||||
|
||||
GameObject dlgGo = null;
|
||||
yield return mgr.OpenAsync(ConfirmDialogView.PrefabName, go => dlgGo = go);
|
||||
|
||||
@@ -329,6 +329,12 @@ namespace HotUpdate.Gameplay
|
||||
{
|
||||
yield return Renew(body, session.Token, done); yield break;
|
||||
}
|
||||
if (req.responseCode == 404)
|
||||
{
|
||||
Debug.LogError("[GameApi] Gameplay endpoint missing (HTTP 404): " + req.url);
|
||||
done(null, "service_not_ready");
|
||||
yield break;
|
||||
}
|
||||
PrototypeReply reply = null;
|
||||
try { reply = JsonUtility.FromJson<PrototypeReply>(req.downloadHandler.text); }
|
||||
catch (Exception) { /* A partial/malformed response leaves the durable request untouched. */ }
|
||||
|
||||
@@ -27,7 +27,11 @@ namespace HotUpdate.Gameplay
|
||||
Go = go;
|
||||
_texts = go.GetComponentsInChildren<TextMeshProUGUI>(true).ToDictionary(t => t.name);
|
||||
_buttons = go.GetComponentsInChildren<Button>(true).ToDictionary(t => t.name);
|
||||
_nodes = go.GetComponentsInChildren<Transform>(true).ToDictionary(t => t.name);
|
||||
// TMP creates one submesh per label/material. Those rendering-only
|
||||
// children share names across labels and are not authored UI bindings.
|
||||
_nodes = go.GetComponentsInChildren<Transform>(true)
|
||||
.Where(t => t.GetComponent(typeof(TMP_SubMeshUI)) == null)
|
||||
.ToDictionary(t => t.name);
|
||||
}
|
||||
public void Text(string name, string value) { _texts["Txt_" + name].text = value; }
|
||||
public Button Button(string name) => _buttons["Btn_" + name];
|
||||
@@ -127,6 +131,7 @@ namespace HotUpdate.Gameplay
|
||||
{
|
||||
switch (code)
|
||||
{
|
||||
case "service_not_ready": return "游戏服务暂不可用,请稍后重试。";
|
||||
case "insufficient_balls": return "弹珠不足。每10分钟自然恢复一枚,也可领取每日任务、签到和免费补给。";
|
||||
case "mail_not_found": return "这封邮件已不可用,请刷新后重试。";
|
||||
case "mail_expired": return "邮件已到期,附件无法领取。";
|
||||
@@ -191,7 +196,7 @@ namespace HotUpdate.Gameplay
|
||||
case "shop_limit": return "今天的这份补给已经领完或买完,明天零点刷新。";
|
||||
case "goods_locked": return "请先通过商品上标注的关卡。";
|
||||
case "invalid_goods": return "商品暂不可用,请重新打开商店。";
|
||||
default: return "暂时无法连接游戏服务,请检查本地 AIServer 是否已更新并启动。";
|
||||
default: return "暂时无法连接游戏服务,请检查网络后重试。";
|
||||
}
|
||||
}
|
||||
void Apply(PrototypeReply r)
|
||||
|
||||
@@ -11,7 +11,8 @@ namespace HotUpdate.Net
|
||||
/// </summary>
|
||||
public static class AuthApi
|
||||
{
|
||||
public const string DefaultHttpBase = "https://game.xworld.ren";
|
||||
public const string DefaultHttpBase = AOT.Boot.BootSettings.ProductionHttpBase;
|
||||
public const string DefaultWsTemplate = AOT.Boot.BootSettings.ProductionWsTemplate;
|
||||
|
||||
[Serializable]
|
||||
class AuthBody
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace HotUpdate.Net
|
||||
public sealed class GameNet : MonoBehaviour
|
||||
{
|
||||
public string HttpBase = AuthApi.DefaultHttpBase;
|
||||
public string WsUrlTemplate = "wss://game.xworld.ren/ws?pid={pid}&token={token}";
|
||||
public string WsUrlTemplate = AuthApi.DefaultWsTemplate;
|
||||
|
||||
public SessionData Session { get; private set; }
|
||||
public WsClient Socket { get; private set; }
|
||||
@@ -60,6 +60,27 @@ namespace HotUpdate.Net
|
||||
}
|
||||
WsUrlTemplate = tpl;
|
||||
}
|
||||
#if UNITY_WEBGL && !UNITY_EDITOR
|
||||
// Serialized Boot scenes used for local Editor testing must not send players to loopback.
|
||||
HttpBase = ResolveRemoteEndpoint(HttpBase, "https", AuthApi.DefaultHttpBase);
|
||||
WsUrlTemplate = ResolveRemoteEndpoint(WsUrlTemplate, "wss", AuthApi.DefaultWsTemplate);
|
||||
_boot.HttpBase = HttpBase;
|
||||
_boot.WsUrlTemplate = WsUrlTemplate;
|
||||
#endif
|
||||
}
|
||||
|
||||
public static string ResolveRemoteEndpoint(string value, string scheme, string fallback)
|
||||
{
|
||||
if (Uri.TryCreate(value, UriKind.Absolute, out var uri) &&
|
||||
uri.Scheme == scheme && !uri.IsLoopback && !uri.Host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Migrate the former production defaults stored in already-shipped Boot scenes.
|
||||
if (uri.IsDefaultPort && uri.Host.Equals("game.xworld.ren", StringComparison.OrdinalIgnoreCase) &&
|
||||
((scheme == "https" && uri.AbsolutePath == "/") || (scheme == "wss" && uri.AbsolutePath == "/ws")))
|
||||
return fallback;
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
public void StartGuestSession()
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace HotUpdate.Net
|
||||
public string BuildWsUrl(string template = null)
|
||||
{
|
||||
var tpl = string.IsNullOrEmpty(template)
|
||||
? "wss://game.xworld.ren/ws?pid={pid}&token={token}"
|
||||
? AuthApi.DefaultWsTemplate
|
||||
: template;
|
||||
return tpl
|
||||
.Replace("{pid}", Pid.ToString())
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
#if !UNITY_WEBGL || UNITY_EDITOR
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
#endif
|
||||
using AOT.Boot;
|
||||
using UnityEngine;
|
||||
|
||||
namespace HotUpdate.Net
|
||||
@@ -17,18 +20,23 @@ namespace HotUpdate.Net
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 基于 ClientWebSocket 的传输(Editor / Standalone)。
|
||||
/// WebGL/微信需另接平台桥,本类在 WebGL 下不可用。
|
||||
/// Editor/Standalone 使用 ClientWebSocket;WebGL 使用微信 SocketTask/浏览器桥。
|
||||
/// </summary>
|
||||
public sealed class WsClient : IDisposable
|
||||
{
|
||||
readonly ConcurrentQueue<string> _inbox = new ConcurrentQueue<string>();
|
||||
readonly ConcurrentQueue<string> _outbox = new ConcurrentQueue<string>();
|
||||
|
||||
#if UNITY_WEBGL && !UNITY_EDITOR
|
||||
int _browserSocket;
|
||||
float _connectDeadline;
|
||||
float _reconnectAt = -1;
|
||||
#else
|
||||
ClientWebSocket _ws;
|
||||
CancellationTokenSource _cts;
|
||||
Task _recvTask;
|
||||
Task _sendTask;
|
||||
#endif
|
||||
string _url;
|
||||
int _reconnectAttempt;
|
||||
bool _manualClose;
|
||||
@@ -48,7 +56,11 @@ namespace HotUpdate.Net
|
||||
_url = url;
|
||||
_manualClose = false;
|
||||
_reconnectAttempt = 0;
|
||||
#if UNITY_WEBGL && !UNITY_EDITOR
|
||||
StartBrowserConnection();
|
||||
#else
|
||||
_ = ConnectInternalAsync();
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>立刻断开并重新连接(重置退避计数)。</summary>
|
||||
@@ -58,11 +70,15 @@ namespace HotUpdate.Net
|
||||
_manualClose = false;
|
||||
AutoReconnect = true;
|
||||
_reconnectAttempt = 0;
|
||||
#if UNITY_WEBGL && !UNITY_EDITOR
|
||||
StartBrowserConnection();
|
||||
#else
|
||||
SafeCancel();
|
||||
try { _ws?.Dispose(); } catch { /* ignore */ }
|
||||
_ws = null;
|
||||
State = WsState.Disconnected;
|
||||
_ = ConnectInternalAsync();
|
||||
#endif
|
||||
}
|
||||
|
||||
public void SendText(string text)
|
||||
@@ -75,26 +91,91 @@ namespace HotUpdate.Net
|
||||
{
|
||||
_manualClose = true;
|
||||
AutoReconnect = false;
|
||||
#if UNITY_WEBGL && !UNITY_EDITOR
|
||||
ReleaseBrowserSocket();
|
||||
_reconnectAt = -1;
|
||||
#else
|
||||
SafeCancel();
|
||||
try { _ws?.Dispose(); } catch { /* ignore */ }
|
||||
_ws = null;
|
||||
#endif
|
||||
State = WsState.Disconnected;
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
#if UNITY_WEBGL && !UNITY_EDITOR
|
||||
TickBrowser();
|
||||
#endif
|
||||
while (_inbox.TryDequeue(out var msg))
|
||||
OnMessage?.Invoke(msg);
|
||||
}
|
||||
|
||||
#if UNITY_WEBGL && !UNITY_EDITOR
|
||||
void ReleaseBrowserSocket()
|
||||
{
|
||||
if (_browserSocket != 0) WebSocketBridge.AIGame_WsDispose(_browserSocket);
|
||||
_browserSocket = 0;
|
||||
}
|
||||
|
||||
void StartBrowserConnection()
|
||||
{
|
||||
ReleaseBrowserSocket();
|
||||
_reconnectAt = -1;
|
||||
LastError = null;
|
||||
State = _reconnectAttempt > 0 ? WsState.Reconnecting : WsState.Connecting;
|
||||
_connectDeadline = Time.realtimeSinceStartup + 15f;
|
||||
_browserSocket = WebSocketBridge.AIGame_WsCreate(_url);
|
||||
if (_browserSocket == 0) BrowserDisconnected("WebSocket bridge unavailable");
|
||||
}
|
||||
|
||||
void BrowserDisconnected(string reason)
|
||||
{
|
||||
ReleaseBrowserSocket();
|
||||
LastError = reason;
|
||||
State = WsState.Disconnected;
|
||||
while (_outbox.TryDequeue(out _)) { }
|
||||
_reconnectAt = -1;
|
||||
if (!_manualClose && AutoReconnect)
|
||||
{
|
||||
_reconnectAttempt++;
|
||||
_reconnectAt = Time.realtimeSinceStartup + Math.Min(30, 1 << Math.Min(5, _reconnectAttempt));
|
||||
}
|
||||
OnDisconnected?.Invoke(reason);
|
||||
if (_reconnectAt >= 0) OnReconnecting?.Invoke(_reconnectAttempt);
|
||||
}
|
||||
|
||||
void TickBrowser()
|
||||
{
|
||||
if (_reconnectAt >= 0 && Time.realtimeSinceStartup >= _reconnectAt)
|
||||
{
|
||||
_reconnectAt = -1;
|
||||
if (!_manualClose && AutoReconnect) StartBrowserConnection();
|
||||
}
|
||||
int current = _browserSocket;
|
||||
// Bound work per frame; callbacks may close or replace this connection.
|
||||
for (int i = 0; current != 0 && current == _browserSocket && i < 100; i++)
|
||||
{
|
||||
string item = WebSocketBridge.AIGame_WsPoll(current);
|
||||
if (string.IsNullOrEmpty(item)) break;
|
||||
if (item[0] == 'O')
|
||||
{
|
||||
State = WsState.Connected;
|
||||
_reconnectAttempt = 0;
|
||||
LastError = null;
|
||||
OnConnected?.Invoke();
|
||||
}
|
||||
else if (item[0] == 'M') OnMessage?.Invoke(item.Substring(1));
|
||||
else if (item[0] == 'E' || item[0] == 'C') BrowserDisconnected(item.Substring(1));
|
||||
}
|
||||
if (_browserSocket != 0 && State != WsState.Connected && Time.realtimeSinceStartup >= _connectDeadline)
|
||||
BrowserDisconnected("WebSocket connection timed out");
|
||||
for (int i = 0; _browserSocket != 0 && State == WsState.Connected && i < 100 && _outbox.TryDequeue(out var text); i++)
|
||||
WebSocketBridge.AIGame_WsSend(_browserSocket, text);
|
||||
}
|
||||
#else
|
||||
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;
|
||||
@@ -144,7 +225,6 @@ namespace HotUpdate.Net
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
async Task RecvLoopAsync(CancellationToken ct)
|
||||
@@ -194,6 +274,7 @@ namespace HotUpdate.Net
|
||||
try { _cts?.Dispose(); } catch { /* ignore */ }
|
||||
_cts = null;
|
||||
}
|
||||
#endif
|
||||
|
||||
public void Dispose() => Close();
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ using UnityEngine.UI;
|
||||
|
||||
namespace HotUpdate.UI
|
||||
{
|
||||
/// <summary>Walks a placeholder explorer over the camp painting and speaks only after a tap.</summary>
|
||||
/// <summary>Walks the camp guide over the camp painting and speaks only after a tap.</summary>
|
||||
public sealed class HomeExplorerPresenter : MonoBehaviour
|
||||
{
|
||||
const int Layer = 31;
|
||||
static readonly Color Key = new Color(0.07f, 0.02f, 0.09f, 1f);
|
||||
static readonly string[] Lines =
|
||||
{
|
||||
"喷泉今天的水声很好听。",
|
||||
@@ -26,6 +25,9 @@ namespace HotUpdate.UI
|
||||
UiPrefabLoader _loader;
|
||||
GameObject _stage;
|
||||
Camera _camera;
|
||||
RenderTexture _texture;
|
||||
RawImage _image;
|
||||
bool _booting;
|
||||
Transform _actor;
|
||||
RectTransform _button;
|
||||
RectTransform _bubble;
|
||||
@@ -41,30 +43,50 @@ namespace HotUpdate.UI
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
_image = GetComponent<RawImage>();
|
||||
if (_image != null) _image.enabled = _camera != null;
|
||||
if (_camera != null) _camera.enabled = true;
|
||||
else StartCoroutine(Boot());
|
||||
else if (!_booting) StartCoroutine(Boot());
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
if (_camera != null) _camera.enabled = false;
|
||||
if (_image != null) _image.enabled = false;
|
||||
if (_bubble != null) _bubble.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (_button != null) _button.GetComponent<Button>().onClick.RemoveListener(Speak);
|
||||
if (_stage != null) Destroy(_stage);
|
||||
if (_camera != null && _camera.targetTexture != null)
|
||||
if (_image != null) _image.texture = null;
|
||||
if (_camera != null) _camera.targetTexture = null;
|
||||
if (_texture != null)
|
||||
{
|
||||
_camera.targetTexture.Release();
|
||||
Destroy(_camera.targetTexture);
|
||||
_texture.Release();
|
||||
Destroy(_texture);
|
||||
}
|
||||
if (_stage != null) Destroy(_stage);
|
||||
_loader?.Dispose();
|
||||
}
|
||||
|
||||
IEnumerator Boot()
|
||||
{
|
||||
_booting = true;
|
||||
try { yield return BootCore(); }
|
||||
finally { _booting = false; }
|
||||
}
|
||||
|
||||
IEnumerator BootCore()
|
||||
{
|
||||
// The serialized material makes the shader a real AssetBundle dependency.
|
||||
// Never display the opaque camera clear color when that dependency is unavailable.
|
||||
if (_image == null || _image.material == null || _image.material.shader == null ||
|
||||
_image.material.shader.name != "AIGame/HomeExplorerCutout" || !_image.material.shader.isSupported)
|
||||
{
|
||||
Debug.LogWarning("[HomeExplorer] Preview material unavailable; keeping the camp background visible.");
|
||||
yield break;
|
||||
}
|
||||
var root = transform.parent;
|
||||
_button = root.Find("Btn_Explorer") as RectTransform;
|
||||
_bubble = root.Find("Panel_ExplorerTalk") as RectTransform;
|
||||
@@ -73,9 +95,9 @@ namespace HotUpdate.UI
|
||||
yield return null;
|
||||
|
||||
GameObject prefab = null;
|
||||
_loader = new UiPrefabLoader(null, "Assets/Bundles/Characters/Explorer");
|
||||
yield return _loader.LoadAsync("ExplorerSimple", loaded => prefab = loaded);
|
||||
if (prefab == null || _stage != null) yield break;
|
||||
_loader = new UiPrefabLoader(null, "Assets/Bundles/Characters/Human/CampGuideStudio");
|
||||
yield return _loader.LoadAsync("CampGuideStudio_Rigged", loaded => prefab = loaded);
|
||||
if (prefab == null || _stage != null || !isActiveAndEnabled) yield break;
|
||||
|
||||
_stage = new GameObject("HomeExplorerStage");
|
||||
_stage.transform.position = new Vector3(40f, 0f, 0f);
|
||||
@@ -97,7 +119,8 @@ namespace HotUpdate.UI
|
||||
camGo.transform.SetParent(_stage.transform, false);
|
||||
_camera = camGo.AddComponent<Camera>();
|
||||
_camera.clearFlags = CameraClearFlags.SolidColor;
|
||||
_camera.backgroundColor = Key;
|
||||
// Read the shipped material: a hot-update DLL may run with an older Art bundle.
|
||||
_camera.backgroundColor = _image.material.GetColor("_Key");
|
||||
_camera.cullingMask = 1 << Layer;
|
||||
_camera.orthographic = true;
|
||||
_camera.orthographicSize = 1.45f;
|
||||
@@ -107,21 +130,11 @@ namespace HotUpdate.UI
|
||||
_camera.depth = -50;
|
||||
_camera.transform.localPosition = new Vector3(0.15f, 1.7f, -3.6f);
|
||||
_camera.transform.LookAt(_stage.transform.TransformPoint(0f, 0.7f, 0f));
|
||||
var rt = new RenderTexture(512, 512, 16, RenderTextureFormat.ARGB32);
|
||||
rt.Create();
|
||||
_camera.targetTexture = rt;
|
||||
|
||||
var raw = gameObject.GetComponent<RawImage>() ?? gameObject.AddComponent<RawImage>();
|
||||
raw.texture = rt;
|
||||
raw.raycastTarget = false;
|
||||
raw.color = Color.white;
|
||||
var shader = Shader.Find("AIGame/HomeExplorerCutout");
|
||||
if (shader != null)
|
||||
{
|
||||
var material = new Material(shader);
|
||||
material.SetColor("_Key", Key);
|
||||
raw.material = material;
|
||||
}
|
||||
_texture = new RenderTexture(512, 512, 16, RenderTextureFormat.ARGB32);
|
||||
_texture.Create();
|
||||
_camera.targetTexture = _texture;
|
||||
_image.texture = _texture;
|
||||
_image.enabled = true;
|
||||
_bubble.gameObject.SetActive(false);
|
||||
if (!_hooked && _button != null)
|
||||
{
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace HotUpdate.UI
|
||||
ShowReconnect(false);
|
||||
foreach (var button in GetComponentsInChildren<Button>(true))
|
||||
{
|
||||
if (button == _reconnectBtn) continue;
|
||||
if (button == _reconnectBtn || button.name == "Btn_Explorer") continue;
|
||||
var key = button.name.StartsWith("Btn_Nav", StringComparison.Ordinal)
|
||||
? button.name.Substring(7)
|
||||
: button.name.StartsWith("Btn_", StringComparison.Ordinal) ? button.name.Substring(4) : null;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.U2D;
|
||||
using UnityEngine.UI;
|
||||
using YooAsset;
|
||||
|
||||
namespace HotUpdate.UI
|
||||
{
|
||||
/// <summary>移植 AtlasManager 的延迟绑定;所有平台异步加载,句柄随 UI 生命周期释放。</summary>
|
||||
public sealed class UiAtlasLoader : MonoBehaviour
|
||||
{
|
||||
private sealed class Entry
|
||||
{
|
||||
public bool Loading = true;
|
||||
public SpriteAtlas Atlas;
|
||||
public AssetHandle Handle;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, Entry> _atlases = new Dictionary<string, Entry>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, Sprite> _sprites = new Dictionary<string, Sprite>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<Image, object> _imageRequests = new Dictionary<Image, object>();
|
||||
private ResourcePackage _preferredPackage;
|
||||
private bool _allowEditorFallback = true;
|
||||
private bool _disposed;
|
||||
|
||||
public void Init(ResourcePackage preferredPackage = null, bool allowEditorFallback = true)
|
||||
{
|
||||
_preferredPackage = preferredPackage;
|
||||
_allowEditorFallback = allowEditorFallback;
|
||||
}
|
||||
|
||||
private void Awake() { SpriteAtlasManager.atlasRequested += RequestAtlas; }
|
||||
|
||||
private void RequestAtlas(string atlasName, Action<SpriteAtlas> callback)
|
||||
{
|
||||
// 只接管本项目的 UI 图集,不拦截字体或第三方图集。
|
||||
if (string.IsNullOrEmpty(atlasName) || !atlasName.StartsWith(UiAtlasPaths.AtlasNamePrefix, StringComparison.Ordinal)
|
||||
|| !atlasName.EndsWith("Atlas", StringComparison.Ordinal)
|
||||
|| atlasName.IndexOfAny(new[] { '/', '\\' }) >= 0) return;
|
||||
StartCoroutine(LoadAtPathAsync(UiAtlasPaths.AtlasRoot + "/" + atlasName + ".spriteatlas", callback));
|
||||
}
|
||||
|
||||
public IEnumerator LoadAtlasAsync(string relativeFolder, Action<SpriteAtlas> onLoaded)
|
||||
{
|
||||
return LoadAtPathAsync(UiAtlasPaths.GetAtlasPath(relativeFolder), onLoaded);
|
||||
}
|
||||
|
||||
private IEnumerator LoadAtPathAsync(string path, Action<SpriteAtlas> onLoaded)
|
||||
{
|
||||
if (_disposed) { onLoaded?.Invoke(null); yield break; }
|
||||
if (_atlases.TryGetValue(path, out var cached))
|
||||
{
|
||||
while (cached.Loading && !_disposed) yield return null;
|
||||
if (!_disposed) onLoaded?.Invoke(cached.Atlas);
|
||||
yield break;
|
||||
}
|
||||
var entry = new Entry();
|
||||
_atlases.Add(path, entry);
|
||||
var packages = new List<ResourcePackage>();
|
||||
if (_preferredPackage != null) packages.Add(_preferredPackage);
|
||||
if (YooAssets.IsInitialized)
|
||||
foreach (string name in new[] { "Art", "DefaultPackage" })
|
||||
if (YooAssets.TryGetPackage(name, out var package) && !packages.Contains(package)) packages.Add(package);
|
||||
try
|
||||
{
|
||||
foreach (var package in packages)
|
||||
{
|
||||
if (package.InitializeStatus != EOperationStatus.Succeeded || !package.IsLocationValid(path)) continue;
|
||||
entry.Handle = package.LoadAssetAsync<SpriteAtlas>(path);
|
||||
yield return entry.Handle;
|
||||
if (_disposed) yield break;
|
||||
if (entry.Handle.Status == EOperationStatus.Succeeded)
|
||||
{
|
||||
entry.Atlas = entry.Handle.GetAssetObject<SpriteAtlas>();
|
||||
if (entry.Atlas != null) break;
|
||||
}
|
||||
entry.Handle.Release();
|
||||
entry.Handle = null;
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
if (entry.Atlas == null && _allowEditorFallback)
|
||||
entry.Atlas = UnityEditor.AssetDatabase.LoadAssetAtPath<SpriteAtlas>(path);
|
||||
#endif
|
||||
}
|
||||
finally
|
||||
{
|
||||
entry.Loading = false;
|
||||
// 失败不留缓存,允许资源包就绪后重试。
|
||||
if (entry.Atlas == null && !_disposed)
|
||||
{
|
||||
entry.Handle?.Release();
|
||||
entry.Handle = null;
|
||||
_atlases.Remove(path);
|
||||
}
|
||||
}
|
||||
if (_disposed) yield break;
|
||||
if (entry.Atlas == null) Debug.LogWarning("[UIAtlas] Atlas unavailable: " + path);
|
||||
onLoaded?.Invoke(entry.Atlas);
|
||||
}
|
||||
|
||||
public IEnumerator WaitForPending()
|
||||
{
|
||||
while (!_disposed)
|
||||
{
|
||||
bool pending = false;
|
||||
foreach (var entry in _atlases.Values) pending |= entry.Loading;
|
||||
if (!pending) yield break;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerator LoadSpriteAsync(string relativeFolder, string spriteName, Action<Sprite> onLoaded)
|
||||
{
|
||||
if (string.IsNullOrEmpty(spriteName) || _disposed) { onLoaded?.Invoke(null); yield break; }
|
||||
string key = UiAtlasPaths.GetAtlasPath(relativeFolder) + "|" + spriteName;
|
||||
if (_sprites.TryGetValue(key, out var sprite) && sprite != null)
|
||||
{
|
||||
onLoaded?.Invoke(sprite);
|
||||
yield break;
|
||||
}
|
||||
SpriteAtlas atlas = null;
|
||||
yield return LoadAtlasAsync(relativeFolder, loaded => atlas = loaded);
|
||||
if (_disposed) yield break;
|
||||
if (atlas != null)
|
||||
{
|
||||
// GetSprite 创建的克隆需缓存并在销毁时释放。
|
||||
if (!_sprites.TryGetValue(key, out sprite) || sprite == null)
|
||||
{
|
||||
sprite = atlas.GetSprite(spriteName);
|
||||
if (sprite != null) _sprites[key] = sprite;
|
||||
}
|
||||
}
|
||||
if (sprite == null) Debug.LogWarning($"[UIAtlas] Sprite unavailable: {relativeFolder}/{spriteName}");
|
||||
onLoaded?.Invoke(sprite);
|
||||
}
|
||||
|
||||
public IEnumerator SetSpriteAsync(Image image, string relativeFolder, string spriteName, Action<Sprite> onLoaded = null)
|
||||
{
|
||||
if (image == null || _disposed) yield break;
|
||||
var request = new object();
|
||||
_imageRequests[image] = request;
|
||||
Sprite sprite = null;
|
||||
yield return LoadSpriteAsync(relativeFolder, spriteName, loaded => sprite = loaded);
|
||||
// 同一个 Image 后来的请求优先,旧请求不能覆盖新图片。
|
||||
if (_disposed || !_imageRequests.TryGetValue(image, out var current) || current != request) yield break;
|
||||
_imageRequests.Remove(image);
|
||||
if (image == null) yield break;
|
||||
image.sprite = sprite;
|
||||
onLoaded?.Invoke(sprite);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_disposed = true;
|
||||
SpriteAtlasManager.atlasRequested -= RequestAtlas;
|
||||
StopAllCoroutines();
|
||||
foreach (var sprite in _sprites.Values) if (sprite != null) Destroy(sprite);
|
||||
_sprites.Clear();
|
||||
foreach (var entry in _atlases.Values) entry.Handle?.Release();
|
||||
_atlases.Clear();
|
||||
_imageRequests.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 870697deb9d62e74f9f7ed8fde8e0b6e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
|
||||
namespace HotUpdate.UI
|
||||
{
|
||||
/// <summary>编辑器生成与运行时加载共用的目录约定。</summary>
|
||||
public static class UiAtlasPaths
|
||||
{
|
||||
public const string TextureRoot = "Assets/Bundles/UI/Texture";
|
||||
public const string AtlasRoot = "Assets/Bundles/UI/Atlas";
|
||||
public const string AtlasNamePrefix = "UI_";
|
||||
|
||||
public static bool IsTexturePath(string path)
|
||||
{
|
||||
return path != null && path.Replace('\\', '/').StartsWith(TextureRoot + "/", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static string GetAtlasName(string relativeFolder)
|
||||
{
|
||||
if (relativeFolder == null) throw new ArgumentNullException(nameof(relativeFolder));
|
||||
relativeFolder = relativeFolder.Replace('\\', '/').Trim('/');
|
||||
if (relativeFolder.Length == 0) return AtlasNamePrefix + "__RootAtlas";
|
||||
foreach (string segment in relativeFolder.Split('/'))
|
||||
if (segment.Length == 0 || segment == "." || segment == "..")
|
||||
throw new ArgumentException("Invalid UI texture folder: " + relativeFolder);
|
||||
// 转义下划线与路径分隔符,避免 A/B 与 A_B 生成同名图集。
|
||||
return AtlasNamePrefix + relativeFolder.Replace("_", "_u").Replace("/", "_s") + "Atlas";
|
||||
}
|
||||
|
||||
public static string GetAtlasPath(string relativeFolder)
|
||||
{
|
||||
return AtlasRoot + "/" + GetAtlasName(relativeFolder) + ".spriteatlas";
|
||||
}
|
||||
|
||||
public static string GetAtlasPathForTexture(string texturePath)
|
||||
{
|
||||
texturePath = texturePath?.Replace('\\', '/');
|
||||
if (!IsTexturePath(texturePath)) throw new ArgumentException("Not a UI texture: " + texturePath);
|
||||
int lastSlash = texturePath.LastIndexOf('/');
|
||||
string folder = lastSlash == TextureRoot.Length ? "" :
|
||||
texturePath.Substring(TextureRoot.Length + 1, lastSlash - TextureRoot.Length - 1);
|
||||
return GetAtlasPath(folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 656fa5a10dcc5f34587fa2724fd61993
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -15,6 +15,7 @@ namespace HotUpdate.UI
|
||||
readonly Dictionary<string, GameObject> _opened = new Dictionary<string, GameObject>(StringComparer.Ordinal);
|
||||
Transform _root;
|
||||
UiPrefabLoader _loader;
|
||||
public UiAtlasLoader Atlases { get; private set; }
|
||||
readonly HashSet<string> _loading = new HashSet<string>(StringComparer.Ordinal);
|
||||
public string Theme { get; private set; } = UiThemeCatalog.Candy;
|
||||
|
||||
@@ -23,6 +24,9 @@ namespace HotUpdate.UI
|
||||
Instance = this;
|
||||
_root = root;
|
||||
_loader = loader ?? new UiPrefabLoader();
|
||||
Atlases = GetComponent<UiAtlasLoader>();
|
||||
if (Atlases == null) Atlases = gameObject.AddComponent<UiAtlasLoader>();
|
||||
Atlases.Init(_loader.PreferredPackage);
|
||||
Theme = UiThemeCatalog.Normalize(theme);
|
||||
}
|
||||
|
||||
@@ -62,6 +66,7 @@ namespace HotUpdate.UI
|
||||
if (siblingIndex >= 0)
|
||||
go.transform.SetSiblingIndex(siblingIndex);
|
||||
_opened[prefabName] = go;
|
||||
yield return Atlases.WaitForPending();
|
||||
onOpened?.Invoke(go);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace HotUpdate.UI
|
||||
readonly List<AssetHandle> _handles = new List<AssetHandle>();
|
||||
bool _disposed;
|
||||
|
||||
public ResourcePackage PreferredPackage => _preferredPackage;
|
||||
|
||||
public UiPrefabLoader(ResourcePackage preferredPackage = null, string folder = PrefabFolder)
|
||||
{
|
||||
_preferredPackage = preferredPackage;
|
||||
|
||||
@@ -3,7 +3,7 @@ Shader "AIGame/HomeExplorerCutout"
|
||||
Properties
|
||||
{
|
||||
_MainTex ("Tex", 2D) = "white" {}
|
||||
_Key ("Key", Color) = (0.07, 0.02, 0.09, 1)
|
||||
_Key ("Key", Color) = (1, 0, 1, 1)
|
||||
}
|
||||
SubShader
|
||||
{
|
||||
@@ -25,8 +25,9 @@ Shader "AIGame/HomeExplorerCutout"
|
||||
fixed4 frag(v2f i) : SV_Target
|
||||
{
|
||||
fixed4 c = tex2D(_MainTex, i.uv);
|
||||
if (distance(c.rgb, _Key.rgb) < 0.12) discard;
|
||||
c.a = 1;
|
||||
float d = distance(c.rgb, _Key.rgb);
|
||||
c.a = smoothstep(0.18, 0.42, d);
|
||||
if (c.a < 0.02) discard;
|
||||
return c;
|
||||
}
|
||||
ENDCG
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2093efbaf4f145c4085babcf451dcb75
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user