using UnityEngine; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine.UI; using UnityEditor.Events; using UnityEngine.Events; using UnityEngine.Localization.Components; using System.Collections.Generic; using TMPro; /// /// Batch-processes all scenes in the project, attaching LocalizeStringEvent components /// to every text element whose content matches a key in the provided mapping. /// /// Covers BOTH legacy UnityEngine.UI.Text and TextMeshPro (TMP_Text, which is the base of /// TextMeshProUGUI and TextMeshPro). Walking only one of the two is the usual mistake and it /// fails silently: measured on a real project, FindObjectsByType<Text> found 1 component /// while FindObjectsByType<TMP_Text> found 13, so a Text-only pass reports success having /// localized almost nothing. /// /// Only call LocalizeAll() after confirming with the user — it modifies and saves every scene. /// It also reports what it did NOT convert; see the return value of LocalizeHierarchy. /// public static class L10nBatchProcessor { /// Localizes every scene. Returns the labels that matched no mapping entry. public static List LocalizeAll(Dictionary mapping, string table) { var unmatched = new List(); // Scope the search to Assets/. An unscoped FindAssets searches the whole project INCLUDING // read-only packages, so it returns package test scenes and this loop then tries to open, // dirty and save assets it must not touch. Measured on a real project: unscoped found 20 // scenes where the project has 1, and all 19 extras were inside read-only packages // (com.unity.addressables test fixtures). string[] scenes = AssetDatabase.FindAssets("t:Scene", new[] { "Assets" }); foreach (var guid in scenes) { var path = AssetDatabase.GUIDToAssetPath(guid); var scene = EditorSceneManager.OpenScene(path, OpenSceneMode.Single); unmatched.AddRange(LocalizeHierarchy(mapping, table, path)); EditorSceneManager.MarkSceneDirty(scene); EditorSceneManager.SaveScene(scene); } return unmatched; } /// /// Wires the open scene. Returns "scene :: object :: text" for every label that was found /// but matched no mapping entry, so partial coverage is reported rather than silent. /// static List LocalizeHierarchy( Dictionary mapping, string table, string scenePath) { var unmatched = new List(); // Both component families. TMP_Text is abstract and covers TextMeshProUGUI and TextMeshPro. var labels = new List(); labels.AddRange(Object.FindObjectsByType( FindObjectsInactive.Include, FindObjectsSortMode.None)); labels.AddRange(Object.FindObjectsByType( FindObjectsInactive.Include, FindObjectsSortMode.None)); foreach (var label in labels) { // Read the current string from whichever family this component belongs to. var current = label is Text legacy ? legacy.text : ((TMP_Text)label).text; if (string.IsNullOrEmpty(current)) continue; var matched = false; foreach (var kvp in mapping) { if (!current.Contains(kvp.Key)) continue; var lse = label.gameObject.GetComponent() ?? label.gameObject.AddComponent(); lse.StringReference = new UnityEngine.Localization.LocalizedString(table, kvp.Value); // Wire the listener through the public UnityEventTools API. The persistent-call // fields could be written directly through SerializedObject instead, but those // are private serialized names with no compatibility guarantee, and it isn't // necessary. A delegate to the component's public `text` setter, handed to // AddPersistentListener, produces the same serialized call: target = the // component, method = set_text, mode = EventDefined (dynamic). // // The setter has no C# method-group name, so the delegate is built by name. // That is reflection over a *public* member, which is fine; reaching for the // private m_* fields would not be. `set_text` is public on both Text and // TMP_Text, so the same call works for either target. var setText = (UnityAction)System.Delegate.CreateDelegate( typeof(UnityAction), label, "set_text"); for (int i = lse.OnUpdateString.GetPersistentEventCount() - 1; i >= 0; i--) { UnityEventTools.RemovePersistentListener(lse.OnUpdateString, i); } UnityEventTools.AddPersistentListener(lse.OnUpdateString, setText); // AddPersistentListener leaves the call at RuntimeOnly, which means the label does // NOT update when the locale changes in the Editor: correct in a build, dormant // while authoring, and it stays dormant through a save and reload. Set the state // explicitly. SetPersistentListenerState is public API on UnityEventBase. // Verified on Unity 6000.5.8f1: without this the listener never fires in Edit mode. var callIndex = lse.OnUpdateString.GetPersistentEventCount() - 1; lse.OnUpdateString.SetPersistentListenerState( callIndex, UnityEventCallState.EditorAndRuntime); EditorUtility.SetDirty(lse); matched = true; break; } // A label the mapping never covered is a coverage gap the caller has to see. // Reporting "localized 12" while silently leaving 9 untouched is the failure mode // this return value exists to prevent. if (!matched) { unmatched.Add($"{scenePath} :: {label.gameObject.name} :: \"{current}\""); } } return unmatched; } }