Adjust system for Crowdin / Weblate support

This commit is contained in:
Atony committed 2026-08-25 00:04:09 -05:00
1 parent 6a7f58effa
commit 6da1048d09
10 files changed
+366 -225

No files matched your search

+9 -1
View File
@@ -1,5 +1,6 @@
global using static Reactor.Utilities.Logger<MiraAPI.MiraApiPlugin>;
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;
}
/// <summary>
/// Gets the specified Culture for string manipulations.
/// </summary>
public static CultureInfo Culture { get; internal set; } = new("en-US");
/// <inheritdoc />
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
@@ -29,4 +29,10 @@ Outlive the timer or eliminate the crew.</string>
<string name="gamesetting.preset.save">Save</string>
<string name="gamesetting.roles">Roles</string>
<string name="gamesetting.modifiers">Modifiers</string>
<string name="roleTeam.crewmate.long">Crewmate</string>
<string name="roleTeam.crewmate.short">Crew</string>
<string name="roleTeam.impostor.long">Impostor</string>
<string name="roleTeam.impostor.short">Imp</string>
<string name="roleTeam.neutral.long">Neutral</string>
<string name="roleTeam.neutral.short">Neut</string>
</resources>
+3 -3
View File
@@ -22,12 +22,12 @@ public interface ICustomRole : IOptionable
/// <summary>
/// Gets the name of the role.
/// </summary>
string RoleName => TranslationManager.BuildTranslationId(IdPart, "name");
string RoleName => MiraLocaleManager.BuildTranslationId(IdPart, "name");
/// <summary>
/// Gets the description of the role. Used in the Intro Cutscene.
/// </summary>
string RoleDescription => TranslationManager.BuildTranslationId(IdPart, "description");
string RoleDescription => MiraLocaleManager.BuildTranslationId(IdPart, "description");
/// <summary>
/// Gets the medium description of the role. Used in the role guide.
@@ -37,7 +37,7 @@ public interface ICustomRole : IOptionable
/// <summary>
/// Gets the long description of the role. Used in the Role Tab and Role Options.
/// </summary>
string RoleLongDescription => TranslationManager.BuildTranslationId(IdPart, "description");
string RoleLongDescription => MiraLocaleManager.BuildTranslationId(IdPart, "description");
/// <summary>
/// Gets the faction / alignment of the role. Used in the role guide.
+14 -8
View File
@@ -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,
}
+270
View File
@@ -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<string, Dictionary<MiraLanguage, Dictionary<string, string>>> Locale = [];
public static Dictionary<MiraLanguage, string> 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<MiraLanguage, string> 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
};
/// <summary>
/// Gets the current language from Among Us settings.
/// </summary>
public static MiraLanguage CurrentLanguage
{
get
{
try
{
var langName = AmongUs.Data.DataManager.Settings.Language.CurrentLanguage;
return (MiraLanguage)langName;
}
catch
{
return MiraLanguage.English;
}
}
}
/// <summary>
/// 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().
/// </summary>
/// <param name="modGuid">The mod GUID (e.g., "mira.example").</param>
public static void Register(string modGuid)
{
var callingAssembly = Assembly.GetCallingAssembly();
var dir = GetModLangDir(modGuid);
LoadInternalStrings(callingAssembly, modGuid, dir);
}
/// <summary>
/// Translates a key into the current language.
/// Searches all mods, with reverse lookup fallback.
/// </summary>
/// <param name="key">The string id to find.</param>
/// <param name="fallback">Fallback string to use if no translation is found.</param>
/// <returns>A <see cref="string"/> based on the key provided.</returns>
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<string, string> ParseXmlFile(string filePath)
{
var dict = new Dictionary<string, string>();
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("<nl>", "\n").Replace("<and>", "&");
dict[name] = value;
}
return dict;
}
}
@@ -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<Il2CppSystem.Object> 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($"<?xml version='1.0' encoding='UTF-8'?>");
Warning($"<resources>");
foreach (var stringName in TranslationController.Instance.currentLanguage.AllStrings)
{
var value = stringName.Value.Replace("\n", "\\%nl\\%");
value = value.Replace("{", "[");
value = value.Replace("}", "]");
Warning($"<string name=\"{stringName.Key}\">{value}</string>");
}
Warning($"</resources>");*/
}
}
+1 -1
View File
@@ -10,6 +10,6 @@ public static class TranslationExtensions
/// </summary>
public static string Translate(this string key)
{
return TranslationManager.Translate(key);
return MiraLocaleManager.Get(key);
}
}
-210
View File
@@ -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<string, Dictionary<MiraLanguage, Dictionary<string, string>>> Translations = [];
/// <summary>
/// Gets the current language from Among Us settings.
/// </summary>
public static MiraLanguage CurrentLanguage
{
get
{
try
{
var langName = AmongUs.Data.DataManager.Settings.Language.CurrentLanguage.ToString();
return Enum.TryParse<MiraLanguage>(langName, out var result) ? result : MiraLanguage.English;
}
catch
{
return MiraLanguage.English;
}
}
}
/// <summary>
/// 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().
/// </summary>
/// <param name="modGuid">The mod GUID (e.g., "mira.example").</param>
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);
}
/// <summary>
/// Translates a key into the current language.
/// Searches all mods, with reverse lookup fallback.
/// </summary>
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<MiraLanguage>(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<string, string> ParseXmlFile(string filePath)
{
var dict = new Dictionary<string, string>();
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("<nl>", "\n").Replace("<and>", "&");
dict[name] = value;
}
return dict;
}
}
@@ -9,9 +9,9 @@ namespace MiraAPI.Utilities.Components;
/// <summary>
/// Utility component for arranging objects in a centered layout, similar to <see cref="GridArrange"/>.
/// </summary>
/// <param name="ptr"></param>
/// <param name="iPtr">The <see cref="IntPtr"/> for the component.</param>
[RegisterInIl2Cpp]
public class CenteredGridArrange(IntPtr ptr) : MonoBehaviour(ptr)
public class CenteredGridArrange(IntPtr iPtr) : MonoBehaviour(iPtr)
{
/// <summary>
/// Gets or sets the cell size, which is used for spacing.