From 25b58eb52aa44975e80b394f21421c44578cf15f7a2ce35011ab2017b3984857 Mon Sep 17 00:00:00 2001 From: copecone Date: Thu, 15 Oct 2026 16:17:07 +0900 Subject: [PATCH] Document UI And Localization --- Localization/LocalizationManager.cs | 61 +++- UI/CommonWarningDialogUI.cs | 76 ++++- UI/DebugMonitorUI.cs | 74 ++++- UI/EscapeHandlerUI.cs | 46 ++- UI/FFmpegDownloadUI.cs | 159 +++++++++- UI/LocalizedText.cs | 38 ++- UI/RendererUIManager.cs | 66 +++- UI/Window/RenderConfigWindow.cs | 461 +++++++++++++++++++++++++++- 8 files changed, 910 insertions(+), 71 deletions(-) diff --git a/Localization/LocalizationManager.cs b/Localization/LocalizationManager.cs index 15bb674..d449e91 100644 --- a/Localization/LocalizationManager.cs +++ b/Localization/LocalizationManager.cs @@ -9,29 +9,49 @@ namespace ADOFAIRenderer.Localization; /// /// Provides access to localized strings and handles language synchronization with the game. +/// +/// Locale files are flat JSON objects ("key": "text") under Localization/Locale/, embedded in the mod +/// assembly as resources; the file name without extension is the locale code (en.json -> "en"). +/// The active locale follows the game language: LanguageSyncPatch calls after the game's +/// RDString.Setup and RDString.ChangeLanguage. Only Korean maps to its own locale ("ko"); every other +/// language uses English ("en"). +/// +/// +/// Lookup order in : 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. +/// /// internal static class LocalizationManager { + /// Loaded locales: locale code (case-insensitive) -> key -> text. Keys are case-sensitive. private static readonly Dictionary> Locales = new(StringComparer.OrdinalIgnoreCase); + /// Locale used when the language has no locale file and for keys missing in the active locale. private static readonly string FallbackLocaleCode = "en"; /// /// Event triggered when the active language changes. + /// Raised by with notify: true 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). /// public static event Action? OnLanguageChanged; /// - /// Currently active SystemLanguage. + /// Currently active SystemLanguage: the game language as passed to , also when it has no + /// locale file of its own (e.g. Japanese, which displays English text). /// public static SystemLanguage CurrentLanguage { get; private set; } = SystemLanguage.English; /// - /// Currently active locale code (e.g. "en", "ko"). + /// Currently active locale code (e.g. "en", "ko"): the locale whose strings returns first. + /// Always a loaded locale or the fallback "en". /// public static string CurrentLocaleCode { get; private set; } = "en"; + /// Set by the first call; later calls return immediately. private static bool _initialized; + /// Initializes on first access to any member, so lookups work before RenderMod calls . static LocalizationManager() { Initialize(); @@ -39,6 +59,8 @@ internal static class LocalizationManager /// /// Initializes the localization system and loads embedded language dictionaries. + /// Runs once; further calls do nothing. Selects the initial language without raising + /// . /// public static void Initialize() { @@ -47,19 +69,26 @@ internal static class LocalizationManager LoadEmbeddedLocales(); - // Detect initial language from game settings + // 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})"); } + /// + /// 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). + /// private static void LogMsg(string msg) { try { Melon.Logger.Msg(msg); } catch { Console.WriteLine(msg); } } + /// Error counterpart of ; falls back to standard error. private static void LogError(string msg) { try { Melon.Logger.Error(msg); } @@ -68,6 +97,9 @@ internal static class LocalizationManager /// /// 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. /// private static void LoadEmbeddedLocales() { @@ -109,7 +141,13 @@ internal static class LocalizationManager /// /// Sets the active language and notifies subscribers if changed. + /// The locale is chosen by ; when that locale is not loaded, "en" is used. /// + /// Game language (as used by RDString.language). + /// + /// Whether to raise when the language or locale changes; false is used for + /// the initial selection. + /// public static void SetLanguage(SystemLanguage language, bool notify = true) { var localeCode = LanguageToLocaleCode(language); @@ -140,7 +178,10 @@ internal static class LocalizationManager /// /// 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). /// + /// Localization key, e.g. "ui.render_config.window_title"; case-sensitive. + /// The localized text, the key when no locale has it, or an empty string for a null/empty key. public static string Get(string key) { if (string.IsNullOrEmpty(key)) return string.Empty; @@ -166,7 +207,12 @@ internal static class LocalizationManager /// /// Retrieves a formatted localized string. + /// The text from is used as a template + /// ({0}, {1}, ...), formatted with the current culture. When the template does not match the + /// arguments (), the unformatted template is returned. /// + /// Localization key. + /// Format arguments; with none, the template is returned as is. public static string Get(string key, params object[] args) { var template = Get(key); @@ -184,6 +230,7 @@ internal static class LocalizationManager /// /// 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. /// private static string LanguageToLocaleCode(SystemLanguage language) { @@ -196,19 +243,22 @@ internal static class LocalizationManager /// /// Attempts to read the game's current language from RDString or Persistence. + /// Falls back to English when both reads throw. /// private static SystemLanguage DetectGameLanguage() { try { - // First attempt: RDString.language + // 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 + // Fallback attempt: Persistence.language, the saved "language" preference (defaults to + // Application.systemLanguage) return Persistence.language; } catch @@ -221,6 +271,7 @@ internal static class LocalizationManager /// /// Convenient shorthand alias for . +/// Every member forwards to the member of the same name in . /// // ReSharper disable once InconsistentNaming internal static class I18n diff --git a/UI/CommonWarningDialogUI.cs b/UI/CommonWarningDialogUI.cs index 86a8b8b..54a04c5 100644 --- a/UI/CommonWarningDialogUI.cs +++ b/UI/CommonWarningDialogUI.cs @@ -11,14 +11,29 @@ using Image = UnityEngine.UI.Image; namespace ADOFAIRenderer.UI; +/// +/// Description of one button of a dialog: label, click action and optional color. +/// internal class DialogButton { // ReSharper disable FieldCanBeMadeReadOnly.Global + /// Button label, shown as given (callers pass already localized text). public string Text; + + /// + /// Action run on the Unity main thread when the button is clicked. The dialog is destroyed after the action + /// returns, also when the action throws (the exception is logged). + /// public Action OnClick; + + /// Normal (idle) background color of the button; null keeps the O5Kit theme button color. public Color? BackgroundColor; // ReSharper restore FieldCanBeMadeReadOnly.Global + /// Creates a button description. + /// Button label. + /// Action run on click, before the dialog closes. + /// Optional normal background color; null uses the theme color. public DialogButton(string text, Action onClick, Color? backgroundColor = null) { Text = text; @@ -27,10 +42,29 @@ internal class DialogButton } } +/// +/// Modal message dialog with a title, a description and one or more buttons. +/// +/// Each call creates its own GameObject (kept across scene loads) with a screen-space overlay +/// canvas: a translucent full-screen backdrop that blocks input to everything below, and an O5Kit window on top of it. +/// While the dialog exists, every enabled UI Toolkit root () is disabled so that it cannot +/// receive input; the roots are enabled again in . Clicking any button runs its action and then +/// destroys the dialog. All members run on the Unity main thread. +/// +/// internal class CommonWarningDialogUI : MonoBehaviour { + /// + /// The most recently created dialog, or null when that dialog has been destroyed. + /// FirstUpdateHook checks it to wait until a startup dialog is closed. + /// If several dialogs are open at once, this refers to the newest one only. + /// public static CommonWarningDialogUI? Instance { get; private set; } + + /// UI Toolkit root elements disabled by this dialog, enabled again when the dialog is destroyed. private readonly List _disabledElements = new(); + + /// O5Kit window holding the title, description and buttons; disposed in . private O5Window? _window; private void Awake() @@ -38,6 +72,10 @@ internal class CommonWarningDialogUI : MonoBehaviour Instance = this; } + /// + /// Disposes the window, enables the UI Toolkit roots disabled in and clears + /// if it still refers to this dialog. + /// private void OnDestroy() { _window?.Dispose(); @@ -58,6 +96,15 @@ internal class CommonWarningDialogUI : MonoBehaviour } } + /// + /// Creates and shows a new dialog. Must be called on the Unity main thread. + /// + /// Window title text. + /// Body text, centered and word-wrapped; TextMeshPro rich text tags are allowed. + /// + /// Buttons laid out left to right with equal widths. With no buttons nothing is shown, because the dialog + /// has no close button and could not be dismissed. + /// public static void ShowDialog(string title, string description, params DialogButton[] buttons) { if (buttons.Length == 0) return; @@ -68,8 +115,14 @@ internal class CommonWarningDialogUI : MonoBehaviour dialog.Initialize(title, description, buttons); } + /// + /// Builds the canvas, backdrop, window and contents. Layout sizes are in canvas units at the 1920x1080 reference + /// resolution of the . + /// private void Initialize(string title, string description, DialogButton[] buttons) { + // uGUI buttons need an EventSystem to receive clicks. When the current scene has none, a child EventSystem + // is created; it is destroyed together with the dialog. if (!UnityEngine.EventSystems.EventSystem.current) { var eventSystemObj = new GameObject("EventSystem"); @@ -78,6 +131,8 @@ internal class CommonWarningDialogUI : MonoBehaviour eventSystemObj.AddComponent(); } + // Disable every enabled UI Toolkit root so that it ignores input while the dialog is open. + // Roots that are already disabled are left alone and are not re-enabled in OnDestroy. foreach (var doc in FindObjectsByType(FindObjectsSortMode.None)) { if (doc && doc.rootVisualElement is { enabledSelf: true }) @@ -87,14 +142,17 @@ internal class CommonWarningDialogUI : MonoBehaviour } } - // Set UI layer (5) + // Set UI layer (5, Unity's built-in "UI" layer) gameObject.layer = 5; - // Canvas setup + // Canvas setup. + // Sorting order 32766 draws the dialog above the RendererUIManager window canvas (32000), the render overlays + // (DebugMonitorUI 9999, EscapeHandlerUI 10000) and below FFmpegDownloadUI (32767, the largest sorting order). var canvas = gameObject.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = 32766; + // Scale with screen size against a 1920x1080 reference; match 0.5 blends width and height scaling equally. var scaler = gameObject.AddComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = new Vector2(1920, 1080); @@ -105,7 +163,8 @@ internal class CommonWarningDialogUI : MonoBehaviour var ctx = RendererUIManager.Context; - // 1. Create full screen backdrop (scrim) to block background input + // 1. Create full screen backdrop (scrim) to block background input. + // Black at 65% opacity, stretched over the whole canvas; raycastTarget makes it swallow clicks. var bgPanelObj = new GameObject("Backdrop"); bgPanelObj.transform.SetParent(transform, false); bgPanelObj.layer = 5; @@ -120,7 +179,8 @@ internal class CommonWarningDialogUI : MonoBehaviour bgRect.offsetMin = Vector2.zero; bgRect.offsetMax = Vector2.zero; - // 2. Create actual native O5Window + // 2. Create the O5Kit window as a child of the backdrop: 540x240 units, draggable, fixed size, + // no close button (the dialog closes only through its buttons), 16-unit inner padding. _window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions { Id = "RendererMod_WarningDialog", @@ -133,7 +193,8 @@ internal class CommonWarningDialogUI : MonoBehaviour Padding = new RectOffset(16, 16, 16, 16) }); - // 3. Build content inside window.Content + // 3. Build content inside window.Content: a vertical stack of the description (takes the remaining height) + // and the button row (40 units high), 16 units apart. var content = _window.Content; var vLayout = content.gameObject.AddComponent(); @@ -144,7 +205,7 @@ internal class CommonWarningDialogUI : MonoBehaviour vLayout.childForceExpandWidth = true; vLayout.childForceExpandHeight = false; - // Description text + // Description text: 18 pt, centered, word-wrapped, light gray (88% white) var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true); descText.text = description; descText.alignment = TextAlignmentOptions.Center; @@ -157,7 +218,7 @@ internal class CommonWarningDialogUI : MonoBehaviour descLE.flexibleWidth = 1f; descLE.flexibleHeight = 1f; - // Button action row + // Button action row: buttons share the row width equally, 10 units apart var btnRowObj = new GameObject("ButtonRow"); btnRowObj.transform.SetParent(content, false); var btnRowRect = btnRowObj.AddComponent(); @@ -176,6 +237,7 @@ internal class CommonWarningDialogUI : MonoBehaviour for (var i = 0; i < buttons.Length; i++) { var btnData = buttons[i]; + // The click handler runs the caller's action, logs any exception from it, and always destroys the dialog. var btn = O5Factory.Button( ctx: ctx, parent: btnRowRect, diff --git a/UI/DebugMonitorUI.cs b/UI/DebugMonitorUI.cs index 0079c42..b2dd7e5 100644 --- a/UI/DebugMonitorUI.cs +++ b/UI/DebugMonitorUI.cs @@ -8,39 +8,73 @@ using UnityEngine.UI; namespace ADOFAIRenderer.UI; +/// +/// On-screen overlay shown while a render is running: a statistics panel in the top-right corner and a level progress +/// bar near the bottom of the screen. +/// +/// Created by RenderManager.StartFFmpegThread on a GameObject kept across scene loads, and reused for later +/// renders. The overlay is drawn on the game screen only; it is not part of the encoded video frames. +/// rebuilds the text every frame on the Unity main thread. +/// +/// internal class DebugMonitorUI : MonoBehaviour { // --- Progress Bar UI Configuration Constants --- + // Sizes and positions are canvas units at the 1920x1080 reference resolution of the CanvasScaler. + + /// Width of the progress bar; the fill width is BarWidth * progress. private const float BarWidth = 800f; + /// Height of the progress bar. private const float BarHeight = 28f; + /// Horizontal offset of the bar center from the screen center (0 = centered). private const float BarPosX = 0f; + /// Vertical offset of the bar center from the screen center; -420 is 120 units above the bottom edge (540 - 420). private const float BarPosY = -420f; // Y position relative to screen center (Reference: 1920x1080) + /// Font size of the percentage text drawn on the bar. private const int BarTextFontSize = 16; // Increased font size for visibility + /// Unfilled bar color: opaque near-black with a slight blue tint. private static readonly Color BarBackgroundColor = new(0.08f, 0.08f, 0.1f, 1.0f); + /// Filled part color: opaque cyan (#00CCFF). private static readonly Color BarFillColor = new(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue + /// Percentage text color: opaque white. private static readonly Color BarTextColor = new(1.0f, 1.0f, 1.0f, 1.0f); // --- Progress Bar Outline Configuration Constants --- + + /// Color of the uGUI drawn around the bar background. private static readonly Color BarOutlineColor = Color.white; // Contrasting white outline + /// + /// Outline.effectDistance of the bar outline: the background is redrawn shifted by +-3 units on both axes, + /// which shows as a border about 3 units wide. + /// private static readonly Vector2 BarOutlineDistance = new(3.0f, -3.0f); // Outline thickness + /// Statistics text inside the top-right panel. private TextMeshProUGUI _text = null!; + /// Progress bar background object (parent of the fill and the percentage text). private GameObject _progressBarObj = null!; + /// Progress bar fill object. private GameObject _progressBarFillObj = null!; + /// Fill rect, anchored to the left edge of the bar; its width is set every frame. private RectTransform _fillRect = null!; + /// Text on the bar: percentage and "current floor / last floor index". private TextMeshProUGUI _progressText = null!; + /// + /// Builds the overlay canvas, the statistics panel and the progress bar. Layout values are canvas units at the + /// 1920x1080 reference resolution. + /// private void Start() { - // Set UI layer (5) + // Set UI layer (5, Unity's built-in "UI" layer) gameObject.layer = 5; - // Canvas setup + // Canvas setup. Sorting order 9999 is below EscapeHandlerUI (10000) and the dialogs/windows of the mod. var canvas = gameObject.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = 9999; - // Dynamic resolution scaling setup (Reference: 1920x1080) + // Dynamic resolution scaling against a 1920x1080 reference resolution; match 0.5 blends width and height var scaler = gameObject.AddComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = new Vector2(1920, 1080); @@ -49,7 +83,7 @@ internal class DebugMonitorUI : MonoBehaviour gameObject.AddComponent(); - // Create background panel + // Create background panel: 360x220 units, pivot and anchor at the top-right corner, inset 30 units from both edges var panelObj = new GameObject("BackgroundPanel"); panelObj.transform.SetParent(transform, false); panelObj.layer = 5; @@ -64,7 +98,7 @@ internal class DebugMonitorUI : MonoBehaviour panelRect.anchoredPosition = new Vector2(-30, -30); panelRect.sizeDelta = new Vector2(360, 220); - // Create text + // Create text: 16 pt rich text, top-left aligned, filling the panel minus a 20-unit margin var textObj = new GameObject("MonitorText"); textObj.transform.SetParent(panelObj.transform, false); textObj.layer = 5; @@ -74,11 +108,11 @@ internal class DebugMonitorUI : MonoBehaviour _text.fontSize = 16; _text.alignment = TextAlignmentOptions.TopLeft; - // Prevent TMPro text clipping + // Wrap long lines and let text that exceeds the panel height overflow instead of being truncated _text.textWrappingMode = TextWrappingModes.Normal; _text.overflowMode = TextOverflowModes.Overflow; - // Load and apply font + // Apply the mod's bundled font (loaded by ModAssets.Load at mod initialization) _text.font = ModAssets.NanumSquareNeoNormal; var textRect = textObj.GetComponent(); @@ -88,7 +122,7 @@ internal class DebugMonitorUI : MonoBehaviour textRect.offsetMax = new Vector2(-20, -20); // --- Progress Bar UI Setup --- - // 1. Progress Bar Container + // 1. Progress Bar Container: centered anchor/pivot, placed at (BarPosX, BarPosY) from the screen center _progressBarObj = new GameObject("ProgressBar"); _progressBarObj.transform.SetParent(transform, false); _progressBarObj.layer = 5; @@ -108,7 +142,8 @@ internal class DebugMonitorUI : MonoBehaviour barRect.anchoredPosition = new Vector2(BarPosX, BarPosY); barRect.sizeDelta = new Vector2(BarWidth, BarHeight); - // 2. Progress Bar Fill + // 2. Progress Bar Fill: anchored to the left edge and stretched vertically (anchor x 0..0, y 0..1). + // sizeDelta.x is the fill width in units; Update sets it to BarWidth * progress. _progressBarFillObj = new GameObject("ProgressBarFill"); _progressBarFillObj.transform.SetParent(_progressBarObj.transform, false); _progressBarFillObj.layer = 5; @@ -123,7 +158,7 @@ internal class DebugMonitorUI : MonoBehaviour _fillRect.anchoredPosition = Vector2.zero; _fillRect.sizeDelta = new Vector2(0, 0); // width will be updated dynamically in Update() - // 3. Progress Bar Text + // 3. Progress Bar Text: centered over the whole bar var progressTextObj = new GameObject("ProgressBarText"); progressTextObj.transform.SetParent(_progressBarObj.transform, false); progressTextObj.layer = 5; @@ -135,7 +170,7 @@ internal class DebugMonitorUI : MonoBehaviour _progressText.font = ModAssets.NanumSquareNeoNormal; _progressText.color = BarTextColor; - // Add progress bar text outline for readability + // Black outline (90% opacity, 1.5-unit offset) keeps the white text readable over both the fill and the background var textOutline = progressTextObj.AddComponent(); textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f); textOutline.effectDistance = new Vector2(1.5f, -1.5f); @@ -147,10 +182,22 @@ internal class DebugMonitorUI : MonoBehaviour textRectBar.offsetMax = Vector2.zero; } + /// + /// Refreshes the statistics text and the progress bar from and the game state. + /// Values fall back to 0 (and the queue limit to RenderManager.MaxQueuedFrames) when no RenderManager exists. + /// private void Update() { if (!_text) return; + // Statistics lines: + // Queue - frames waiting in ImageQueue for the render thread / queue frame limit + // Rendered Frames - RenderManager.frameCount (frames produced by the game) + // Frames Sent - RenderManager.framesSent (frames handed to the encoder; written by the render thread) + // Throttling - RenderManager.throttleCount (times the main thread started waiting because ImageQueue + // held more frames than its limit) + // Readback failures- RenderManager.readbackFailureCount (AsyncGPUReadback requests that completed with an error) + // Mono heap - GC.GetTotalMemory(false), managed heap size in MiB without forcing a collection var sb = new StringBuilder(); sb.AppendLine("ADOFAI Renderer Realtime Monitor"); sb.AppendLine(); @@ -158,6 +205,7 @@ internal class DebugMonitorUI : MonoBehaviour var queueCount = 0; if (RenderManager.Instance) { + // ImageQueue is shared with the render thread, so its count is read under the same lock lock (RenderManager.Instance.ImageQueue) { queueCount = RenderManager.Instance.ImageQueue.Count; @@ -177,7 +225,9 @@ internal class DebugMonitorUI : MonoBehaviour _text.text = sb.ToString(); - // Update Progress Bar + // Update Progress Bar. + // progress = seqID of the current floor / index of the last floor (listFloors.Count - 1), clamped to 0..1. + // The text shows the percentage with one decimal and "(current / last)". var progress = 0f; var currentFloor = 0; var totalFloors = 0; diff --git a/UI/EscapeHandlerUI.cs b/UI/EscapeHandlerUI.cs index 5d41ddd..b70d2ca 100644 --- a/UI/EscapeHandlerUI.cs +++ b/UI/EscapeHandlerUI.cs @@ -6,15 +6,33 @@ using UnityEngine.UI; namespace ADOFAIRenderer.UI; +/// +/// Hold-Escape-to-cancel handler shown during a render. +/// +/// Created by RenderManager when the render reaches the OnScene stage, on a GameObject kept across scene loads. +/// While Escape is held, a centered panel shows a countdown from 3 seconds; holding it for the full 3 seconds +/// (measured in real time with a , independent of the patched game time) calls +/// RenderManager.CancelRender. Releasing Escape earlier hides the panel and resets the countdown. +/// The component destroys its GameObject as soon as RenderManager.IsActive is false (stage below OnScene). +/// Runs on the Unity main thread. +/// +/// internal class EscapeHandlerUI : MonoBehaviour { + /// Centered countdown panel (background and text); inactive while Escape is not held. private GameObject _containerObj = null!; + /// Countdown text inside . private TextMeshProUGUI _textComponent = null!; + /// Real-time hold duration of the current Escape press; null while Escape is not held. private Stopwatch? _escapeStopwatch; + /// + /// Builds the overlay canvas and the hidden countdown panel. Layout values are canvas units at the 1920x1080 + /// reference resolution. + /// private void Start() { - // Set UI layer (5) + // Set UI layer (5, Unity's built-in "UI" layer) gameObject.layer = 5; // Canvas setup @@ -22,7 +40,7 @@ internal class EscapeHandlerUI : MonoBehaviour canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = 10000; // Placed above debug monitor - // Responsive Canvas Scaler setup + // Canvas Scaler: 1920x1080 reference resolution, width and height matched equally (0.5) var scaler = gameObject.AddComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = new Vector2(1920, 1080); @@ -31,7 +49,7 @@ internal class EscapeHandlerUI : MonoBehaviour gameObject.AddComponent(); - // 1. Create container and background panel object + // 1. Create container and background panel object: 460x160 units at the screen center _containerObj = new GameObject("EscapeContainer"); _containerObj.transform.SetParent(transform, false); _containerObj.layer = 5; @@ -46,7 +64,7 @@ internal class EscapeHandlerUI : MonoBehaviour containerRect.anchoredPosition = Vector2.zero; // Screen center containerRect.sizeDelta = new Vector2(460, 160); - // 2. Create text object + // 2. Create text object: 60 pt centered rich text, no wrapping, 10-unit margin inside the panel var textObj = new GameObject("EscapeText"); textObj.transform.SetParent(_containerObj.transform, false); textObj.layer = 5; @@ -57,7 +75,8 @@ internal class EscapeHandlerUI : MonoBehaviour _textComponent.textWrappingMode = TextWrappingModes.NoWrap; _textComponent.richText = true; - // Apply game's default font (RDString.fontData.fontTMP) + // Apply the game's TextMeshPro font for the current language (RDString.fontData.fontTMP, assigned in + // RDString.Setup). It is null before RDString.Setup has run; TextMeshPro's default font is kept then. if (RDString.fontData.fontTMP != null) _textComponent.font = RDString.fontData.fontTMP; @@ -71,9 +90,13 @@ internal class EscapeHandlerUI : MonoBehaviour _containerObj.SetActive(false); } + /// + /// Tracks the Escape key every frame: shows and updates the countdown while it is held, cancels the render after + /// 3 seconds of holding, and hides the panel when it is released. + /// private void Update() { - // Destroy self if rendering is no longer active + // Destroy self once the render is inactive (stage below OnScene: finished, cancelled or failed) if (!RenderManager.IsActive) { Destroy(gameObject); @@ -82,6 +105,7 @@ internal class EscapeHandlerUI : MonoBehaviour if (Input.GetKey(KeyCode.Escape)) { + // First frame of the press: start timing and show the panel if (_escapeStopwatch == null) { _escapeStopwatch = new Stopwatch(); @@ -92,13 +116,14 @@ internal class EscapeHandlerUI : MonoBehaviour var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds; var remaining = Mathf.Max(0f, 3f - elapsed); - // Update remaining time text + // Update remaining time text (seconds, one decimal). The text is fixed English, not localized. _textComponent.text = $"Cancelling in {remaining:F1}s..\nHold Esc to cancel render"; - // Micro pulse animation (container smoothly scales up while holding) + // Grow animation: the panel scales linearly from 1.0 to 1.15 over the 3-second hold var scale = 1.0f + elapsed / 3.0f * 0.15f; _containerObj.transform.localScale = new Vector3(scale, scale, 1f); + // Held for the full 3 seconds: reset the panel, then cancel if (elapsed < 3f) return; _escapeStopwatch.Stop(); @@ -106,12 +131,14 @@ internal class EscapeHandlerUI : MonoBehaviour _containerObj.transform.localScale = Vector3.one; // Reset scale _containerObj.SetActive(false); - // Cancel rendering and exit scene + // Cancel rendering: RenderManager.CancelRender resets the render state, stops the encoder as cancelled and + // ends the render. StopFFmpeg also destroys this handler's GameObject. if (RenderManager.Instance) RenderManager.Instance.CancelRender(); } else { + // Escape released before 3 seconds: stop timing and hide the panel if (_escapeStopwatch == null) return; _escapeStopwatch.Stop(); _escapeStopwatch = null; @@ -120,6 +147,7 @@ internal class EscapeHandlerUI : MonoBehaviour } } + /// Stops the hold stopwatch if a press is in progress. private void OnDestroy() { if (_escapeStopwatch == null) return; diff --git a/UI/FFmpegDownloadUI.cs b/UI/FFmpegDownloadUI.cs index 440198a..2375dd4 100644 --- a/UI/FFmpegDownloadUI.cs +++ b/UI/FFmpegDownloadUI.cs @@ -15,27 +15,71 @@ using UnityEngine.UI; namespace ADOFAIRenderer.UI; +/// +/// Modal dialog that installs the FFmpeg shared libraries into MelonLoader's UserLibs folder. +/// +/// Shown by FirstUpdateHook when reports missing libraries. On Windows and Linux +/// the "Download" button downloads a prebuilt BtbN FFmpeg build (GPL, shared libraries) and extracts the libraries +/// into UserLibs; on macOS the button opens https://ffmpeg.org, because the dialog text tells the user to build FFmpeg +/// manually there. The libraries are found by FFmpeg.AutoGen through ffmpeg.RootPath (set to UserLibs in +/// RenderMod.OnInitializeMelon). +/// +/// +/// Threads: UI construction and all Unity object access happen on the Unity main thread. The download and extraction +/// run on thread-pool threads (); they post UI updates to +/// , which drains on the main thread. +/// +/// +/// Flow: buttons -> (Download) progress bar + status text -> download to a temporary archive in UserLibs -> extract +/// libraries -> "complete" for 1 second -> dialog destroyed. On failure the status shows the error and the buttons +/// return so the user can retry or cancel. Cancel destroys the dialog at once and cancels a running download. +/// +/// internal class FFmpegDownloadUI : MonoBehaviour { + /// Row with the Download/Open Website and Cancel buttons; hidden while a download runs. private GameObject _buttonContainerObj = null!; + /// Progress bar background (rounded, theme colors); shown when the download starts. private GameObject _progressBarObj = null!; + /// Progress bar fill object. private GameObject _progressBarFillObj = null!; + /// Fill rect; its anchorMax.x is the progress fraction 0..1 (see ). private RectTransform _fillRect = null!; + /// Text centered on the progress bar (percentage or "Extracting..."). private TextMeshProUGUI _progressText = null!; + /// Status line below the progress bar; shown when the download starts. private GameObject _statusTextObj = null!; + /// Status message, colored by state (gray starting, cyan downloading, yellow extracting, green done, red failed). private TextMeshProUGUI _statusText = null!; + /// O5Kit window holding the dialog contents; disposed in . private O5Window? _window; + /// True from the Download click until the download fails; prevents a second concurrent download. private bool _isDownloading; + + /// + /// Archive URL for the current platform: a fixed BtbN FFmpeg-Builds autobuild (2026-05-31, FFmpeg master + /// N-124714-g49a77d37be, GPL, shared libraries). Windows gets the win64 .zip; every other platform gets the + /// linux64 .tar.xz (macOS never downloads, see ). + /// private string DownloadUrl => Application.platform switch { RuntimePlatform.WindowsPlayer => "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-N-124714-g49a77d37be-win64-gpl-shared.zip", _ => "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-N-124714-g49a77d37be-linux64-gpl-shared.tar.xz" }; + /// Cancellation for the running download; created per download, cancelled by Cancel, disposed in OnDestroy. private System.Threading.CancellationTokenSource? _cts; + /// Actions posted from background threads, run in order on the main thread by . private readonly System.Collections.Concurrent.ConcurrentQueue _mainThreadQueue = new(); + /// + /// Whether UserLibs contains at least one file for each of the five FFmpeg libraries the mod uses + /// (avcodec, avformat, avutil, swscale, swresample), matched by platform-specific file name patterns: + /// avcodec-*.dll on Windows, libavcodec.*.dylib on macOS, libavcodec.so* elsewhere. + /// Only file presence is checked, not the library version or whether it loads. + /// + /// false when UserLibs does not exist or any library is missing. public static bool CheckFFmpegExists() { var userLibs = MelonEnvironment.UserLibsDirectory; @@ -68,6 +112,10 @@ internal class FFmpegDownloadUI : MonoBehaviour return true; } + /// + /// Creates the dialog on a new GameObject kept across scene loads, unless the libraries are already present. + /// Main thread only. + /// public static void ShowDownloadUI() { @@ -78,8 +126,13 @@ internal class FFmpegDownloadUI : MonoBehaviour obj.AddComponent(); } + /// + /// The open dialog, or null after it is destroyed. FirstUpdateHook waits while it is set and checks + /// the libraries again after it closes. + /// public static FFmpegDownloadUI? Instance { get; private set; } + /// Registers ; destroys the dialog again when the libraries already exist. private void Awake() { Instance = this; @@ -89,9 +142,13 @@ internal class FFmpegDownloadUI : MonoBehaviour } } + /// + /// Builds the canvas, backdrop and window: description, button row, and the hidden progress bar and status line. + /// Layout values are canvas units at the 1920x1080 reference resolution. + /// private void Start() { - // Set UI layer (5) + // Set UI layer (5, Unity's built-in "UI" layer) gameObject.layer = 5; // Canvas setup @@ -99,7 +156,7 @@ internal class FFmpegDownloadUI : MonoBehaviour canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = 32767; // Topmost - // Responsive Canvas Scaler setup + // Canvas Scaler: 1920x1080 reference resolution, width and height matched equally (0.5) var scaler = gameObject.AddComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = new Vector2(1920, 1080); @@ -110,7 +167,7 @@ internal class FFmpegDownloadUI : MonoBehaviour var ctx = RendererUIManager.Context; - // 1. Create full screen backdrop + // 1. Create full screen backdrop: black at 65% opacity, blocks clicks to everything below var bgPanelObj = new GameObject("Backdrop"); bgPanelObj.transform.SetParent(transform, false); bgPanelObj.layer = 5; @@ -125,7 +182,8 @@ internal class FFmpegDownloadUI : MonoBehaviour bgRect.offsetMin = Vector2.zero; bgRect.offsetMax = Vector2.zero; - // 2. Create actual native O5Window + // 2. Create the O5Kit window: 580x270 units, draggable, fixed size, no close button (Cancel closes it). + // The title is localized once here; the dialog does not follow later language changes. _window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions { Id = "RendererMod_FFmpegDownloadDialog", @@ -138,6 +196,8 @@ internal class FFmpegDownloadUI : MonoBehaviour Padding = new RectOffset(16, 16, 16, 16) }); + // Vertical stack: description (takes the remaining height), buttons (40), progress bar (24), status (22), + // 14 units apart var content = _window.Content; var vLayout = content.gameObject.AddComponent(); @@ -148,7 +208,7 @@ internal class FFmpegDownloadUI : MonoBehaviour vLayout.childForceExpandWidth = true; vLayout.childForceExpandHeight = false; - // 3. Description text + // 3. Description text: "required" line, then either the macOS build/website lines or the download/size lines var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true); var descStrBuilder = new StringBuilder(); descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_required")); @@ -172,7 +232,7 @@ internal class FFmpegDownloadUI : MonoBehaviour descLE.flexibleWidth = 1f; descLE.flexibleHeight = 1f; - // 4. Button container + // 4. Button container: two equal-width 40-unit buttons, 10 units apart _buttonContainerObj = new GameObject("ButtonContainer"); _buttonContainerObj.transform.SetParent(content, false); _buttonContainerObj.layer = 5; @@ -189,6 +249,7 @@ internal class FFmpegDownloadUI : MonoBehaviour btnLayout.childForceExpandWidth = true; btnLayout.childForceExpandHeight = true; + // Primary button: download and install (Windows/Linux), or open the FFmpeg website and close the dialog (macOS) Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer ? StartDownloadProcess : () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); }; @@ -220,7 +281,8 @@ internal class FFmpegDownloadUI : MonoBehaviour cancelBtnLE.flexibleWidth = 1f; cancelBtnLE.preferredHeight = 40f; - // 5. Progress bar container + // 5. Progress bar container (hidden until the download starts): rounded background in the theme object color + // with a rounded outline drawn over it _progressBarObj = new GameObject("ProgressBarContainer"); _progressBarObj.transform.SetParent(content, false); _progressBarObj.layer = 5; @@ -251,7 +313,8 @@ internal class FFmpegDownloadUI : MonoBehaviour barOutlineRect.offsetMin = Vector2.zero; barOutlineRect.offsetMax = Vector2.zero; - // Progress bar fill image + // Progress bar fill image: anchored at the left edge with zero size offset, so its width is set entirely by + // anchorMax.x (0 = empty, 1 = full) in UpdateProgress _progressBarFillObj = new GameObject("ProgressBarFill"); _progressBarFillObj.transform.SetParent(_progressBarObj.transform, false); _progressBarFillObj.layer = 5; @@ -268,7 +331,7 @@ internal class FFmpegDownloadUI : MonoBehaviour _fillRect.anchoredPosition = Vector2.zero; _fillRect.sizeDelta = Vector2.zero; - // Progress text + // Progress text: 13 pt white, centered over the bar var progressTextObj = new GameObject("ProgressBarText"); progressTextObj.transform.SetParent(_progressBarObj.transform, false); progressTextObj.layer = 5; @@ -286,7 +349,7 @@ internal class FFmpegDownloadUI : MonoBehaviour progressTextRect.offsetMin = Vector2.zero; progressTextRect.offsetMax = Vector2.zero; - // 6. Status text + // 6. Status text (hidden until the download starts): 14 pt, centered _statusTextObj = new GameObject("StatusText"); _statusTextObj.transform.SetParent(content, false); _statusTextObj.layer = 5; @@ -310,6 +373,7 @@ internal class FFmpegDownloadUI : MonoBehaviour statusRect.offsetMax = Vector2.zero; } + /// Runs every action posted by the background download task, in posting order. private void Update() { // Execute queued actions on main thread @@ -319,11 +383,19 @@ internal class FFmpegDownloadUI : MonoBehaviour } } + /// + /// Posts an action to run on the main thread during the next . Safe to call from any thread. + /// Actions posted after the dialog is destroyed are never run. + /// private void ExecuteOnMainThread(Action action) { _mainThreadQueue.Enqueue(action); } + /// + /// Download button handler (Windows/Linux): switches the dialog to progress mode and starts the background task + /// that downloads the archive, extracts the libraries and closes the dialog. Ignored while a download runs. + /// private void StartDownloadProcess() { if (_isDownloading) return; @@ -336,7 +408,9 @@ internal class FFmpegDownloadUI : MonoBehaviour _cts = new System.Threading.CancellationTokenSource(); - // Start download in background thread + // Start download in background thread. Everything inside runs on thread-pool threads: Unity objects are only + // touched through UpdateStatus/UpdateProgress/ExecuteOnMainThread. The task is not awaited; its outcome is + // reported through the status text. Task.Run(async () => { string tempZipPath = ""; @@ -351,6 +425,8 @@ internal class FFmpegDownloadUI : MonoBehaviour Directory.CreateDirectory(userLibsDir); } + // Temporary archive path in UserLibs: ffmpeg_temp.zip (Windows, macOS) or ffmpeg_temp.tar.xz (others). + // It is deleted after extraction (ExtractDllsAsync) or on cancellation. string extension = Application.platform switch { RuntimePlatform.WindowsPlayer => ".zip", @@ -359,6 +435,8 @@ internal class FFmpegDownloadUI : MonoBehaviour }; tempZipPath = Path.Combine(userLibsDir, "ffmpeg_temp" + extension); + // percent is the downloaded fraction 0..1. Progress created on a thread-pool thread (no + // SynchronizationContext) invokes this callback on thread-pool threads; it only posts UI updates. var progressReporter = new Progress(percent => { UpdateProgress(percent, $"{percent * 100f:F1}%"); @@ -367,7 +445,7 @@ internal class FFmpegDownloadUI : MonoBehaviour await DownloadFileAsync(DownloadUrl, tempZipPath, progressReporter, _cts.Token); - // Extract after download complete + // Extract after download complete; the bar is held at 99% while extracting UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_extracting"), new Color(0.9f, 0.9f, 0.2f)); UpdateProgress(0.99f, LocalizationManager.Get("ui.ffmpeg_download.progress_extracting")); @@ -377,9 +455,10 @@ internal class FFmpegDownloadUI : MonoBehaviour UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_complete"), new Color(0.2f, 0.9f, 0.2f)); UpdateProgress(1f, "100%"); + // Keep the "complete" message visible for 1000 ms before closing await Task.Delay(1000); - // Destroy UI on main thread + // Destroy UI on main thread. FirstUpdateHook then sees Instance == null and checks the libraries again. ExecuteOnMainThread(() => { Destroy(gameObject); @@ -387,7 +466,8 @@ internal class FFmpegDownloadUI : MonoBehaviour } catch (OperationCanceledException) { - // Clean up temporary file on cancellation + // Clean up temporary file on cancellation. Cancellation comes from CancelProcess, which has already + // destroyed the dialog, so the status update below is posted but never shown. if (!string.IsNullOrEmpty(tempZipPath) && File.Exists(tempZipPath)) { try { File.Delete(tempZipPath); } @@ -403,7 +483,9 @@ internal class FFmpegDownloadUI : MonoBehaviour Melon.Logger.Error($"Error during FFmpeg download/installation: {ex}"); UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_failed", ex.Message), new Color(1f, 0.3f, 0.3f)); - // Reset buttons on failure + // Reset buttons on failure so the user can retry (Download) or close the dialog (Cancel). + // The label is set to "Retry" only if a child at "DownloadButton/DownloadText" exists. The button made by + // O5Factory.Button in Start is a GameObject named "O5Button", which this path does not match. ExecuteOnMainThread(() => { _isDownloading = false; @@ -418,6 +500,10 @@ internal class FFmpegDownloadUI : MonoBehaviour }); } + /// + /// Cancel button handler: requests cancellation of a running download and destroys the dialog immediately. + /// An extraction already in progress is not cancellable and finishes in the background. + /// private void CancelProcess() { if (_isDownloading) @@ -427,6 +513,7 @@ internal class FFmpegDownloadUI : MonoBehaviour Destroy(gameObject); } + /// Posts a status text and color change to the main thread. Callable from any thread. private void UpdateStatus(string message, Color color) { ExecuteOnMainThread(() => @@ -439,6 +526,9 @@ internal class FFmpegDownloadUI : MonoBehaviour }); } + /// Posts a progress bar update to the main thread. Callable from any thread. + /// Filled fraction 0..1 (not 0..100); becomes the fill's anchorMax.x. + /// Text shown on the bar. private void UpdateProgress(float percent, string text) { ExecuteOnMainThread(() => @@ -454,15 +544,28 @@ internal class FFmpegDownloadUI : MonoBehaviour }); } + /// + /// Streams the HTTP response body of to (created or + /// overwritten) in 8 KiB chunks. Runs on a thread-pool thread. + /// + /// Archive URL. + /// Output file path. + /// + /// Receives the downloaded fraction 0..1 after every chunk; not reported when the server sends no Content-Length. + /// + /// Cancels the request and the copy loop (). + /// The server returned a non-success status code. private async Task DownloadFileAsync(string url, string destinationPath, IProgress progress, System.Threading.CancellationToken cancellationToken) { using var client = new HttpClient(); - // Add User-Agent header to prevent blocking + // Add User-Agent header to prevent blocking by servers that reject requests without one client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ADOFAIRenderer/1.0"); + // ResponseHeadersRead returns after the headers, so the body is streamed instead of buffered in memory using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); + // Total size from Content-Length, or -1 when unknown. File stream: 8192-byte buffer, asynchronous I/O. var totalBytes = response.Content.Headers.ContentLength ?? -1L; await using var contentStream = await response.Content.ReadAsStreamAsync(); await using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true); @@ -483,12 +586,28 @@ internal class FFmpegDownloadUI : MonoBehaviour } } + /// + /// Extracts the shared libraries from the downloaded archive into , overwriting + /// files of the same name, and deletes the archive afterwards (also on failure; a failed delete is logged as a + /// warning). Runs on thread-pool threads and is not cancellable. + /// + /// Windows/macOS (.zip): every entry ending in .dll (Windows) or .dylib (macOS) is extracted flat, by its file + /// name only, ignoring its folder inside the archive. + /// Other platforms (.tar.xz): the archive is unpacked with the system tar command into the temporary + /// folder "ffmpeg_temp_extracted" in ; every file matching *.so* in any + /// subfolder is copied flat to , then the temporary folder is deleted. + /// + /// + /// Downloaded archive path (.zip or .tar.xz despite the parameter name). + /// UserLibs folder. + /// tar exited with a non-zero code (message includes its standard error). private async Task ExtractDllsAsync(string zipPath, string outputDirectory) { try { if (Application.platform == RuntimePlatform.OSXPlayer || Application.platform == RuntimePlatform.WindowsPlayer) { + // .zip: extract matching library entries flat into outputDirectory await Task.Run(() => { var targetExtension = Application.platform == RuntimePlatform.OSXPlayer ? ".dylib" : ".dll"; @@ -522,6 +641,7 @@ internal class FFmpegDownloadUI : MonoBehaviour } Directory.CreateDirectory(tempExtractDir); + // tar receives forward-slash paths, quoted for spaces: tar -xf "" -C "" var archiveUnixPath = zipPath.Replace('\\', '/'); var tempExtractUnixDir = tempExtractDir.Replace('\\', '/'); @@ -545,7 +665,8 @@ internal class FFmpegDownloadUI : MonoBehaviour } } - // Find .so files and copy to outputDirectory + // Find .so files (including versioned names such as libavcodec.so.) and copy them flat to + // outputDirectory var soFiles = Directory.GetFiles(tempExtractDir, "*.so*", SearchOption.AllDirectories); foreach (var soFile in soFiles) { @@ -582,6 +703,10 @@ internal class FFmpegDownloadUI : MonoBehaviour } } + /// + /// Disposes the window and the cancellation source and clears . A download that was not + /// cancelled keeps running in the background; its UI updates are posted but no longer drained. + /// private void OnDestroy() { _window?.Dispose(); diff --git a/UI/LocalizedText.cs b/UI/LocalizedText.cs index 96bf389..65162a6 100644 --- a/UI/LocalizedText.cs +++ b/UI/LocalizedText.cs @@ -8,15 +8,25 @@ namespace ADOFAIRenderer.UI; /// /// Component that binds a TextMeshProUGUI component to a localization key or dynamic string provider, /// automatically updating the displayed text whenever the active language changes. +/// +/// The text is set when the binding is made (), when the component is enabled, +/// and on every event. A key and a dynamic provider are mutually +/// exclusive: setting one clears the other. With neither set, leaves the text unchanged. +/// Runs on the Unity main thread. +/// /// internal class LocalizedText : MonoBehaviour { + /// Text component that receives the string; looked up on the same GameObject if not assigned by Bind. private TextMeshProUGUI? _textComponent; + /// Localization key resolved through ; null when a provider is used. private string? _key; + /// Function that returns the full text (already localized/formatted); null when a key is used. private Func? _dynamicProvider; /// - /// Gets or sets the localization key. Setting this will clear any dynamic provider. + /// Gets or sets the localization key. Setting this will clear any dynamic provider and refresh the text immediately. + /// A null or empty key leaves the current text as it is. /// public string? Key { @@ -30,8 +40,11 @@ internal class LocalizedText : MonoBehaviour } /// - /// Sets a dynamic string provider function. + /// Sets a dynamic string provider function, clears the key and refreshes the text immediately. + /// The provider is called on every refresh (enable and language change), so it can combine several keys or + /// runtime values, e.g. a label that depends on UI state. /// + /// Function returning the text to display; called on the Unity main thread. public void SetDynamic(Func provider) { _dynamicProvider = provider; @@ -39,27 +52,35 @@ internal class LocalizedText : MonoBehaviour Refresh(); } + /// + /// Finds the text component and subscribes to language changes. The subscription lasts until , + /// also while the component is disabled (a refresh of a disabled object only changes its text). + /// private void Awake() { EnsureComponent(); LocalizationManager.OnLanguageChanged += HandleLanguageChanged; } + /// Refreshes the text each time the component is enabled. private void OnEnable() { Refresh(); } + /// Unsubscribes from . private void OnDestroy() { LocalizationManager.OnLanguageChanged -= HandleLanguageChanged; } + /// Language change handler; the new language is read by . private void HandleLanguageChanged(SystemLanguage language) { Refresh(); } + /// Assigns from the same GameObject if it is not set yet. private void EnsureComponent() { if (_textComponent == null) @@ -70,6 +91,8 @@ internal class LocalizedText : MonoBehaviour /// /// Refreshes the text using either the dynamic provider or the static localization key. + /// The provider takes precedence; with neither set, or without a text component, nothing changes. + /// A key that is missing in every locale is displayed as the key itself (see ). /// public void Refresh() { @@ -88,7 +111,13 @@ internal class LocalizedText : MonoBehaviour /// /// Binds a TextMeshProUGUI component to a localization key. + /// Reuses the already on the target's GameObject or adds one, then sets the key, + /// which replaces any earlier binding and updates the text immediately. /// + /// Text to localize. + /// Localization key, e.g. "ui.render_config.codec". + /// The binding component. + /// is null. public static LocalizedText Bind(TextMeshProUGUI target, string key) { if (target == null) throw new ArgumentNullException(nameof(target)); @@ -100,7 +129,12 @@ internal class LocalizedText : MonoBehaviour /// /// Binds a TextMeshProUGUI component to a dynamic string provider function. + /// Reuses or adds the component like and updates the text immediately. /// + /// Text to localize. + /// Function returning the text; called on every refresh. + /// The binding component. + /// is null. public static LocalizedText Bind(TextMeshProUGUI target, Func provider) { if (target == null) throw new ArgumentNullException(nameof(target)); diff --git a/UI/RendererUIManager.cs b/UI/RendererUIManager.cs index 1bd224f..267d963 100644 --- a/UI/RendererUIManager.cs +++ b/UI/RendererUIManager.cs @@ -8,9 +8,20 @@ namespace ADOFAIRenderer.UI; using UnityEngine; +/// +/// Assets loaded from the mod's embedded asset bundles (fonts and the color conversion compute shader). +/// +/// is called once from RenderMod.OnInitializeMelon; the fields stay null until then. +/// Bundles are obtained from BundleLoader.GetBundle by name ("fonts", "shaders"). Main thread only. +/// +/// [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] internal static class ModAssets { + /// + /// Loads the font and shader bundles and the assets inside them, logging after each bundle. + /// Exceptions from bundle or asset loading are not caught here and propagate to the caller. + /// public static void Load() { FontBundle = BundleLoader.GetBundle("fonts"); @@ -23,24 +34,49 @@ internal static class ModAssets Melon.Logger.Msg("Shader loaded successfully."); } + /// Asset bundle "fonts" holding the TextMeshPro font assets. public static AssetBundle FontBundle = null!; + /// Asset bundle "shaders" holding the compute shaders. public static AssetBundle ShaderBundle = null!; + /// + /// NanumSquare Neo, asset "NanumSquareNeoOTF-Bd SDF" (Bd = Bold weight). Used as the regular UI font of the mod + /// (O5Kit , DebugMonitorUI text). + /// public static TMP_FontAsset NanumSquareNeoNormal = null!; + /// + /// NanumSquare Neo, asset "NanumSquareNeoOTF-Eb SDF" (Eb = ExtraBold weight). Used as the O5Kit medium font + /// (). + /// public static TMP_FontAsset NanumSquareNeoBold = null!; - + + /// + /// Compute shader "ColorConversion" that converts captured frames to the encoder's YUV pixel format on the GPU + /// (used by FrameReader). + /// public static ComputeShader ColorConversionShader = null!; } +/// +/// O5Kit font provider for the mod's UI: the bundled NanumSquare Neo fonts for regular and medium text, and O5Kit's +/// default monospace font. +/// internal sealed class RendererFontProvider : IFontProvider { + /// O5Kit default provider, used only for . private static readonly DefaultFontProvider DefaultFontProvider = new(); private bool _disposed; + /// Regular text font: . public TMP_FontAsset Regular => ModAssets.NanumSquareNeoNormal; + /// Medium (emphasis) text font: . public TMP_FontAsset Medium => ModAssets.NanumSquareNeoBold; + /// Monospace font (e.g. hex color input): O5Kit's default monospace font. public TMP_FontAsset Monospace => DefaultFontProvider.Monospace; + /// + /// Marks the provider disposed. The font assets belong to and are not unloaded. + /// public void Dispose() { if (_disposed) return; @@ -48,19 +84,39 @@ internal sealed class RendererFontProvider : IFontProvider } } +/// +/// Owner of the mod's O5Kit UI: the shared (config, theme, fonts) and the window manager +/// with its canvas. +/// +/// Added once by RenderMod.OnInitializeMelon to a GameObject kept across scene loads. Other UI code reads +/// and through the static . +/// drives O5Kit's per-frame work (object ticks, tooltip, keyboard shortcuts). Main thread only. +/// +/// internal class RendererUIManager : MonoBehaviour { + /// The single instance, assigned in . public static RendererUIManager Instance = null!; + /// Shared O5Kit context used to build every mod window and dialog. public static O5Context Context => Instance._context; + /// Window manager whose canvas holds the mod windows (e.g. RenderConfigWindow). public O5WindowManager WindowManager => _windowManager; private O5Context _context = null!; private O5WindowManager _windowManager = null!; + /// + /// Creates the O5Kit context with the mod theme and fonts, then the window manager and its canvas + /// ("RendererMod_Canvas"), and lowers that canvas's sorting order to . + /// private void Awake() { Instance = this; - + + // O5Kit config: UI scale 1.0, middle click resets a control to its default value, slider drag sensitivity 1.0. + // Theme: O5Kit's dark theme with the mod colors. Colors are RGBA 0..1; the accent color + // (1.000, 0.294, 0.079) is orange-red (#FF4B14 approx.). Sizes are canvas units: corner radius 12, + // outline width 2, control height 40, body font 18, H1 font 48. _context = new O5Context( config: new O5Config { UIScale = 1.0f, @@ -101,14 +157,20 @@ internal class RendererUIManager : MonoBehaviour if (windowCanvas != null) windowCanvas.sortingOrder = WindowCanvasSortingOrder; } + /// + /// Sorting order of the window canvas: below CommonWarningDialogUI (32766) and FFmpegDownloadUI (32767), above the + /// render overlays (DebugMonitorUI 9999, EscapeHandlerUI 10000). + /// private const int WindowCanvasSortingOrder = 32000; + /// Runs O5Kit's per-frame updates: object ticks, tooltip, and keyboard shortcut handling. private void Update() { O5Object.TickAll(); Context.Tooltip.Tick(); O5Kit.Input.O5ShortcutManager.HandleUpdate(); } + /// Disposes the window manager, then the context. private void OnDestroy() { _windowManager.Dispose(); _context.Dispose(); diff --git a/UI/Window/RenderConfigWindow.cs b/UI/Window/RenderConfigWindow.cs index 22a252d..a28e519 100644 --- a/UI/Window/RenderConfigWindow.cs +++ b/UI/Window/RenderConfigWindow.cs @@ -18,19 +18,70 @@ using UnityEngine.UI; namespace ADOFAIRenderer.UI.Window; +/// +/// The "Render Config" window: edits a and the global output directory, manages presets, +/// and starts a render ("Go Render"). +/// +/// Opened from the level editor through RenderManager (after the unsaved-changes check) with +/// . The window is built once and then hidden/shown; its state lives in static fields for the +/// whole game session. All members run on the Unity main thread. +/// +/// +/// Data flow: +/// +/// Each control's change callback writes its value into right away and calls +/// (the preset dropdown then shows the Custom label). +/// writes a config back into every control without invoking the callbacks +/// (preset selection, window reopen). +/// reads every control into the config again before saving a preset or rendering, +/// and converts NTSC frame rates (29.97 etc.) to their exact fractions. +/// The output directory is stored in GlobalSettings.Current.OutputDirectory (null = default folder), +/// not in the render config, and is saved to the global settings file. +/// +/// +/// +/// File layout: fields; ; (cards in display order: Presets, top section, +/// Video Encoding, Audio Encoding, Global Settings, Advanced Settings, Go Render, version label); config/UI sync and +/// render start; preset handling (Custom label, select, save, delete, language change); layout helpers +/// (columns, field slots, cards); control factory helpers; enable/disable rules for dependent fields; window visibility. +/// +/// +/// Layout sizes are O5Kit canvas units (the canvas scales with the screen); label rows are 18 units, field rows use +/// 3 units of spacing between label and control, dropdown controls are 50 units high. +/// +/// internal static class RenderConfigWindow { + // =================================================================================================== + // Fields + // =================================================================================================== + + /// The window, created by the first call and reused (hidden, not destroyed, on close). private static O5Window? _configWindow; // Presets controls + /// Preset selector: preset names, plus the Custom label at index 0 while the values differ from the preset. private static O5Dropdown? _presetDropdown; + /// Name used by the Save button; set to the selected preset's name when a preset is loaded. private static O5InputField? _presetNameInput; + /// Saves the current values under the name in . private static O5Button? _savePresetButton; + /// + /// Two-step delete button (red theme): the first click arms it ("Confirm?", wider, brighter red) for 3 seconds, + /// a second click within that time deletes the preset. + /// private static O5Button? _deletePresetButton; + /// Layout element of the delete button; preferred width 90 units normally, 110 while armed. // ReSharper disable once InconsistentNaming private static LayoutElement? _deletePresetButtonLE; + /// True while the delete button is armed and waiting for the confirming click. private static bool _isDeletePending; + /// Coroutine (on RendererUIManager) that disarms the delete button after 3 seconds. private static Coroutine? _deleteResetCoroutine; + /// + /// Name of the preset whose values were last loaded or saved. The Custom label is compared against this preset, + /// and Delete targets it when the dropdown shows the Custom label. null when no presets exist. + /// private static string? _lastLoadedPresetName; /// @@ -39,9 +90,15 @@ internal static class RenderConfigWindow /// private static RenderConfig? _uiConfig; + /// + /// , created on first access with the initializer values + /// (RenderFPS, VideoFPS, RenderWidth and RenderHeight start at 0; fills them). + /// private static RenderConfig UIConfig => _uiConfig ??= new RenderConfig(); - // Controls cache for validation & sync + // Controls cache for validation & sync. + // Each field holds the control bound to the RenderConfig property of the same name; ApplyConfigToUI writes them, + // SyncUItoConfig reads them. They stay null until BuildUI runs. private static O5Slider? _renderFpsSlider; private static O5Slider? _videoFPSSlider; private static O5Slider? _renderWidthSlider; @@ -51,6 +108,8 @@ internal static class RenderConfigWindow private static O5Dropdown? _codecDropdown; private static O5Dropdown? _rateControlDropdown; + // Bitrate / max bitrate / QP inputs keep their label and the running label color tween, so UpdateRateControlUI + // can dim the label together with the blocked input (see SetFieldBlocked). private static O5InputField? _bitrateInput; private static TMPro.TextMeshProUGUI? _bitrateLabel; private static ITweenHandle? _bitrateLabelTween; @@ -71,6 +130,9 @@ internal static class RenderConfigWindow private static O5Dropdown? _channelsDropdown; private static O5Slider? _audioBitrateSlider; + /// + /// Output directory input. Bound to GlobalSettings.Current.OutputDirectory, not to a RenderConfig property. + /// private static O5InputField? _outputDirInput; private static O5Dropdown? _fileExtensionDropdown; @@ -78,6 +140,8 @@ internal static class RenderConfigWindow private static O5Toggle? _processAudioToggle; private static O5Toggle? _nonTileToggle; + // Planet color overrides: the toggle enables the override, the picker holds the color. A disabled toggle stores + // null in AdvancedConfig (no override is applied by PersistencePatch) and blocks the picker. private static O5Toggle? _firePlanetToggle; private static O5ColorPicker? _firePlanetPicker; private static O5Toggle? _icePlanetToggle; @@ -85,10 +149,26 @@ internal static class RenderConfigWindow private static O5Toggle? _windPlanetToggle; private static O5ColorPicker? _windPlanetPicker; + // Colors shown in a planet picker when its override is off (and its middle-click default value) private static readonly Color DefaultFirePlanetColor = new(1f, 0f, 0f, 1f); // #FF0000 private static readonly Color DefaultIcePlanetColor = new(0f, 0f, 1f, 1f); // #0000FF private static readonly Color DefaultWindPlanetColor = new(0.3f, 0.7f, 0f, 1f); // #4DB300 + // =================================================================================================== + // Window creation + // =================================================================================================== + + /// + /// Shows the window, building it on the first call. + /// + /// First call: creates a 900x600 resizable, draggable window with a close button, builds the controls + /// () and subscribes . + /// Later calls: reloads the preset list from disk and selects GlobalSettings.Current.LastPresetName + /// (or the first preset). + /// + /// Then writes into the controls, shows the window and brings it to the front. + /// The close button hides the window (closing open color pickers and disarming the delete button first). + /// public static void CreateWindow() { if (_configWindow == null) @@ -127,6 +207,24 @@ internal static class RenderConfigWindow _configWindow.BringToFront(); } + // =================================================================================================== + // UI construction + // =================================================================================================== + + /// + /// Builds every control of the window inside a vertical scroll view (10 units between items, 8 units padding). + /// + /// The initial preset is GlobalSettings.Current.LastPresetName if it exists, otherwise the first preset whose + /// name starts with "QHD", otherwise the first preset. Its values are copied into only when + /// RenderFPS is 0 or less at that point (see the comment at that check). Each control is created with the config + /// value and a change callback that writes back into the config. + /// + /// + /// Sliders use a sliderFilter that snaps dragged values to the nearest entry of a snap list (common frame + /// rates, resolutions, bitrates); values typed into the slider's input box are not snapped. + /// + /// + /// Content rect of the window. private static void BuildUI(RectTransform content) { var ctx = RendererUIManager.Context; @@ -136,6 +234,10 @@ internal static class RenderConfigWindow var config = UIConfig; EnsureDefaultConfig(config); + // Snap lists for slider dragging (Snap picks the entry nearest to the dragged value; order does not matter). + // Render FPS: standard video rates, then multiples of 120 and of 288 up to the 3000 maximum. + // Video FPS: standard video rates, then multiples of 60 and of 72 up to the 600 maximum. + // Audio bitrate: kbps values. Width/height: common resolutions grouped by aspect ratio. double[] renderFpsSnap = { 23.976, 24, 29.97, 30, 59.94, 60, 144, 120, 240, 360, 480, 600, 720, // +120 288, 576, 864, 1152, 1440, 1728, // +288 @@ -161,6 +263,9 @@ internal static class RenderConfigWindow 864, 960, 1536, 2048, 3072 // 4:3 }; + // --------------------------------------------------------------------------------------------- + // Section 1: Presets card - preset dropdown, then a row with name input, Save and Delete buttons + // --------------------------------------------------------------------------------------------- // Presets (Collapsible Card) var (_, presetContent) = CreateCard( ctx, scrollContent, @@ -169,6 +274,8 @@ internal static class RenderConfigWindow var presets = PresetManager.GetPresets(); var presetNames = presets.Select(p => p.Name).ToList(); + // Initial preset: last used preset if it still exists, else the first "QHD..." preset, else the first preset, + // else "" (no presets on disk) var initialPresetName = GlobalSettings.Current.LastPresetName; if (string.IsNullOrEmpty(initialPresetName) || !presetNames.Contains(initialPresetName)) { @@ -177,6 +284,10 @@ internal static class RenderConfigWindow } _lastLoadedPresetName = initialPresetName; + // Copies the initial preset into a config that has not been filled yet (RenderFPS <= 0). + // EnsureDefaultConfig at the start of BuildUI sets RenderFPS to 60 when it is 0 or less, so on the first build + // this condition is false and the config keeps the defaults (EnsureDefaultConfig values and RenderConfig + // initializers). UpdatePresetLabel then shows the Custom label unless those defaults equal the preset. if (config.RenderFPS <= 0) { var initialPreset = presets.FirstOrDefault(p => string.Equals(p.Name, initialPresetName, StringComparison.OrdinalIgnoreCase)) ?? presets.FirstOrDefault(); @@ -187,6 +298,7 @@ internal static class RenderConfigWindow } EnsureDefaultConfig(config); + // Selecting a preset loads it into the config and all controls (OnPresetSelected) _presetDropdown = CreateDropdownField( ctx, presetContent, "ui.render_config.preset_select", @@ -199,6 +311,8 @@ internal static class RenderConfigWindow } ); + // Preset actions row (40 units high): name input takes the remaining width, Save and Delete are 90 units wide, + // 8 units apart var actionsRowObj = new GameObject("PresetActionsRow"); actionsRowObj.transform.SetParent(presetContent, false); var actionsRowRect = actionsRowObj.AddComponent(); @@ -248,6 +362,8 @@ internal static class RenderConfigWindow saveLE.flexibleWidth = 0f; saveLE.preferredHeight = 40f; + // The Delete button is built with a copy of the context whose button color is the theme's SoftRed + // (hover: SoftRed at 80% brightness, alpha also scaled by the Color multiplication) var redTheme = ctx.Theme with { ObjectButton = ctx.Theme.SoftRed, @@ -267,6 +383,7 @@ internal static class RenderConfigWindow onClick: () => OnDeletePresetClicked(config), height: 40f ); + // Delete label follows both the language and the armed state ("Delete" / "Confirm?") if (_deletePresetButton.Label != null) { LocalizedText.Bind(_deletePresetButton.Label, () => @@ -281,9 +398,17 @@ internal static class RenderConfigWindow UpdateDeleteButtonState(); - // 1-3. Top section: 좌/우 컨테이너 1개씩으로 3행을 쌓는다 (중앙선 1개) + // --------------------------------------------------------------------------------------------- + // Section 2: Top section (no card) - two columns, three rows: + // Render FPS | Video FPS + // Render Width | Render Height + // Delay After End| Show Judgement Text + // One left and one right column container hold all three rows, so the column boundary is the same on every row. + // --------------------------------------------------------------------------------------------- var (topLeft, topRight) = CreateTwoColumnPanel(scrollContent, spacing: 8f, columnSpacing: 10f); + // Render FPS (RenderConfig.RenderFPS): game frames simulated per second of render time (the game clock advances + // 1 / RenderFPS seconds per frame), 0.01..3000, 3 decimals _renderFpsSlider = CreateSlider( ctx: ctx, parent: CreateFieldSlot(topLeft), labelKey: "ui.render_config.render_fps", id: "slider_render_fps", @@ -293,6 +418,7 @@ internal static class RenderConfigWindow onChanged: val => { config.RenderFPS = val; MarkConfigModified(); } ); + // Video FPS (RenderConfig.VideoFPS): frame rate of the output video, 0.01..600, 3 decimals _videoFPSSlider = CreateSlider( ctx: ctx, parent: CreateFieldSlot(topRight), labelKey: "ui.render_config.video_fps", id: "slider_video_fps", @@ -302,6 +428,8 @@ internal static class RenderConfigWindow onChanged: val => { config.VideoFPS = val; MarkConfigModified(); } ); + // Render width/height in pixels (RenderConfig.RenderWidth/RenderHeight): slider range 0..15360 / 0..8640, + // typed values are raised to at least 2 (inputFilter). Odd values are rejected later by TryGoRender. _renderWidthSlider = CreateSlider( ctx: ctx, parent: CreateFieldSlot(topLeft), labelKey: "ui.render_config.render_width", id: "slider_render_width", @@ -320,6 +448,8 @@ internal static class RenderConfigWindow onChanged: val => { config.RenderHeight = (int)Math.Round(val); MarkConfigModified(); } ); + // Delay After End (RenderConfig.DelayAfterEnd): seconds of render time recorded after the end condition is met, + // 0..9999.999 _delayAfterEndSlider = CreateSlider( ctx: ctx, parent: CreateFieldSlot(topLeft), labelKey: "ui.render_config.delay_after_end", id: "slider_delay_after_end", @@ -328,6 +458,7 @@ internal static class RenderConfigWindow onChanged: val => { config.DelayAfterEnd = val; MarkConfigModified(); } ); + // Show Judgement Text (RenderConfig.ShowJudgementText): keeps the hit judgement texts visible during the render _showJudgementToggle = CreateToggleField( ctx, CreateFieldSlot(topRight), labelKey: "", @@ -339,6 +470,14 @@ internal static class RenderConfigWindow generateLabel: false ); + // --------------------------------------------------------------------------------------------- + // Section 3: Video Encoding card (RenderConfig.VideoConfig) + // Codec | Rate Control Mode + // Bitrate | Max Bitrate + // QP | Force Software Encoder + // Keyframe Interval (full width) + // Bitrate, Max Bitrate and QP are enabled or blocked by the rate control mode (UpdateRateControlUI). + // --------------------------------------------------------------------------------------------- // 4. Video Encoding Config (Collapsible Card) var (_, videoContent) = CreateCard( ctx, scrollContent, @@ -346,6 +485,8 @@ internal static class RenderConfigWindow ); var (videoLeft, videoRight) = CreateTwoColumnPanel(videoContent); + // Every Codec enum value is offered. Changing the codec also replaces the sample rate choices with those of the + // codec's audio codec (UpdateVideoCodecUI). var codecs = (Codec[])Enum.GetValues(typeof(Codec)); _codecDropdown = CreateDropdownField( ctx, CreateFieldSlot(videoLeft), @@ -360,6 +501,8 @@ internal static class RenderConfigWindow MarkConfigModified(); }); + // Bitrate / Max Bitrate in kbps (label text) and QP: integer inputs. A value that does not parse, or is out of + // range (bitrate <= 0, QP < 0), leaves the config value unchanged; the input box keeps showing the typed text. _bitrateInput = CreateInputField( ctx, CreateFieldSlot(videoLeft), "ui.render_config.bitrate", @@ -413,6 +556,7 @@ internal static class RenderConfigWindow generateLabel: true ); + // Keyframe interval in seconds (VideoConfig.KeyframeInterval), 0.1..10; full card width _keyframeIntervalSlider = CreateSlider( ctx: ctx, parent: videoContent, labelKey: "ui.render_config.keyframe_interval", id: "slider_keyframe_interval", @@ -421,6 +565,11 @@ internal static class RenderConfigWindow onChanged: val => { config.VideoConfig.KeyframeInterval = val; MarkConfigModified(); } ); + // --------------------------------------------------------------------------------------------- + // Section 4: Audio Encoding card (RenderConfig.AudioConfig) + // Render Audio | Audio Bitrate (kbps) + // Channels | Sample Rate + // --------------------------------------------------------------------------------------------- // 5. Audio Encoding Config (Collapsible Card) var (_, audioContent) = CreateCard( ctx, scrollContent, @@ -439,6 +588,7 @@ internal static class RenderConfigWindow generateLabel: false ); + // Audio bitrate: the slider shows kbps (64..384), the config stores bits per second (kbps * 1000) _audioBitrateSlider = CreateSlider( ctx: ctx, parent: CreateFieldSlot(audioRight), labelKey: "ui.render_config.audio_bitrate", id: "slider_audio_bitrate", @@ -448,6 +598,7 @@ internal static class RenderConfigWindow onChanged: val => { config.AudioConfig.AudioBitrate = (int)Math.Round(val) * 1000; MarkConfigModified(); } ); + // Output channel count: 1 (mono) or 2 (stereo) var channels = new[] { 1, 2 }; _channelsDropdown = CreateDropdownField( ctx, CreateFieldSlot(audioLeft), @@ -458,6 +609,8 @@ internal static class RenderConfigWindow c => { config.AudioConfig.AudioChannels = c; MarkConfigModified(); } ); + // Sample rate in Hz, shown as kHz. Choices: the rates supported by the audio codec paired with the selected video + // codec (CodecUtil.AudioCodecMatch -> CodecUtil.AvailableSampleRate). _sampleRateDropdown = CreateDropdownField( ctx, CreateFieldSlot(audioRight), "ui.render_config.sample_rate", @@ -467,12 +620,20 @@ internal static class RenderConfigWindow r => { config.AudioConfig.AudioSampleRate = r; MarkConfigModified(); } ); + // --------------------------------------------------------------------------------------------- + // Section 5: Global Settings card - output directory (GlobalSettings, shared by all presets) + // Output Directory (full width) + // Browse... | Reset to Default + // --------------------------------------------------------------------------------------------- // 6. Global Settings (Collapsible Card) var (_, globalContent) = CreateCard( ctx, scrollContent, "ui.render_config.global_settings" ); + // Typing updates GlobalSettings.Current.OutputDirectory in memory only (saved by Go Render, Browse or Reset). + // Empty text or the default folder (case-insensitive) stores null, meaning "use the default folder". + // The input shows EffectiveOutputDirectory: the stored folder if it exists, else the default. _outputDirInput = CreateFullWidthInputField( ctx, globalContent, "ui.render_config.output_dir", @@ -495,6 +656,9 @@ internal static class RenderConfigWindow ); var (globalLeft, globalRight) = CreateTwoColumnPanel(globalContent); + // Browse: opens the OS folder picker (FolderDialogUtil) at the current folder; a selected folder is written to + // the input and saved to the global settings immediately. FolderDialogUtil runs the callback through the + // caller's SynchronizationContext, i.e. on the Unity main thread; cancel or failure passes null (ignored here). CreateButton( ctx, globalLeft, "ui.render_config.browse", @@ -517,6 +681,7 @@ internal static class RenderConfigWindow } ); + // Reset to Default: shows the default folder and saves null (default) to the global settings immediately CreateButton( ctx, globalRight, "ui.render_config.reset_default", @@ -529,6 +694,12 @@ internal static class RenderConfigWindow } ); + // --------------------------------------------------------------------------------------------- + // Section 6: Advanced Settings card (RenderConfig.AdvancedConfig) + // File Extension | Random Seed + // Process Audio | Non-Tile Mode + // "Planet Colors" label, then three columns: Fire | Ice | Wind (toggle + color picker each) + // --------------------------------------------------------------------------------------------- // 7. Advanced Settings (Collapsible Card) var (_, advContent) = CreateCard( ctx, scrollContent, @@ -536,6 +707,7 @@ internal static class RenderConfigWindow ); var (advLeft, advRight) = CreateTwoColumnPanel(advContent); + // Output container, chosen by file extension (AdvancedConfig.FileExtension, without the dot) var extensions = new[] { "mkv", "mp4", "mov", "webm", "avi" }; _fileExtensionDropdown = CreateDropdownField( ctx, CreateFieldSlot(advLeft), @@ -550,6 +722,8 @@ internal static class RenderConfigWindow } ); + // Process Audio (AdvancedConfig.ProcessAudio): answers the game's AudioSource.GetSpectrumData queries from the + // offline song audio at the render time (AudioSpectrumGenerator), so audio-reactive visuals follow the rendered audio _processAudioToggle = CreateToggleField( ctx, CreateFieldSlot(advLeft), labelKey: "", @@ -565,6 +739,8 @@ internal static class RenderConfigWindow generateLabel: false ); + // Random Seed (AdvancedConfig.RandomSeed): integer passed to UnityEngine.Random.InitState before the game scene + // loads; empty text stores null (no seeding). Text that is neither empty nor an integer keeps the previous value. _randomSeedInput = CreateInputField( ctx, CreateFieldSlot(advRight), "ui.render_config.random_seed", @@ -579,6 +755,8 @@ internal static class RenderConfigWindow } ); + // Non-Tile Mode (AdvancedConfig.NonTile): hides the floor tiles (including hold meshes and the end portal) and the + // planets during the render, leaving the background and decorations _nonTileToggle = CreateToggleField( ctx, CreateFieldSlot(advRight), labelKey: "", @@ -594,7 +772,9 @@ internal static class RenderConfigWindow generateLabel: false ); - // 7-1. Planet Color Settings (Optional) + // 7-1. Planet Color Settings (Optional). + // For each planet: the toggle turns the override on/off (off stores null and blocks/collapses the picker); + // the picker writes its color into the config only while the toggle is on. var planetColorsLabel = CreateLabel(ctx, advContent, "ui.render_config.planet_colors"); // ReSharper disable once InconsistentNaming var planetLabelLE = planetColorsLabel.gameObject.AddComponent(); @@ -635,6 +815,7 @@ internal static class RenderConfigWindow } } ); + // No override stored: start with the picker blocked (second argument: no animation) if (!config.AdvancedConfig.firePlanetColor.HasValue) { _firePlanetPicker.SetBlocked(true, true); @@ -712,6 +893,9 @@ internal static class RenderConfigWindow _windPlanetPicker.SetBlocked(true, true); } + // --------------------------------------------------------------------------------------------- + // Section 7: Go Render button (48 units high) and version label + // --------------------------------------------------------------------------------------------- // 8. Go Render Button CreateButton( ctx, scrollContent, @@ -721,7 +905,8 @@ internal static class RenderConfigWindow height: 48f ); - // 9. Version Label + // 9. Version Label: 12 pt dim centered text, "ADOFAI Renderer v" plus "(Expired)" after + // RenderMod.ExpirationTime (GetVersionText) var versionLabel = O5Factory.ControlText(ctx, scrollContent, 12f, noPad: true); LocalizedText.Bind(versionLabel, GetVersionText); versionLabel.alignment = TMPro.TextAlignmentOptions.Center; @@ -734,10 +919,19 @@ internal static class RenderConfigWindow return; + // Returns the entry of array closest to value (first one on ties in array order) double Snap(double[] array, double value) => array.OrderBy(n => Math.Abs(n - value)).First(); } + // =================================================================================================== + // Config <-> UI synchronization and render start + // =================================================================================================== + + /// + /// Replaces unset or invalid top-level values with defaults: 2560x1440 when width/height is 0 or less, 60 FPS when + /// RenderFPS/VideoFPS is 0 or less, 15 seconds when DelayAfterEnd is negative. Other values are not checked. + /// private static void EnsureDefaultConfig(RenderConfig config) { if (config.RenderWidth <= 0) config.RenderWidth = 2560; @@ -747,6 +941,11 @@ internal static class RenderConfigWindow if (config.DelayAfterEnd < 0) config.DelayAfterEnd = 15.0; } + /// + /// Writes every value of (after ) into its control without + /// invoking the change callbacks, refreshes the dependent fields (sample rates, rate control fields) and updates the + /// preset dropdown label. The output directory input is set from GlobalSettings.EffectiveOutputDirectory. + /// private static void ApplyConfigToUI(RenderConfig config) { EnsureDefaultConfig(config); @@ -772,6 +971,7 @@ internal static class RenderConfigWindow _renderAudioToggle?.Set(config.AudioConfig.RenderAudio, false); _sampleRateDropdown?.Set(config.AudioConfig.AudioSampleRate, false); _channelsDropdown?.Set(config.AudioConfig.AudioChannels, false); + // Config stores bits per second, the slider shows kbps _audioBitrateSlider?.Set(config.AudioConfig.AudioBitrate / 1000.0, false, noFilter: true); _outputDirInput?.Set(GlobalSettings.EffectiveOutputDirectory, false); @@ -781,6 +981,8 @@ internal static class RenderConfigWindow _processAudioToggle?.Set(config.AdvancedConfig.ProcessAudio, false); _nonTileToggle?.Set(config.AdvancedConfig.NonTile, false); + // Planet colors: toggle on when an override is stored; the picker shows the stored color or the default and is + // blocked without an override var fireColor = config.AdvancedConfig.firePlanetColor; _firePlanetToggle?.Set(fireColor.HasValue, false); _firePlanetPicker?.Set(fireColor ?? DefaultFirePlanetColor, false); @@ -796,6 +998,9 @@ internal static class RenderConfigWindow _windPlanetPicker?.Set(windColor ?? DefaultWindPlanetColor, false); _windPlanetPicker?.SetBlocked(!windColor.HasValue, true); + // The sample rate list depends on the codec. UpdateVideoCodecUI writes the dropdown's resulting value back into + // the config: O5Dropdown.SetValues selects the first listed rate when the current one is not in the new list, + // so a rate the codec does not support becomes the codec's first rate. UpdateVideoCodecUI(config.VideoConfig.Codec, config); UpdateRateControlUI(config.VideoConfig.RateControlMode, noAnimate: true); @@ -841,6 +1046,15 @@ internal static class RenderConfigWindow return JsonConvert.SerializeObject(a, settings) == JsonConvert.SerializeObject(b, settings); } + /// + /// Go Render button handler: reads the controls into , validates, saves the global settings, + /// hides the window and starts a for RenderManager.Instance.levelPath. + /// + /// Validation: width and height must be even, and a non-default output directory must be a valid path; it is + /// converted to a full path and created if missing. On a validation failure an error dialog is shown, the error is + /// logged and the window stays open. The session receives a copy of the config (RenderSession.Start). + /// + /// private static void TryGoRender() { var config = UIConfig; @@ -854,7 +1068,8 @@ internal static class RenderConfigWindow return; } - // Sync Output Directory + // Sync Output Directory: empty or default folder -> null (default); otherwise the full path, created if needed. + // The error message here is fixed English text (the locale key "ui.render_config.invalid_path" is not used). if (_outputDirInput != null) { var path = _outputDirInput.Value.Trim(); @@ -888,6 +1103,12 @@ internal static class RenderConfigWindow new RenderSession().Start(config, RenderManager.Instance.levelPath); } + /// + /// Reads every control into , applying the same parsing rules as the change callbacks + /// (unparsable or out-of-range numbers keep the previous value; empty seed = null; planet color = picker value only + /// while its toggle is on). Render and video FPS values near 23.976/23.98, 29.97 and 59.94 are replaced by the exact + /// NTSC fractions 24000/1001, 30000/1001 and 60000/1001. The output directory is not handled here. + /// private static void SyncUItoConfig(RenderConfig target) { if (_renderFpsSlider != null) target.RenderFPS = _renderFpsSlider.Value; @@ -954,6 +1175,8 @@ internal static class RenderConfigWindow return; + // Maps rounded NTSC rates to their exact values (N * 1000 / 1001). A value within 0.001 of 29.97, 59.94, + // 23.976 or 23.98 is replaced; any other value is returned unchanged. // ReSharper disable once InconsistentNaming double AlignNTSCFramerate(double framerate) { @@ -967,6 +1190,10 @@ internal static class RenderConfigWindow } } + // =================================================================================================== + // Preset handling: Custom label, select, save, delete, dropdown reload, language change + // =================================================================================================== + /// /// Whether the text is the Custom label of any language. A preset with such a name could not be told apart /// from the Custom entry of the dropdown after a language change. @@ -974,8 +1201,17 @@ internal static class RenderConfigWindow private static bool IsCustomPresetLabel(string text) => text == CustomPresetLabel || text == "(Modified)" || text == "(수정됨)"; + /// + /// Dropdown entry shown while the values differ from the last loaded preset, in the current language + /// ("ui.render_config.preset_custom": "(Modified)" in en.json, "(수정됨)" in ko.json). + /// private static string CustomPresetLabel => LocalizationManager.Get("ui.render_config.preset_custom"); + /// + /// Shows the Custom label in the preset dropdown: inserts it at index 0 if missing and selects it (without invoking + /// the dropdown callback). Called by every value change callback. Does nothing before the dropdown exists or when the + /// Custom label is already selected. + /// private static void MarkConfigModified() { if (_presetDropdown == null) return; @@ -994,6 +1230,11 @@ internal static class RenderConfigWindow UpdateDeleteButtonState(); } + /// + /// Preset dropdown callback: loads the selected preset into and all controls, remembers it + /// as the last preset (saved to the global settings), puts its name in the name input and removes the Custom label. + /// Selecting the Custom label, or a name that is no longer on disk, only disarms the delete button. + /// private static void OnPresetSelected(string? selectedName, RenderConfig config) { ResetDeletePendingState(); @@ -1020,6 +1261,16 @@ internal static class RenderConfigWindow UpdateDeleteButtonState(); } + /// + /// Save button handler: reads the controls into and saves it under the trimmed name from + /// the name input. + /// + /// Empty name, or a name equal to a Custom label: a validation dialog is shown, nothing is saved. + /// The name differs from the last loaded preset and an existing preset has the same name or the same file name: + /// an overwrite confirmation dialog is shown; Confirm saves. + /// Otherwise the preset is saved directly (re-saving the loaded preset needs no confirmation). + /// + /// private static void OnSavePresetClicked(RenderConfig config) { SyncUItoConfig(config); @@ -1064,6 +1315,10 @@ internal static class RenderConfigWindow } } + /// + /// Writes the preset file (PresetManager.SavePreset), stores the name as the last preset in the global + /// settings, and reloads the dropdown with that preset selected (which also removes the Custom label). + /// private static void DoSavePreset(string name, RenderConfig config) { PresetManager.SavePreset(name, config); @@ -1073,6 +1328,12 @@ internal static class RenderConfigWindow ReloadPresetDropdown(selectName: name); } + /// + /// Delete button handler with a two-click confirmation. + /// The target is the selected preset, or the last loaded preset while the Custom label is selected. + /// First click: arms the button (label "Confirm?", width 110 units, color (0.95, 0.25, 0.25)) and starts a + /// 3-second coroutine that disarms it. Second click while armed: disarms and deletes the preset. + /// private static void OnDeletePresetClicked(RenderConfig config) { var targetPresetName = _presetDropdown?.Value; @@ -1111,12 +1372,20 @@ internal static class RenderConfigWindow DoDeletePreset(targetPresetName, config); } + /// + /// Disarms the delete button after 3 seconds. Uses , i.e. scaled game time, run on + /// RendererUIManager. + /// private static System.Collections.IEnumerator ResetDeletePendingAfterDelay() { yield return new WaitForSeconds(3f); ResetDeletePendingState(); } + /// + /// Disarms the delete button: stops the disarm coroutine, and if the button is armed (or still shows a label other + /// than "Delete") restores the "Delete" label, the 90-unit width and the SoftRed color. + /// private static void ResetDeletePendingState() { if (_deleteResetCoroutine != null) @@ -1143,6 +1412,11 @@ internal static class RenderConfigWindow } } + /// + /// Deletes the preset file, then selects the first remaining preset (or none): stores it as the last preset, + /// reloads the dropdown, and loads its values into , the controls and the name input. + /// With no presets left the config and controls keep their values. + /// private static void DoDeletePreset(string name, RenderConfig config) { PresetManager.DeletePreset(name); @@ -1163,6 +1437,11 @@ internal static class RenderConfigWindow } } + /// + /// Rebuilds the dropdown from the presets on disk and selects (or the first preset + /// when it is null or missing), recording the selection as . With no presets the + /// dropdown holds only the Custom label and the last preset is cleared. Does not load preset values into the config. + /// private static void ReloadPresetDropdown(string? selectName = null) { ResetDeletePendingState(); @@ -1190,6 +1469,12 @@ internal static class RenderConfigWindow UpdateDeleteButtonState(); } + /// + /// Enables the delete button only when the target preset exists on disk. The target is + /// , else the dropdown value, and the last loaded preset while the Custom label is + /// selected. + /// + /// Explicit target; every current caller passes none. private static void UpdateDeleteButtonState(string? presetName = null) { var targetName = presetName ?? _presetDropdown?.Value; @@ -1205,6 +1490,10 @@ internal static class RenderConfigWindow _deletePresetButton?.SetBlocked(!canDelete); } + /// + /// handler: rebuilds the preset dropdown so that the Custom label + /// appears in the new language. All other texts follow the language through their bindings. + /// private static void OnLanguageChanged(SystemLanguage language) { if (_configWindow == null || _presetDropdown == null) return; @@ -1234,6 +1523,10 @@ internal static class RenderConfigWindow UpdateDeleteButtonState(); } + /// + /// Version label text: "ADOFAI Renderer v" + the MelonInfo version, followed by the localized "(Expired)" once the + /// current UTC time reaches RenderMod.ExpirationTime. + /// private static string GetVersionText() { var versionText = $"ADOFAI Renderer v{Melon.Instance.Info.Version}"; @@ -1242,6 +1535,10 @@ internal static class RenderConfigWindow return versionText; } + /// + /// Logs as an error and shows it in a dialog titled "Invalid Settings" (localized) with a + /// single "OK" button (fixed English text). + /// private static void ShowValidationError(string message) { Melon.Logger.Error(message); @@ -1252,12 +1549,29 @@ internal static class RenderConfigWindow ); } - // 카드(또는 상단 섹션)당 하나의 다열 컨테이너를 만들어 - // 가로 한 줄 각 필드 사이 중앙이 행마다 어긋나지 않도록 한다. - // - 패널 1개 = 균등 분할 (행마다 HLG를 만들던 기존 방식의 어긋남 방지) - // - 각 컬럼은 preferredWidth=0 + flexibleWidth=1 로 항상 1:1:...:1 균등 분할 - // - 각 필드는 Field 슬롯(label 18 + 간격 3 + 컨트롤 50)으로 높이를 고정해 - // 좌/우 스택을 써도 행 단위 Y 정렬이 유지되도록 한다. + // =================================================================================================== + // Layout helpers: column panels, field slots, cards + // =================================================================================================== + + /// + /// Creates one multi-column container for a card (or the top section) and returns its columns. Fields are stacked + /// vertically inside each column, so one panel holds all rows of a card. + /// + /// The panel is a that stretches to the parent width; the columns are + /// units apart. + /// Every column has preferredWidth 0 and flexibleWidth 1 (see ), so the width is + /// always split equally (1:1:...:1) regardless of the label or value text lengths, and the column boundaries are at + /// the same x position on every row. + /// Rows line up across columns when the fields in the same row have the same height: put each field in a + /// (label 18 + spacing 3 + control; dropdown controls are 50 units high). + /// + /// + /// Card content or scroll content. + /// Number of columns, at least 1. + /// Horizontal gap between columns, in units. + /// Vertical gap between fields inside a column, in units. + /// The column rects, left to right. + /// is less than 1. private static RectTransform[] CreateMultiColumnPanel( Transform parent, int columnCount, float spacing = 8f, float columnSpacing = 8f) { @@ -1290,6 +1604,7 @@ internal static class RenderConfigWindow return columns; } + /// Two-column form of ; returns the left and right column. private static (RectTransform left, RectTransform right) CreateTwoColumnPanel( Transform parent, float spacing = 8f, float columnSpacing = 8f) { @@ -1297,6 +1612,10 @@ internal static class RenderConfigWindow return (cols[0], cols[1]); } + /// + /// Creates one column: a (children stretched to the column width, + /// units apart) with a layout element of min/preferred width 0 and flexible width 1. + /// private static RectTransform CreateColumn(Transform parent, string name, float spacing) { var colObj = new GameObject(name); @@ -1312,9 +1631,9 @@ internal static class RenderConfigWindow colLayout.childForceExpandHeight = false; // ReSharper disable once InconsistentNaming - // 핵심: 좌/우 선호 너비를 0으로 강제해 항상 정확히 절반씩 나누도록 한다. - // (기존 행마다 HLG 방식은 라벨/값 텍스트 길이에 따라 preferred가 달라져 - // 중앙이 (W + P1 - P2) / 2 로 행마다 틀어졌음) + // Preferred width 0 with flexible width 1: the HorizontalLayoutGroup distributes the whole width through the + // flexible weights, so every column gets an exact equal share. With non-zero preferred widths (which depend on + // the label and value text lengths) the split would differ from row to row. var colLE = colObj.AddComponent(); colLE.minWidth = 0f; colLE.preferredWidth = 0f; @@ -1323,6 +1642,10 @@ internal static class RenderConfigWindow return colRect; } + /// + /// Creates a field slot ("Field") in a column: a vertical stack with 3 units between its children (typically a label + /// and a control), stretched to the column width. The slot's height is the sum of its children's heights. + /// private static RectTransform CreateFieldSlot(Transform column) { var slotObj = new GameObject("Field"); @@ -1344,6 +1667,12 @@ internal static class RenderConfigWindow return slotRect; } + /// + /// Creates a collapsible O5Kit card (O5Factory.Card) with a localized title. The title text + /// (child "Header/TitleTrigger/TitleText", or any descendant named "TitleText") is set to 18 pt and bound to + /// so it follows language changes. The remaining parameters are passed to O5Kit unchanged. + /// + /// The card root and the content rect that receives the card's fields. // ReSharper disable once UnusedTupleComponentInReturnValue private static (RectTransform card, RectTransform content) CreateCard( O5Context ctx, @@ -1377,6 +1706,13 @@ internal static class RenderConfigWindow return (card, content); } + // =================================================================================================== + // Control factory helpers (each binds its texts to localization keys through LocalizedText) + // =================================================================================================== + + /// + /// Creates a localized text label (default 13 pt, theme dim text color unless is given). + /// private static TMPro.TextMeshProUGUI CreateLabel( O5Context ctx, Transform parent, @@ -1392,6 +1728,24 @@ internal static class RenderConfigWindow return label; } + /// + /// Creates an O5Kit slider with a localized label (the slider's own label; no separate label row). + /// + /// O5Kit context. + /// Field slot or column that receives the slider. + /// Localization key of the slider label. + /// O5Kit control id. + /// Value restored by middle click. + /// Minimum value. + /// Maximum value. + /// Initial value. + /// Numeric display format, e.g. "F0", "F3". + /// How values outside min..max are clamped (O5Kit ). + /// Applied to dragged values (used for snapping); typed values are not filtered by it. + /// Applied to values typed into the slider's input box. + /// Called with the new value on every change. + /// Called when an edit is completed. + /// Whether the filled part of the track is drawn. private static O5Slider CreateSlider( O5Context ctx, Transform parent, @@ -1435,6 +1789,9 @@ internal static class RenderConfigWindow return slider; } + /// + /// Creates an O5Kit button with a localized label. null uses the theme control height. + /// private static O5Button CreateButton( O5Context ctx, Transform parent, @@ -1460,6 +1817,7 @@ internal static class RenderConfigWindow return button; } + /// Same as the CreateInputField overload with an out label parameter, without returning the label. private static O5InputField CreateInputField( O5Context ctx, Transform colParent, @@ -1473,6 +1831,20 @@ internal static class RenderConfigWindow return CreateInputField(ctx, colParent, labelKey, defaultValue, value, id, out _, characterValidation, onChanged); } + /// + /// Creates a labeled text input in (normally a field slot): an 18-unit label row and an + /// O5Kit input below it. The label and the placeholder are both bound to . + /// + /// O5Kit context. + /// Field slot or column that receives the label and the input. + /// Localization key of the label and the placeholder. + /// Value restored by middle click; null for none. + /// Initial text. + /// O5Kit control id. + /// Receives the label text component (used to dim the label of a blocked field). + /// TextMeshPro character filter for typed text, e.g. Integer. + /// Called with the raw text on every change; parsing and validation are up to the caller. + /// The input field. private static O5InputField CreateInputField( O5Context ctx, Transform colParent, @@ -1484,6 +1856,8 @@ internal static class RenderConfigWindow TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None, Action? onChanged = null) { + // Labeled text input: an 18-unit label row (13 pt, dim) above an O5Kit input. The label key also serves as the + // placeholder. characterValidation restricts typed characters (e.g. Integer). onChanged receives the raw text. label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); label.text = LocalizationManager.Get(labelKey); label.color = ctx.Theme.TextDim; @@ -1514,6 +1888,20 @@ internal static class RenderConfigWindow return inputField; } + // =================================================================================================== + // Dependent field rules (rate control, codec) + // =================================================================================================== + + /// + /// Blocks or unblocks an input and dims its label: a blocked label uses the theme dim text color at 40% of its + /// alpha. The label color changes with a 0.2-second tween (any running tween for that label is killed first), or + /// immediately with . + /// + /// Input to block; may be null. + /// Label above the input; may be null. + /// Stored tween handle of this label, replaced by the new tween. + /// True to block (disable) the field. + /// True to apply the change without animation. private static void SetFieldBlocked( O5InputField? input, TMPro.TextMeshProUGUI? label, @@ -1542,6 +1930,10 @@ internal static class RenderConfigWindow } } + /// + /// Enables only the video fields used by the rate control mode: + /// CBR - Bitrate; VBR - Bitrate and Max Bitrate; CQP - QP. Values of blocked fields stay in the config. + /// private static void UpdateRateControlUI(RateControlMode mode, bool noAnimate = false) { bool blockBitrate = mode is RateControlMode.CQP; @@ -1553,6 +1945,11 @@ internal static class RenderConfigWindow SetFieldBlocked(_qpInput, _qpLabel, ref _qpLabelTween, blockQp, noAnimate); } + /// + /// Replaces the sample rate choices with those supported by the audio codec paired with . + /// O5Dropdown.SetValues keeps the current rate when it is in the new list and otherwise selects the first entry; + /// the resulting rate is written to when one is given. + /// private static void UpdateVideoCodecUI(Codec codec, RenderConfig? config = null) { if (_sampleRateDropdown == null) return; @@ -1565,6 +1962,14 @@ internal static class RenderConfigWindow } } + // =================================================================================================== + // More control factory helpers + // =================================================================================================== + + /// + /// Creates a labeled text input that spans the full width of : its own vertical container + /// with an 18-unit label row, 3 units of spacing and the input. The label key also serves as the placeholder. + /// private static O5InputField CreateFullWidthInputField( O5Context ctx, Transform parent, @@ -1614,6 +2019,11 @@ internal static class RenderConfigWindow return input; } + /// + /// Creates an O5Kit toggle whose text is localized with . With + /// an 18-unit label row is added above it (text from , + /// empty when the key is empty), which keeps the toggle aligned with labeled fields in the neighboring column. + /// private static O5Toggle CreateToggleField( O5Context ctx, Transform colParent, @@ -1656,6 +2066,11 @@ internal static class RenderConfigWindow return toggle; } + /// + /// Creates a labeled dropdown: an 18-unit label row and a container of preferred height 50 units holding the O5Kit + /// dropdown. converts values to their shown text. The change callback is not invoked for + /// a null value. + /// private static O5Dropdown CreateDropdownField( O5Context ctx, Transform colParent, @@ -1697,6 +2112,11 @@ internal static class RenderConfigWindow ); } + /// + /// Creates an O5Kit color picker in a container one control row high (theme control height for the body font). + /// The picker's popup is positioned on the window manager canvas. is used both while + /// editing and on completion. The header is re-proportioned for narrow (three-column) layouts, see below. + /// private static O5ColorPicker CreateColorPickerField( O5Context ctx, Transform colParent, @@ -1728,9 +2148,10 @@ internal static class RenderConfigWindow id: id ); - // 3열 등 다열 레이아웃 환경에 맞춰 O5ColorPicker 헤더 내부 비율 및 폰트 크기 조정 - // O5Kit 기본값(Preview: 0~0.5, HexInput: 0.62~1.0, fontSize: 19)은 너비가 좁을 때 - // Hex 문자열(8자리 RGBA) 앞부분이 잘려 보이는 현상이 발생하므로 공간을 재분배합니다. + // Header proportions and font size for multi-column (e.g. three-column) layouts. + // O5Kit's header layout (anchor x ranges: Preview 0..0.5, ToggleArea 0..0.6, HexInput 0.62..1.0; hex font size 19) + // leaves too little width in a narrow column, and the start of the 8-digit RGBA hex text is cut off. + // Here: Preview 0..0.28, ToggleArea 0..0.30, HexInput 0.32..1.0, hex font size 15. if (picker.Rect.Find("Header") is RectTransform header) { if (header.Find("Preview") is RectTransform preview) @@ -1757,6 +2178,11 @@ internal static class RenderConfigWindow return picker; } + // =================================================================================================== + // Window visibility + // =================================================================================================== + + /// Shows or hides the window; hiding first collapses all open color pickers. private static void SetWindowActive(bool enabled) { if (!enabled) @@ -1766,6 +2192,7 @@ internal static class RenderConfigWindow _configWindow?.Rect.gameObject.SetActive(enabled); } + /// Collapses the three planet color pickers without animation. private static void CloseAllPickers() { _firePlanetPicker?.SetExpanded(false, true);