Files

409 lines
23 KiB
C#

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;
/// <summary>通过 Pipeline 检查图集生成、自动增删移动、GUID、边界及实际 YooAsset 构建映射。</summary>
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<SpriteAtlas>).ToArray();
SpriteAtlasUtility.PackAtlases(atlases, EditorUserBuildSettings.activeBuildTarget);
var sourceBorders = new Dictionary<string, Vector4>();
foreach (var group in groups)
{
var atlas = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(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<Sprite>())
{
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<Texture2D>(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<bool> 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<SpriteAtlas>(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<string> 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<SpriteAtlas>(atlasPath) == null);
Check(HasPackables(childAtlas, 1), "Removing parent atlas affected child atlas");
}
finally
{
AssetDatabase.DeleteAsset(root);
UiSpriteAtlasGenerator.GenerateAll();
}
Check(AssetDatabase.LoadAssetAtPath<SpriteAtlas>(atlasPath) == null
&& AssetDatabase.LoadAssetAtPath<SpriteAtlas>(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<BundleCollectorGroup> { 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<string>("path"), isLoaded = s.Value<bool>("isLoaded"), isActive = s.Value<bool>("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<Camera>();
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<Canvas>(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<string> 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<string>("packageRoot");
YooAssets.Initialize();
var package = YooAssets.CreatePackage("UiAtlasVerification");
var root = new GameObject("UiAtlasVerification");
var loader = root.AddComponent<UiAtlasLoader>();
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<string>("packageVersion"), 60));
await Until(() => manifest.IsDone);
Check(manifest.Status == EOperationStatus.Succeeded, "Manifest failed: " + manifest.Error);
prefabHandle = package.LoadAssetAsync<GameObject>("UI_ConfirmDialog");
await Until(() => prefabHandle.IsDone);
Check(prefabHandle.Status == EOperationStatus.Succeeded, "Dialog failed: " + prefabHandle.Error);
instance = Object.Instantiate(prefabHandle.GetAssetObject<GameObject>());
await Coroutine(loader, loader.WaitForPending());
var images = instance.GetComponentsInChildren<Image>(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<Image>();
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();
}
}
}