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
@@ -319,6 +319,8 @@ namespace AIGame.EditorTools
try
{
Dictionary<string, GameObject> map = new Dictionary<string, GameObject>();
var spritePaths = root["assets"]?["sprites"]?.ToObject<Dictionary<string, string>>()
?? new Dictionary<string, string>();
List<Action> deferred = new List<Action>();
foreach (JToken node in nodes)
@@ -343,7 +345,7 @@ namespace AIGame.EditorTools
foreach (JToken comp in (node["components"] as JArray) ?? new JArray())
{
ApplyComponent(go, comp as JObject, map, deferred, Path.GetFileName(jsonPath));
ApplyComponent(go, comp as JObject, map, deferred, Path.GetFileName(jsonPath), spritePaths);
}
go.SetActive(active);
}
@@ -524,7 +526,7 @@ namespace AIGame.EditorTools
}
// ---------- 组件 ----------
private static void ApplyComponent(GameObject go, JObject comp, Dictionary<string, GameObject> map, List<Action> deferred, string fileName)
private static void ApplyComponent(GameObject go, JObject comp, Dictionary<string, GameObject> map, List<Action> deferred, string fileName, Dictionary<string, string> spritePaths)
{
if (comp == null)
{
@@ -564,7 +566,7 @@ namespace AIGame.EditorTools
{
Image img = GetOrAdd<Image>(go);
string spriteName = (string)comp["sprite"];
Sprite sprite = ResolveSprite(spriteName, fileName);
Sprite sprite = ResolveSprite(spriteName, fileName, spritePaths);
img.sprite = sprite;
string imageType = (string)comp["imageType"];
if (string.IsNullOrEmpty(imageType) && sprite != null
@@ -591,6 +593,20 @@ namespace AIGame.EditorTools
img.color = ParseColor((string)comp["color"], Color.white);
break;
}
case "RawImage":
{
RawImage image = GetOrAdd<RawImage>(go);
string materialPath = (string)comp["material"];
if (!string.IsNullOrEmpty(materialPath))
{
image.material = AssetDatabase.LoadAssetAtPath<Material>(materialPath);
if (image.material == null)
throw new InvalidOperationException("RawImage material not found: " + materialPath);
}
image.color = ParseColor((string)comp["color"], Color.white);
image.raycastTarget = comp["raycastTarget"] != null && (bool)comp["raycastTarget"];
break;
}
case "TextMeshProUGUI":
{
TextMeshProUGUI t = GetOrAdd<TextMeshProUGUI>(go);
@@ -622,9 +638,9 @@ namespace AIGame.EditorTools
{
btn.transition = Selectable.Transition.SpriteSwap;
SpriteState state = btn.spriteState;
state.highlightedSprite = ResolveSprite((string)comp["spriteHighlighted"], fileName);
state.pressedSprite = ResolveSprite((string)comp["spritePressed"], fileName);
state.disabledSprite = ResolveSprite((string)comp["spriteDisabled"], fileName);
state.highlightedSprite = ResolveSprite((string)comp["spriteHighlighted"], fileName, spritePaths);
state.pressedSprite = ResolveSprite((string)comp["spritePressed"], fileName, spritePaths);
state.disabledSprite = ResolveSprite((string)comp["spriteDisabled"], fileName, spritePaths);
btn.spriteState = state;
}
else
@@ -1027,12 +1043,19 @@ namespace AIGame.EditorTools
return null;
}
private static Sprite ResolveSprite(string spriteName, string fileName)
private static Sprite ResolveSprite(string spriteName, string fileName, Dictionary<string, string> spritePaths)
{
if (string.IsNullOrEmpty(spriteName))
{
return null;
}
if (spritePaths.TryGetValue(spriteName, out string declaredPath))
{
var declaredSprite = AssetDatabase.LoadAssetAtPath<Sprite>(declaredPath);
if (declaredSprite == null)
throw new InvalidOperationException(fileName + ": declared Sprite is missing: " + spriteName + " (" + declaredPath + ")");
return declaredSprite;
}
string[] guids = AssetDatabase.FindAssets(spriteName, new[] { TextureRoot });
foreach (string guid in guids)
{
@@ -6,12 +6,13 @@ using YooAsset.Editor;
namespace AIGame.EditorTools
{
/// <summary>把 Candy UI Prefab 收进 Art;图片/字体通过 Prefab 依赖进入资源包。</summary>
/// <summary>UI Prefab 与自动图集收进 Art;每个图集与原图绑定为同一资源包。</summary>
public static class SetupUiBundleCollector
{
[MenuItem("Tools/UI/Setup UI Bundle Collector")]
public static string Setup()
{
UiSpriteAtlasGenerator.GenerateAll();
var package = BundleCollectorSettingData.Setting.Packages.FirstOrDefault(p => p.PackageName == "Art");
if (package == null) throw new System.InvalidOperationException("Set up the Art package before UI collection.");
var group = package.Groups.FirstOrDefault(g => g.GroupName == "UI");
@@ -36,9 +37,29 @@ namespace AIGame.EditorTools
};
BundleCollectorSettingData.CreateCollector(group, collector);
}
ConfigureAtlasCollector(group, HotUpdate.UI.UiAtlasPaths.AtlasRoot,
ECollectorType.MainAssetCollector, nameof(CollectUiAtlas), "ui");
ConfigureAtlasCollector(group, HotUpdate.UI.UiAtlasPaths.TextureRoot,
ECollectorType.DependAssetCollector, nameof(CollectUiAtlasTexture), "");
package.CheckConfigError();
BundleCollectorSettingData.SaveFile();
return "Art/UI collects Candy UI by prefab name, including sprite/font dependencies.";
return "Art/UI: UI prefabs by name; generated atlases by name; source sprites in the same bundle as their atlas.";
}
private static void ConfigureAtlasCollector(BundleCollectorGroup group, string path,
ECollectorType type, string filter, string tags)
{
var collector = group.Collectors.FirstOrDefault(c => c.CollectPath == path);
bool created = collector == null;
if (created) collector = new BundleCollector { CollectPath = path };
collector.CollectorGUID = AssetDatabase.AssetPathToGUID(path);
collector.CollectorType = type;
collector.AddressRuleName = nameof(AddressByFileName);
collector.PackRuleName = nameof(PackUiAtlas);
collector.FilterRuleName = filter;
collector.AssetTags = tags;
if (created) BundleCollectorSettingData.CreateCollector(group, collector);
else BundleCollectorSettingData.ModifyCollector(group, collector);
}
}
}
@@ -0,0 +1,59 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using HotUpdate.UI;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.U2D;
using YooAsset.Editor;
namespace AIGame.EditorTools
{
/// <summary>检查实际构建产物,防止 SBP 的旧 Prefab 依赖缓存重新带入原图像素。</summary>
public static class UiAtlasBuildValidation
{
public sealed class BundleCheck
{
public string FileName;
public long Bytes;
public int SourceTextureCount;
}
public static BundleCheck[] Inspect(string packageDirectory)
{
string reportPath = Directory.GetFiles(packageDirectory, "*.report").Single();
var report = JObject.Parse(File.ReadAllText(reportPath));
string packageName = report["Summary"].Value<string>("BuildPackageName");
bool uniqueNames = report["Summary"].Value<bool>("UniqueBundleName");
var bundles = ((JArray)report["BundleInfos"]).ToDictionary(b => b.Value<string>("BundleName"));
var checks = new List<BundleCheck>();
foreach (string guid in AssetDatabase.FindAssets("l:" + UiSpriteAtlasGenerator.GeneratedLabel,
new[] { UiAtlasPaths.AtlasRoot }))
{
string atlasPath = AssetDatabase.GUIDToAssetPath(guid);
if (AssetDatabase.LoadAssetAtPath<SpriteAtlas>(atlasPath) == null) continue;
string bundleName = new PackUiAtlas().GetPackRuleResult(
new BundlePackRuleData(atlasPath, UiAtlasPaths.AtlasRoot, "UI", "")).GetBundleName(packageName, uniqueNames);
if (!bundles.TryGetValue(bundleName, out var info))
throw new InvalidOperationException("Generated UI atlas missing from build report: " + atlasPath);
string fileName = info.Value<string>("FileName");
string path = Path.Combine(packageDirectory, fileName);
var bundle = AssetBundle.LoadFromFile(path);
if (bundle == null) throw new IOException("Cannot validate UI atlas bundle: " + path);
try
{
// 已剥离的源图仍有 Sprite 元数据,但不应再有可加载的原图 Texture2D。
checks.Add(new BundleCheck { FileName = fileName, Bytes = new FileInfo(path).Length,
SourceTextureCount = bundle.LoadAllAssets<Texture2D>().Length });
}
finally { bundle.Unload(true); }
}
if (checks.Count == 0) throw new InvalidOperationException("No generated UI atlas bundles validated.");
return checks.ToArray();
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f0217b082b10a8846a737948f0b191c0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,49 @@
#if UNITY_EDITOR
using System;
using System.IO;
using HotUpdate.UI;
using UnityEditor;
using UnityEngine;
using UnityEngine.U2D;
using YooAsset.Editor;
namespace AIGame.EditorTools
{
[DisplayName("UI 图片与所属图集同包")]
public sealed class PackUiAtlas : IBundlePackRule
{
public BundlePackRuleResult GetPackRuleResult(BundlePackRuleData data)
{
string path = data.AssetPath.Replace('\\', '/');
if (UiAtlasPaths.IsTexturePath(path)) path = UiAtlasPaths.GetAtlasPathForTexture(path);
else if (!path.StartsWith(UiAtlasPaths.AtlasRoot + "/", StringComparison.Ordinal)
|| !path.EndsWith(".spriteatlas", StringComparison.Ordinal))
throw new ArgumentException("Not a UI atlas input: " + path);
return new BundlePackRuleResult(Path.ChangeExtension(path, null), DefaultBundlePackRule.AssetBundleFileExtension);
}
}
[DisplayName("自动生成的 UI 图集")]
public sealed class CollectUiAtlas : IAssetFilterRule
{
public string FindAssetType => "SpriteAtlas";
public bool IsCollectAsset(AssetFilterRuleData data)
{
var atlas = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(data.AssetPath);
return atlas != null && Array.IndexOf(AssetDatabase.GetLabels(atlas), UiSpriteAtlasGenerator.GeneratedLabel) >= 0;
}
}
[DisplayName("UI 图集原图")]
public sealed class CollectUiAtlasTexture : IAssetFilterRule
{
public string FindAssetType => "Texture2D";
public bool IsCollectAsset(AssetFilterRuleData data)
{
return UiAtlasPaths.IsTexturePath(data.AssetPath)
&& AssetDatabase.LoadAssetAtPath<SpriteAtlas>(UiAtlasPaths.GetAtlasPathForTexture(data.AssetPath)) != null
&& (AssetImporter.GetAtPath(data.AssetPath) as TextureImporter)?.textureType == TextureImporterType.Sprite;
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d1a326e23fcef0d4b8e03135f02685ef
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,206 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using HotUpdate.UI;
using UnityEditor;
using UnityEditor.U2D;
using UnityEngine;
using UnityEngine.U2D;
using Object = UnityEngine.Object;
namespace AIGame.EditorTools
{
/// <summary>移植 ClientTrunk SpriteAtalsGenerator 的按目录打图集规则,增量维护并保留 GUID。</summary>
public static class UiSpriteAtlasGenerator
{
public const string GeneratedLabel = "AIGame.UI.GeneratedAtlas";
private static bool s_Busy;
[MenuItem("Tools/UI/Generate Sprite Atlases")]
public static void GenerateFromMenu()
{
Debug.Log("[UIAtlas] " + GenerateAll());
}
public static string GenerateAll()
{
if (s_Busy) return "Generation already in progress.";
if (EditorApplication.isPlayingOrWillChangePlaymode)
throw new InvalidOperationException("Generate UI atlases in Edit Mode.");
s_Busy = true;
try
{
var groups = CollectTextures();
EnsureFolder(UiAtlasPaths.AtlasRoot);
// V1 matches the reference project and supports SpriteAtlas late binding in bundles.
if (EditorSettings.spritePackerMode != SpritePackerMode.AlwaysOnAtlas)
EditorSettings.spritePackerMode = SpritePackerMode.AlwaysOnAtlas;
int changed = 0, deleted = 0;
foreach (var group in groups)
{
string path = group.Key;
var atlas = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(path);
bool created = atlas == null;
if (created)
{
atlas = new SpriteAtlas();
AssetDatabase.CreateAsset(atlas, path);
}
bool dirty = Configure(atlas, group.Value.ToArray()) || created;
var labels = AssetDatabase.GetLabels(atlas);
if (!labels.Contains(GeneratedLabel))
{
AssetDatabase.SetLabels(atlas, labels.Concat(new[] { GeneratedLabel }).ToArray());
dirty = true;
}
if (!dirty) continue;
EditorUtility.SetDirty(atlas);
AssetDatabase.SaveAssetIfDirty(atlas);
changed++;
}
foreach (string guid in AssetDatabase.FindAssets("l:" + GeneratedLabel, new[] { UiAtlasPaths.AtlasRoot }))
{
string path = AssetDatabase.GUIDToAssetPath(guid);
if (groups.ContainsKey(path) || !path.EndsWith(".spriteatlas", StringComparison.Ordinal)) continue;
if (!AssetDatabase.DeleteAsset(path)) throw new IOException("Cannot remove stale UI atlas: " + path);
deleted++;
}
return $"atlases={groups.Count}, textures={groups.Values.Sum(g => g.Count)}, updated={changed}, removed={deleted}";
}
finally { s_Busy = false; }
}
public static SortedDictionary<string, List<Object>> CollectTextures()
{
var groups = new SortedDictionary<string, List<Object>>(StringComparer.Ordinal);
var names = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
if (!AssetDatabase.IsValidFolder(UiAtlasPaths.TextureRoot)) return groups;
foreach (string path in AssetDatabase.FindAssets("t:Texture2D", new[] { UiAtlasPaths.TextureRoot })
.Select(AssetDatabase.GUIDToAssetPath).OrderBy(p => p, StringComparer.Ordinal))
{
var importer = AssetImporter.GetAtPath(path) as TextureImporter;
if (importer == null || importer.textureType != TextureImporterType.Sprite)
throw new InvalidOperationException("UI atlas input must be a Sprite: " + path);
string atlasPath = UiAtlasPaths.GetAtlasPathForTexture(path);
if (!groups.TryGetValue(atlasPath, out var textures))
{
groups.Add(atlasPath, textures = new List<Object>());
names.Add(atlasPath, new HashSet<string>(StringComparer.Ordinal));
}
var sprites = AssetDatabase.LoadAllAssetsAtPath(path).OfType<Sprite>().ToArray();
if (sprites.Length == 0) throw new InvalidOperationException("UI texture has no sprites: " + path);
foreach (var sprite in sprites)
if (!names[atlasPath].Add(sprite.name))
throw new InvalidOperationException($"Duplicate sprite name '{sprite.name}' in {atlasPath}: {path}");
textures.Add(AssetDatabase.LoadAssetAtPath<Texture2D>(path));
}
// Names are also YooAsset addresses; reject case-only collisions before writing assets.
if (groups.Keys.Distinct(StringComparer.OrdinalIgnoreCase).Count() != groups.Count)
throw new InvalidOperationException("UI atlas folder names differ only by case.");
return groups;
}
private static bool Configure(SpriteAtlas atlas, Object[] textures)
{
bool changed = false;
if (!atlas.GetPackables().SequenceEqual(textures))
{
atlas.Remove(atlas.GetPackables());
atlas.Add(textures);
changed = true;
}
var packing = atlas.GetPackingSettings();
var desiredPacking = packing;
desiredPacking.enableRotation = false;
desiredPacking.enableTightPacking = false;
desiredPacking.enableAlphaDilation = true;
desiredPacking.padding = 4;
if (!packing.Equals(desiredPacking)) { atlas.SetPackingSettings(desiredPacking); changed = true; }
var settings = atlas.GetTextureSettings();
var desiredSettings = settings;
desiredSettings.readable = false;
desiredSettings.generateMipMaps = false;
desiredSettings.sRGB = true;
desiredSettings.filterMode = FilterMode.Bilinear;
if (!settings.Equals(desiredSettings)) { atlas.SetTextureSettings(desiredSettings); changed = true; }
// SBP 的 PackAllAtlases 需要启用此标志,才能建立打包纹理关联并剥离原图像素。
// 资源仍由 YooAsset 收集到对应图集 bundle;运行时加载器保留延迟绑定兼容。
if (!atlas.IsIncludeInBuild()) { atlas.SetIncludeInBuild(true); changed = true; }
changed |= SetPlatform(atlas, "DefaultTexturePlatform", TextureImporterFormat.Automatic, false);
changed |= SetPlatform(atlas, "Standalone", TextureImporterFormat.DXT5, true);
changed |= SetPlatform(atlas, "iPhone", TextureImporterFormat.ASTC_6x6, true);
changed |= SetPlatform(atlas, "Android", TextureImporterFormat.ASTC_6x6, true);
changed |= SetPlatform(atlas, "WebGL", TextureImporterFormat.ASTC_8x8, true);
return changed;
}
private static bool SetPlatform(SpriteAtlas atlas, string platform, TextureImporterFormat format, bool overridden)
{
var settings = atlas.GetPlatformSettings(platform);
if (settings.maxTextureSize == 2048 && settings.format == format && settings.overridden == overridden
&& !settings.crunchedCompression && settings.compressionQuality == 100
&& settings.textureCompression == TextureImporterCompression.CompressedHQ) return false;
settings.name = platform;
settings.overridden = overridden;
settings.maxTextureSize = 2048;
settings.format = format;
settings.textureCompression = TextureImporterCompression.CompressedHQ;
settings.compressionQuality = 100;
settings.crunchedCompression = false;
atlas.SetPlatformSettings(settings);
return true;
}
private static void EnsureFolder(string path)
{
if (AssetDatabase.IsValidFolder(path)) return;
string parent = Path.GetDirectoryName(path).Replace('\\', '/');
EnsureFolder(parent);
AssetDatabase.CreateFolder(parent, Path.GetFileName(path));
}
}
[InitializeOnLoad]
public sealed class UiSpriteAtlasPostprocessor : AssetPostprocessor
{
private static bool s_Pending;
static UiSpriteAtlasPostprocessor() { Schedule(); }
private void OnPreprocessTexture()
{
if (!UiAtlasPaths.IsTexturePath(assetPath)) return;
var importer = (TextureImporter)assetImporter;
importer.textureType = TextureImporterType.Sprite;
if (importer.spriteImportMode == SpriteImportMode.None) importer.spriteImportMode = SpriteImportMode.Single;
importer.mipmapEnabled = false;
importer.alphaIsTransparency = true;
importer.npotScale = TextureImporterNPOTScale.None;
}
private static void OnPostprocessAllAssets(string[] imported, string[] deleted, string[] moved, string[] movedFrom)
{
if (imported.Concat(deleted).Concat(moved).Concat(movedFrom)
.Any(p => p == UiAtlasPaths.TextureRoot || UiAtlasPaths.IsTexturePath(p))) Schedule();
}
private static void Schedule()
{
if (s_Pending) return;
s_Pending = true;
EditorApplication.update += UpdateAtlases;
}
private static void UpdateAtlases()
{
if (EditorApplication.isCompiling || EditorApplication.isUpdating || EditorApplication.isPlayingOrWillChangePlaymode
|| BuildPipeline.isBuildingPlayer) return;
s_Pending = false;
EditorApplication.update -= UpdateAtlases;
try { UiSpriteAtlasGenerator.GenerateAll(); }
catch (Exception ex) { Debug.LogException(ex); }
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2889ecd3f89a1a247aae474366e328b8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: