using System; using System.Collections.Generic; using System.IO; using System.Linq; using ADOFAIRenderer.API; using ADOFAIRenderer.Core; using ADOFAIRenderer.Core.Data; using ADOFAIRenderer.Data; using ADOFAIRenderer.Localization; using ADOFAIRenderer.Util.OS; using MelonLoader; using Newtonsoft.Json; using O5Kit.Control; using O5Kit.Core; using O5Kit.Factory; using UnityEngine; 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 main Unity 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; /// /// Configuration edited by this window. Kept for the whole game session; a render started from the window /// receives a copy of it (RenderSession.Start), so values adjusted during the render are not written back here. /// 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. // 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; private static O5Slider? _renderHeightSlider; private static O5Slider? _delayAfterEndSlider; private static O5Toggle? _showJudgementToggle; 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; private static O5InputField? _maxBitrateInput; private static TMPro.TextMeshProUGUI? _maxBitrateLabel; private static ITweenHandle? _maxBitrateLabelTween; private static O5InputField? _qpInput; private static TMPro.TextMeshProUGUI? _qpLabel; private static ITweenHandle? _qpLabelTween; private static O5Slider? _keyframeIntervalSlider; private static O5Toggle? _forceSoftwareToggle; private static O5Toggle? _renderAudioToggle; private static O5Dropdown? _sampleRateDropdown; 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; private static O5InputField? _randomSeedInput; private static O5Toggle? _processAudioToggle; private static O5Toggle? _nonTileToggle; private static O5Toggle? _highResBloomCorrectionToggle; // === [MARKER: FIX_SET_FRAMERATE_BUG START] === private static O5Toggle? _fixCustomFrameRateToggle; // === [MARKER: FIX_SET_FRAMERATE_BUG END] === // 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; private static O5ColorPicker? _icePlanetPicker; 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) { _configWindow = RendererUIManager.Instance.WindowManager.Create(new O5WindowOptions { Id = "RendererMod_RenderConfig", Title = LocalizationManager.Get("ui.render_config.window_title"), Size = new Vector2(900, 600), Draggable = true, Resizable = true, ShowCloseButton = true }); if (_configWindow.TitleText != null) { LocalizedText.Bind(_configWindow.TitleText, "ui.render_config.window_title"); } _configWindow.CloseRequested += win => { CloseAllPickers(); ResetDeletePendingState(); win.Rect.gameObject.SetActive(false); }; BuildUI(_configWindow.Content); LocalizationManager.OnLanguageChanged += OnLanguageChanged; } else { ReloadPresetDropdown(GlobalSettings.Current.LastPresetName); } var config = UIConfig; ApplyConfigToUI(config); SetWindowActive(true); _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; var (_, scrollContent, _) = O5Factory.ScrollView(ctx, content, spacing: 10f, padding: 8f); 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 3000 // MAX }; double[] videoFpsSnap = { 23.976, 24, 29.97, 30, 59.94, 60, 72, 120, 180, 240, 300, 360, 420, 480, 540, // +60 144, 216, 288, 432, 504, 576, // +72 600 // MAX }; double[] audioBitrateSnap = { 64, 96, 128, 192, 256, 320, 384 }; double[] renderWidthSnap = { 256, 426, 640, 854, 960, 1280, 1366, 1600, 1920, 2048, 2560, 3200, 3840, 4096, 5120, 5760, 6016, 7680, 10240, 15360, // 16:9 230, 384, 576, 768, 1152, 1440, 1680, 2880, 3072, // 16:10 192, 320, 480, 800, 1024, 1400, 2732, 6400, 11520 // 4:3 }; double[] renderHeightSnap = { 144, 240, 360, 480, 540, 720, 768, 900, 1080, 1152, 1440, 1800, 2160, 2304, 2880, 3240, 3384, 4320, 5760, 8640, // 16:9 400, 600, 800, 1050, 1200, 1600, 1920, 2400, 3200, 4800, 9600, // 16:10 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, "ui.render_config.presets" ); 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)) { initialPresetName = presetNames.FirstOrDefault(n => n.StartsWith("QHD", StringComparison.OrdinalIgnoreCase)) ?? (presetNames.Count > 0 ? presetNames[0] : ""); } _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(); if (initialPreset != null) { config.CopyFrom(initialPreset.Config); } } EnsureDefaultConfig(config); // Selecting a preset loads it into the config and all controls (OnPresetSelected) _presetDropdown = CreateDropdownField( ctx, presetContent, "ui.render_config.preset_select", initialPresetName, initialPresetName, presetNames, p => p, "dd_presets", selectedName => { OnPresetSelected(selectedName, config); } ); // 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(); var actionsHLayout = actionsRowObj.AddComponent(); actionsHLayout.spacing = 8f; actionsHLayout.childAlignment = TextAnchor.MiddleCenter; actionsHLayout.childControlWidth = true; actionsHLayout.childControlHeight = true; actionsHLayout.childForceExpandWidth = false; actionsHLayout.childForceExpandHeight = false; // ReSharper disable once InconsistentNaming var actionsRowLE = actionsRowObj.AddComponent(); actionsRowLE.preferredHeight = 40f; actionsRowLE.flexibleWidth = 1f; _presetNameInput = O5Factory.Input( ctx: ctx, parent: actionsRowRect, defaultValue: "", value: initialPresetName, onChanged: null, placeholder: LocalizationManager.Get("ui.render_config.preset_name_placeholder"), icon: null, id: "input_preset_name" ); if (_presetNameInput.Placeholder != null) { LocalizedText.Bind(_presetNameInput.Placeholder, "ui.render_config.preset_name_placeholder"); } // ReSharper disable once InconsistentNaming var inputLE = _presetNameInput.Rect.GetComponent() ?? _presetNameInput.Rect.gameObject.AddComponent(); inputLE.flexibleWidth = 1f; inputLE.preferredHeight = 40f; _savePresetButton = CreateButton( ctx: ctx, parent: actionsRowRect, textKey: "ui.render_config.preset_save", id: "btn_preset_save", onClick: () => OnSavePresetClicked(config), height: 40f ); // ReSharper disable once InconsistentNaming var saveLE = _savePresetButton.Rect.GetComponent() ?? _savePresetButton.Rect.gameObject.AddComponent(); saveLE.preferredWidth = 90f; 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, ButtonHover = ctx.Theme.SoftRed * 0.8f }; var redCtx = new O5Context( config: ctx.Config, theme: redTheme, fonts: ctx.Fonts, tween: ctx.Tween ); _deletePresetButton = CreateButton( ctx: redCtx, parent: actionsRowRect, textKey: "ui.render_config.preset_delete", id: "btn_preset_delete", 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, () => _isDeletePending ? LocalizationManager.Get("ui.render_config.preset_delete_confirm") : LocalizationManager.Get("ui.render_config.preset_delete")); } _deletePresetButtonLE = _deletePresetButton.Rect.GetComponent() ?? _deletePresetButton.Rect.gameObject.AddComponent(); _deletePresetButtonLE.preferredWidth = 90f; _deletePresetButtonLE.flexibleWidth = 0f; _deletePresetButtonLE.preferredHeight = 40f; UpdateDeleteButtonState(); // --------------------------------------------------------------------------------------------- // 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", defaultValue: 60, min: 0.01, max: 3000, value: 60, format: "F3", clampMode: ClampMode.All, sliderFilter: value => Snap(renderFpsSnap, value), 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", defaultValue: 60, min: 0.01, max: 600, value: 60, format: "F3", clampMode: ClampMode.All, sliderFilter: value => Snap(videoFpsSnap, value), 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", defaultValue: 2560, min: 0, max: 15360, value: 2560, format: "F0", clampMode: ClampMode.All, sliderFilter: value => Snap(renderWidthSnap, value), inputFilter: value => Math.Max(value, 2), onChanged: val => { config.RenderWidth = (int)Math.Round(val); MarkConfigModified(); } ); _renderHeightSlider = CreateSlider( ctx: ctx, parent: CreateFieldSlot(topRight), labelKey: "ui.render_config.render_height", id: "slider_render_height", defaultValue: 1440, min: 0, max: 8640, value: 1440, format: "F0", clampMode: ClampMode.All, sliderFilter: value => Snap(renderHeightSnap, value), inputFilter: value => Math.Max(value, 2), 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", defaultValue: 15, min: 0, max: 9999.999, value: 15, format: "F3", clampMode: ClampMode.All, 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: "", defaultValue: false, value: config.ShowJudgementText, toggleTextKey: "ui.render_config.show_judgement", id: "toggle_show_judgement", onChanged: val => { config.ShowJudgementText = val; MarkConfigModified(); }, 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, "ui.render_config.video_encoding" ); 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), "ui.render_config.codec", Codec.H264, config.VideoConfig.Codec, codecs, c => c.ToString(), "dd_video_codec", c => { config.VideoConfig.Codec = c; UpdateVideoCodecUI(c, config); 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", "30000", config.VideoConfig.Bitrate.ToString(), "input_bitrate", out _bitrateLabel, TMPro.TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var b) && b > 0) config.VideoConfig.Bitrate = b; MarkConfigModified(); } ); _qpInput = CreateInputField( ctx, CreateFieldSlot(videoLeft), "ui.render_config.qp", "18", config.VideoConfig.QP.ToString(), "input_qp", out _qpLabel, TMPro.TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var qp) && qp >= 0) config.VideoConfig.QP = qp; MarkConfigModified(); } ); var rateModes = (RateControlMode[])Enum.GetValues(typeof(RateControlMode)); _rateControlDropdown = CreateDropdownField( ctx, CreateFieldSlot(videoRight), "ui.render_config.rate_control", RateControlMode.CBR, config.VideoConfig.RateControlMode, rateModes, m => m.ToString(), "dd_rate_control", m => { config.VideoConfig.RateControlMode = m; UpdateRateControlUI(m); MarkConfigModified(); } ); _maxBitrateInput = CreateInputField( ctx, CreateFieldSlot(videoRight), "ui.render_config.max_bitrate", "50000", config.VideoConfig.MaxBitrate.ToString(), "input_max_bitrate", out _maxBitrateLabel, TMPro.TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var mb) && mb > 0) config.VideoConfig.MaxBitrate = mb; MarkConfigModified(); } ); _forceSoftwareToggle = CreateToggleField( ctx, CreateFieldSlot(videoRight), labelKey: "", defaultValue: false, value: config.VideoConfig.ForceSoftwareEncoder, toggleTextKey: "ui.render_config.force_software", id: "toggle_force_software", onChanged: val => { config.VideoConfig.ForceSoftwareEncoder = val; MarkConfigModified(); }, 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", defaultValue: 2, min: 0.1, max: 10, value: 2, format: "F1", clampMode: ClampMode.All, 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, "ui.render_config.audio_encoding" ); var (audioLeft, audioRight) = CreateTwoColumnPanel(audioContent); _renderAudioToggle = CreateToggleField( ctx, CreateFieldSlot(audioLeft), labelKey: "", defaultValue: true, value: config.AudioConfig.RenderAudio, toggleTextKey: "ui.render_config.render_audio", id: "toggle_render_audio", onChanged: val => { config.AudioConfig.RenderAudio = val; MarkConfigModified(); }, 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", defaultValue: 256, min: 64, max: 384, value: 256, format: "F0", clampMode: ClampMode.All, sliderFilter: value => Snap(audioBitrateSnap, value), 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), "ui.render_config.channels", 2, config.AudioConfig.AudioChannels, channels, c => c == 1 ? "1 (Mono)" : "2 (Stereo)", "dd_channels", 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", 48000, config.AudioConfig.AudioSampleRate, CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[config.VideoConfig.Codec]], r => $"{r/1000.0} kHz", "dd_sample_rate", 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", GlobalSettings.DefaultOutputDirectory, GlobalSettings.EffectiveOutputDirectory, "input_output_dir", val => { var trimmed = val?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(trimmed) || string.Equals(trimmed, GlobalSettings.DefaultOutputDirectory, StringComparison.Ordinal)) { GlobalSettings.Current.OutputDirectory = null; } else { GlobalSettings.Current.OutputDirectory = trimmed; } } ); 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 main Unity thread; cancel or failure passes null (ignored here). CreateButton( ctx, globalLeft, "ui.render_config.browse", "btn_browse_output", () => { var initial = string.IsNullOrWhiteSpace(_outputDirInput.Value) ? GlobalSettings.DefaultOutputDirectory : _outputDirInput.Value; FolderDialogUtil.OpenFolderBrowser(initial, selected => { Melon.Logger.Msg("Output Directory Selected: " + selected); if (!string.IsNullOrWhiteSpace(selected)) { _outputDirInput.Set(selected); GlobalSettings.Current.OutputDirectory = selected; GlobalSettings.Save(); } }); } ); // Reset to Default: shows the default folder and saves null (default) to the global settings immediately CreateButton( ctx, globalRight, "ui.render_config.reset_default", "btn_reset_output", () => { _outputDirInput.Set(GlobalSettings.DefaultOutputDirectory); GlobalSettings.Current.OutputDirectory = null; GlobalSettings.Save(); } ); // --------------------------------------------------------------------------------------------- // Section 6: Advanced Settings card (RenderConfig.AdvancedConfig) // File Extension | Random Seed // Process Audio | Non-Tile Mode // High-Resolution Bloom Correction | // "Planet Colors" label, then three columns: Fire | Ice | Wind (toggle + color picker each) // --------------------------------------------------------------------------------------------- // 7. Advanced Settings (Collapsible Card) var (_, advContent) = CreateCard( ctx, scrollContent, "ui.render_config.advanced_settings" ); 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), "ui.render_config.file_extension", "mkv", config.AdvancedConfig.FileExtension, extensions, e => e, "dd_adv_file_extension", e => { if (!string.IsNullOrEmpty(e)) config.AdvancedConfig.FileExtension = e; MarkConfigModified(); } ); // 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: "", defaultValue: true, value: config.AdvancedConfig.ProcessAudio, toggleTextKey: "ui.render_config.process_audio", id: "toggle_process_audio", onChanged: val => { config.AdvancedConfig.ProcessAudio = val; MarkConfigModified(); }, generateLabel: false ); // High-Resolution Bloom Correction (AdvancedConfig.HighResBloomCorrection): the game's bloom computes its blur // size as if the frame were 720 pixels high (VideoBloomPatch) _highResBloomCorrectionToggle = CreateToggleField( ctx, CreateFieldSlot(advLeft), labelKey: "", defaultValue: false, value: config.AdvancedConfig.HighResBloomCorrection, toggleTextKey: "ui.render_config.high_res_bloom_correction", id: "toggle_high_res_bloom_correction", onChanged: val => { config.AdvancedConfig.HighResBloomCorrection = val; MarkConfigModified(); }, 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", "", config.AdvancedConfig.RandomSeed?.ToString() ?? "", "input_random_seed", TMPro.TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var s)) config.AdvancedConfig.RandomSeed = s; else if (string.IsNullOrWhiteSpace(val)) config.AdvancedConfig.RandomSeed = null; MarkConfigModified(); } ); // 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: "", defaultValue: false, value: config.AdvancedConfig.NonTile, toggleTextKey: "ui.render_config.non_tile", id: "toggle_non_tile", onChanged: val => { config.AdvancedConfig.NonTile = val; MarkConfigModified(); }, generateLabel: false ); // === [MARKER: FIX_SET_FRAMERATE_BUG START] === // Fix SetFramerate Render Timing (AdvancedConfig.FixCustomFrameRate): defers manual camera rendering to frame end _fixCustomFrameRateToggle = CreateToggleField( ctx, CreateFieldSlot(advRight), labelKey: "", defaultValue: true, value: config.AdvancedConfig.FixCustomFrameRate, toggleTextKey: "ui.render_config.fix_custom_frame_rate", id: "toggle_fix_custom_frame_rate", onChanged: val => { config.AdvancedConfig.FixCustomFrameRate = val; MarkConfigModified(); }, generateLabel: false ); // === [MARKER: FIX_SET_FRAMERATE_BUG END] === // 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(); planetLabelLE.preferredHeight = 18f; planetLabelLE.minHeight = 18f; var planetCols = CreateMultiColumnPanel(advContent, 3); var (fireCol, iceCol, windCol) = (planetCols[0], planetCols[1], planetCols[2]); var fireSlot = CreateFieldSlot(fireCol); _firePlanetToggle = CreateToggleField( ctx, fireSlot, labelKey: "", defaultValue: false, value: config.AdvancedConfig.firePlanetColor.HasValue, toggleTextKey: "ui.render_config.fire_planet", id: "toggle_fire_planet", onChanged: val => { _firePlanetPicker?.SetBlocked(!val); config.AdvancedConfig.firePlanetColor = val ? _firePlanetPicker?.Value : null; if (!val) _firePlanetPicker?.SetExpanded(false); MarkConfigModified(); }, generateLabel: false ); _firePlanetPicker = CreateColorPickerField( ctx, fireSlot, DefaultFirePlanetColor, config.AdvancedConfig.firePlanetColor ?? DefaultFirePlanetColor, "cp_fire_planet", val => { if (_firePlanetToggle?.Value == true) { config.AdvancedConfig.firePlanetColor = val; MarkConfigModified(); } } ); // No override stored: start with the picker blocked (second argument: no animation) if (!config.AdvancedConfig.firePlanetColor.HasValue) { _firePlanetPicker.SetBlocked(true, true); } var iceSlot = CreateFieldSlot(iceCol); _icePlanetToggle = CreateToggleField( ctx, iceSlot, labelKey: "", defaultValue: false, value: config.AdvancedConfig.icePlanetColor.HasValue, toggleTextKey: "ui.render_config.ice_planet", id: "toggle_ice_planet", onChanged: val => { _icePlanetPicker?.SetBlocked(!val); config.AdvancedConfig.icePlanetColor = val ? _icePlanetPicker?.Value : null; if (!val) _icePlanetPicker?.SetExpanded(false); MarkConfigModified(); }, generateLabel: false ); _icePlanetPicker = CreateColorPickerField( ctx, iceSlot, DefaultIcePlanetColor, config.AdvancedConfig.icePlanetColor ?? DefaultIcePlanetColor, "cp_ice_planet", val => { if (_icePlanetToggle?.Value == true) { config.AdvancedConfig.icePlanetColor = val; MarkConfigModified(); } } ); if (!config.AdvancedConfig.icePlanetColor.HasValue) { _icePlanetPicker.SetBlocked(true, true); } var windSlot = CreateFieldSlot(windCol); _windPlanetToggle = CreateToggleField( ctx, windSlot, labelKey: "", defaultValue: false, value: config.AdvancedConfig.windPlanetColor.HasValue, toggleTextKey: "ui.render_config.wind_planet", id: "toggle_wind_planet", onChanged: val => { _windPlanetPicker?.SetBlocked(!val); config.AdvancedConfig.windPlanetColor = val ? _windPlanetPicker?.Value : null; if (!val) _windPlanetPicker?.SetExpanded(false); MarkConfigModified(); }, generateLabel: false ); _windPlanetPicker = CreateColorPickerField( ctx, windSlot, DefaultWindPlanetColor, config.AdvancedConfig.windPlanetColor ?? DefaultWindPlanetColor, "cp_wind_planet", val => { if (_windPlanetToggle?.Value == true) { config.AdvancedConfig.windPlanetColor = val; MarkConfigModified(); } } ); if (!config.AdvancedConfig.windPlanetColor.HasValue) { _windPlanetPicker.SetBlocked(true, true); } // --------------------------------------------------------------------------------------------- // Section 7: Go Render button (48 units high) and version label // --------------------------------------------------------------------------------------------- // 8. Go Render Button CreateButton( ctx, scrollContent, "ui.render_config.go_render", "btn_go_render", onClick: TryGoRender, height: 48f ); // 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; versionLabel.color = ctx.Theme.TextDim; // ReSharper disable once InconsistentNaming var versionLE = versionLabel.gameObject.AddComponent(); versionLE.preferredHeight = 18f; versionLE.minHeight = 18f; 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; if (config.RenderHeight <= 0) config.RenderHeight = 1440; if (config.RenderFPS <= 0) config.RenderFPS = 60.0; if (config.VideoFPS <= 0) config.VideoFPS = 60.0; 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); // Sliders show the config values as they are (noFilter: true). // O5Slider.Set applies the slider filter (snapping to the nearest snap value) unless noFilter is set; // the filter is meant for dragging, and typed values are not snapped either (O5Slider.SetFromInput). _renderFpsSlider?.Set(config.RenderFPS, false, noFilter: true); _videoFPSSlider?.Set(config.VideoFPS, false, noFilter: true); _renderWidthSlider?.Set(config.RenderWidth, false, noFilter: true); _renderHeightSlider?.Set(config.RenderHeight, false, noFilter: true); _delayAfterEndSlider?.Set(config.DelayAfterEnd, false, noFilter: true); _showJudgementToggle?.Set(config.ShowJudgementText, false); _codecDropdown?.Set(config.VideoConfig.Codec, false); _rateControlDropdown?.Set(config.VideoConfig.RateControlMode, false); _bitrateInput?.Set(config.VideoConfig.Bitrate.ToString(), false); _maxBitrateInput?.Set(config.VideoConfig.MaxBitrate.ToString(), false); _qpInput?.Set(config.VideoConfig.QP.ToString(), false); _keyframeIntervalSlider?.Set(config.VideoConfig.KeyframeInterval, false, noFilter: true); _forceSoftwareToggle?.Set(config.VideoConfig.ForceSoftwareEncoder, false); _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); _fileExtensionDropdown?.Set(config.AdvancedConfig.FileExtension, false); _randomSeedInput?.Set(config.AdvancedConfig.RandomSeed?.ToString() ?? "", false); _processAudioToggle?.Set(config.AdvancedConfig.ProcessAudio, false); _nonTileToggle?.Set(config.AdvancedConfig.NonTile, false); _highResBloomCorrectionToggle?.Set(config.AdvancedConfig.HighResBloomCorrection, false); // === [MARKER: FIX_SET_FRAMERATE_BUG START] === _fixCustomFrameRateToggle?.Set(config.AdvancedConfig.FixCustomFrameRate, false); // === [MARKER: FIX_SET_FRAMERATE_BUG END] === // 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); _firePlanetPicker?.SetBlocked(!fireColor.HasValue, true); var iceColor = config.AdvancedConfig.icePlanetColor; _icePlanetToggle?.Set(iceColor.HasValue, false); _icePlanetPicker?.Set(iceColor ?? DefaultIcePlanetColor, false); _icePlanetPicker?.SetBlocked(!iceColor.HasValue, true); var windColor = config.AdvancedConfig.windPlanetColor; _windPlanetToggle?.Set(windColor.HasValue, false); _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); UpdatePresetLabel(config); } /// /// Sets the preset dropdown from the config values. /// Every value equals the last loaded preset (_lastLoadedPresetName): the preset name is shown. /// Any value differs (edited, or the preset file changed): the Custom label is shown (MarkConfigModified), /// the same state as after editing a value in the window. /// private static void UpdatePresetLabel(RenderConfig config) { if (_presetDropdown == null) return; var presetName = _lastLoadedPresetName; var preset = string.IsNullOrEmpty(presetName) ? null : PresetManager.GetPresets().FirstOrDefault(p => string.Equals(p.Name, presetName, StringComparison.OrdinalIgnoreCase)); if (preset == null || !ConfigValuesEqual(config, preset.Config)) { MarkConfigModified(); return; } if (_presetDropdown.Values.Contains(CustomPresetLabel)) { _presetDropdown.SetValues(_presetDropdown.Values.Where(v => v != CustomPresetLabel).ToList()); } _presetDropdown.Set(preset.Name, false); UpdateDeleteButtonState(); } /// /// Compares two configs by their serialized JSON (the same form as the preset files), so every saved value is compared. /// private static bool ConfigValuesEqual(RenderConfig a, RenderConfig b) { var settings = new JsonSerializerSettings(); settings.Converters.Add(new ColorJsonConverter()); 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; SyncUItoConfig(config); // The encoders use 4:2:0 chroma subsampling, which needs an even width and height. if (config.RenderWidth % 2 != 0 || config.RenderHeight % 2 != 0) { ShowValidationError(LocalizationManager.Get( "ui.render_config.validation_even_size", config.RenderWidth, config.RenderHeight)); return; } // 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(); if (string.IsNullOrWhiteSpace(path) || string.Equals(path, GlobalSettings.DefaultOutputDirectory, StringComparison.Ordinal)) { GlobalSettings.Current.OutputDirectory = null; } else { try { var fullPath = Path.GetFullPath(path); if (!Directory.Exists(fullPath)) { Directory.CreateDirectory(fullPath); } GlobalSettings.Current.OutputDirectory = fullPath; } catch (Exception ex) { ShowValidationError($"Output directory path is invalid:\n{ex.Message}"); return; } } } GlobalSettings.Save(); SetWindowActive(false); 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; if (_videoFPSSlider != null) target.VideoFPS = _videoFPSSlider.Value; target.RenderFPS = AlignNTSCFramerate(target.RenderFPS); target.VideoFPS = AlignNTSCFramerate(target.VideoFPS); if (_renderWidthSlider != null) target.RenderWidth = (int)Math.Round(_renderWidthSlider.Value); if (_renderHeightSlider != null) target.RenderHeight = (int)Math.Round(_renderHeightSlider.Value); if (_delayAfterEndSlider != null) target.DelayAfterEnd = _delayAfterEndSlider.Value; if (_showJudgementToggle != null) target.ShowJudgementText = _showJudgementToggle.Value; if (_codecDropdown != null) target.VideoConfig.Codec = _codecDropdown.Value; if (_rateControlDropdown != null) target.VideoConfig.RateControlMode = _rateControlDropdown.Value; if (_bitrateInput != null && int.TryParse(_bitrateInput.Value, out var bitrate) && bitrate > 0) { target.VideoConfig.Bitrate = bitrate; } if (_maxBitrateInput != null && int.TryParse(_maxBitrateInput.Value, out var maxBitrate) && maxBitrate > 0) { target.VideoConfig.MaxBitrate = maxBitrate; } if (_qpInput != null && int.TryParse(_qpInput.Value, out var qp) && qp >= 0) { target.VideoConfig.QP = qp; } if (_keyframeIntervalSlider != null) target.VideoConfig.KeyframeInterval = _keyframeIntervalSlider.Value; if (_forceSoftwareToggle != null) target.VideoConfig.ForceSoftwareEncoder = _forceSoftwareToggle.Value; if (_renderAudioToggle != null) target.AudioConfig.RenderAudio = _renderAudioToggle.Value; if (_sampleRateDropdown != null) target.AudioConfig.AudioSampleRate = _sampleRateDropdown.Value; if (_channelsDropdown != null) target.AudioConfig.AudioChannels = _channelsDropdown.Value; if (_audioBitrateSlider != null) target.AudioConfig.AudioBitrate = (int)Math.Round(_audioBitrateSlider.Value) * 1000; if (_fileExtensionDropdown != null && !string.IsNullOrEmpty(_fileExtensionDropdown.Value)) target.AdvancedConfig.FileExtension = _fileExtensionDropdown.Value; if (_randomSeedInput != null) { if (int.TryParse(_randomSeedInput.Value, out var seed)) { target.AdvancedConfig.RandomSeed = seed; } else if (string.IsNullOrWhiteSpace(_randomSeedInput.Value)) { target.AdvancedConfig.RandomSeed = null; } } if (_processAudioToggle != null) target.AdvancedConfig.ProcessAudio = _processAudioToggle.Value; if (_nonTileToggle != null) target.AdvancedConfig.NonTile = _nonTileToggle.Value; if (_highResBloomCorrectionToggle != null) target.AdvancedConfig.HighResBloomCorrection = _highResBloomCorrectionToggle.Value; // === [MARKER: FIX_SET_FRAMERATE_BUG START] === if (_fixCustomFrameRateToggle != null) target.AdvancedConfig.FixCustomFrameRate = _fixCustomFrameRateToggle.Value; // === [MARKER: FIX_SET_FRAMERATE_BUG END] === // Planet Colors target.AdvancedConfig.firePlanetColor = (_firePlanetToggle?.Value == true && _firePlanetPicker != null) ? _firePlanetPicker.Value : null; target.AdvancedConfig.icePlanetColor = (_icePlanetToggle?.Value == true && _icePlanetPicker != null) ? _icePlanetPicker.Value : null; target.AdvancedConfig.windPlanetColor = (_windPlanetToggle?.Value == true && _windPlanetPicker != null) ? _windPlanetPicker.Value : null; 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) { const double tolerance = 0.001; if (Math.Abs(framerate - 29.97) < tolerance) framerate = 30000.0 / 1001; else if (Math.Abs(framerate - 59.94) < tolerance) framerate = 60000.0 / 1001; else if (Math.Abs(framerate - 23.976) < tolerance || Math.Abs(framerate - 23.98) < tolerance) framerate = 24000.0 / 1001; return framerate; } } // =================================================================================================== // 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. /// 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; var customLabel = CustomPresetLabel; if (_presetDropdown.Value == customLabel) return; var currentValues = _presetDropdown.Values; if (!currentValues.Contains(customLabel)) { var list = new List(currentValues); list.Insert(0, customLabel); _presetDropdown.SetValues(list); } _presetDropdown.Set(customLabel, false); 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(); if (string.IsNullOrEmpty(selectedName) || selectedName == CustomPresetLabel) return; var preset = PresetManager.GetPresets().FirstOrDefault(p => string.Equals(p.Name, selectedName, StringComparison.OrdinalIgnoreCase)); if (preset == null) return; GlobalSettings.Current.LastPresetName = preset.Name; GlobalSettings.Save(); _lastLoadedPresetName = preset.Name; config.CopyFrom(preset.Config); ApplyConfigToUI(config); _presetNameInput?.Set(preset.Name, false); if (_presetDropdown != null && _presetDropdown.Values.Contains(CustomPresetLabel)) { var cleaned = _presetDropdown.Values.Where(v => v != CustomPresetLabel).ToList(); _presetDropdown.SetValues(cleaned); _presetDropdown.Set(preset.Name, false); } 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); var name = _presetNameInput?.Value.Trim(); if (string.IsNullOrWhiteSpace(name) || IsCustomPresetLabel(name)) { CommonWarningDialogUI.ShowDialog( LocalizationManager.Get("ui.render_config.validation_title"), LocalizationManager.Get("ui.render_config.preset_name_empty"), new DialogButton(LocalizationManager.Get("ui.render_config.confirm"), () => { }) ); return; } bool isCurrentPreset = !string.IsNullOrEmpty(_lastLoadedPresetName) && string.Equals(_lastLoadedPresetName, name, StringComparison.OrdinalIgnoreCase); // A preset with another name can share the file of this name (PresetManager.GetSafeFileName), e.g. "a/b" and "a_b". var fileName = PresetManager.GetSafeFileName(name); var existing = PresetManager.GetPresets().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase) || string.Equals(PresetManager.GetSafeFileName(p.Name), fileName, StringComparison.OrdinalIgnoreCase)); if (existing != null && !isCurrentPreset) { CommonWarningDialogUI.ShowDialog( LocalizationManager.Get("ui.render_config.preset_overwrite_title"), LocalizationManager.Get("ui.render_config.preset_overwrite_desc", existing.Name), new DialogButton( LocalizationManager.Get("ui.render_config.confirm"), () => DoSavePreset(name, config) ), new DialogButton( LocalizationManager.Get("ui.render_config.cancel"), () => { } ) ); } else { DoSavePreset(name, config); } } /// /// 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); GlobalSettings.Current.LastPresetName = name; GlobalSettings.Save(); _lastLoadedPresetName = name; 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; if (string.IsNullOrWhiteSpace(targetPresetName) || targetPresetName == CustomPresetLabel) { targetPresetName = _lastLoadedPresetName; if (string.IsNullOrWhiteSpace(targetPresetName) || targetPresetName == CustomPresetLabel) { return; } } if (!_isDeletePending) { _isDeletePending = true; if (_deletePresetButtonLE != null) _deletePresetButtonLE.preferredWidth = 110f; if (_deletePresetButton != null) { if (_deletePresetButton.Label != null) { _deletePresetButton.Label.text = LocalizationManager.Get("ui.render_config.preset_delete_confirm"); } _deletePresetButton.NormalColor = new Color(0.95f, 0.25f, 0.25f, 1f); _deletePresetButton.UpdateVisual(); } if (_deleteResetCoroutine != null) { RendererUIManager.Instance.StopCoroutine(_deleteResetCoroutine); } _deleteResetCoroutine = RendererUIManager.Instance.StartCoroutine(ResetDeletePendingAfterDelay()); return; } ResetDeletePendingState(); 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) { RendererUIManager.Instance.StopCoroutine(_deleteResetCoroutine); _deleteResetCoroutine = null; } if (!_isDeletePending && (_deletePresetButton == null || _deletePresetButton.Label?.text == LocalizationManager.Get("ui.render_config.preset_delete"))) { return; } _isDeletePending = false; if (_deletePresetButtonLE) _deletePresetButtonLE.preferredWidth = 90f; if (_deletePresetButton != null) { if (_deletePresetButton.Label != null) { _deletePresetButton.Label.text = LocalizationManager.Get("ui.render_config.preset_delete"); } _deletePresetButton.NormalColor = RendererUIManager.Context.Theme.SoftRed; _deletePresetButton.UpdateVisual(); } } /// /// 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); var presets = PresetManager.GetPresets(); var fallbackName = presets.Count > 0 ? presets[0].Name : ""; GlobalSettings.Current.LastPresetName = fallbackName; GlobalSettings.Save(); _lastLoadedPresetName = fallbackName; ReloadPresetDropdown(selectName: fallbackName); var fallbackPreset = presets.FirstOrDefault(p => string.Equals(p.Name, fallbackName, StringComparison.OrdinalIgnoreCase)); if (fallbackPreset != null) { config.CopyFrom(fallbackPreset.Config); ApplyConfigToUI(config); _presetNameInput?.Set(fallbackPreset.Name, false); } } /// /// 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(); var presets = PresetManager.GetPresets(); var names = presets.Select(p => p.Name).ToList(); if (names.Count == 0) { _presetDropdown?.SetValues(new List { CustomPresetLabel }); _presetDropdown?.Set(CustomPresetLabel, false); _lastLoadedPresetName = null; UpdateDeleteButtonState(); return; } _presetDropdown?.SetValues(names); var targetName = selectName; if (string.IsNullOrEmpty(targetName) || !names.Contains(targetName)) { targetName = names[0]; } _presetDropdown?.Set(targetName, false); _lastLoadedPresetName = targetName; 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; if (targetName == CustomPresetLabel) { targetName = _lastLoadedPresetName; } bool canDelete = !string.IsNullOrEmpty(targetName) && targetName != CustomPresetLabel && PresetManager.GetPresets().Any(p => string.Equals(p.Name, targetName, StringComparison.OrdinalIgnoreCase)); _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; // The preset names are not localized, only the Custom label is. A dropdown value that is not a preset name // is the Custom label (of the previous language), which is shown only while the values are modified. var names = PresetManager.GetPresets().Select(p => p.Name).ToList(); if (names.Count == 0) { _presetDropdown.SetValues(new List { CustomPresetLabel }); _presetDropdown.Set(CustomPresetLabel, false); UpdateDeleteButtonState(); return; } var current = _presetDropdown.Value; var isModified = string.IsNullOrEmpty(current) || !names.Contains(current); _presetDropdown.SetValues(names); if (isModified) { MarkConfigModified(); return; } _presetDropdown.Set(current, false); 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}"; if (DateTime.UtcNow >= RenderMod.ExpirationTime) versionText += " " + LocalizationManager.Get("ui.render_config.expired"); 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); CommonWarningDialogUI.ShowDialog( LocalizationManager.Get("ui.render_config.validation_title"), message, new DialogButton("OK", () => { }) ); } // =================================================================================================== // 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) { if (columnCount < 1) throw new ArgumentOutOfRangeException(nameof(columnCount), "Column count must be at least 1."); var panelObj = new GameObject($"{columnCount}ColPanel"); panelObj.transform.SetParent(parent, false); panelObj.AddComponent(); var hLayout = panelObj.AddComponent(); hLayout.spacing = spacing; hLayout.padding = new RectOffset(0, 0, 0, 0); hLayout.childAlignment = TextAnchor.UpperCenter; hLayout.childControlWidth = true; hLayout.childControlHeight = true; hLayout.childForceExpandWidth = true; hLayout.childForceExpandHeight = false; // ReSharper disable once InconsistentNaming var panelLE = panelObj.AddComponent(); panelLE.flexibleWidth = 1f; var columns = new RectTransform[columnCount]; for (var i = 0; i < columnCount; i++) { columns[i] = CreateColumn(panelObj.transform, $"Col{i + 1}", columnSpacing); } 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) { var cols = CreateMultiColumnPanel(parent, 2, spacing, columnSpacing); 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); colObj.transform.SetParent(parent, false); var colRect = colObj.AddComponent(); var colLayout = colObj.AddComponent(); colLayout.spacing = spacing; colLayout.padding = new RectOffset(0, 0, 0, 0); colLayout.childControlWidth = true; colLayout.childControlHeight = true; colLayout.childForceExpandWidth = true; colLayout.childForceExpandHeight = false; // ReSharper disable once InconsistentNaming // 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; colLE.flexibleWidth = 1f; 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"); slotObj.transform.SetParent(column, false); var slotRect = slotObj.AddComponent(); var slotLayout = slotObj.AddComponent(); slotLayout.spacing = 3f; slotLayout.padding = new RectOffset(0, 0, 0, 0); slotLayout.childControlWidth = true; slotLayout.childControlHeight = true; slotLayout.childForceExpandWidth = true; slotLayout.childForceExpandHeight = false; // ReSharper disable once InconsistentNaming var slotLE = slotObj.AddComponent(); slotLE.flexibleWidth = 1f; 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, Transform parent, string titleKey, bool activeValue = true, Action? onActiveChanged = null, Action? onDeleteClick = null, bool showDeleteButton = false, bool showActiveToggle = false) { var (card, content) = O5Factory.Card( ctx, parent, LocalizationManager.Get(titleKey), activeValue, onActiveChanged, onDeleteClick, showDeleteButton, showActiveToggle ); var titleTmp = card.Find("Header/TitleTrigger/TitleText")?.GetComponent() ?? card.GetComponentsInChildren(true).FirstOrDefault(t => t.gameObject.name == "TitleText"); if (titleTmp) { titleTmp.fontSize = 18f; LocalizedText.Bind(titleTmp, titleKey); } 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, string textKey, float fontSize = 13f, bool noPad = true, Color? color = null) { var label = O5Factory.ControlText(ctx, parent, fontSize, noPad: noPad); label.text = LocalizationManager.Get(textKey); label.color = color ?? ctx.Theme.TextDim; LocalizedText.Bind(label, textKey); 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, string labelKey, string id, double defaultValue, double min, double max, double value, string format = "F0", ClampMode clampMode = ClampMode.All, Func? sliderFilter = null, Func? inputFilter = null, Action? onChanged = null, Action? onComplete = null, bool showFill = true) { var slider = O5Factory.Slider( ctx: ctx, parent: parent, defaultValue: defaultValue, min: min, max: max, value: value, format: format, clampMode: clampMode, sliderFilter: sliderFilter, inputFilter: inputFilter, onComplete: onComplete, onChanged: onChanged, text: LocalizationManager.Get(labelKey), id: id, showFill: showFill ); if (slider.Label != null) { LocalizedText.Bind(slider.Label, labelKey); } return slider; } /// /// Creates an O5Kit button with a localized label. null uses the theme control height. /// private static O5Button CreateButton( O5Context ctx, Transform parent, string textKey, string id, Action onClick, float? height = null) { var button = O5Factory.Button( ctx: ctx, parent: parent, onClick: onClick, text: LocalizationManager.Get(textKey), id: id, height: height ); if (button.Label != null) { LocalizedText.Bind(button.Label, textKey); } return button; } /// Same as the CreateInputField overload with an out label parameter, without returning the label. private static O5InputField CreateInputField( O5Context ctx, Transform colParent, string labelKey, string? defaultValue, string value, string id, TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None, Action? onChanged = null) { 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, string labelKey, string? defaultValue, string value, string id, out TMPro.TextMeshProUGUI label, 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; LocalizedText.Bind(label, labelKey); // ReSharper disable once InconsistentNaming var labelLE = label.gameObject.AddComponent(); labelLE.preferredHeight = 18f; labelLE.minHeight = 18f; var inputField = O5Factory.Input( ctx: ctx, parent: colParent, defaultValue: defaultValue, value: value, onChanged: onChanged, placeholder: LocalizationManager.Get(labelKey), icon: null, id: id ); if (inputField.Placeholder != null) { LocalizedText.Bind(inputField.Placeholder, labelKey); } inputField.InputField.characterValidation = characterValidation; 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, ref ITweenHandle? tweenHandle, bool blocked, bool noAnimate = false) { input?.SetBlocked(blocked, noAnimate); if (label == null) return; tweenHandle?.Kill(); var ctx = RendererUIManager.Context; var baseColor = ctx.Theme.TextDim; var targetColor = blocked ? new Color(baseColor.r, baseColor.g, baseColor.b, baseColor.a * 0.4f) : baseColor; if (noAnimate) { label.color = targetColor; } else { tweenHandle = ctx.Tween.TweenColor(label, targetColor, 0.2f); } } /// /// 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; bool blockMaxBitrate = mode is RateControlMode.CBR or RateControlMode.CQP; bool blockQp = mode is RateControlMode.CBR or RateControlMode.VBR; SetFieldBlocked(_bitrateInput, _bitrateLabel, ref _bitrateLabelTween, blockBitrate, noAnimate); SetFieldBlocked(_maxBitrateInput, _maxBitrateLabel, ref _maxBitrateLabelTween, blockMaxBitrate, noAnimate); 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; _sampleRateDropdown.SetValues(CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[codec]]); if (config != null && _sampleRateDropdown.Value is var currentRate) { config.AudioConfig.AudioSampleRate = currentRate; } } // =================================================================================================== // 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, string labelKey, string? defaultValue, string value, string id, Action? onChanged = null) { var container = new GameObject($"FullWidth_{id}"); container.transform.SetParent(parent, false); var containerRect = container.AddComponent(); var layout = container.AddComponent(); layout.spacing = 3f; layout.childControlWidth = true; layout.childControlHeight = true; layout.childForceExpandWidth = true; layout.childForceExpandHeight = false; var label = O5Factory.ControlText(ctx, containerRect, 13f, noPad: true); label.text = LocalizationManager.Get(labelKey); label.color = ctx.Theme.TextDim; LocalizedText.Bind(label, labelKey); // ReSharper disable once InconsistentNaming var labelLE = label.gameObject.AddComponent(); labelLE.preferredHeight = 18f; labelLE.minHeight = 18f; var input = O5Factory.Input( ctx: ctx, parent: containerRect, defaultValue: defaultValue, value: value, onChanged: onChanged, placeholder: LocalizationManager.Get(labelKey), icon: null, id: id ); if (input.Placeholder != null) { LocalizedText.Bind(input.Placeholder, labelKey); } 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, string labelKey, bool? defaultValue, bool value, string toggleTextKey, string id, Action? onChanged = null, bool generateLabel = true) { if (generateLabel) { var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); label.text = string.IsNullOrEmpty(labelKey) ? "" : LocalizationManager.Get(labelKey); label.color = ctx.Theme.TextDim; LocalizedText.Bind(label, labelKey); // ReSharper disable once InconsistentNaming var labelLE = label.gameObject.AddComponent(); labelLE.preferredHeight = 18f; labelLE.minHeight = 18f; } var toggle = O5Factory.Toggle( ctx: ctx, parent: colParent, defaultValue: defaultValue, value: value, onChanged: onChanged, text: !string.IsNullOrEmpty(toggleTextKey) ? LocalizationManager.Get(toggleTextKey) : "", id: id ); if (toggle.Label != null && !string.IsNullOrEmpty(toggleTextKey)) { LocalizedText.Bind(toggle.Label, toggleTextKey); } 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, string labelKey, T defaultValue, T value, IReadOnlyList values, Func display, string id, Action? onChanged = null) { var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); label.text = LocalizationManager.Get(labelKey); label.color = ctx.Theme.TextDim; LocalizedText.Bind(label, labelKey); // ReSharper disable once InconsistentNaming var labelLE = label.gameObject.AddComponent(); labelLE.preferredHeight = 18f; labelLE.minHeight = 18f; var ddContainer = new GameObject($"DD_{id}"); ddContainer.transform.SetParent(colParent, false); // ReSharper disable once InconsistentNaming var ddLE = ddContainer.AddComponent(); ddLE.preferredHeight = 50f; ddLE.minHeight = 0f; return O5Factory.DropDown( ctx: ctx, parent: ddContainer.transform, defaultValue: defaultValue, value: value, values: values, display: display, onChanged: val => { if (val != null) onChanged?.Invoke(val); }, id: id ); } /// /// 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, Color? defaultValue, Color value, string id, Action? onChanged = null) { var cpContainer = new GameObject($"CP_{id}"); cpContainer.transform.SetParent(colParent, false); // ReSharper disable once InconsistentNaming var cpLE = cpContainer.AddComponent(); var height = ctx.Theme.ControlHeightFor(ctx.Theme.FontSizeBody); cpLE.preferredHeight = height; cpLE.minHeight = height; var canvasRect = RendererUIManager.Instance.WindowManager.Canvas.Root.GetComponent(); var picker = O5Factory.ColorPicker( ctx: ctx, parent: cpContainer.transform, canvasRect: canvasRect, canvasCamera: null, defaultValue: defaultValue, value: value, onChanged: onChanged, onComplete: onChanged, id: id ); // 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) { preview.anchorMax = new Vector2(0.28f, 1f); } if (header.Find("ToggleArea") is RectTransform toggleArea) { toggleArea.anchorMax = new Vector2(0.30f, 1f); } if (header.Find("HexInput") is RectTransform hexInput) { hexInput.anchorMin = new Vector2(0.32f, 0f); var tmpInput = hexInput.GetComponentInChildren(); if (tmpInput != null && tmpInput.textComponent != null) { tmpInput.textComponent.fontSize = 15f; } } } return picker; } // =================================================================================================== // Window visibility // =================================================================================================== /// Shows or hides the window; hiding first collapses all open color pickers. private static void SetWindowActive(bool enabled) { if (!enabled) { CloseAllPickers(); } _configWindow?.Rect.gameObject.SetActive(enabled); } /// Collapses the three planet color pickers without animation. private static void CloseAllPickers() { _firePlanetPicker?.SetExpanded(false, true); _icePlanetPicker?.SetExpanded(false, true); _windPlanetPicker?.SetExpanded(false, true); } }