SHA256
298 lines
12 KiB
C#
298 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using MelonLoader;
|
|
using Newtonsoft.Json;
|
|
using UnityEngine;
|
|
|
|
namespace ADOFAIRenderer.Localization;
|
|
|
|
/// <summary>
|
|
/// Provides access to localized strings and handles language synchronization with the game.
|
|
/// <para>
|
|
/// Locale files are flat JSON objects (<c>"key": "text"</c>) under <c>Localization/Locale/</c>, embedded in the mod
|
|
/// assembly as resources; the file name without extension is the locale code (<c>en.json</c> -> "en").
|
|
/// The active locale follows the game language: <c>LanguageSyncPatch</c> calls <see cref="SetLanguage"/> after the game's
|
|
/// <c>RDString.Setup</c> and <c>RDString.ChangeLanguage</c>. Only Korean maps to its own locale ("ko"); every other
|
|
/// language uses English ("en").
|
|
/// </para>
|
|
/// <para>
|
|
/// Lookup order in <see cref="Get(string)"/>: active locale, then English, then the key itself.
|
|
/// Intended for the Unity main thread; the dictionaries are filled once during initialization and only read afterwards.
|
|
/// </para>
|
|
/// </summary>
|
|
internal static class LocalizationManager
|
|
{
|
|
/// <summary>Loaded locales: locale code (case-insensitive) -> key -> text. Keys are case-sensitive.</summary>
|
|
private static readonly Dictionary<string, Dictionary<string, string>> Locales = new(StringComparer.OrdinalIgnoreCase);
|
|
/// <summary>Locale used when the language has no locale file and for keys missing in the active locale.</summary>
|
|
private static readonly string FallbackLocaleCode = "en";
|
|
|
|
/// <summary>
|
|
/// Event triggered when the active language changes.
|
|
/// Raised by <see cref="SetLanguage"/> with <c>notify: true</c> when the language or the resolved locale differs from
|
|
/// the current one; the argument is the new game language. Exceptions from handlers are caught and logged
|
|
/// (an exception stops the remaining handlers of that invocation).
|
|
/// </summary>
|
|
public static event Action<SystemLanguage>? OnLanguageChanged;
|
|
|
|
/// <summary>
|
|
/// Currently active SystemLanguage: the game language as passed to <see cref="SetLanguage"/>, also when it has no
|
|
/// locale file of its own (e.g. Japanese, which displays English text).
|
|
/// </summary>
|
|
public static SystemLanguage CurrentLanguage { get; private set; } = SystemLanguage.English;
|
|
|
|
/// <summary>
|
|
/// Currently active locale code (e.g. "en", "ko"): the locale whose strings <see cref="Get(string)"/> returns first.
|
|
/// Always a loaded locale or the fallback "en".
|
|
/// </summary>
|
|
public static string CurrentLocaleCode { get; private set; } = "en";
|
|
|
|
/// <summary>Set by the first <see cref="Initialize"/> call; later calls return immediately.</summary>
|
|
private static bool _initialized;
|
|
|
|
/// <summary>Initializes on first access to any member, so lookups work before <c>RenderMod</c> calls <see cref="Initialize"/>.</summary>
|
|
static LocalizationManager()
|
|
{
|
|
Initialize();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes the localization system and loads embedded language dictionaries.
|
|
/// Runs once; further calls do nothing. Selects the initial language without raising
|
|
/// <see cref="OnLanguageChanged"/>.
|
|
/// </summary>
|
|
public static void Initialize()
|
|
{
|
|
if (_initialized) return;
|
|
_initialized = true;
|
|
|
|
LoadEmbeddedLocales();
|
|
|
|
// Detect initial language from game settings.
|
|
// At mod initialization RDString.Setup has usually not run yet, so this reads the field's initial value
|
|
// (English); LanguageSyncPatch applies the real language when the game calls RDString.Setup.
|
|
var initialLang = DetectGameLanguage();
|
|
SetLanguage(initialLang, notify: false);
|
|
|
|
LogMsg($"[Localization] Initialized. Active language: {CurrentLanguage} ({CurrentLocaleCode})");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logs through the MelonLoader logger of the mod, or to the console when that logger is not available
|
|
/// (e.g. when this class is used outside the running mod).
|
|
/// </summary>
|
|
private static void LogMsg(string msg)
|
|
{
|
|
try { Melon<RenderMod>.Logger.Msg(msg); }
|
|
catch { Console.WriteLine(msg); }
|
|
}
|
|
|
|
/// <summary>Error counterpart of <see cref="LogMsg"/>; falls back to standard error.</summary>
|
|
private static void LogError(string msg)
|
|
{
|
|
try { Melon<RenderMod>.Logger.Error(msg); }
|
|
catch { Console.Error.WriteLine(msg); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads all *.json locale files embedded in this assembly.
|
|
/// A resource is used when its name ends with ".json" and contains "Localization.Locale" (the manifest name built
|
|
/// from the folder path, see the EmbeddedResource item in the .csproj). A file that fails to read or parse is
|
|
/// logged and skipped; a later resource with the same locale code replaces an earlier one.
|
|
/// </summary>
|
|
private static void LoadEmbeddedLocales()
|
|
{
|
|
var assembly = typeof(LocalizationManager).Assembly;
|
|
var resourceNames = assembly.GetManifestResourceNames();
|
|
|
|
foreach (var resourceName in resourceNames)
|
|
{
|
|
if (!resourceName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (!resourceName.Contains("Localization.Locale")) continue;
|
|
|
|
try
|
|
{
|
|
using var stream = assembly.GetManifestResourceStream(resourceName);
|
|
if (stream == null) continue;
|
|
|
|
using var reader = new StreamReader(stream);
|
|
var json = reader.ReadToEnd();
|
|
var dict = JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
|
|
|
|
if (dict != null)
|
|
{
|
|
// Extract locale code from resource name: e.g. "ADOFAIRenderer.Localization.Locale.ko.json" -> "ko"
|
|
var parts = resourceName.Split('.');
|
|
if (parts.Length >= 2)
|
|
{
|
|
var code = parts[^2].ToLowerInvariant();
|
|
Locales[code] = dict;
|
|
LogMsg($"[Localization] Loaded locale '{code}' ({dict.Count} keys).");
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogError($"[Localization] Failed to load locale resource '{resourceName}': {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the active language and notifies subscribers if changed.
|
|
/// The locale is chosen by <see cref="LanguageToLocaleCode"/>; when that locale is not loaded, "en" is used.
|
|
/// </summary>
|
|
/// <param name="language">Game language (as used by <c>RDString.language</c>).</param>
|
|
/// <param name="notify">
|
|
/// Whether to raise <see cref="OnLanguageChanged"/> when the language or locale changes; <c>false</c> is used for
|
|
/// the initial selection.
|
|
/// </param>
|
|
public static void SetLanguage(SystemLanguage language, bool notify = true)
|
|
{
|
|
var localeCode = LanguageToLocaleCode(language);
|
|
if (!Locales.ContainsKey(localeCode))
|
|
{
|
|
// Fallback to English if translation for this language does not exist
|
|
localeCode = FallbackLocaleCode;
|
|
}
|
|
|
|
var isChanged = language != CurrentLanguage || localeCode != CurrentLocaleCode;
|
|
|
|
CurrentLanguage = language;
|
|
CurrentLocaleCode = localeCode;
|
|
|
|
if (isChanged && notify)
|
|
{
|
|
LogMsg($"[Localization] Language changed to: {CurrentLanguage} ({CurrentLocaleCode})");
|
|
try
|
|
{
|
|
OnLanguageChanged?.Invoke(CurrentLanguage);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogError($"[Localization] Error during OnLanguageChanged notification: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a localized string for the specified key.
|
|
/// Order: active locale, then English, then the key itself (so a missing key is visible in the UI).
|
|
/// </summary>
|
|
/// <param name="key">Localization key, e.g. <c>"ui.render_config.window_title"</c>; case-sensitive.</param>
|
|
/// <returns>The localized text, the key when no locale has it, or an empty string for a null/empty key.</returns>
|
|
public static string Get(string key)
|
|
{
|
|
if (string.IsNullOrEmpty(key)) return string.Empty;
|
|
if (!_initialized) Initialize();
|
|
|
|
// 1. Try currently active locale
|
|
if (Locales.TryGetValue(CurrentLocaleCode, out var activeDict) && activeDict.TryGetValue(key, out var val))
|
|
{
|
|
return val;
|
|
}
|
|
|
|
// 2. Fallback to English locale
|
|
if (CurrentLocaleCode != FallbackLocaleCode &&
|
|
Locales.TryGetValue(FallbackLocaleCode, out var fallbackDict) &&
|
|
fallbackDict.TryGetValue(key, out var fallbackVal))
|
|
{
|
|
return fallbackVal;
|
|
}
|
|
|
|
// 3. Fallback to raw key if missing
|
|
return key;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a formatted localized string.
|
|
/// The text from <see cref="Get(string)"/> is used as a <see cref="string.Format(string, object[])"/> template
|
|
/// (<c>{0}</c>, <c>{1}</c>, ...), formatted with the current culture. When the template does not match the
|
|
/// arguments (<see cref="FormatException"/>), the unformatted template is returned.
|
|
/// </summary>
|
|
/// <param name="key">Localization key.</param>
|
|
/// <param name="args">Format arguments; with none, the template is returned as is.</param>
|
|
public static string Get(string key, params object[] args)
|
|
{
|
|
var template = Get(key);
|
|
if (args.Length == 0) return template;
|
|
|
|
try
|
|
{
|
|
return string.Format(template, args);
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
return template;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps Unity's SystemLanguage enum to an internal locale code.
|
|
/// Korean -> "ko"; every other language -> "en". A new locale file also needs an entry here.
|
|
/// </summary>
|
|
private static string LanguageToLocaleCode(SystemLanguage language)
|
|
{
|
|
return language switch
|
|
{
|
|
SystemLanguage.Korean => "ko",
|
|
_ => "en"
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to read the game's current language from RDString or Persistence.
|
|
/// Falls back to English when both reads throw.
|
|
/// </summary>
|
|
private static SystemLanguage DetectGameLanguage()
|
|
{
|
|
try
|
|
{
|
|
// First attempt: RDString.language, a public static field of the game (initial value English,
|
|
// assigned from Persistence.language in RDString.Setup)
|
|
return RDString.language;
|
|
}
|
|
catch
|
|
{
|
|
try
|
|
{
|
|
// Fallback attempt: Persistence.language, the saved "language" preference (defaults to
|
|
// Application.systemLanguage)
|
|
return Persistence.language;
|
|
}
|
|
catch
|
|
{
|
|
return SystemLanguage.English;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convenient shorthand alias for <see cref="LocalizationManager"/>.
|
|
/// Every member forwards to the member of the same name in <see cref="LocalizationManager"/>.
|
|
/// </summary>
|
|
// ReSharper disable once InconsistentNaming
|
|
internal static class I18n
|
|
{
|
|
/// <inheritdoc cref="LocalizationManager.Get(string)"/>
|
|
public static string Get(string key) => LocalizationManager.Get(key);
|
|
|
|
/// <inheritdoc cref="LocalizationManager.Get(string, object[])"/>
|
|
public static string Get(string key, params object[] args) => LocalizationManager.Get(key, args);
|
|
|
|
/// <inheritdoc cref="LocalizationManager.CurrentLanguage"/>
|
|
public static SystemLanguage CurrentLanguage => LocalizationManager.CurrentLanguage;
|
|
|
|
/// <inheritdoc cref="LocalizationManager.CurrentLocaleCode"/>
|
|
public static string CurrentLocaleCode => LocalizationManager.CurrentLocaleCode;
|
|
|
|
/// <inheritdoc cref="LocalizationManager.OnLanguageChanged"/>
|
|
public static event Action<SystemLanguage>? OnLanguageChanged
|
|
{
|
|
add => LocalizationManager.OnLanguageChanged += value;
|
|
remove => LocalizationManager.OnLanguageChanged -= value;
|
|
}
|
|
}
|