using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using AIGame.EditorTools; using HotUpdate.UI; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditor.SceneManagement; using UnityEditor.U2D; using UnityEngine; using UnityEngine.U2D; using UnityEngine.UI; using YooAsset; using YooAsset.Editor; using Object = UnityEngine.Object; /// 通过 Pipeline 检查图集生成、自动增删移动、GUID、边界及实际 YooAsset 构建映射。 public static class VerifyUiAtlases { static string Output => Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/output/ui_atlas")); static int checks; static void Check(bool value, string message) { if (!value) throw new Exception(message); checks++; } static void Save(string name, object result) { Directory.CreateDirectory(Output); File.WriteAllText(Path.Combine(Output, name + ".json"), JsonConvert.SerializeObject(result, Formatting.Indented)); } public static string Editor() { checks = 0; SetupUiBundleCollector.Setup(); var groups = UiSpriteAtlasGenerator.CollectTextures(); var guids = groups.Keys.ToDictionary(p => p, AssetDatabase.AssetPathToGUID); string unchanged = UiSpriteAtlasGenerator.GenerateAll(); Check(unchanged.Contains("updated=0"), "Regeneration is not idempotent: " + unchanged); Check(EditorSettings.spritePackerMode == SpritePackerMode.AlwaysOnAtlas, "SpriteAtlas V1 packing disabled"); Check(UiAtlasPaths.GetAtlasPath("A/B") != UiAtlasPaths.GetAtlasPath("A_B"), "Nested directory name collision"); string[] platforms = { "Standalone", "iPhone", "Android", "WebGL" }; TextureImporterFormat[] formats = { TextureImporterFormat.DXT5, TextureImporterFormat.ASTC_6x6, TextureImporterFormat.ASTC_6x6, TextureImporterFormat.ASTC_8x8 }; var atlases = groups.Keys.Select(AssetDatabase.LoadAssetAtPath).ToArray(); SpriteAtlasUtility.PackAtlases(atlases, EditorUserBuildSettings.activeBuildTarget); var sourceBorders = new Dictionary(); foreach (var group in groups) { var atlas = AssetDatabase.LoadAssetAtPath(group.Key); Check(AssetDatabase.AssetPathToGUID(group.Key) == guids[group.Key], "Atlas GUID changed: " + group.Key); Check(atlas.GetPackables().SequenceEqual(group.Value), "Packables differ: " + group.Key); Check(!atlas.GetPackingSettings().enableRotation && !atlas.GetPackingSettings().enableTightPacking, "UI packing changed"); Check(!atlas.GetTextureSettings().generateMipMaps && atlas.IsIncludeInBuild(), "Atlas must participate in SBP packing without mipmaps"); for (int i = 0; i < platforms.Length; i++) { var settings = atlas.GetPlatformSettings(platforms[i]); Check(settings.overridden && settings.format == formats[i] && settings.maxTextureSize == 2048 && !settings.crunchedCompression, "Wrong platform: " + platforms[i] + " " + group.Key); } foreach (var texture in group.Value) { string path = AssetDatabase.GetAssetPath(texture); var importer = (TextureImporter)AssetImporter.GetAtPath(path); Check(importer.textureCompression == TextureImporterCompression.Uncompressed && !importer.crunchedCompression, "UI source texture must remain uncompressed: " + path); Check(importer.GetDefaultPlatformTextureSettings().format == TextureImporterFormat.RGBA32, "UI source default format must be RGBA32: " + path); foreach (string platform in platforms) Check(!importer.GetPlatformTextureSettings(platform).overridden, "UI source still has a platform override: " + platform + " " + path); sourceBorders[path] = importer.spriteBorder; foreach (var sprite in AssetDatabase.LoadAllAssetsAtPath(path).OfType()) { var packed = atlas.GetSprite(sprite.name); Check(packed != null, "Sprite absent from packed atlas: " + path); Check(packed.border == sprite.border, "Packed sprite border changed: " + path); Object.DestroyImmediate(packed); } if (UiSpriteNineSliceRules.IsStandardControl(path)) Check(importer.spriteBorder.sqrMagnitude > 0, "Nine-slice border absent: " + path); } } var collect = BundleCollectorSettingData.Setting.BeginCollect("Art", false, false, true); var inputs = collect.CollectAssets.ToDictionary(a => a.AssetInfo.AssetPath); var map = new TaskGetBuildMap().CreateBuildMap(false, new ScriptableBuildParameters { PackageName = "Art", EnableSharePackRule = true, UseAssetDependencyDB = false }); foreach (var group in groups) { Check(inputs[group.Key].CollectorType == ECollectorType.MainAssetCollector, "Atlas is not addressable"); var bundle = map.GetBundleInfo(inputs[group.Key].BundleName); Check(bundle.GetAllPackAssetPaths().Contains(group.Key), "Atlas omitted from build map"); foreach (var texture in group.Value) { string path = AssetDatabase.GetAssetPath(texture); Check(inputs[path].BundleName == inputs[group.Key].BundleName, "Sprite and atlas collected into different bundles: " + path); Check(bundle.GetAllPackAssetPaths().Contains(path), "Sprite omitted from its atlas bundle: " + path); Check(((TextureImporter)AssetImporter.GetAtPath(path)).spriteBorder == sourceBorders[path], "Original border modified"); } } Save("editor", new { passed = true, checks, unchanged, target = EditorUserBuildSettings.activeBuildTarget.ToString(), atlases = groups.Select(g => new { path = g.Key, guid = guids[g.Key], textures = g.Value.Count }).ToArray() }); return "PASS: " + checks + " checks, " + unchanged; } public static string ApplyUncompressedSources() { Check(!EditorApplication.isPlayingOrWillChangePlaymode, "Apply source import settings in Edit Mode"); var paths = AssetDatabase.FindAssets("t:Texture2D", new[] { UiAtlasPaths.TextureRoot }) .Select(AssetDatabase.GUIDToAssetPath).OrderBy(p => p, StringComparer.Ordinal).ToArray(); var guids = paths.ToDictionary(p => p, AssetDatabase.AssetPathToGUID); var borders = paths.ToDictionary(p => p, p => ((TextureImporter)AssetImporter.GetAtPath(p)).spriteBorder); int updated = 0; AssetDatabase.StartAssetEditing(); try { foreach (string path in paths) { var importer = (TextureImporter)AssetImporter.GetAtPath(path); if (!TexturePlatformPostprocessor.ConfigureUiAtlasSource(importer)) continue; importer.SaveAndReimport(); updated++; } } finally { AssetDatabase.StopAssetEditing(); } foreach (string path in paths) { var importer = (TextureImporter)AssetImporter.GetAtPath(path); Check(AssetDatabase.AssetPathToGUID(path) == guids[path] && importer.spriteBorder == borders[path], "Source GUID or border changed: " + path); Check(!TexturePlatformPostprocessor.ConfigureUiAtlasSource(importer), "Source settings not idempotent: " + path); Check(AssetDatabase.LoadAssetAtPath(path).format == TextureFormat.RGBA32, "Imported source texture is compressed: " + path); } Save("source_uncompressed", new { passed = true, textures = paths.Length, updated, format = "RGBA32", compression = "Uncompressed", platformOverrides = false, guidsAndBordersPreserved = true }); return $"PASS: {paths.Length} uncompressed source textures, updated={updated}; GUIDs and borders preserved."; } static async Task Until(Func condition) { DateTime end = DateTime.UtcNow.AddSeconds(20); while (!condition()) { if (DateTime.UtcNow > end) throw new Exception("Automatic atlas update timed out"); await Task.Delay(100); } } static bool HasPackables(string path, int count) { var atlas = AssetDatabase.LoadAssetAtPath(path); return atlas != null && atlas.GetPackables().Length == count; } static void WriteTexture(string path) { var texture = new Texture2D(24, 24); try { var pixels = Enumerable.Repeat(new Color32(100, 160, 220, 200), 24 * 24).ToArray(); texture.SetPixels32(pixels); texture.Apply(); File.WriteAllBytes(path, texture.EncodeToPNG()); AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport); } finally { Object.DestroyImmediate(texture); } } public static async Task AutomaticChanges() { checks = 0; string folder = "__AtlasVerify_" + Guid.NewGuid().ToString("N"); string root = UiAtlasPaths.TextureRoot + "/" + folder; string atlasPath = UiAtlasPaths.GetAtlasPath(folder); string childAtlas = UiAtlasPaths.GetAtlasPath(folder + "/Child"); AssetDatabase.CreateFolder(UiAtlasPaths.TextureRoot, folder); try { WriteTexture(root + "/a.png"); await Until(() => HasPackables(atlasPath, 1)); string guid = AssetDatabase.AssetPathToGUID(atlasPath); var importer = (TextureImporter)AssetImporter.GetAtPath(root + "/a.png"); Check(importer.textureType == TextureImporterType.Sprite && !importer.mipmapEnabled, "New UI texture not configured"); Check(importer.textureCompression == TextureImporterCompression.Uncompressed && !importer.GetPlatformTextureSettings("WebGL").overridden, "New UI source is compressed"); WriteTexture(root + "/b.png"); await Until(() => HasPackables(atlasPath, 2)); Check(AssetDatabase.AssetPathToGUID(atlasPath) == guid, "Adding texture recreated atlas"); AssetDatabase.CreateFolder(root, "Child"); Check(string.IsNullOrEmpty(AssetDatabase.MoveAsset(root + "/b.png", root + "/Child/b.png")), "Move failed"); await Until(() => HasPackables(atlasPath, 1) && HasPackables(childAtlas, 1)); Check(AssetDatabase.AssetPathToGUID(atlasPath) == guid, "Moving texture recreated source atlas"); AssetDatabase.DeleteAsset(root + "/a.png"); await Until(() => AssetDatabase.LoadAssetAtPath(atlasPath) == null); Check(HasPackables(childAtlas, 1), "Removing parent atlas affected child atlas"); } finally { AssetDatabase.DeleteAsset(root); UiSpriteAtlasGenerator.GenerateAll(); } Check(AssetDatabase.LoadAssetAtPath(atlasPath) == null && AssetDatabase.LoadAssetAtPath(childAtlas) == null, "Temporary atlases not removed"); Save("automatic", new { passed = true, checks, added = true, moved = true, deleted = true, cleaned = true }); return "PASS: automatic add/move/delete and GUID retention; temporary resources deleted."; } public static string BuildIsolated() { SetupUiBundleCollector.Setup(); const string packageName = "UiAtlasVerification"; var settings = BundleCollectorSettingData.Setting; Check(settings.Packages.All(p => p.PackageName != packageName), "Verification package already exists"); var production = settings.GetPackage("Art"); var ui = production.Groups.Single(g => g.GroupName == "UI"); var group = new BundleCollectorGroup { GroupName = "UI", ActiveRuleName = nameof(EnableGroup) }; // Clone only UI collectors; use one existing dialog to exercise Prefab Sprite late binding. foreach (var source in ui.Collectors) { bool prefab = source.CollectPath == UiPrefabLoader.PrefabFolder; string path = prefab ? UiPrefabLoader.PrefabFolder + "/UI_ConfirmDialog.prefab" : source.CollectPath; group.Collectors.Add(new BundleCollector { CollectPath = path, CollectorGUID = AssetDatabase.AssetPathToGUID(path), CollectorType = source.CollectorType, AddressRuleName = source.AddressRuleName, PackRuleName = source.PackRuleName, FilterRuleName = source.FilterRuleName, AssetTags = source.AssetTags }); } var package = new BundleCollectorPackage { PackageName = packageName, EnableAddressable = true, AutoCollectShaders = true, Groups = new List { group } }; settings.Packages.Add(package); try { var parameters = new ScriptableBuildParameters { BuildOutputRoot = Path.Combine(Output, "Build"), BundledFileRoot = Path.Combine(Output, "Builtin"), BuildPipeline = EBuildPipeline.ScriptableBuildPipeline.ToString(), BuildBundleType = (int)EBundleType.AssetBundle, BuildTarget = EditorUserBuildSettings.activeBuildTarget, PackageName = packageName, PackageVersion = "verify_" + DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"), EnableSharePackRule = true, VerifyBuildingResult = true, FileNameStyle = EFileNameStyle.BundleName_HashName, BundledCopyOption = EBundledCopyOption.OnlyCopyAll, CompressOption = ECompressOption.LZ4, BuiltinShadersBundleName = DefaultBundlePackRule.CreateShadersPackRuleResult().GetBundleName(packageName, settings.UniqueBundleName) }; var result = new ScriptableBuildPipeline().Run(parameters, true); Check(result.Success, "Isolated UI bundle build failed: " + result.ErrorInfo); Save("build", new { passed = true, packageName, packageRoot = parameters.GetBundledRootDirectory(), packageVersion = parameters.PackageVersion, target = EditorUserBuildSettings.activeBuildTarget.ToString() }); return result.OutputPackageDirectory; } finally { settings.Packages.Remove(package); } } public static string PrepareRuntimeScene() { Check(!EditorApplication.isPlayingOrWillChangePlaymode, "Stop Play Mode before preparing verification"); var setup = EditorSceneManager.GetSceneManagerSetup(); for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.sceneCount; i++) Check(!UnityEngine.SceneManagement.SceneManager.GetSceneAt(i).isDirty, "Existing scene has unsaved changes"); string json = JsonConvert.SerializeObject(setup.Select(s => new { s.path, s.isLoaded, s.isActive }).ToArray()); SessionState.SetString("AIGame.UiAtlasVerify.SceneSetup", json); Directory.CreateDirectory(Output); File.WriteAllText(Path.Combine(Output, "scene_setup.json"), json); EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); return "Empty test scene ready; original scene setup retained in SessionState."; } public static string RestoreRuntimeScene() { Check(!EditorApplication.isPlayingOrWillChangePlaymode, "Stop Play Mode before restoring scenes"); string json = SessionState.GetString("AIGame.UiAtlasVerify.SceneSetup", ""); string backupPath = Path.Combine(Output, "scene_setup.json"); if (string.IsNullOrEmpty(json) && File.Exists(backupPath)) json = File.ReadAllText(backupPath); Check(!string.IsNullOrEmpty(json), "No original scene setup retained"); var restored = JArray.Parse(json).Select(s => new SceneSetup { path = s.Value("path"), isLoaded = s.Value("isLoaded"), isActive = s.Value("isActive") }).ToArray(); if (restored.Length == 0) EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); else EditorSceneManager.RestoreSceneManagerSetup(restored); SessionState.EraseString("AIGame.UiAtlasVerify.SceneSetup"); if (File.Exists(backupPath)) File.Delete(backupPath); return "Original scene setup restored."; } static async Task Coroutine(MonoBehaviour host, IEnumerator routine) { bool completed = false; host.StartCoroutine(Finish(routine, () => completed = true)); await Until(() => completed); } static IEnumerator Finish(IEnumerator routine, Action done) { yield return routine; done(); } static void CaptureDialog(GameObject dialog) { var cameraObject = new GameObject("UiAtlasCaptureCamera", typeof(Camera)); var camera = cameraObject.GetComponent(); var target = new RenderTexture(540, 960, 24); var pixels = new Texture2D(540, 960, TextureFormat.RGB24, false); var previous = RenderTexture.active; try { camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(0.08f, 0.12f, 0.2f); camera.orthographic = true; camera.cullingMask = 1 << LayerMask.NameToLayer("UI"); camera.transform.position = new Vector3(0, 0, -50); camera.targetTexture = target; foreach (var canvas in dialog.GetComponentsInChildren(true)) { canvas.renderMode = RenderMode.ScreenSpaceCamera; canvas.worldCamera = camera; canvas.planeDistance = 10; } dialog.SetActive(true); Canvas.ForceUpdateCanvases(); camera.Render(); RenderTexture.active = target; pixels.ReadPixels(new Rect(0, 0, 540, 960), 0, 0); pixels.Apply(); File.WriteAllBytes(Path.Combine(Output, "dialog.png"), pixels.EncodeToPNG()); } finally { RenderTexture.active = previous; camera.targetTexture = null; Object.Destroy(cameraObject); Object.Destroy(target); Object.Destroy(pixels); } } public static async Task Runtime() { Check(Application.isPlaying, "Run in the isolated Play Mode scene"); Check(!YooAssets.IsInitialized, "Refusing to interfere with existing resource packages"); checks = 0; var build = JObject.Parse(File.ReadAllText(Path.Combine(Output, "build.json"))); string packageRoot = build.Value("packageRoot"); YooAssets.Initialize(); var package = YooAssets.CreatePackage("UiAtlasVerification"); var root = new GameObject("UiAtlasVerification"); var loader = root.AddComponent(); loader.Init(package, false); // 关掉 AssetDatabase fallback,必须从真实资源包异步加载。 AssetHandle prefabHandle = null; GameObject instance = null; try { #if UNITY_WEBGL var webFileSystem = new FileSystemParameters("YooAsset.WebServerFileSystem", new Uri(packageRoot + "/").AbsoluteUri); webFileSystem.AddParameter(EFileSystemParameter.DisableUnityWebCache, true); var init = package.InitializePackageAsync(new WebPlayModeOptions { WebServerFileSystemParameters = webFileSystem }); #else var init = package.InitializePackageAsync(new OfflinePlayModeOptions { BuiltinFileSystemParameters = FileSystemParameters.CreateDefaultBuiltinFileSystemParameters(packageRoot) }); #endif await Until(() => init.IsDone); Check(init.Status == EOperationStatus.Succeeded, "Initialize failed: " + init.Error); var manifest = package.LoadPackageManifestAsync(new LoadPackageManifestOptions(build.Value("packageVersion"), 60)); await Until(() => manifest.IsDone); Check(manifest.Status == EOperationStatus.Succeeded, "Manifest failed: " + manifest.Error); prefabHandle = package.LoadAssetAsync("UI_ConfirmDialog"); await Until(() => prefabHandle.IsDone); Check(prefabHandle.Status == EOperationStatus.Succeeded, "Dialog failed: " + prefabHandle.Error); instance = Object.Instantiate(prefabHandle.GetAssetObject()); await Coroutine(loader, loader.WaitForPending()); var images = instance.GetComponentsInChildren(true).Where(i => i.sprite != null).ToArray(); Check(images.Length > 0, "Dialog has no sprite references"); foreach (var image in images) Check(image.sprite.packed && image.sprite.texture != null, "Prefab sprite has no packed texture: " + image.name); CaptureDialog(instance); Sprite a = null, b = null; bool firstDone = false, secondDone = false; loader.StartCoroutine(Finish(loader.LoadSpriteAsync("Common", "btn_primary_normal", s => a = s), () => firstDone = true)); loader.StartCoroutine(Finish(loader.LoadSpriteAsync("Common", "btn_primary_normal", s => b = s), () => secondDone = true)); await Until(() => firstDone && secondDone); Check(a != null && a.texture != null && ReferenceEquals(a, b), "Concurrent sprite requests did not share a valid packed texture"); var imageObject = new GameObject("DynamicImage", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); imageObject.transform.SetParent(root.transform); var target = imageObject.GetComponent(); bool oldDone = false, latestDone = false; Sprite latest = null; loader.StartCoroutine(Finish(loader.SetSpriteAsync(target, "Home", "home_ico_coin"), () => oldDone = true)); loader.StartCoroutine(Finish(loader.SetSpriteAsync(target, "Home", "home_ico_diamond", s => latest = s), () => latestDone = true)); await Until(() => oldDone && latestDone); Check(target.sprite != null && target.sprite.packed && target.sprite.texture != null && target.sprite == latest, "Latest dynamic image request has no valid packed texture"); var homeTexture = new { target.sprite.texture.width, target.sprite.texture.height, format = target.sprite.texture.format.ToString() }; Sprite cachedClone = a; Object.Destroy(instance); instance = null; Object.Destroy(root); await Until(() => root == null && cachedClone == null); Check(cachedClone == null, "Cached sprite clones not released on destruction"); Save("runtime", new { passed = true, checks, realBundles = true, editorFallback = false, prefabLateBinding = true, concurrentCache = true, dynamicSprite = true, disposed = true, homeTexture }); return "PASS: " + checks + " checks; real YooAsset bundles, automatic Prefab binding, async sprites and cleanup."; } finally { if (instance != null) Object.Destroy(instance); if (root != null) Object.Destroy(root); await Task.Delay(100); prefabHandle?.Release(); YooAssets.Destroy(); } } }