diff --git a/Localization/Locale/en.json b/Localization/Locale/en.json new file mode 100644 index 0000000..f6a667e --- /dev/null +++ b/Localization/Locale/en.json @@ -0,0 +1,85 @@ +{ + "ui.button.presets": "Presets", + "ui.placeholder.render_fps": "Render FPS", + "ui.placeholder.video_fps": "Video FPS", + "ui.placeholder.render_width": "Render Width (px)", + "ui.placeholder.render_height": "Render Height (px)", + "ui.button.video_config": "Video Encoding Config", + "ui.button.audio_config": "Audio Encoding Config", + "ui.placeholder.delay_after_end": "Delay After Level End", + "ui.toggle.show_judgement": "Show Judgement", + "ui.button.go_render": "Go Render", + "ui.button.advanced_settings": "Advanced Settings...", + "ui.version.expired": " (Expired)", + "ui.validation.required_fields": "Required input fields are empty or invalid. Please fill in all settings.", + + "ui.advanced.title": "Advanced Settings", + "ui.advanced.file_extension": "File Extension", + "ui.advanced.process_audio": "Process Audio", + "ui.advanced.non_tile_mode": "Non-Tile Mode", + "ui.advanced.random_seed": "Random Seed (Optional)", + "ui.button.cancel": "Cancel", + "ui.button.save": "Save", + + "ui.video.title": "Video Encoding Settings", + "ui.video.codec": "Codec", + "ui.video.rate_control": "Bitrate Control Mode", + "ui.video.bitrate": "Bitrate (kbps)", + "ui.video.max_bitrate": "Max Bitrate (kbps)", + "ui.video.qp": "Quality (QP)", + "ui.video.keyframe_interval": "Keyframe Interval (s)", + "ui.video.force_software": "Force Software Encoder", + "ui.video.qp_zero_warning": "Warning: Setting QP to 0 applies lossless encoding and may result in very large file sizes.", + + "ui.audio.title": "Audio Encoding Settings", + "ui.audio.rendering": "Audio Rendering", + "ui.audio.sample_rate": "Sample Rate (Hz)", + "ui.audio.channels": "Audio Channels", + "ui.audio.channels_mono": "1 (Mono)", + "ui.audio.channels_stereo": "2 (Stereo)", + "ui.audio.bitrate": "Audio Bitrate (kbps)", + + "ui.preset.title": "Preset Settings", + "ui.preset.new_name": "New Preset Name", + "ui.preset.no_presets": "No saved presets.", + "ui.button.load": "Load", + "ui.button.delete": "Delete", + "ui.button.close": "Close", + "ui.preset.save_failed": "Save Failed", + "ui.preset.enter_name": "Please enter a preset name.", + "ui.preset.invalid_inputs": "Cannot save preset because required input values are empty or invalid.", + "ui.preset.save_error": "An error occurred while saving the preset: {0}", + "ui.preset.delete_title": "Delete Preset", + "ui.preset.delete_confirm": "Are you sure you want to delete the preset '{0}'?", + "ui.button.yes": "Yes", + "ui.button.no": "No", + "ui.button.ok": "OK", + + "ui.ffmpeg.title": "FFmpeg Library Installation Required", + "ui.ffmpeg.desc_main": "FFmpeg shared libraries are required to render video.", + "ui.ffmpeg.desc_osx": "For macOS, reliable public builds are unavailable, so FFmpeg 8.1 must be built manually.\nClick the button below to open the official FFmpeg website.", + "ui.ffmpeg.desc_auto": "Click the button below to automatically download and install from the internet.\n(Download size: ~70MB, extracted to UserLibs folder)", + "ui.ffmpeg.button_download": "Download & Install", + "ui.ffmpeg.button_open_web": "Open Download Page", + "ui.ffmpeg.button_cancel": "Cancel", + "ui.ffmpeg.button_opening": "Opening...", + "ui.ffmpeg.status_downloading": "Downloading... ({0:F1}%)", + "ui.ffmpeg.status_extracting": "Extracting FFmpeg files...", + "ui.ffmpeg.status_done": "Done! Restarting audio/video engine...", + "ui.ffmpeg.status_failed": "Failed to download FFmpeg: {0}", + + "ui.escape.cancelling": "Cancelling in {0:F1}s..\nHold Esc to cancel render", + + "ui.monitor.title": "ADOFAI Renderer Realtime Monitor", + "ui.monitor.queue": "• Queue: {0} / {1} frames", + "ui.monitor.rendered_frames": "• Rendered Frames: {0}", + "ui.monitor.frames_sent": "• Frames Sent: {0}", + "ui.monitor.throttling": "• Throttling Controls: {0} times", + "ui.monitor.readback_failures": "• Readback Hard Failures: {0} times", + "ui.monitor.skip_retry": "• Render Skip-Retry: {0} / {1}", + "ui.monitor.memory": "• Mono Heap Memory: {0:F2} MB", + + "ui.notification.complete_title": "Rendering Complete!", + "ui.notification.complete_msg": "{0} saved successfully.", + "ui.notification.video_fallback": "Video" +} diff --git a/Localization/Locale/ko.json b/Localization/Locale/ko.json new file mode 100644 index 0000000..ecc7747 --- /dev/null +++ b/Localization/Locale/ko.json @@ -0,0 +1,85 @@ +{ + "ui.button.presets": "프리셋", + "ui.placeholder.render_fps": "렌더링 FPS", + "ui.placeholder.video_fps": "영상 FPS", + "ui.placeholder.render_width": "렌더링 너비 (px)", + "ui.placeholder.render_height": "렌더링 높이 (px)", + "ui.button.video_config": "비디오 인코딩 설정", + "ui.button.audio_config": "오디오 인코딩 설정", + "ui.placeholder.delay_after_end": "레벨 종료 후 대기 시간 (초)", + "ui.toggle.show_judgement": "판정 텍스트 표시", + "ui.button.go_render": "렌더링 시작", + "ui.button.advanced_settings": "고급 설정...", + "ui.version.expired": " (만료됨)", + "ui.validation.required_fields": "필수 입력란이 비어 있거나 올바르지 않습니다. 모든 설정을 확인해 주세요.", + + "ui.advanced.title": "고급 설정", + "ui.advanced.file_extension": "파일 확장자", + "ui.advanced.process_audio": "오디오 처리", + "ui.advanced.non_tile_mode": "논타일 모드", + "ui.advanced.random_seed": "랜덤 시드 (선택 사항)", + "ui.button.cancel": "취소", + "ui.button.save": "저장", + + "ui.video.title": "비디오 인코딩 설정", + "ui.video.codec": "코덱", + "ui.video.rate_control": "비트레이트 제어 모드", + "ui.video.bitrate": "비트레이트 (kbps)", + "ui.video.max_bitrate": "최대 비트레이트 (kbps)", + "ui.video.qp": "품질 (QP)", + "ui.video.keyframe_interval": "키프레임 간격 (초)", + "ui.video.force_software": "강제 소프트웨어 인코더", + "ui.video.qp_zero_warning": "경고: QP를 0으로 설정하면 무손실 인코딩이 적용되어 파일 크기가 매우 커질 수 있습니다.", + + "ui.audio.title": "오디오 인코딩 설정", + "ui.audio.rendering": "오디오 렌더링", + "ui.audio.sample_rate": "샘플레이트 (Hz)", + "ui.audio.channels": "오디오 채널", + "ui.audio.channels_mono": "1 (모노)", + "ui.audio.channels_stereo": "2 (스테레오)", + "ui.audio.bitrate": "오디오 비트레이트 (kbps)", + + "ui.preset.title": "프리셋 설정", + "ui.preset.new_name": "새 프리셋 이름", + "ui.preset.no_presets": "저장된 프리셋이 없습니다.", + "ui.button.load": "불러오기", + "ui.button.delete": "삭제", + "ui.button.close": "닫기", + "ui.preset.save_failed": "저장 실패", + "ui.preset.enter_name": "프리셋 이름을 입력해 주세요.", + "ui.preset.invalid_inputs": "필수 입력값이 비어 있거나 올바르지 않아 프리셋을 저장할 수 없습니다.", + "ui.preset.save_error": "프리셋 저장 중 오류가 발생했습니다: {0}", + "ui.preset.delete_title": "프리셋 삭제", + "ui.preset.delete_confirm": "정말 '{0}' 프리셋을 삭제하시겠습니까?", + "ui.button.yes": "예", + "ui.button.no": "아니오", + "ui.button.ok": "확인", + + "ui.ffmpeg.title": "FFmpeg 라이브러리 설치 필요", + "ui.ffmpeg.desc_main": "영상을 렌더링하려면 FFmpeg 공유 라이브러리가 필요합니다.", + "ui.ffmpeg.desc_osx": "macOS의 경우 신뢰할 수 있는 빌드가 제공되지 않아 FFmpeg 8.1을 직접 빌드해야 합니다.\n아래 버튼을 눌러 공식 FFmpeg 웹사이트를 방문하세요.", + "ui.ffmpeg.desc_auto": "아래 버튼을 눌러 인터넷에서 자동으로 다운로드하여 설치합니다.\n(다운로드 크기: 약 70MB, UserLibs 폴더에 압축 해제됨)", + "ui.ffmpeg.button_download": "다운로드 및 설치", + "ui.ffmpeg.button_open_web": "다운로드 페이지 열기", + "ui.ffmpeg.button_cancel": "취소", + "ui.ffmpeg.button_opening": "열리는 중...", + "ui.ffmpeg.status_downloading": "다운로드 중... ({0:F1}%)", + "ui.ffmpeg.status_extracting": "FFmpeg 파일 압축 해제 중...", + "ui.ffmpeg.status_done": "완료! 오디오/비디오 엔진을 재시작합니다...", + "ui.ffmpeg.status_failed": "FFmpeg 다운로드 실패: {0}", + + "ui.escape.cancelling": "{0:F1}초 후 취소됩니다..\n렌더링을 취소하려면 Esc를 길게 누르세요", + + "ui.monitor.title": "ADOFAI Renderer 실시간 모니터", + "ui.monitor.queue": "• 대기열: {0} / {1} 프레임", + "ui.monitor.rendered_frames": "• 렌더링된 프레임: {0}", + "ui.monitor.frames_sent": "• 전송된 프레임: {0}", + "ui.monitor.throttling": "• 쓰로틀링 제어: {0}회", + "ui.monitor.readback_failures": "• 리드백 실패: {0}회", + "ui.monitor.skip_retry": "• 렌더 건너뜀-재시도: {0} / {1}", + "ui.monitor.memory": "• 모노 힙 메모리: {0:F2} MB", + + "ui.notification.complete_title": "렌더링 완료!", + "ui.notification.complete_msg": "{0}이(가) 성공적으로 저장되었습니다.", + "ui.notification.video_fallback": "동영상" +} diff --git a/Localization/LocalizationManager.cs b/Localization/LocalizationManager.cs new file mode 100644 index 0000000..181f77b --- /dev/null +++ b/Localization/LocalizationManager.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using MelonLoader; +using Newtonsoft.Json; +using UnityEngine; + +namespace ADOFAIRenderer.Localization; + +/// +/// Provides access to localized strings and handles language synchronization with the game. +/// +internal static class LocalizationManager +{ + private static readonly Dictionary> Locales = new(StringComparer.OrdinalIgnoreCase); + private static readonly string FallbackLocaleCode = "en"; + + /// + /// Event triggered when the active language changes. + /// + public static event Action? OnLanguageChanged; + + /// + /// Currently active SystemLanguage. + /// + public static SystemLanguage CurrentLanguage { get; private set; } = SystemLanguage.English; + + /// + /// Currently active locale code (e.g. "en", "ko"). + /// + public static string CurrentLocaleCode { get; private set; } = "en"; + + private static bool _initialized; + + /// + /// Initializes the localization system and loads embedded language dictionaries. + /// + public static void Initialize() + { + if (_initialized) return; + _initialized = true; + + LoadEmbeddedLocales(); + + // Detect initial language from game settings + var initialLang = DetectGameLanguage(); + SetLanguage(initialLang, notify: false); + + LogMsg($"[Localization] Initialized. Active language: {CurrentLanguage} ({CurrentLocaleCode})"); + } + + private static void LogMsg(string msg) + { + try { Melon.Logger.Msg(msg); } + catch { Console.WriteLine(msg); } + } + + private static void LogError(string msg) + { + try { Melon.Logger.Error(msg); } + catch { Console.Error.WriteLine(msg); } + } + + /// + /// Loads all *.json locale files embedded in this assembly. + /// + 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>(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}"); + } + } + } + + /// + /// Sets the active language and notifies subscribers if changed. + /// + 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}"); + } + } + } + + /// + /// Retrieves a localized string for the specified key. + /// + public static string Get(string key) + { + if (string.IsNullOrEmpty(key)) return string.Empty; + + // 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; + } + + /// + /// Retrieves a formatted localized string. + /// + public static string Get(string key, params object[] args) + { + var template = Get(key); + if (args == null || args.Length == 0) return template; + + try + { + return string.Format(template, args); + } + catch (FormatException) + { + return template; + } + } + + /// + /// Maps Unity's SystemLanguage enum to an internal locale code. + /// + private static string LanguageToLocaleCode(SystemLanguage language) + { + return language switch + { + SystemLanguage.Korean => "ko", + SystemLanguage.English => "en", + _ => "en" + }; + } + + /// + /// Attempts to read the game's current language from RDString or Persistence. + /// + private static SystemLanguage DetectGameLanguage() + { + try + { + // First attempt: RDString.language + return RDString.language; + } + catch + { + try + { + // Fallback attempt: Persistence.language + return Persistence.language; + } + catch + { + return SystemLanguage.English; + } + } + } +} + +/// +/// Convenient shorthand alias for . +/// +internal static class I18n +{ + /// + public static string Get(string key) => LocalizationManager.Get(key); + + /// + public static string Get(string key, params object[] args) => LocalizationManager.Get(key, args); + + /// + public static SystemLanguage CurrentLanguage => LocalizationManager.CurrentLanguage; + + /// + public static string CurrentLocaleCode => LocalizationManager.CurrentLocaleCode; + + /// + public static event Action? OnLanguageChanged + { + add => LocalizationManager.OnLanguageChanged += value; + remove => LocalizationManager.OnLanguageChanged -= value; + } +} diff --git a/Patch/Game/LanguageSyncPatch.cs b/Patch/Game/LanguageSyncPatch.cs new file mode 100644 index 0000000..88daa71 --- /dev/null +++ b/Patch/Game/LanguageSyncPatch.cs @@ -0,0 +1,26 @@ +using ADOFAIRenderer.Localization; +using HarmonyLib; +using UnityEngine; + +namespace ADOFAIRenderer.Patch.Game; + +/// +/// Synchronizes mod localization with ADOFAI's in-game language settings. +/// +[HarmonyPatch(typeof(RDString))] +internal static class LanguageSyncPatch +{ + [HarmonyPatch(nameof(RDString.ChangeLanguage))] + [HarmonyPostfix] + public static void OnChangeLanguage(SystemLanguage language) + { + LocalizationManager.SetLanguage(language); + } + + [HarmonyPatch(nameof(RDString.Setup))] + [HarmonyPostfix] + public static void OnSetup() + { + LocalizationManager.SetLanguage(RDString.language); + } +}