using System; using System.Collections.Generic; using System.IO; using System.Linq; using AibisDream.UI; using UnityEditor; using UnityEditor.AddressableAssets; using UnityEditor.AddressableAssets.Settings; using UnityEditor.AddressableAssets.Settings.GroupSchemas; using UnityEditor.Build; using UnityEditor.Build.Reporting; using UnityEngine; namespace AibisDream.EditorTools.Localization { internal readonly struct LocalizedObjSpriteDirectory { internal LocalizedObjSpriteDirectory( string localeCode, string folderName, string addressRoot, string groupName) { LocaleCode = localeCode; FolderName = folderName; AddressRoot = addressRoot; GroupName = groupName; } internal string LocaleCode { get; } internal string FolderName { get; } internal string AddressRoot { get; } internal string GroupName { get; } } public readonly struct LocalizedObjSpriteValidationIssue { public LocalizedObjSpriteValidationIssue(string assetPath, string message) { AssetPath = assetPath; Message = message; } public string AssetPath { get; } public string Message { get; } public override string ToString() { return $"{AssetPath}: {Message}"; } } /// /// Validates locale-specific Obj folders without depending on Yarn references or /// Unity Localization asset tables. A logical image becomes localized as soon as /// it appears in any non-Chinese locale folder. /// public static class LocalizedObjSpriteValidator { internal const string DefaultArtRoot = "Assets/RawResources/Art"; internal const string DefaultFolderName = "Obj"; internal const string DefaultGroupName = "Shared_UI"; internal static readonly LocalizedObjSpriteDirectory[] LocaleDirectories = { new("en", "Obj_en", "Sprite/Obj_en", "Shared_ObjEn"), new("ja-JP", "Obj_ja-JP", "Sprite/Obj_ja-JP", "Shared_ObjJaJp"), new("ru", "Obj_ru", "Sprite/Obj_ru", "Shared_ObjRu"), new("es", "Obj_es", "Sprite/Obj_es", "Shared_ObjEs"), new("pt-BR", "Obj_pt-BR", "Sprite/Obj_pt-BR", "Shared_ObjPtBr") }; private const string ValidateMenuPath = "Tools/AIBIS/Localization/Validate Localized Obj Sprites"; private const string ConfigureMenuPath = "Tools/AIBIS/Localization/Configure Localized Obj Addressables"; [MenuItem(ValidateMenuPath)] public static void ValidateFromMenu() { IReadOnlyList issues = Validate(); LogIssues(issues); if (issues.Count == 0) { Debug.Log( "[LocalizedObjSpriteValidator] Validation passed. " + "All detected localized Obj sprite groups and Addressables are complete."); } } public static IReadOnlyList Validate( string artRoot = DefaultArtRoot, bool validateAddressables = true) { var issues = new List(); if (!Directory.Exists(artRoot)) { issues.Add(new LocalizedObjSpriteValidationIssue( ToAssetPath(artRoot), "Art root does not exist.")); return issues; } string defaultDirectory = Path.Combine(artRoot, DefaultFolderName); ValidateDirectoryExists( defaultDirectory, LocalizedObjSpriteNaming.DefaultLocaleCode, issues); var localizedRelativePaths = new HashSet( StringComparer.OrdinalIgnoreCase); foreach (LocalizedObjSpriteDirectory directory in LocaleDirectories) { string localeDirectory = Path.Combine(artRoot, directory.FolderName); if (!ValidateDirectoryExists(localeDirectory, directory.LocaleCode, issues)) continue; foreach (string filePath in Directory.EnumerateFiles( localeDirectory, "*.png", SearchOption.AllDirectories)) { localizedRelativePaths.Add(GetRelativePath(localeDirectory, filePath)); } } foreach (string relativePath in localizedRelativePaths.OrderBy( path => path, StringComparer.OrdinalIgnoreCase)) { ValidateRequiredFile( Path.Combine(defaultDirectory, relativePath), LocalizedObjSpriteNaming.DefaultLocaleCode, issues); foreach (LocalizedObjSpriteDirectory directory in LocaleDirectories) { ValidateRequiredFile( Path.Combine(artRoot, directory.FolderName, relativePath), directory.LocaleCode, issues); } } if (validateAddressables) ValidateAddressableConfiguration(issues); return issues; } [MenuItem(ConfigureMenuPath)] public static void ConfigureAddressables() { AddressableAssetSettings settings = AddressableAssetSettingsDefaultObject.Settings; if (settings == null) throw new InvalidOperationException("AddressableAssetSettings does not exist."); foreach (LocalizedObjSpriteDirectory directory in LocaleDirectories) { string assetPath = $"{DefaultArtRoot}/{directory.FolderName}"; EnsureAssetFolder(assetPath); AddressableAssetGroup group = settings.FindGroup(directory.GroupName); if (group == null) { group = settings.CreateGroup( directory.GroupName, false, false, true, null, typeof(BundledAssetGroupSchema), typeof(ContentUpdateGroupSchema)); } ConfigureGroup(settings, group); string guid = AssetDatabase.AssetPathToGUID(assetPath); AddressableAssetEntry entry = settings.CreateOrMoveEntry(guid, group, false, false); entry.address = directory.AddressRoot; } settings.SetDirty( AddressableAssetSettings.ModificationEvent.BatchModification, null, true, true); EditorUtility.SetDirty(settings); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Debug.Log( "[LocalizedObjSpriteValidator] Localized Obj folders and " + "Addressable groups configured."); } public static void ConfigureFromCommandLine() { ConfigureAddressables(); } internal static void LogIssues( IReadOnlyList issues) { foreach (LocalizedObjSpriteValidationIssue issue in issues) Debug.LogError($"[LocalizedObjSpriteValidator] {issue}"); } private static void ConfigureGroup( AddressableAssetSettings settings, AddressableAssetGroup group) { BundledAssetGroupSchema bundled = group.GetSchema() ?? group.AddSchema(); bundled.BuildPath.SetVariableByName( settings, AddressableAssetSettings.kLocalBuildPath); bundled.LoadPath.SetVariableByName( settings, AddressableAssetSettings.kLocalLoadPath); bundled.IncludeInBuild = true; bundled.Compression = BundledAssetGroupSchema.BundleCompressionMode.LZ4; bundled.BundleMode = BundledAssetGroupSchema.BundlePackingMode.PackTogether; EditorUtility.SetDirty(bundled); ContentUpdateGroupSchema contentUpdate = group.GetSchema() ?? group.AddSchema(); contentUpdate.StaticContent = false; EditorUtility.SetDirty(contentUpdate); EditorUtility.SetDirty(group); } private static void ValidateAddressableConfiguration( ICollection issues) { AddressableAssetSettings settings = AddressableAssetSettingsDefaultObject.Settings; if (settings == null) { issues.Add(new LocalizedObjSpriteValidationIssue( "Assets/AddressableAssetsData", "AddressableAssetSettings does not exist.")); return; } ValidateAddressableFolder( settings, $"{DefaultArtRoot}/{DefaultFolderName}", LocalizedObjSpriteNaming.DefaultAddressRoot, DefaultGroupName, false, issues); foreach (LocalizedObjSpriteDirectory directory in LocaleDirectories) { ValidateAddressableFolder( settings, $"{DefaultArtRoot}/{directory.FolderName}", directory.AddressRoot, directory.GroupName, true, issues); } } private static void ValidateAddressableFolder( AddressableAssetSettings settings, string assetPath, string expectedAddress, string expectedGroup, bool validateGroupSchema, ICollection issues) { string guid = AssetDatabase.AssetPathToGUID(assetPath); AddressableAssetEntry entry = string.IsNullOrEmpty(guid) ? null : settings.FindAssetEntry(guid); if (entry == null) { issues.Add(new LocalizedObjSpriteValidationIssue( assetPath, "Folder is not registered as an Addressable entry.")); return; } if (!string.Equals(entry.address, expectedAddress, StringComparison.Ordinal)) { issues.Add(new LocalizedObjSpriteValidationIssue( assetPath, $"Address is '{entry.address}', expected '{expectedAddress}'.")); } AddressableAssetGroup group = entry.parentGroup; if (group == null || !string.Equals( group.Name, expectedGroup, StringComparison.Ordinal)) { issues.Add(new LocalizedObjSpriteValidationIssue( assetPath, $"Group is '{group?.Name ?? ""}', expected '{expectedGroup}'.")); return; } if (!validateGroupSchema) return; BundledAssetGroupSchema bundled = group.GetSchema(); if (bundled == null || !bundled.IncludeInBuild || bundled.Compression != BundledAssetGroupSchema.BundleCompressionMode.LZ4 || bundled.BundleMode != BundledAssetGroupSchema.BundlePackingMode.PackTogether || bundled.BuildPath.GetName(settings) != AddressableAssetSettings.kLocalBuildPath || bundled.LoadPath.GetName(settings) != AddressableAssetSettings.kLocalLoadPath) { issues.Add(new LocalizedObjSpriteValidationIssue( assetPath, "Addressable group must use local paths, LZ4, Include In Build, and Pack Together.")); } ContentUpdateGroupSchema contentUpdate = group.GetSchema(); if (contentUpdate == null || contentUpdate.StaticContent) { issues.Add(new LocalizedObjSpriteValidationIssue( assetPath, "Addressable group must use non-static content update settings.")); } } private static bool ValidateDirectoryExists( string directoryPath, string localeCode, ICollection issues) { if (Directory.Exists(directoryPath)) return true; issues.Add(new LocalizedObjSpriteValidationIssue( ToAssetPath(directoryPath), $"Missing required '{localeCode}' Obj directory.")); return false; } private static void ValidateRequiredFile( string filePath, string localeCode, ICollection issues) { if (File.Exists(filePath)) return; issues.Add(new LocalizedObjSpriteValidationIssue( ToAssetPath(filePath), $"Missing required '{localeCode}' sprite variant.")); } private static string GetRelativePath(string rootPath, string filePath) { string normalizedRoot = Path.GetFullPath(rootPath) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; string normalizedFile = Path.GetFullPath(filePath); return normalizedFile.Substring(normalizedRoot.Length); } private static void EnsureAssetFolder(string path) { string[] segments = path.Split('/'); string current = segments[0]; for (int index = 1; index < segments.Length; index++) { string next = $"{current}/{segments[index]}"; if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(current, segments[index]); current = next; } } private static string ToAssetPath(string path) { return path.Replace('\\', '/'); } } public sealed class LocalizedObjSpriteBuildValidator : IPreprocessBuildWithReport { // Run before build preprocessors that mutate version information. public int callbackOrder => -1000; public void OnPreprocessBuild(BuildReport report) { IReadOnlyList issues = LocalizedObjSpriteValidator.Validate(); if (issues.Count == 0) return; LocalizedObjSpriteValidator.LogIssues(issues); string summary = string.Join( Environment.NewLine, issues.Take(20).Select(issue => issue.ToString())); if (issues.Count > 20) { summary += Environment.NewLine + $"... and {issues.Count - 20} more issue(s)."; } throw new BuildFailedException( "Localized Obj sprite validation failed:" + Environment.NewLine + summary); } } }