using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; using UnityEngine.Localization.Settings; using UnityEngine.Localization.SmartFormat.Core.Extensions; using UnityEngine.Localization.SmartFormat.Core.Formatting; using UnityEngine.Localization.SmartFormat.Extensions; using UnityEngine.Localization.SmartFormat.PersistentVariables; using UnityEngine.Localization.Tables; using UnityEngine.Pool; using UnityEngine.ResourceManagement.AsyncOperations; namespace UnityEngine.Localization { /// /// Provides a way to reference a inside of a specific and request the localized string. /// /// /// This example shows how to localize a [MonoBehaviour](https://docs.unity3d.com/ScriptReference/MonoBehaviour.html) so that it updates automatically when the active locale changes. /// This example uses asynchronous loading to load the localized assets in the background. /// /// /// /// This example shows how to localize a [MonoBehaviour](https://docs.unity3d.com/ScriptReference/MonoBehaviour.html) immediately. /// This example uses synchronous loading, which may cause a pause when first loading the localized assets. /// /// /// /// This example shows how to localize a [ScriptableObject](https://docs.unity3d.com/ScriptReference/ScriptableObject.html) to represent a quest in a game. /// /// /// /// This example shows how to use local variables to represent a health counter. /// /// /// /// This example shows how to bind to a UI Toolkit property. Note that this requires Unity 2023.2 and above. /// /// /// /// This example shows how to use local variables when you bind to a UI Toolkit property. Note that this requires Unity 2023.2 and above. /// /// [Serializable] public partial class LocalizedString : LocalizedReference, IVariableGroup, IDictionary, IVariableValueChanged, IDisposable { [SerializeField] List m_LocalVariables = new List(); CallbackArray m_ChangeHandler; string m_CurrentStringChangedValue; // Kept in sync with m_Variables so that users can make changes via the inspector without issues(duplicate/empty names etc). readonly Dictionary m_VariableLookup = new Dictionary(); readonly List m_UsedVariables = new List(); Action m_OnVariableChanged; Action m_SelectedLocaleChanged; Action> m_AutomaticLoadingCompleted; Action.TableEntryResult>> m_CompletedSourceValue; bool m_WaitingForVariablesEndUpdate; /// public event Action ValueChanged; internal override bool ForceSynchronous => WaitForCompletion || LocalizationSettings.StringDatabase.AsynchronousBehaviour == AsynchronousBehaviour.ForceSynchronous; /// /// Arguments that will be passed through to Smart Format. These arguments are not serialized and will need to be set at runtime. /// See to add persistent serialized arguments. /// public IList Arguments { get; set; } /// /// The current loading operation for the string when using or if one is not available. /// A string may not be immediately available, such as when loading the asset, so all string operations are wrapped /// with an . /// See also /// public AsyncOperationHandle CurrentLoadingOperationHandle { get; internal set; } /// /// Delegate used by . /// /// The localized string. public delegate void ChangeHandler(string value); /// /// Provides a callback that will be invoked when the translated string has changed. /// The following events will trigger an update: /// /// The first time the action is added to the event. /// The changing. /// If the string is currently using a which supports and it's value has changed. /// When is called. /// The or changing. /// /// When the first is added, a loading operation (see ) automatically starts. /// When the loading operation is completed, the localized string value is sent to the subscriber. /// If you add additional subscribers after loading has completed, they are also sent the latest localized string value. /// This ensures that a subscriber will always have the correct localized value regardless of when it was added. /// /// /// This example shows how the event can be used to trigger updates to a string. /// /// public event ChangeHandler StringChanged { add { if (value == null) throw new ArgumentNullException(); m_ChangeHandler.Add(value); if (m_ChangeHandler.Length == 1) { LocalizationSettings.ValidateSettingsExist(); ForceUpdate(); // We subscribe after the first update as its possible that a SelectedLocaleChanged may be fired // during ForceUpdate when using WaitForCompletion and we want to avoid this. LocalizationSettings.SelectedLocaleChanged += m_SelectedLocaleChanged; } else if (CurrentLoadingOperationHandle.IsValid() && CurrentLoadingOperationHandle.IsDone) { // Call the event with the latest value. value(m_CurrentStringChangedValue); } } remove { m_ChangeHandler.RemoveByMovingTail(value); if (m_ChangeHandler.Length == 0) { LocalizationSettings.SelectedLocaleChanged -= m_SelectedLocaleChanged; ClearLoadingOperation(); } } } /// /// Returns if has any subscribers. /// public bool HasChangeHandler => m_ChangeHandler.Length != 0; /// /// Initializes and returns an empty instance of a . /// public LocalizedString() { m_SelectedLocaleChanged = HandleLocaleChange; m_OnVariableChanged = OnVariableChanged; m_AutomaticLoadingCompleted = AutomaticLoadingCompleted; m_CompletedSourceValue = CompletedSourceValue; } /// /// Initializes and returns an instance of a . /// /// Reference to the String Table Collection. /// This can either be the name of the collection as a or the Collection Guid as a [System.Guid](https://docs.microsoft.com/en-us/dotnet/api/system.guid). /// Reference to the String Table Collection entry. /// This can either be the name of the Key as a or the Key Id. /// /// This example shows the different ways to construct a LocalizedString. /// /// /// /// This example shows how a LocalizedString could be set up in the Editor. /// By using the Guid and Id the table and entry references will not be lost if the table collection name or entry name was to be changed. /// Note: The performance benefits to using a Guid and Id are negligible. /// /// public LocalizedString(TableReference tableReference, TableEntryReference entryReference) : this() { TableReference = tableReference; TableEntryReference = entryReference; } /// /// Provides a way to force a refresh of the string when using . /// /// /// This will only force the refresh if there is currently no active , if one is still being executed then it will be ignored and will be returned. /// If a string is not static and will change during game play, such as when using format arguments, then this can be used to force the string to update itself. /// You may wish to call this if the values inside of the list have changed or you wish to force all subscribers to update. /// /// Returns if a new refresh could be requested or if it could not, such as when is still loading. /// /// This example shows how the string can be refreshed, such as when showing dynamic values like the current time. /// /// public bool RefreshString() { if (m_ChangeHandler.Length == 0 || !CurrentLoadingOperationHandle.IsValid()) return false; if (!CurrentLoadingOperationHandle.IsDone) { #if !UNITY_WEBGL if (ForceSynchronous) { CurrentLoadingOperationHandle.WaitForCompletion(); return true; } else #endif return false; } var entry = CurrentLoadingOperationHandle.Result.Entry; var formatCache = entry?.GetOrCreateFormatCache(); if (formatCache != null) { formatCache.LocalVariables = this; formatCache.VariableTriggers.Clear(); } var translatedText = LocalizationSettings.StringDatabase.GenerateLocalizedString(CurrentLoadingOperationHandle.Result.Table, entry, TableReference, TableEntryReference, LocalizationSettings.SelectedLocale, Arguments); if (formatCache != null) { formatCache.LocalVariables = null; UpdateVariableListeners(entry?.FormatCache?.VariableTriggers); } m_CurrentStringChangedValue = translatedText; InvokeChangeHandler(m_CurrentStringChangedValue); return true; } /// /// Provides a translated string from a with the and /// the translated string that matches . /// /// /// The event provides a notification once the operation has finished and the string has been found or an error has occurred. /// A string table may have already been loaded during a previous operation or when using Preload mode. /// Check the property to see if the string table has already been loaded and the translated string is immediately available. /// See [Async operation handling](https://docs.unity3d.com/Packages/com.unity.addressables@latest/index.html?subfolder=/manual/AddressableAssetsAsyncOperationHandle.html) for further details. /// To force the operation to complete immediately, call . /// /// Returns the loading operation for the request. /// /// This example shows how can be used to request an updated string when the changes. /// /// /// /// This example shows how can be forced to complete immediately using . /// /// public AsyncOperationHandle GetLocalizedStringAsync() => GetLocalizedStringAsync(Arguments); /// /// Provides a translated string from a with the and /// the translated string that matches . /// Uses [WaitForCompletion](xref:UnityEngine.ResourceManagement.AsyncOperations.AsyncOperationHandle.WaitForCompletion) to force the loading to complete synchronously. /// Please note that [WaitForCompletion](xref:UnityEngine.ResourceManagement.AsyncOperations.AsyncOperationHandle.WaitForCompletion) is not supported on /// [WebGL](https://docs.unity3d.com/Packages/com.unity.addressables@latest/index.html?subfolder=/manual/SynchronousAddressables.html#webgl). /// /// The localized string for the or if it is not . public string GetLocalizedString() => GetLocalizedStringAsync().WaitForCompletion(); /// /// Provides a translated string from a with the and /// the translated string that matches . /// /// The arguments to pass into the Smart String formatter or [String.Format](https://docs.microsoft.com/en-us/dotnet/api/system.string.format). /// Returns the loading operation for the request. public AsyncOperationHandle GetLocalizedStringAsync(params object[] arguments) => GetLocalizedStringAsync((IList)arguments); /// /// Provides a translated string from a with the and /// the translated string that matches . /// Uses [WaitForCompletion](xref:UnityEngine.ResourceManagement.AsyncOperations.AsyncOperationHandle.WaitForCompletion) to force the loading to complete synchronously. /// Please note that [WaitForCompletion](xref:UnityEngine.ResourceManagement.AsyncOperations.AsyncOperationHandle.WaitForCompletion) is not supported on /// [WebGL](https://docs.unity3d.com/Packages/com.unity.addressables@latest/index.html?subfolder=/manual/SynchronousAddressables.html#webgl). /// /// The arguments to pass into the Smart String formatter or [String.Format](https://docs.microsoft.com/en-us/dotnet/api/system.string.format). /// The localized string for the or if it is not . public string GetLocalizedString(params object[] arguments) => GetLocalizedStringAsync((IList)arguments).WaitForCompletion(); /// /// Provides a translated string from a with the and /// the translated string that matches . /// /// The arguments to pass into the Smart String formatter or [String.Format](https://docs.microsoft.com/en-us/dotnet/api/system.string.format). /// The localized string for the or if it is not . public string GetLocalizedString(IList arguments) => GetLocalizedStringAsync(arguments).WaitForCompletion(); /// /// Provides a translated string from a with the and /// the translated string that matches . /// /// The arguments to pass into the Smart String formatter or [String.Format](https://docs.microsoft.com/en-us/dotnet/api/system.string.format). /// Returns the loading operation for the request. public AsyncOperationHandle GetLocalizedStringAsync(IList arguments) { LocalizationSettings.ValidateSettingsExist(); return LocalizationSettings.StringDatabase.GetLocalizedStringAsync(TableReference, TableEntryReference, arguments, LocaleOverride, FallbackState, m_LocalVariables.Count > 0 ? this : null); } /// /// Returns the number of local variables inside this localized string. /// public int Count => m_VariableLookup.Count; /// /// Returns a collection containing all the unique local variable names. /// public ICollection Keys => m_VariableLookup.Keys; /// /// Returns all the local variables for this localized string. /// public ICollection Values => m_VariableLookup.Values.Select(s => s.variable).ToList(); /// /// Implemented as part of the IDictionary interface but not actually used. Will always return . /// public bool IsReadOnly => false; /// /// Gets or sets the with the specified name. /// /// The name of the variable. /// The found variable. /// Thrown if a variable with the specified name does not exist. /// /// This example shows how to get and add a local variable. /// /// public IVariable this[string name] { get => m_VariableLookup[name].variable; set => Add(name, value); } /// /// Gets the with the specified name. /// /// The name of the variable. /// The variable that was found or . /// if a variable was found and if one could not. /// /// This example shows how to get and add a local variable using TryGetValue. /// /// public bool TryGetValue(string name, out IVariable value) { if (m_VariableLookup.TryGetValue(name, out var v)) { value = v.variable; return true; } value = default; return false; } /// /// Adds a new Local Variable to use during formatting. /// /// The name of the variable, must be unique. Note the name should not contain any whitespace, if any is found then it will be replaced with with '-'. /// The variable to use when formatting. See also , , , , . /// Thrown when is null or empty. /// Thrown when variable is null. /// /// This example shows how to get and add a local variable. /// /// public void Add(string name, IVariable variable) { if (string.IsNullOrEmpty(name)) throw new ArgumentException(nameof(name), "Name must not be null or empty."); if (variable == null) throw new ArgumentNullException(nameof(variable)); name = name.ReplaceWhiteSpaces("-"); if (m_VariableLookup.TryGetValue(name, out var value)) { if (ReferenceEquals(value.variable, variable)) return; m_LocalVariables.Remove(value); } var v = new VariableNameValuePair { name = name, variable = variable }; m_VariableLookup[name] = v; m_LocalVariables.Add(v); } /// /// /// /// The local variable name and value to add. public void Add(KeyValuePair item) => Add(item.Key, item.Value); /// /// Removes a local variable with the specified name. /// /// The name of the variable to be removed. /// if a variable with the specified name was removed, if one was not. /// /// This example shows how to remove a local variable. /// /// public bool Remove(string name) { if (m_VariableLookup.TryGetValue(name, out var v)) { m_LocalVariables.Remove(v); m_VariableLookup.Remove(name); return true; } return false; } /// /// Removes a local variable with the specified key. /// /// The item to be removed, only the Key field will be considered. /// if a variable with the specified name was removed, if one was not. public bool Remove(KeyValuePair item) => Remove(item.Key); /// /// Returns if a local variable with the specified name exists. /// /// The variable name to check for. /// if a matching variable could be found or if one could not. public bool ContainsKey(string name) => m_VariableLookup.ContainsKey(name); /// /// /// /// The item to check for. Both the Key and Value must match. /// if a matching variable could be found or if one could not. public bool Contains(KeyValuePair item) => TryGetValue(item.Key, out var v) && v == item.Value; /// /// Copies the local variables into an array starting at . /// /// The array to copy the local variables into. /// The index to start copying the items into. /// Thrown when the array is null. public void CopyTo(KeyValuePair[] array, int arrayIndex) { if (array == null) throw new ArgumentNullException(nameof(array)); foreach (var entry in m_VariableLookup) { array[arrayIndex++] = new KeyValuePair(entry.Key, entry.Value.variable); } } /// /// /// /// The enumerator that can be used to iterate through all the local variables. IEnumerator> IEnumerable>.GetEnumerator() { foreach (var v in m_VariableLookup) { yield return new KeyValuePair(v.Key, v.Value.variable); } } /// /// Returns an enumerator for all local variables in this localized string. /// /// The enumerator that can be used to iterate through all the local variables. public IEnumerator GetEnumerator() { foreach (var v in m_VariableLookup) { yield return new KeyValuePair(v.Key, v.Value.variable); } } /// /// Removes all local variables. /// public void Clear() { m_VariableLookup.Clear(); m_LocalVariables.Clear(); } /// /// Allows for accessing metadata in a smart string. /// struct StringTableEntryVariable : IVariableGroup { readonly string m_Localized; readonly StringTableEntry m_StringTableEntry; public StringTableEntryVariable(string localized, StringTableEntry entry) { m_Localized = localized; m_StringTableEntry = entry; } public bool TryGetValue(string key, out IVariable value) { foreach (var md in m_StringTableEntry.MetadataEntries) { if (md is IMetadataVariable v && v.VariableName == key) { value = v; return true; } } value = null; return false; } /// /// Returns the localized string by default. /// /// The localized string. public override string ToString() => m_Localized; } /// /// Provides access to both the current local variables and those from the parent. /// struct ChainedLocalVariablesGroup : IVariableGroup { IVariableGroup ParentGroup { get; set; } IVariableGroup Group { get; set; } public ChainedLocalVariablesGroup(IVariableGroup group, IVariableGroup parent) { Group = group; ParentGroup = parent; } public bool TryGetValue(string key, out IVariable value) { if (Group.TryGetValue(key, out value)) return true; if (ParentGroup.TryGetValue(key, out value)) return true; value = null; return false; } } /// /// Thrown when , no locale is available, the table is still loading, or the entry is missing. public object GetSourceValue(ISelectorInfo selector) { if (IsEmpty) throw new DataNotReadyException("{Empty}"); // Determine what Locale we should use. var locale = LocaleOverride; if (locale == null && selector.FormatDetails.FormatCache != null) locale = LocalizationSettings.AvailableLocales.GetLocale(selector.FormatDetails.FormatCache.Table.LocaleIdentifier); if (locale == null && LocalizationSettings.SelectedLocaleAsync.IsDone) locale = LocalizationSettings.SelectedLocaleAsync.Result; if (locale == null) throw new DataNotReadyException("{No Available Locale}"); var operation = LocalizationSettings.StringDatabase.GetTableEntryAsync(TableReference, TableEntryReference, locale, FallbackState); if (!operation.IsDone) { operation.Completed += m_CompletedSourceValue; throw new DataNotReadyException(); } var entry = operation.Result.Entry; if (entry == null) throw new DataNotReadyException("{Missing Entry}"); // If the entry is not smart then we do not need to forward as much information to the child. if (!entry.IsSmart) { var result = LocalizationSettings.StringDatabase.GenerateLocalizedString(operation.Result.Table, entry, TableReference, TableEntryReference, locale, Arguments); return new StringTableEntryVariable(result, entry); } var formatCache = entry?.GetOrCreateFormatCache(); if (formatCache != null) { formatCache.VariableTriggers.Clear(); if (m_VariableLookup.Count > 0) { // Use the child and parent local variables. formatCache.LocalVariables = new ChainedLocalVariablesGroup(this, selector.FormatDetails.FormatCache.LocalVariables); } else { // Just use the parents local variables. formatCache.LocalVariables = selector.FormatDetails.FormatCache.LocalVariables; } } using (ListPool.Get(out var args)) { if (selector.CurrentValue != null) args.Add(selector.CurrentValue); if (Arguments != null) args.AddRange(Arguments); var result = LocalizationSettings.StringDatabase.GenerateLocalizedString(operation.Result.Table, entry, TableReference, TableEntryReference, locale, args); if (formatCache != null) { formatCache.LocalVariables = null; // Subscribe to changes to local variables. // Note: This could cause issues if the nested string is being used in multiple places. // We may need to consider keeping multiple lists of callbacks in the future. UpdateVariableListeners(formatCache.VariableTriggers); } return new StringTableEntryVariable(result, entry); } } void CompletedSourceValue(AsyncOperationHandle.TableEntryResult> _) => ValueChanged?.Invoke(this); /// protected internal override void ForceUpdate() { if (m_ChangeHandler.Length != 0) { HandleLocaleChange(null); } ValueChanged?.Invoke(this); } void UpdateVariableListeners(List variables) { // Unsubscribe from any old ones foreach (var gv in m_UsedVariables) { gv.ValueChanged -= m_OnVariableChanged; } m_UsedVariables.Clear(); if (variables == null) return; foreach (var gv in variables) { m_UsedVariables.Add(gv); gv.ValueChanged += m_OnVariableChanged; } } void OnVariableChanged(IVariable globalVariable) { if (m_WaitingForVariablesEndUpdate) return; if (PersistentVariablesSource.IsUpdating) { // Its possible that multiple global variables will be changed, we don't want to force the // string to be updated for each change so we defer and do a single update during EndUpdate. m_WaitingForVariablesEndUpdate = true; PersistentVariablesSource.EndUpdate += OnVariablesSourceUpdateCompleted; } else { RefreshString(); ValueChanged?.Invoke(this); } } void OnVariablesSourceUpdateCompleted() { PersistentVariablesSource.EndUpdate -= OnVariablesSourceUpdateCompleted; m_WaitingForVariablesEndUpdate = false; RefreshString(); ValueChanged?.Invoke(this); } void InvokeChangeHandler(string value) { try { m_ChangeHandler.LockForChanges(); var len = m_ChangeHandler.Length; if (len == 1) { m_ChangeHandler.SingleDelegate(value); } else if (len > 1) { var array = m_ChangeHandler.MultiDelegates; for (int i = 0; i < len; ++i) array[i](value); } } catch (Exception ex) { Debug.LogException(ex); } m_ChangeHandler.UnlockForChanges(); } void HandleLocaleChange(Locale locale) { // Cancel any previous loading operations. ClearLoadingOperation(); m_CurrentStringChangedValue = null; #if MODULE_UITK && UNITY_2023_3_OR_NEWER && UNITY_EDITOR HandleLocaleChangeDataBinding(locale); #endif #if UNITY_EDITOR m_CurrentTable = TableReference; m_CurrentTableEntry = TableEntryReference; // Don't update if we have no selected Locale if (!LocalizationSettings.Instance.IsPlayingOrWillChangePlaymode && LocaleOverride == null && LocalizationSettings.SelectedLocale == null) return; #endif if (IsEmpty) { #if UNITY_EDITOR // If we are empty and playing or previewing then we should force an update. if (!LocalizationSettings.Instance.IsPlayingOrWillChangePlaymode) InvokeChangeHandler(null); #endif return; } CurrentLoadingOperationHandle = LocalizationSettings.StringDatabase.GetTableEntryAsync(TableReference, TableEntryReference, LocaleOverride, FallbackState); AddressablesInterface.Acquire(CurrentLoadingOperationHandle); if (!CurrentLoadingOperationHandle.IsDone) { #if !UNITY_WEBGL if (ForceSynchronous) { CurrentLoadingOperationHandle.WaitForCompletion(); } else #endif { CurrentLoadingOperationHandle.Completed += m_AutomaticLoadingCompleted; return; } } AutomaticLoadingCompleted(CurrentLoadingOperationHandle); } void AutomaticLoadingCompleted(AsyncOperationHandle loadOperation) { if (loadOperation.Status != AsyncOperationStatus.Succeeded) { CurrentLoadingOperationHandle = default; return; } RefreshString(); } void ClearLoadingOperation() { if (CurrentLoadingOperationHandle.IsValid()) { // We should only call this if we are not done as its possible that the internal list is null if its not been used. if (!CurrentLoadingOperationHandle.IsDone) CurrentLoadingOperationHandle.Completed -= m_AutomaticLoadingCompleted; AddressablesInterface.Release(CurrentLoadingOperationHandle); CurrentLoadingOperationHandle = default; } } /// /// Clears the current loading operation. /// protected override void Reset() => ClearLoadingOperation(); public override void OnAfterDeserialize() { m_VariableLookup.Clear(); foreach (var v in m_LocalVariables) { if (!string.IsNullOrEmpty(v.name)) { m_VariableLookup[v.name] = v; } } } /// /// Removes and releases internal references to Addressable assets. /// ~LocalizedString() { ClearLoadingOperation(); } /// /// Removes and releases internal references to Addressable assets. /// void IDisposable.Dispose() { m_ChangeHandler.Clear(); ClearLoadingOperation(); LocalizationSettings.SelectedLocaleChanged -= m_SelectedLocaleChanged; GC.SuppressFinalize(this); } } }