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:
udx
2026-10-24 18:04:41 +08:00
co-authored by Cursor
parent 6ebc9401ce
commit 45e62132c8
1432 changed files with 2971856 additions and 2798 deletions
@@ -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;