From 6da1048d09e59439760e17c475968a5266dc20c2 Mon Sep 17 00:00:00 2001 From: Atony <75191600+AtonyGit@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:04:09 -0500 Subject: [PATCH] Adjust system for Crowdin / Weblate support --- MiraAPI/MiraApiPlugin.cs | 10 +- .../English.xml => Locale/en_US.xml} | 6 + .../SChinese.xml => Locale/zh_CN.xml} | 0 MiraAPI/Roles/ICustomRole.cs | 6 +- MiraAPI/Translation/MiraLanguage.cs | 22 +- MiraAPI/Translation/MiraLocaleManager.cs | 270 ++++++++++++++++++ .../Translation/MiraLocalizationProvider.cs | 61 ++++ MiraAPI/Translation/TranslationExtensions.cs | 2 +- MiraAPI/Translation/TranslationManager.cs | 210 -------------- .../Components/CenteredGridArrange.cs | 4 +- 10 files changed, 366 insertions(+), 225 deletions(-) rename MiraAPI/Resources/{Translations/English.xml => Locale/en_US.xml} (85%) rename MiraAPI/Resources/{Translations/SChinese.xml => Locale/zh_CN.xml} (100%) create mode 100644 MiraAPI/Translation/MiraLocaleManager.cs create mode 100644 MiraAPI/Translation/MiraLocalizationProvider.cs delete mode 100644 MiraAPI/Translation/TranslationManager.cs diff --git a/MiraAPI/MiraApiPlugin.cs b/MiraAPI/MiraApiPlugin.cs index c841fae..e6a0148 100644 --- a/MiraAPI/MiraApiPlugin.cs +++ b/MiraAPI/MiraApiPlugin.cs @@ -1,5 +1,6 @@ global using static Reactor.Utilities.Logger; using System; +using System.Globalization; using BepInEx; using BepInEx.Configuration; using BepInEx.Unity.IL2CPP; @@ -8,6 +9,7 @@ using MiraAPI.PluginLoading; using MiraAPI.Translation; using MiraAPI.VanillaEvents; using Reactor; +using Reactor.Localization; using Reactor.Networking; using Reactor.Networking.Attributes; using Reactor.Utilities; @@ -30,6 +32,11 @@ public partial class MiraApiPlugin : BasePlugin, IMiraPlugin return Config; } + /// + /// Gets the specified Culture for string manipulations. + /// + public static CultureInfo Culture { get; internal set; } = new("en-US"); + /// public string OptionsTitleText => "MiraAPI"; @@ -70,7 +77,8 @@ public partial class MiraApiPlugin : BasePlugin, IMiraPlugin PluginManager = new MiraPluginManager(); PluginManager.Initialize(this, this); - TranslationManager.Register("mira.api"); + LocalizationManager.Register(new MiraLocalizationProvider()); + MiraLocaleManager.Register("mira.api"); IL2CPPChainloader.Instance.Finished += ModCompatibility diff --git a/MiraAPI/Resources/Translations/English.xml b/MiraAPI/Resources/Locale/en_US.xml similarity index 85% rename from MiraAPI/Resources/Translations/English.xml rename to MiraAPI/Resources/Locale/en_US.xml index 8dc539e..be6b023 100644 --- a/MiraAPI/Resources/Translations/English.xml +++ b/MiraAPI/Resources/Locale/en_US.xml @@ -29,4 +29,10 @@ Outlive the timer or eliminate the crew. Save Roles Modifiers + Crewmate + Crew + Impostor + Imp + Neutral + Neut diff --git a/MiraAPI/Resources/Translations/SChinese.xml b/MiraAPI/Resources/Locale/zh_CN.xml similarity index 100% rename from MiraAPI/Resources/Translations/SChinese.xml rename to MiraAPI/Resources/Locale/zh_CN.xml diff --git a/MiraAPI/Roles/ICustomRole.cs b/MiraAPI/Roles/ICustomRole.cs index e1b3b9f..8e233b8 100644 --- a/MiraAPI/Roles/ICustomRole.cs +++ b/MiraAPI/Roles/ICustomRole.cs @@ -22,12 +22,12 @@ public interface ICustomRole : IOptionable /// /// Gets the name of the role. /// - string RoleName => TranslationManager.BuildTranslationId(IdPart, "name"); + string RoleName => MiraLocaleManager.BuildTranslationId(IdPart, "name"); /// /// Gets the description of the role. Used in the Intro Cutscene. /// - string RoleDescription => TranslationManager.BuildTranslationId(IdPart, "description"); + string RoleDescription => MiraLocaleManager.BuildTranslationId(IdPart, "description"); /// /// Gets the medium description of the role. Used in the role guide. @@ -37,7 +37,7 @@ public interface ICustomRole : IOptionable /// /// Gets the long description of the role. Used in the Role Tab and Role Options. /// - string RoleLongDescription => TranslationManager.BuildTranslationId(IdPart, "description"); + string RoleLongDescription => MiraLocaleManager.BuildTranslationId(IdPart, "description"); /// /// Gets the faction / alignment of the role. Used in the role guide. diff --git a/MiraAPI/Translation/MiraLanguage.cs b/MiraAPI/Translation/MiraLanguage.cs index 2027a9d..d6d2624 100644 --- a/MiraAPI/Translation/MiraLanguage.cs +++ b/MiraAPI/Translation/MiraLanguage.cs @@ -7,18 +7,24 @@ namespace MiraAPI.Translation; public enum MiraLanguage { English, - Spanish, + Latam, + Brazilian, + Portuguese, Korean, Russian, - Portuguese, - Arabic, + Dutch, Filipino, - Polish, - SChinese, - TChinese, French, German, - Japanese, - Dutch, Italian, + Japanese, + Spanish, + SChinese, + TChinese, + Irish, + Polish, + Turkish, + Swedish, + Lithuanian, + Czech, } diff --git a/MiraAPI/Translation/MiraLocaleManager.cs b/MiraAPI/Translation/MiraLocaleManager.cs new file mode 100644 index 0000000..71c4b2b --- /dev/null +++ b/MiraAPI/Translation/MiraLocaleManager.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Linq; +using System.Xml.Linq; +using Reactor.Utilities; +using UnityEngine; + +namespace MiraAPI.Translation; + +public static class MiraLocaleManager +{ + private const string LangDirectory = "mira_languages"; + + private static readonly Dictionary>> Locale = []; + + public static Dictionary LangList { get; } = new() + { + { MiraLanguage.English, "en_US" }, + { MiraLanguage.Latam, "es_419" }, + { MiraLanguage.Brazilian, "pt_BR" }, + { MiraLanguage.Portuguese, "pt_PT" }, + { MiraLanguage.Korean, "ko_KR" }, + { MiraLanguage.Russian, "ru_RU" }, + { MiraLanguage.Dutch, "nl_NL" }, + { MiraLanguage.Filipino, "fil_PH" }, + { MiraLanguage.French, "fr_FR" }, + { MiraLanguage.German, "de_DE" }, + { MiraLanguage.Italian, "it_IT" }, + { MiraLanguage.Japanese, "ja_JP" }, + { MiraLanguage.Spanish, "es_ES" }, + { MiraLanguage.SChinese, "zh_CN" }, + { MiraLanguage.TChinese, "zh_TW" }, + { MiraLanguage.Irish, "ga_IE" }, + { MiraLanguage.Polish, "pl_PL" }, // Custom + { MiraLanguage.Turkish, "tr_TR" }, // Custom + { MiraLanguage.Swedish, "sv_SE" }, // Custom + { MiraLanguage.Lithuanian, "lt_LT" }, // Custom + { MiraLanguage.Czech, "cs_CZ" }, // Custom + }; + public static Dictionary LangCultureList { get; } = new() + { + { MiraLanguage.English, "en-US" }, + { MiraLanguage.Latam, "es-419" }, + { MiraLanguage.Brazilian, "pt-BR" }, + { MiraLanguage.Portuguese, "pt-PT" }, + { MiraLanguage.Korean, "ko-KR" }, + { MiraLanguage.Russian, "ru-RU" }, + { MiraLanguage.Dutch, "nl-NL" }, + { MiraLanguage.Filipino, "fil-PH" }, + { MiraLanguage.French, "fr-FR" }, + { MiraLanguage.German, "de-DE" }, + { MiraLanguage.Italian, "it-IT" }, + { MiraLanguage.Japanese, "ja-JP" }, + { MiraLanguage.Spanish, "es-ES" }, + { MiraLanguage.SChinese, "zh-CN" }, + { MiraLanguage.TChinese, "zh-TW" }, + { MiraLanguage.Irish, "ga-IE" }, + { MiraLanguage.Polish, "pl-PL" }, // Custom + { MiraLanguage.Turkish, "tr-TR" }, // Custom + { MiraLanguage.Swedish, "sv-SE" }, // Custom + { MiraLanguage.Lithuanian, "lt-LT" }, // Custom + { MiraLanguage.Czech, "cs-CZ" }, // Custom + }; + + /// + /// Gets the current language from Among Us settings. + /// + public static MiraLanguage CurrentLanguage + { + get + { + try + { + var langName = AmongUs.Data.DataManager.Settings.Language.CurrentLanguage; + return (MiraLanguage)langName; + } + catch + { + return MiraLanguage.English; + } + } + } + + /// + /// Registers translations for a mod. Copies embedded XML to mira_languages/{modGuid}/ on first run, + /// then loads all XML files from that directory into memory. + /// Call once per mod during plugin Load(). + /// + /// The mod GUID (e.g., "mira.example"). + public static void Register(string modGuid) + { + var callingAssembly = Assembly.GetCallingAssembly(); + var dir = GetModLangDir(modGuid); + + LoadInternalStrings(callingAssembly, modGuid, dir); + } + + /// + /// Translates a key into the current language. + /// Searches all mods, with reverse lookup fallback. + /// + /// The string id to find. + /// Fallback string to use if no translation is found. + /// A based on the key provided. + public static string Get(string key, string fallback = "") + { + var currentLang = CurrentLanguage; + + foreach (var (_, modLocale) in Locale) + { + if (modLocale.TryGetValue(currentLang, out var langDict) && + langDict.TryGetValue(key, out var translated)) + { + return translated; + } + } + + foreach (var (_, modLocale) in Locale) + { + if (modLocale.TryGetValue(MiraLanguage.English, out var engDict) && + engDict.TryGetValue(key, out var english)) + { + return english; + } + } + + string? bestStructuredKey = null; + foreach (var (_, modLocale) in Locale) + { + if (modLocale.TryGetValue(MiraLanguage.English, out var engDict)) + { + foreach (var (structuredKey, englishValue) in engDict) + { + if (englishValue == key) + { + bestStructuredKey = structuredKey; + break; + } + } + } + + if (bestStructuredKey != null) break; + } + + if (bestStructuredKey != null) + { + foreach (var (_, modLocale) in Locale) + { + if (modLocale.TryGetValue(currentLang, out var langDict) && + langDict.TryGetValue(bestStructuredKey, out var revTranslated)) + { + return revTranslated; + } + } + + foreach (var (_, modLocale) in Locale) + { + if (modLocale.TryGetValue(MiraLanguage.English, out var engDict) && + engDict.TryGetValue(bestStructuredKey, out var revEnglish)) + { + return revEnglish; + } + } + } + + return fallback != string.Empty ? fallback : key; + } + + public static string BuildTranslationId(string idPart, string suffix) + { + return idPart.StartsWith('#') ? idPart[1..] : $"{idPart}.{suffix}"; + } + + private static string GetModLangDir(string modGuid) + { + return Path.Combine(Application.persistentDataPath, LangDirectory, modGuid); + } + + private static void LoadInternalStrings(Assembly assembly, string modGuid, string dir) + { + Directory.CreateDirectory(dir); + var resourcePrefix = $"{assembly.GetName().Name}.Resources.Locale."; + + foreach (var locale in LangList) + { + using var resourceStream = + assembly.GetManifestResourceStream(resourcePrefix + locale.Value); + if (resourceStream == null) + { + // Silently skipped + continue; + } + + using StreamReader reader = new(resourceStream); + string xmlContent = reader.ReadToEnd(); + try + { + var dict = ParseXmlFile(xmlContent); + Locale[modGuid][locale.Key] = dict; + Info($"Loaded {locale.Key} translation for mod {modGuid} ({dict.Count} keys)"); + } + catch (Exception e) + { + Error($"Failed to load translation {resourcePrefix}.{locale.Value}: {e.Message}"); + } + } + + if (!Locale.TryGetValue(modGuid, out _)) + { + Locale[modGuid] = []; + } + + foreach (var filePath in Directory.GetFiles(dir, "*")) + { + var fileName = Path.GetFileNameWithoutExtension(filePath); + + var lang = LangList.ContainsValue(fileName) + ? LangList.First(x => x.Value == fileName).Key + : (MiraLanguage)(-1); + if ((int)lang == -1) continue; + + try + { + var dict = ParseXmlFile(filePath); + Locale[modGuid][lang] = dict; + Info($"Loaded {lang} translation for mod {modGuid} ({dict.Count} keys)"); + } + catch (Exception e) + { + Error($"Failed to load translation {filePath}: {e.Message}"); + } + } + } + + private static Dictionary ParseXmlFile(string filePath) + { + var dict = new Dictionary(); + var doc = XDocument.Load(filePath); + + if (doc.Root == null) return dict; + + foreach (var element in doc.Root.Elements("string")) + { + var name = element.Attribute("name")?.Value; + var value = element.Value; + + if (string.IsNullOrEmpty(name)) continue; + + if (value.Contains('[')) + { + value = value.Replace("[", "<"); + } + + if (value.Contains(']')) + { + value = value.Replace("]", ">"); + } + + value = value.Replace("", "\n").Replace("", "&"); + + dict[name] = value; + } + + return dict; + } +} diff --git a/MiraAPI/Translation/MiraLocalizationProvider.cs b/MiraAPI/Translation/MiraLocalizationProvider.cs new file mode 100644 index 0000000..c3f81d6 --- /dev/null +++ b/MiraAPI/Translation/MiraLocalizationProvider.cs @@ -0,0 +1,61 @@ +using System.Linq; +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using Reactor.Localization; +using Reactor.Localization.Providers; +using Reactor.Utilities; + +namespace MiraAPI.Translation; + +public class MiraLocalizationProvider : LocalizationProvider +{ + public override int Priority => ReactorPriority.Normal; + private static LocalizationProvider? _reactorProvider; + + public override bool TryGetText(StringNames stringName, out string? result) + { + if ((int)stringName < 0 && _reactorProvider!.TryGetText(stringName, out var reactorText)) + { + if (reactorText.IsNullOrWhiteSpace()) + { + result = "STRMISS"; + return true; + } + var localeText = MiraLocaleManager.Get(reactorText!); + if (!localeText.Contains("STRMISS")) + { + result = localeText; + return true; + } + } + result = null; + return false; + } + + public override bool TryGetTextFormatted(StringNames stringName, Il2CppReferenceArray parts, out string? result) + { + if (!TryGetText(stringName, out result)) return false; + + result = Il2CppSystem.String.Format(result, parts); + return true; + } + + public override void OnLanguageChanged(SupportedLangs newLanguage) + { + _reactorProvider ??= LocalizationManager.Providers.First(x => x is HardCodedLocalizationProvider); + + if (MiraLocaleManager.LangCultureList.TryGetValue((MiraLanguage)newLanguage, out var culture)) + { + MiraApiPlugin.Culture = new(culture); + } + /*Warning($""); + Warning($""); + foreach (var stringName in TranslationController.Instance.currentLanguage.AllStrings) + { + var value = stringName.Value.Replace("\n", "\\%nl\\%"); + value = value.Replace("{", "["); + value = value.Replace("}", "]"); + Warning($"{value}"); + } + Warning($"");*/ + } +} \ No newline at end of file diff --git a/MiraAPI/Translation/TranslationExtensions.cs b/MiraAPI/Translation/TranslationExtensions.cs index 60b367a..5f0edbd 100644 --- a/MiraAPI/Translation/TranslationExtensions.cs +++ b/MiraAPI/Translation/TranslationExtensions.cs @@ -10,6 +10,6 @@ public static class TranslationExtensions /// public static string Translate(this string key) { - return TranslationManager.Translate(key); + return MiraLocaleManager.Get(key); } } diff --git a/MiraAPI/Translation/TranslationManager.cs b/MiraAPI/Translation/TranslationManager.cs deleted file mode 100644 index a6fbe63..0000000 --- a/MiraAPI/Translation/TranslationManager.cs +++ /dev/null @@ -1,210 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Reflection; -using System.Xml.Linq; -using Reactor.Utilities; -using UnityEngine; - -namespace MiraAPI.Translation; - -public static class TranslationManager -{ - private const string LangDirectory = "mira_languages"; - - private static readonly Dictionary>> Translations = []; - - /// - /// Gets the current language from Among Us settings. - /// - public static MiraLanguage CurrentLanguage - { - get - { - try - { - var langName = AmongUs.Data.DataManager.Settings.Language.CurrentLanguage.ToString(); - return Enum.TryParse(langName, out var result) ? result : MiraLanguage.English; - } - catch - { - return MiraLanguage.English; - } - } - } - - /// - /// Registers translations for a mod. Copies embedded XML to mira_languages/{modGuid}/ on first run, - /// then loads all XML files from that directory into memory. - /// Call once per mod during plugin Load(). - /// - /// The mod GUID (e.g., "mira.example"). - public static void Register(string modGuid) - { - var callingAssembly = Assembly.GetCallingAssembly(); - var dir = GetModLangDir(modGuid); - - // Copy embedded XML resources to disk (first-run only) - CopyEmbeddedToDisk(callingAssembly, modGuid, dir); - - // Load all XML files from disk into memory - LoadFromDisk(modGuid, dir); - } - - /// - /// Translates a key into the current language. - /// Searches all mods, with reverse lookup fallback. - /// - public static string Translate(string key) - { - var currentLang = CurrentLanguage; - - foreach (var (_, modTranslations) in Translations) - { - if (modTranslations.TryGetValue(currentLang, out var langDict) && - langDict.TryGetValue(key, out var translated)) - { - return translated; - } - } - - foreach (var (_, modTranslations) in Translations) - { - if (modTranslations.TryGetValue(MiraLanguage.English, out var engDict) && - engDict.TryGetValue(key, out var english)) - { - return english; - } - } - - string? bestStructuredKey = null; - foreach (var (_, modTranslations) in Translations) - { - if (modTranslations.TryGetValue(MiraLanguage.English, out var engDict)) - { - foreach (var (structuredKey, englishValue) in engDict) - { - if (englishValue == key) - { - bestStructuredKey = structuredKey; - break; - } - } - } - - if (bestStructuredKey != null) break; - } - - if (bestStructuredKey != null) - { - foreach (var (_, modTranslations) in Translations) - { - if (modTranslations.TryGetValue(currentLang, out var langDict) && - langDict.TryGetValue(bestStructuredKey, out var revTranslated)) - { - return revTranslated; - } - } - - foreach (var (_, modTranslations) in Translations) - { - if (modTranslations.TryGetValue(MiraLanguage.English, out var engDict) && - engDict.TryGetValue(bestStructuredKey, out var revEnglish)) - { - return revEnglish; - } - } - } - - return key; - } - - public static string BuildTranslationId(string idPart, string suffix) - { - return idPart.StartsWith('#') ? idPart[1..] : $"{idPart}.{suffix}"; - } - - private static string GetModLangDir(string modGuid) - { - return Path.Combine(Application.persistentDataPath, LangDirectory, modGuid); - } - - private static void CopyEmbeddedToDisk(Assembly assembly, string modGuid, string dir) - { - Directory.CreateDirectory(dir); - - var resourcePrefix = $"{assembly.GetName().Name}.Resources.Translations."; - - foreach (var resourceName in assembly.GetManifestResourceNames()) - { - if (!resourceName.StartsWith(resourcePrefix)) continue; - - var fileName = resourceName.Substring(resourcePrefix.Length); - var diskPath = Path.Combine(dir, fileName); - - try - { - using var stream = assembly.GetManifestResourceStream(resourceName); - if (stream == null) continue; - - using var fs = File.Create(diskPath); - stream.CopyTo(fs); - Info($"Installed translation: {modGuid}/{fileName}"); - } - catch (Exception e) - { - Warning($"Failed to copy {resourceName}: {e.Message}"); - } - } - } - - private static void LoadFromDisk(string modGuid, string dir) - { - if (!Directory.Exists(dir)) return; - - if (!Translations.ContainsKey(modGuid)) - { - Translations[modGuid] = []; - } - - foreach (var filePath in Directory.GetFiles(dir, "*.xml")) - { - var fileName = Path.GetFileNameWithoutExtension(filePath); - - if (!Enum.TryParse(fileName, out var lang)) continue; - - try - { - var dict = ParseXmlFile(filePath); - Translations[modGuid][lang] = dict; - Info($"Loaded {lang} translation for mod {modGuid} ({dict.Count} keys)"); - } - catch (Exception e) - { - Error($"Failed to load translation {filePath}: {e.Message}"); - } - } - } - - private static Dictionary ParseXmlFile(string filePath) - { - var dict = new Dictionary(); - var doc = XDocument.Load(filePath); - - if (doc.Root == null) return dict; - - foreach (var element in doc.Root.Elements("string")) - { - var name = element.Attribute("name")?.Value; - var value = element.Value; - - if (string.IsNullOrEmpty(name)) continue; - - value = value.Replace("", "\n").Replace("", "&"); - - dict[name] = value; - } - - return dict; - } -} diff --git a/MiraAPI/Utilities/Components/CenteredGridArrange.cs b/MiraAPI/Utilities/Components/CenteredGridArrange.cs index ee32b26..a8d4ccd 100644 --- a/MiraAPI/Utilities/Components/CenteredGridArrange.cs +++ b/MiraAPI/Utilities/Components/CenteredGridArrange.cs @@ -9,9 +9,9 @@ namespace MiraAPI.Utilities.Components; /// /// Utility component for arranging objects in a centered layout, similar to . /// -/// +/// The for the component. [RegisterInIl2Cpp] -public class CenteredGridArrange(IntPtr ptr) : MonoBehaviour(ptr) +public class CenteredGridArrange(IntPtr iPtr) : MonoBehaviour(iPtr) { /// /// Gets or sets the cell size, which is used for spacing.