From 0c8156aaf6b57e1720dd0f94d31713350660d3a1 Mon Sep 17 00:00:00 2001 From: AlexejheroYTB Date: Sun, 23 Oct 2022 16:36:10 +0300 Subject: [PATCH] Add localization API (#64) Co-authored-by: js6pak --- Directory.Build.props | 2 +- .../ExampleLocalizationProvider.cs | 26 ++++ Reactor.Example/ExamplePlugin.cs | 23 +++- Reactor.sln | 2 + Reactor/Localization/CustomStringName.cs | 82 ------------- .../Extensions/SupportedLangsExtensions.cs | 39 ++++++ Reactor/Localization/LocalizationManager.cs | 111 ++++++++++++++++++ Reactor/Localization/LocalizationProvider.cs | 90 ++++++++++++++ .../Localization/Patches/GetStringPatch.cs | 85 ++++++++++++++ .../Patches/LanguageChangedPatch.cs | 22 ++++ .../HardCodedLocalizationProvider.cs | 36 ++++++ .../Utilities/CustomStringName.cs | 33 ++++++ Reactor/ReactorPlugin.cs | 6 + Reactor/Utilities/ReactorPriority.cs | 52 ++++++++ 14 files changed, 523 insertions(+), 86 deletions(-) create mode 100644 Reactor.Example/ExampleLocalizationProvider.cs delete mode 100644 Reactor/Localization/CustomStringName.cs create mode 100644 Reactor/Localization/Extensions/SupportedLangsExtensions.cs create mode 100644 Reactor/Localization/LocalizationManager.cs create mode 100644 Reactor/Localization/LocalizationProvider.cs create mode 100644 Reactor/Localization/Patches/GetStringPatch.cs create mode 100644 Reactor/Localization/Patches/LanguageChangedPatch.cs create mode 100644 Reactor/Localization/Providers/HardCodedLocalizationProvider.cs create mode 100644 Reactor/Localization/Utilities/CustomStringName.cs create mode 100644 Reactor/Utilities/ReactorPriority.cs diff --git a/Directory.Build.props b/Directory.Build.props index 3052ee6..3ecaf53 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -31,7 +31,7 @@ - + $(MSBuildThisFileDirectory)\stylecop.ruleset diff --git a/Reactor.Example/ExampleLocalizationProvider.cs b/Reactor.Example/ExampleLocalizationProvider.cs new file mode 100644 index 0000000..90c8418 --- /dev/null +++ b/Reactor.Example/ExampleLocalizationProvider.cs @@ -0,0 +1,26 @@ +using Reactor.Localization; + +namespace Reactor.Example; + +public class ExampleLocalizationProvider : LocalizationProvider +{ + public override bool TryGetText(StringNames stringName, out string? result) + { + if (stringName == (StringNames) 1337) + { + switch (CurrentLanguage) + { + case SupportedLangs.English: + result = "Cringe English"; + return true; + + default: + result = "Based " + CurrentLanguage; + return true; + } + } + + result = null; + return false; + } +} diff --git a/Reactor.Example/ExamplePlugin.cs b/Reactor.Example/ExamplePlugin.cs index fc1922d..4a768e8 100644 --- a/Reactor.Example/ExamplePlugin.cs +++ b/Reactor.Example/ExamplePlugin.cs @@ -2,6 +2,8 @@ using BepInEx; using BepInEx.Unity.IL2CPP; using Il2CppInterop.Runtime.Attributes; +using Reactor.Localization; +using Reactor.Localization.Utilities; using Reactor.Networking; using Reactor.Networking.Attributes; using Reactor.Networking.Rpc; @@ -19,9 +21,14 @@ namespace Reactor.Example; [ReactorModFlags(ModFlags.RequireOnAllClients)] public partial class ExamplePlugin : BasePlugin { + private static StringNames _helloStringName; + public override void Load() { this.AddComponent(); + + _helloStringName = CustomStringName.CreateAndRegister("Hello!"); + LocalizationManager.Register(new ExampleLocalizationProvider()); } [RegisterInIl2Cpp] @@ -34,19 +41,29 @@ public ExampleComponent(IntPtr ptr) : base(ptr) { TestWindow = new DragWindow(new Rect(60, 20, 0, 0), "Example", () => { + if (GUILayout.Button("Log CustomStringName")) + { + Logger.Info(TranslationController.Instance.GetString(_helloStringName)); + } + + if (GUILayout.Button("Log localized string")) + { + Logger.Info(TranslationController.Instance.GetString((StringNames) 1337)); + } + if (AmongUsClient.Instance && PlayerControl.LocalPlayer) { if (GUILayout.Button("Send ExampleRpc")) { - var name = PlayerControl.LocalPlayer.Data.PlayerName; - Rpc.Instance.Send(new ExampleRpc.Data($"Send: from {name}"), ackCallback: () => + var playerName = PlayerControl.LocalPlayer.Data.PlayerName; + Rpc.Instance.Send(new ExampleRpc.Data($"Send: from {playerName}"), ackCallback: () => { Logger.Info("Got an acknowledgement for example rpc"); }); if (!AmongUsClient.Instance.AmHost) { - Rpc.Instance.SendTo(AmongUsClient.Instance.HostId, new ExampleRpc.Data($"SendTo: from {name} to host")); + Rpc.Instance.SendTo(AmongUsClient.Instance.HostId, new ExampleRpc.Data($"SendTo: from {playerName} to host")); } } diff --git a/Reactor.sln b/Reactor.sln index 11fdc70..ba2a0bc 100644 --- a/Reactor.sln +++ b/Reactor.sln @@ -12,6 +12,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Files", "Solution AmongUs.props = AmongUs.props Directory.Build.props = Directory.Build.props nuget.config = nuget.config + stylecop.json = stylecop.json + stylecop.ruleset = stylecop.ruleset EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Reactor.Networking.Shared", "Reactor.Networking.Shared\Reactor.Networking.Shared.csproj", "{1E68CA20-22EB-4E57-B525-81970BE6363D}" diff --git a/Reactor/Localization/CustomStringName.cs b/Reactor/Localization/CustomStringName.cs deleted file mode 100644 index 7034dd4..0000000 --- a/Reactor/Localization/CustomStringName.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using HarmonyLib; -using Il2CppInterop.Runtime.InteropTypes.Arrays; - -namespace Reactor.Localization; - -/// -/// Provides a way to use custom static StringNames. -/// -public class CustomStringName -{ - private static int _lastId = -1; - - private static readonly List _list = new(); - - /// - /// Gets a list of all s. - /// - public static IReadOnlyList List => _list.AsReadOnly(); - - /// - /// Registers a with specified . - /// - /// The value. - /// A . - public static CustomStringName Register(string value) - { - var customStringName = new CustomStringName(_lastId--, value); - _list.Add(customStringName); - - return customStringName; - } - - /// - /// Gets the id. - /// - public int Id { get; } - - /// - /// Gets the value. - /// - public string Value { get; } - - private CustomStringName(int id, string value) - { - Id = id; - Value = value; - } - - /// - /// Defines an implicit conversion of a to a . - /// - /// The . - /// A . - public static implicit operator StringNames(CustomStringName name) => (StringNames) name.Id; - - /// - /// Defines an explicit conversion of a to a . - /// - /// The . - /// A . - public static explicit operator CustomStringName?(StringNames name) => List.SingleOrDefault(x => x.Id == (int) name); - - [HarmonyPatch(typeof(TranslationController), nameof(TranslationController.GetString), typeof(StringNames), typeof(Il2CppReferenceArray))] - private static class GetStringPatch - { - public static bool Prefix(StringNames id, Il2CppReferenceArray parts, ref string __result) - { - var customStringName = (CustomStringName?) id; - - if (customStringName != null) - { - __result = string.Format(CultureInfo.InvariantCulture, customStringName.Value, parts); - return false; - } - - return true; - } - } -} diff --git a/Reactor/Localization/Extensions/SupportedLangsExtensions.cs b/Reactor/Localization/Extensions/SupportedLangsExtensions.cs new file mode 100644 index 0000000..03c26ef --- /dev/null +++ b/Reactor/Localization/Extensions/SupportedLangsExtensions.cs @@ -0,0 +1,39 @@ +using System; +using System.Globalization; + +namespace Reactor.Localization.Extensions; + +/// +/// Provides extension methods for . +/// +public static class SupportedLangsExtensions +{ + /// + /// Gets a from the specified . + /// + /// The . + /// a . + public static CultureInfo ToCultureInfo(this SupportedLangs language) + { + return language switch + { + SupportedLangs.English => CultureInfo.GetCultureInfo("en"), + SupportedLangs.Latam => CultureInfo.GetCultureInfo("es"), + SupportedLangs.Brazilian => CultureInfo.GetCultureInfo("pt-BR"), + SupportedLangs.Portuguese => CultureInfo.GetCultureInfo("pt"), + SupportedLangs.Korean => CultureInfo.GetCultureInfo("ko"), + SupportedLangs.Russian => CultureInfo.GetCultureInfo("ru"), + SupportedLangs.Dutch => CultureInfo.GetCultureInfo("nl"), + SupportedLangs.Filipino => CultureInfo.GetCultureInfo("fil"), + SupportedLangs.French => CultureInfo.GetCultureInfo("fr"), + SupportedLangs.German => CultureInfo.GetCultureInfo("de"), + SupportedLangs.Italian => CultureInfo.GetCultureInfo("it"), + SupportedLangs.Japanese => CultureInfo.GetCultureInfo("ja"), + SupportedLangs.Spanish => CultureInfo.GetCultureInfo("es"), + SupportedLangs.SChinese => CultureInfo.GetCultureInfo("zh-Hans"), + SupportedLangs.TChinese => CultureInfo.GetCultureInfo("zh-Hant"), + SupportedLangs.Irish => CultureInfo.GetCultureInfo("ga"), + _ => throw new ArgumentOutOfRangeException(nameof(language), language, null), + }; + } +} diff --git a/Reactor/Localization/LocalizationManager.cs b/Reactor/Localization/LocalizationManager.cs new file mode 100644 index 0000000..6f12b09 --- /dev/null +++ b/Reactor/Localization/LocalizationManager.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using Il2CppInterop.Runtime.InteropTypes.Arrays; + +namespace Reactor.Localization; + +/// +/// Handles custom localization. +/// +public static class LocalizationManager +{ + private static readonly List _providers = new(); + + /// + /// Gets registered s. + /// + public static IReadOnlyList Providers { get; } = _providers.AsReadOnly(); + + /// + /// Registers a new to be used for obtaining translations. + /// + /// A instance. + public static void Register(LocalizationProvider provider) + { + if (!_providers.Contains(provider)) + { + _providers.Add(provider); + + if (TranslationController.InstanceExists) + { + provider.SetLanguage(TranslationController.Instance.currentLanguage.languageID); + } + + _providers.Sort((a, b) => b.Priority - a.Priority); + } + } + + /// + /// Unregisters a . + /// + /// The to unregister. + public static void Unregister(LocalizationProvider provider) + { + _providers.Remove(provider); + } + + internal static bool TryGetTextFormatted(StringNames stringName, Il2CppReferenceArray parts, out string text) + { + foreach (var provider in _providers) + { + if (provider.TryGetTextFormatted(stringName, parts, out text!)) + { + return true; + } + } + + text = string.Empty; + return false; + } + + internal static bool TryGetText(StringNames stringName, out string text) + { + foreach (var provider in _providers) + { + if (provider.TryGetText(stringName, out text!)) + { + return true; + } + } + + text = string.Empty; + return false; + } + + internal static bool TryGetStringName(SystemTypes systemType, out StringNames stringName) + { + foreach (var provider in _providers) + { + if (provider.TryGetStringName(systemType, out var stringNameNullable)) + { + stringName = stringNameNullable!.Value; + return true; + } + } + + stringName = default; + return false; + } + + internal static bool TryGetStringName(TaskTypes taskTypes, out StringNames stringName) + { + foreach (var provider in _providers) + { + if (provider.TryGetStringName(taskTypes, out var stringNameNullable)) + { + stringName = stringNameNullable!.Value; + return true; + } + } + + stringName = default; + return false; + } + + internal static void OnLanguageChanged(SupportedLangs newLanguage) + { + foreach (var provider in _providers) + { + provider.SetLanguage(newLanguage); + } + } +} diff --git a/Reactor/Localization/LocalizationProvider.cs b/Reactor/Localization/LocalizationProvider.cs new file mode 100644 index 0000000..ef341a3 --- /dev/null +++ b/Reactor/Localization/LocalizationProvider.cs @@ -0,0 +1,90 @@ +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using Reactor.Utilities; + +namespace Reactor.Localization; + +/// +/// The required implementation of a localization provider class. +/// +public abstract class LocalizationProvider +{ + /// + /// Gets the current language Among Us is set to. + ///
+ /// If used from , this property will return the old language, not the new one. + ///
+ public SupportedLangs? CurrentLanguage { get; private set; } + + /// + /// Gets the priority of this . + /// The higher the priority is, the earlier it will be invoked in relation to other providers. + ///
+ /// You can use the class for this value if you want to make this easier. + ///
+ public virtual int Priority => 0; + + /// + /// Returns the localized text for the given . + /// + /// The to localize. + /// The representation of the given . + /// Whether or not this can handle this . + public virtual bool TryGetText(StringNames stringName, out string? result) + { + result = null; + return false; + } + + /// + /// Returns the localized text for the given . + /// + /// The to localize. + /// The arguments used for formatting. + /// The representation of the given . + /// Whether or not this can handle this . + public virtual bool TryGetTextFormatted(StringNames stringName, Il2CppReferenceArray parts, out string? result) + { + if (!TryGetText(stringName, out result)) return false; + + result = Il2CppSystem.String.Format(result, parts); + return true; + } + + /// + /// Returns the equivalent for the given . + /// + /// The value. + /// The representation of the given . + /// Whether or not this can handle this . + public virtual bool TryGetStringName(SystemTypes systemType, out StringNames? result) + { + result = null; + return false; + } + + /// + /// Returns the equivalent for the given . + /// + /// The value. + /// The representation of the given . + /// Whether or not this can handle this . + public virtual bool TryGetStringName(TaskTypes taskType, out StringNames? result) + { + result = null; + return false; + } + + /// + /// This method is called when the Among Us language is changed by the user. + /// + /// The new language that Among Us has been set to. + public virtual void OnLanguageChanged(SupportedLangs newLanguage) + { + } + + internal void SetLanguage(SupportedLangs newLanguage) + { + OnLanguageChanged(newLanguage); + CurrentLanguage = newLanguage; + } +} diff --git a/Reactor/Localization/Patches/GetStringPatch.cs b/Reactor/Localization/Patches/GetStringPatch.cs new file mode 100644 index 0000000..aa765c5 --- /dev/null +++ b/Reactor/Localization/Patches/GetStringPatch.cs @@ -0,0 +1,85 @@ +using HarmonyLib; +using Il2CppInterop.Runtime.InteropTypes.Arrays; + +namespace Reactor.Localization.Patches; + +[HarmonyPatch] +internal static class GetStringPatch +{ + [HarmonyPatch(typeof(TranslationController), nameof(TranslationController.GetString), typeof(StringNames), typeof(Il2CppReferenceArray))] + [HarmonyPatch(typeof(TranslationController), nameof(TranslationController.GetStringWithDefault), typeof(StringNames), typeof(string), typeof(Il2CppReferenceArray))] + [HarmonyPrefix] + public static bool StringNamesPatch(StringNames id, Il2CppReferenceArray parts, out string __result) + { + if (LocalizationManager.TryGetTextFormatted(id, parts, out __result)) + { + return false; + } + + return true; + } + + [HarmonyPatch(typeof(TranslationController), nameof(TranslationController.GetString), typeof(SystemTypes))] + [HarmonyPrefix] + public static bool SystemTypesStringPatch(TranslationController __instance, SystemTypes room, ref string __result) + { + if (LocalizationManager.TryGetStringName(room, out var stringName)) + { + if (LocalizationManager.TryGetText(stringName, out var text)) + { + __result = text; + return false; + } + + __result = __instance.GetString(stringName); + return false; + } + + return true; + } + + [HarmonyPatch(typeof(TranslationController), nameof(TranslationController.GetSystemName))] + [HarmonyPrefix] + public static bool SystemTypesStringNamesPatch(SystemTypes room, ref StringNames __result) + { + if (LocalizationManager.TryGetStringName(room, out var stringName)) + { + __result = stringName; + return false; + } + + return true; + } + + [HarmonyPatch(typeof(TranslationController), nameof(TranslationController.GetString), typeof(TaskTypes))] + [HarmonyPrefix] + public static bool TaskTypesStringPatch(TranslationController __instance, TaskTypes task, ref string __result) + { + if (LocalizationManager.TryGetStringName(task, out var stringName)) + { + if (LocalizationManager.TryGetText(stringName, out var text)) + { + __result = text; + return false; + } + + __result = __instance.GetString(stringName); + return false; + } + + return true; + } + + [HarmonyPatch(typeof(TranslationController), nameof(TranslationController.GetTaskName))] + [HarmonyPrefix] + public static bool TaskTypesStringNamesPatch(TaskTypes task, ref StringNames __result) + { + if (LocalizationManager.TryGetStringName(task, out var stringName)) + { + __result = stringName; + return false; + } + + return true; + } +} diff --git a/Reactor/Localization/Patches/LanguageChangedPatch.cs b/Reactor/Localization/Patches/LanguageChangedPatch.cs new file mode 100644 index 0000000..53fb806 --- /dev/null +++ b/Reactor/Localization/Patches/LanguageChangedPatch.cs @@ -0,0 +1,22 @@ +using HarmonyLib; + +namespace Reactor.Localization.Patches; + +[HarmonyPatch] +internal static class LanguageChangedPatch +{ + [HarmonyPatch(typeof(TranslationController), nameof(TranslationController.Initialize))] + [HarmonyPostfix] + public static void Initialize(TranslationController __instance) + { + if (TranslationController.Instance.GetInstanceID() == __instance.GetInstanceID()) + LocalizationManager.OnLanguageChanged(__instance.currentLanguage.languageID); + } + + [HarmonyPatch(typeof(TranslationController), nameof(TranslationController.SetLanguage))] + [HarmonyPostfix] + public static void SetLanguage(TranslationController __instance) + { + LocalizationManager.OnLanguageChanged(__instance.currentLanguage.languageID); + } +} diff --git a/Reactor/Localization/Providers/HardCodedLocalizationProvider.cs b/Reactor/Localization/Providers/HardCodedLocalizationProvider.cs new file mode 100644 index 0000000..8db5b69 --- /dev/null +++ b/Reactor/Localization/Providers/HardCodedLocalizationProvider.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using Reactor.Utilities; + +namespace Reactor.Localization.Providers; + +/// +/// Utility for adding hard-coded localization. +/// +public sealed class HardCodedLocalizationProvider : LocalizationProvider +{ + private static readonly Dictionary _strings = new(); + + /// + /// Adds a custom, hard-coded translation for a . + /// + /// The . + /// The text. + public static void Register(StringNames stringName, string value) + { + if (_strings.ContainsKey(stringName)) + { + Warning($"Registering StringName {stringName} that already exists"); + } + + _strings[stringName] = value; + } + + /// + public override int Priority => ReactorPriority.Low; + + /// + public override bool TryGetText(StringNames stringName, out string? result) + { + return _strings.TryGetValue(stringName, out result); + } +} diff --git a/Reactor/Localization/Utilities/CustomStringName.cs b/Reactor/Localization/Utilities/CustomStringName.cs new file mode 100644 index 0000000..e41236f --- /dev/null +++ b/Reactor/Localization/Utilities/CustomStringName.cs @@ -0,0 +1,33 @@ +using Reactor.Localization.Providers; + +namespace Reactor.Localization.Utilities; + +/// +/// Provides a way to use custom static . +/// +public static class CustomStringName +{ + private static int _lastId = int.MinValue + 1; + + /// + /// Creates an returns a unique value. + /// + /// A unique . + public static StringNames Create() + { + var id = _lastId++; + return (StringNames) id; + } + + /// + /// Creates, registeres and returns a unique value. + /// + /// The text. + /// A unique . + public static StringNames CreateAndRegister(string text) + { + var stringName = Create(); + HardCodedLocalizationProvider.Register(stringName, text); + return stringName; + } +} diff --git a/Reactor/ReactorPlugin.cs b/Reactor/ReactorPlugin.cs index 228bc3d..a007ca9 100644 --- a/Reactor/ReactorPlugin.cs +++ b/Reactor/ReactorPlugin.cs @@ -6,6 +6,8 @@ using BepInEx.Unity.IL2CPP; using HarmonyLib; using Il2CppInterop.Runtime.Attributes; +using Reactor.Localization; +using Reactor.Localization.Providers; using Reactor.Networking; using Reactor.Networking.Attributes; using Reactor.Networking.Rpc; @@ -44,11 +46,15 @@ public ReactorPlugin() { PluginSingleton.Instance = this; PluginSingleton.Initialize(); + RegisterInIl2CppAttribute.Initialize(); ModList.Initialize(); + RegisterCustomRpcAttribute.Initialize(); MessageConverterAttribute.Initialize(); MethodRpcAttribute.Initialize(); + + LocalizationManager.Register(new HardCodedLocalizationProvider()); } /// diff --git a/Reactor/Utilities/ReactorPriority.cs b/Reactor/Utilities/ReactorPriority.cs new file mode 100644 index 0000000..3c68407 --- /dev/null +++ b/Reactor/Utilities/ReactorPriority.cs @@ -0,0 +1,52 @@ +namespace Reactor.Utilities; + +/// +/// Class containing common priorities to be used in various places across Reactor. +/// +public static class ReactorPriority +{ + /// + /// Lowest priority, will happen last. + /// + public const int Last = -800; + + /// + /// Very low priority. + /// + public const int VeryLow = -600; + + /// + /// Low priority. + /// + public const int Low = -400; + + /// + /// Lower than normal priority. + /// + public const int LowerThanNormal = -200; + + /// + /// Normal priority. + /// + public const int Normal = 0; + + /// + /// Higher than normal priority. + /// + public const int HigherThanNormal = 200; + + /// + /// High priority. + /// + public const int High = 400; + + /// + /// Very high priority. + /// + public const int VeryHigh = 600; + + /// + /// Highest priority, will happen first. + /// + public const int First = 800; +}