using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Text; using Newtonsoft.Json; using UnityEditor; using UnityEditor.Localization; using UnityEngine; using UnityEngine.Localization; using UnityEngine.Localization.Settings; using UnityEngine.Localization.Tables; using UnityEditor.SceneManagement; public static class UnityLocalizationProjectSync { private const string SessionKey = "Bansonic.Localization.Sync.Completed"; private const string RootDirectory = "Assets/Localization"; private const string SettingsDirectory = RootDirectory + "/Settings"; private const string LocalesDirectory = RootDirectory + "/Locales"; private const string TablesDirectory = RootDirectory + "/Tables"; private const string SettingsAssetPath = SettingsDirectory + "/ProjectLocalizationSettings.asset"; private const string ZhJsonPath = "Assets/StreamingAssets/localization/zh-CN.json"; private const string EnJsonPath = "Assets/StreamingAssets/localization/en-US.json"; private static readonly string[] LiteralAssetExtensions = { ".prefab", ".unity" }; private static readonly string[] SkippedPathPrefixes = { "Assets/Airy UI/_Demo Scenes/", "Assets/Cartoon UI/", "Assets/Spine Examples/", "Assets/TextMesh Pro/Examples & Extras/", "Assets/Localization/" }; private static readonly Regex LocalizedTextRegex = new Regex(@"LocalizationService\.(Get|GetFormat)\(\s*""(?[^""]+)""\s*,\s*""(?(?:\\.|[^""])*)""", RegexOptions.Compiled); private static readonly Regex LiteralRegex = new Regex(@"LocalizationService\.LocalizeLiteral\(\s*""(?(?:\\.|[^""])*)""\s*\)", RegexOptions.Compiled); private static readonly Regex NoticeLiteralRegex = new Regex(@"gNotice\.\w+\.display\(\s*""(?(?:\\.|[^""])*)""", RegexOptions.Compiled); private static readonly Regex YamlTextRegex = new Regex(@"^\s*m_(Text|text):\s*(?.*)$", RegexOptions.Compiled); private static readonly HashSet ScriptableObjectStringExclusionPaths = new HashSet(StringComparer.OrdinalIgnoreCase) { "songName" }; [InitializeOnLoadMethod] private static void ScheduleAutoSync() { EditorApplication.delayCall += AutoSyncIfNeeded; } [MenuItem("Tools/Localization/Rebuild Official Localization Assets")] public static void RebuildOfficialLocalizationAssets() { try { EnsureLocalizationAssets(forceRebuild: true); Debug.Log("[Localization] Official localization assets rebuilt successfully."); } catch (Exception ex) { Debug.LogError($"[Localization] Rebuild failed: {ex}"); throw; } } [MenuItem("Tools/Localization/Import JSON To Official Tables")] public static void ImportJsonToOfficialTables() { try { EnsureLocalizationAssets(forceRebuild: true); Debug.Log("[Localization] Imported JSON packs into official localization tables."); } catch (Exception ex) { Debug.LogError($"[Localization] Import JSON failed: {ex}"); throw; } } [MenuItem("Tools/Localization/Export Official Tables To JSON")] public static void ExportOfficialTablesToJson() { try { EnsureDirectories(); LocalizationSettings settings = EnsureSettingsAsset(); LocalizationEditorSettings.ActiveLocalizationSettings = settings; Locale zhLocale = EnsureLocale("zh-CN", "Chinese (Simplified)", $"{LocalesDirectory}/zh-CN.asset"); Locale enLocale = EnsureLocale("en-US", "English (United States)", $"{LocalesDirectory}/en-US.asset"); StringTableCollection gameTextCollection = LocalizationEditorSettings.GetStringTableCollection(LocalizationService.GameTextTableCollectionName); StringTableCollection literalCollection = LocalizationEditorSettings.GetStringTableCollection(LocalizationService.LiteralTextTableCollectionName); Dictionary zhEntries = ExtractEntries(gameTextCollection, zhLocale); Dictionary enEntries = ExtractEntries(gameTextCollection, enLocale); Dictionary zhLiterals = ExtractEntries(literalCollection, zhLocale); Dictionary enLiterals = ExtractEntries(literalCollection, enLocale); SavePack(ZhJsonPath, "zh-CN", zhEntries, zhLiterals); SavePack(EnJsonPath, "en-US", enEntries, enLiterals); AssetDatabase.Refresh(); Debug.Log("[Localization] Exported official localization tables to JSON."); } catch (Exception ex) { Debug.LogError($"[Localization] Export JSON failed: {ex}"); throw; } } private static void AutoSyncIfNeeded() { if (EditorApplication.isPlayingOrWillChangePlaymode) { return; } if (SessionState.GetBool(SessionKey, false)) { return; } SessionState.SetBool(SessionKey, true); try { if (EditorApplication.isCompiling || EditorApplication.isUpdating) { return; } if (HasCoreLocalizationAssets()) { return; } EnsureLocalizationShellAssets(); } catch (Exception ex) { Debug.LogError($"[Localization] Auto sync failed: {ex}"); } } private static void EnsureLocalizationAssets(bool forceRebuild) { EnsureDirectories(); LocalizationSettings settings = EnsureSettingsAsset(); LocalizationEditorSettings.ActiveLocalizationSettings = settings; Locale zhLocale = EnsureLocale("zh-CN", "Chinese (Simplified)", $"{LocalesDirectory}/zh-CN.asset"); Locale enLocale = EnsureLocale("en-US", "English (United States)", $"{LocalesDirectory}/en-US.asset"); var locales = new List { zhLocale, enLocale }; StringTableCollection gameTextCollection = EnsureStringCollection(LocalizationService.GameTextTableCollectionName, locales); StringTableCollection literalCollection = EnsureStringCollection(LocalizationService.LiteralTextTableCollectionName, locales); LocalizationLanguagePack zhPack = LoadPack(ZhJsonPath); LocalizationLanguagePack enPack = LoadPack(EnJsonPath); Dictionary zhEntries = BuildEntryMap(zhPack?.entries, scanCodeFallbacks: true, preferJsonValues: true); Dictionary enEntries = BuildEntryMap(enPack?.entries, scanCodeFallbacks: false, preferJsonValues: true); BackfillMissingEntries(zhEntries, enEntries); Dictionary zhLiterals = BuildLiteralMap(zhPack?.literals, enPack?.literals, english: false); Dictionary enLiterals = BuildLiteralMap(zhPack?.literals, enPack?.literals, english: true); SavePack(ZhJsonPath, "zh-CN", zhEntries, zhLiterals); SavePack(EnJsonPath, "en-US", enEntries, enLiterals); ApplyEntries(gameTextCollection, zhLocale, zhEntries); ApplyEntries(gameTextCollection, enLocale, enEntries); ApplyEntries(literalCollection, zhLocale, zhLiterals); ApplyEntries(literalCollection, enLocale, enLiterals); SetPreloadFlags(gameTextCollection); SetPreloadFlags(literalCollection); EditorUtility.SetDirty(settings); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); } private static bool HasCoreLocalizationAssets() { if (!File.Exists(SettingsAssetPath)) { return false; } LocalizationSettings settings = AssetDatabase.LoadAssetAtPath(SettingsAssetPath); if (settings == null) { return false; } if (LocalizationEditorSettings.GetLocale("zh-CN") == null) { return false; } if (LocalizationEditorSettings.GetLocale("en-US") == null) { return false; } if (LocalizationEditorSettings.GetStringTableCollection(LocalizationService.GameTextTableCollectionName) == null) { return false; } if (LocalizationEditorSettings.GetStringTableCollection(LocalizationService.LiteralTextTableCollectionName) == null) { return false; } return true; } private static void EnsureLocalizationShellAssets() { EnsureDirectories(); LocalizationSettings settings = EnsureSettingsAsset(); LocalizationEditorSettings.ActiveLocalizationSettings = settings; Locale zhLocale = EnsureLocale("zh-CN", "Chinese (Simplified)", $"{LocalesDirectory}/zh-CN.asset"); Locale enLocale = EnsureLocale("en-US", "English (United States)", $"{LocalesDirectory}/en-US.asset"); var locales = new List { zhLocale, enLocale }; EnsureStringCollection(LocalizationService.GameTextTableCollectionName, locales); EnsureStringCollection(LocalizationService.LiteralTextTableCollectionName, locales); EditorUtility.SetDirty(settings); AssetDatabase.SaveAssets(); } private static void EnsureDirectories() { Directory.CreateDirectory(SettingsDirectory); Directory.CreateDirectory(LocalesDirectory); Directory.CreateDirectory(TablesDirectory); } private static LocalizationSettings EnsureSettingsAsset() { var settings = AssetDatabase.LoadAssetAtPath(SettingsAssetPath); if (settings != null) { return settings; } settings = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(settings, SettingsAssetPath); return settings; } private static Locale EnsureLocale(string code, string localeName, string assetPath) { Locale locale = LocalizationEditorSettings.GetLocale(code); if (locale == null) { locale = AssetDatabase.LoadAssetAtPath(assetPath); } if (locale == null) { locale = Locale.CreateLocale(new LocaleIdentifier(code)); locale.name = localeName; locale.LocaleName = localeName; AssetDatabase.CreateAsset(locale, assetPath); } if (LocalizationEditorSettings.GetLocale(code) == null) { LocalizationEditorSettings.AddLocale(locale); } EditorUtility.SetDirty(locale); return locale; } private static StringTableCollection EnsureStringCollection(string tableName, IList locales) { StringTableCollection collection = LocalizationEditorSettings.GetStringTableCollection(tableName); if (collection == null) { collection = LocalizationEditorSettings.CreateStringTableCollection(tableName, TablesDirectory, locales); } for (int i = 0; i < locales.Count; i++) { if (collection.GetTable(locales[i].Identifier) == null) { collection.AddNewTable(locales[i].Identifier); } } EditorUtility.SetDirty(collection); return collection; } private static LocalizationLanguagePack LoadPack(string assetPath) { if (!File.Exists(assetPath)) { return new LocalizationLanguagePack(); } string json = File.ReadAllText(assetPath); LocalizationLanguagePack pack = JsonConvert.DeserializeObject(json); return pack ?? new LocalizationLanguagePack(); } private static Dictionary BuildEntryMap(Dictionary seedEntries, bool scanCodeFallbacks, bool preferJsonValues) { var result = new Dictionary(StringComparer.Ordinal); if (seedEntries != null) { foreach (var pair in seedEntries) { if (!string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value)) { result[pair.Key] = pair.Value; } } } if (!scanCodeFallbacks) { return result; } foreach (string sourceFile in EnumerateSourceFiles("Assets")) { string content = File.ReadAllText(sourceFile); foreach (Match match in LocalizedTextRegex.Matches(content)) { string key = Regex.Unescape(match.Groups["key"].Value); string fallback = Regex.Unescape(match.Groups["fallback"].Value); if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(fallback)) { continue; } if (!result.ContainsKey(key) || !preferJsonValues) { result[key] = fallback; } } } return result; } private static Dictionary BuildLiteralMap( Dictionary zhSeed, Dictionary enSeed, bool english) { var result = new Dictionary(StringComparer.Ordinal); Dictionary primary = english ? enSeed : zhSeed; if (primary != null) { foreach (var pair in primary) { if (!string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value)) { result[pair.Key] = pair.Value; } } } foreach (string sourceFile in EnumerateSourceFiles("Assets")) { string content = File.ReadAllText(sourceFile); MergeLiteralMatches(content, LiteralRegex, result, english); MergeLiteralMatches(content, NoticeLiteralRegex, result, english); } foreach (string assetPath in EnumerateLiteralAssetPaths()) { foreach (string literal in ReadLiteralStringsFromYaml(assetPath)) { if (!result.ContainsKey(literal)) { result[literal] = literal; } } } foreach (string literal in EnumerateScriptableObjectLiterals()) { if (!result.ContainsKey(literal)) { result[literal] = literal; } } return result; } private static void MergeLiteralMatches(string content, Regex regex, Dictionary target, bool english) { foreach (Match match in regex.Matches(content)) { string literal = Regex.Unescape(match.Groups["literal"].Value); if (!ShouldCaptureLiteral(literal)) { continue; } if (!target.ContainsKey(literal)) { target[literal] = literal; } } } private static IEnumerable EnumerateSourceFiles(string rootDirectory) { return Directory.EnumerateFiles(rootDirectory, "*.cs", SearchOption.AllDirectories) .Where(path => path.IndexOf("\\Editor\\", StringComparison.OrdinalIgnoreCase) < 0); } private static IEnumerable EnumerateLiteralAssetPaths() { return Directory.EnumerateFiles("Assets", "*.*", SearchOption.AllDirectories) .Where(path => LiteralAssetExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) .Where(path => !ShouldSkipAssetPath(path)); } private static IEnumerable ReadLiteralStringsFromYaml(string assetPath) { foreach (string rawLine in File.ReadLines(assetPath)) { Match match = YamlTextRegex.Match(rawLine); if (!match.Success) { continue; } string value; try { value = DecodeYamlText(match.Groups["value"].Value); } catch (Exception ex) { Debug.LogWarning($"[Localization] Skipped malformed YAML text in {assetPath}: {ex.Message}"); continue; } if (ShouldCaptureLiteral(value)) { yield return value; } } } private static IEnumerable EnumerateScriptableObjectLiterals() { string[] guids = AssetDatabase.FindAssets("t:ScriptableObject", new[] { "Assets" }); for (int i = 0; i < guids.Length; i++) { string assetPath = AssetDatabase.GUIDToAssetPath(guids[i]); if (string.IsNullOrWhiteSpace(assetPath) || ShouldSkipAssetPath(assetPath)) { continue; } ScriptableObject asset = AssetDatabase.LoadAssetAtPath(assetPath); if (asset == null) { continue; } SerializedObject serializedObject = new SerializedObject(asset); SerializedProperty iterator = serializedObject.GetIterator(); bool enterChildren = true; while (iterator.NextVisible(enterChildren)) { enterChildren = true; if (iterator.propertyType != SerializedPropertyType.String) { continue; } if (ShouldSkipScriptableObjectString(asset, iterator)) { continue; } string literal = iterator.stringValue; if (ShouldCaptureLiteral(literal)) { yield return literal.Trim(); } } } } private static bool ShouldSkipScriptableObjectString(ScriptableObject asset, SerializedProperty property) { if (asset == null || property == null) { return true; } if (string.Equals(property.propertyPath, "m_Script", StringComparison.OrdinalIgnoreCase)) { return true; } if (asset is SongData && ScriptableObjectStringExclusionPaths.Contains(property.propertyPath)) { return true; } return false; } private static string DecodeYamlText(string rawValue) { if (string.IsNullOrWhiteSpace(rawValue)) { return string.Empty; } string value = rawValue.Trim(); if (value == "\"\"" || value == "''") { return string.Empty; } bool isDoubleQuoted = value.Length >= 2 && value.StartsWith("\"", StringComparison.Ordinal) && value.EndsWith("\"", StringComparison.Ordinal); bool isSingleQuoted = value.Length >= 2 && value.StartsWith("'", StringComparison.Ordinal) && value.EndsWith("'", StringComparison.Ordinal); if (isDoubleQuoted || isSingleQuoted) { if (isDoubleQuoted) { try { value = JsonConvert.DeserializeObject(value); } catch { value = value.Substring(1, value.Length - 2); } } else { value = value.Substring(1, value.Length - 2); } } else { value = DecodeYamlEscapes(value); } return value.Trim(); } private static string DecodeYamlEscapes(string value) { if (string.IsNullOrEmpty(value) || value.IndexOf('\\') < 0) { return value; } var builder = new StringBuilder(value.Length); for (int i = 0; i < value.Length; i++) { char current = value[i]; if (current != '\\' || i + 1 >= value.Length) { builder.Append(current); continue; } char next = value[i + 1]; switch (next) { case 'n': builder.Append('\n'); i++; break; case 'r': builder.Append('\r'); i++; break; case 't': builder.Append('\t'); i++; break; case '"': case '\'': case '\\': builder.Append(next); i++; break; case 'u' when i + 5 < value.Length: string hex = value.Substring(i + 2, 4); if (ushort.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out ushort code)) { builder.Append((char)code); i += 5; } else { builder.Append(current); } break; case 'x': builder.Append(next); i++; break; default: builder.Append(current); break; } } return builder.ToString(); } private static bool ShouldCaptureLiteral(string literal) { if (string.IsNullOrWhiteSpace(literal)) { return false; } string trimmed = literal.Trim(); if (trimmed.Length == 0 || trimmed.Length > 512) { return false; } if (trimmed.StartsWith("guid:", StringComparison.OrdinalIgnoreCase)) { return false; } bool hasLetterLikeContent = trimmed.Any(ch => char.IsLetter(ch) || IsCjk(ch)); return hasLetterLikeContent; } private static bool IsCjk(char ch) { return ch >= 0x4E00 && ch <= 0x9FFF; } private static bool ShouldSkipAssetPath(string path) { string normalized = path.Replace('\\', '/'); for (int i = 0; i < SkippedPathPrefixes.Length; i++) { if (normalized.StartsWith(SkippedPathPrefixes[i], StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } private static void BackfillMissingEntries(Dictionary zhEntries, Dictionary enEntries) { foreach (string key in zhEntries.Keys.ToArray()) { if (!enEntries.ContainsKey(key)) { enEntries[key] = zhEntries[key]; } } foreach (string key in enEntries.Keys.ToArray()) { if (!zhEntries.ContainsKey(key)) { zhEntries[key] = enEntries[key]; } } } private static void ApplyEntries(StringTableCollection collection, Locale locale, Dictionary entries) { if (collection == null || locale == null) { return; } StringTable table = collection.GetTable(locale.Identifier) as StringTable; if (table == null) { table = collection.AddNewTable(locale.Identifier) as StringTable; } foreach (var pair in entries) { if (string.IsNullOrWhiteSpace(pair.Key)) { continue; } StringTableEntry entry = table.GetEntry(pair.Key); if (entry == null) { table.AddEntry(pair.Key, pair.Value ?? string.Empty); } else if (entry.LocalizedValue != (pair.Value ?? string.Empty)) { entry.Value = pair.Value ?? string.Empty; } } EditorUtility.SetDirty(table); EditorUtility.SetDirty(table.SharedData); } private static Dictionary ExtractEntries(StringTableCollection collection, Locale locale) { var result = new Dictionary(StringComparer.Ordinal); if (collection == null || locale == null) { return result; } StringTable table = collection.GetTable(locale.Identifier) as StringTable; if (table == null) { return result; } foreach (StringTableEntry entry in table.Values) { if (entry == null || string.IsNullOrWhiteSpace(entry.Key)) { continue; } result[entry.Key] = entry.LocalizedValue ?? string.Empty; } return result; } private static void SavePack(string assetPath, string languageCode, Dictionary entries, Dictionary literals) { var pack = new LocalizationLanguagePack { languageCode = languageCode, entries = new Dictionary(entries, StringComparer.Ordinal), literals = new Dictionary(literals, StringComparer.Ordinal) }; string json = JsonConvert.SerializeObject(pack, Formatting.Indented); File.WriteAllText(assetPath, json); } private static void SetPreloadFlags(StringTableCollection collection) { foreach (var table in collection.StringTables) { LocalizationEditorSettings.SetPreloadTableFlag(table, true); EditorUtility.SetDirty(table); } } }