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; using MelonLoader; using O5Kit.Control; using O5Kit.Core; using O5Kit.Factory; using UnityEngine; using UnityEngine.UI; namespace ADOFAIRenderer.UI.Window; internal static class RenderConfigWindow { private static O5Window? _configWindow; // Presets controls private static O5Dropdown? _presetDropdown; private static O5InputField? _presetNameInput; private static O5Button? _savePresetButton; private static O5Button? _deletePresetButton; private static string? _lastLoadedPresetName; // Controls cache for validation & sync 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; 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; private static O5InputField? _outputDirInput; private static O5Dropdown? _fileExtensionDropdown; private static O5InputField? _randomSeedInput; private static O5Toggle? _processAudioToggle; private static O5Toggle? _nonTileToggle; private static O5Toggle? _firePlanetToggle; private static O5ColorPicker? _firePlanetPicker; private static O5Toggle? _icePlanetToggle; private static O5ColorPicker? _icePlanetPicker; private static O5Toggle? _windPlanetToggle; private static O5ColorPicker? _windPlanetPicker; 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 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 }); _configWindow.CloseRequested += win => { CloseAllPickers(); win.Rect.gameObject.SetActive(false); }; BuildUI(_configWindow.Content); } else { ReloadPresetDropdown(GlobalSettings.Current.LastPresetName); } // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract var config = RenderManager.Instance.Configuration ??= new RenderConfig(); ApplyConfigToUI(config); SetWindowActive(true); _configWindow.BringToFront(); } private static void BuildUI(RectTransform content) { var ctx = RendererUIManager.Context; var (_, scrollContent, _) = O5Factory.ScrollView(ctx, content, spacing: 10f, padding: 8f); // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract var config = RenderManager.Instance.Configuration ??= new RenderConfig(); EnsureDefaultConfig(config); 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 }; // Presets (Collapsible Card) var (_, presetContent) = O5Factory.Card( ctx, scrollContent, LocalizationManager.Get("ui.render_config.presets"), activeValue: true, onActiveChanged: null, onDeleteClick: null, showDeleteButton: false, showActiveToggle: false ); var presets = PresetManager.GetPresets(); var presetNames = presets.Select(p => p.Name).ToList(); 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; 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); _presetDropdown = CreateDropdownField( ctx, presetContent, LocalizationManager.Get("ui.render_config.preset_select"), initialPresetName, initialPresetName, presetNames, p => p, "dd_presets", selectedName => { OnPresetSelected(selectedName, config); } ); 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" ); var inputLE = _presetNameInput.Rect.GetComponent() ?? _presetNameInput.Rect.gameObject.AddComponent(); inputLE.flexibleWidth = 1f; inputLE.preferredHeight = 40f; _savePresetButton = O5Factory.Button( ctx: ctx, parent: actionsRowRect, onClick: () => OnSavePresetClicked(config), text: LocalizationManager.Get("ui.render_config.preset_save"), id: "btn_preset_save", height: 40f ); var saveLE = _savePresetButton.Rect.GetComponent() ?? _savePresetButton.Rect.gameObject.AddComponent(); saveLE.preferredWidth = 90f; saveLE.flexibleWidth = 0f; saveLE.preferredHeight = 40f; 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 = O5Factory.Button( ctx: redCtx, parent: actionsRowRect, onClick: () => OnDeletePresetClicked(config), text: LocalizationManager.Get("ui.render_config.preset_delete"), id: "btn_preset_delete", height: 40f ); var deleteLE = _deletePresetButton.Rect.GetComponent() ?? _deletePresetButton.Rect.gameObject.AddComponent(); deleteLE.preferredWidth = 90f; deleteLE.flexibleWidth = 0f; deleteLE.preferredHeight = 40f; UpdateDeleteButtonState(); // 1-3. Top section: 좌/우 컨테이너 1개씩으로 3행을 쌓는다 (중앙선 1개) var (topLeft, topRight) = CreateTwoColumnPanel(scrollContent, spacing: 8f, columnSpacing: 10f); _renderFpsSlider = O5Factory.Slider( ctx: ctx, parent: CreateFieldSlot(topLeft), defaultValue: 60, min: 0.01, max: 3000, value: 60, format: "F3", clampMode: ClampMode.All, sliderFilter: value => Snap(renderFpsSnap, value), inputFilter: null, onComplete: null, onChanged: val => { config.RenderFPS = val; MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.render_fps"), id: "slider_render_fps", showFill: true ); _videoFPSSlider = O5Factory.Slider( ctx: ctx, parent: CreateFieldSlot(topRight), defaultValue: 60, min: 0.01, max: 600, value: 60, format: "F3", clampMode: ClampMode.All, sliderFilter: value => Snap(videoFpsSnap, value), inputFilter: null, onComplete: null, onChanged: val => { config.VideoFPS = val; MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.video_fps"), id: "slider_video_fps", showFill: true ); _renderWidthSlider = O5Factory.Slider( ctx: ctx, parent: CreateFieldSlot(topLeft), defaultValue: 2560, min: 0, max: 15360, value: 2560, format: "F0", clampMode: ClampMode.All, sliderFilter: value => Snap(renderWidthSnap, value), inputFilter: value => Math.Max(value, 2), onComplete: null, onChanged: val => { config.RenderWidth = (int)Math.Round(val); MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.render_width"), id: "slider_render_width", showFill: true ); _renderHeightSlider = O5Factory.Slider( ctx: ctx, parent: CreateFieldSlot(topRight), defaultValue: 1440, min: 0, max: 8640, value: 1440, format: "F0", clampMode: ClampMode.All, sliderFilter: value => Snap(renderHeightSnap, value), inputFilter: value => Math.Max(value, 2), onComplete: null, onChanged: val => { config.RenderHeight = (int)Math.Round(val); MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.render_height"), id: "slider_render_height", showFill: true ); _delayAfterEndSlider = O5Factory.Slider( ctx: ctx, parent: CreateFieldSlot(topLeft), defaultValue: 15, min: 0, max: 9999.999, value: 15, format: "F3", clampMode: ClampMode.All, sliderFilter: null, inputFilter: null, onComplete: null, onChanged: val => { config.DelayAfterEnd = val; MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.delay_after_end"), id: "slider_delay_after_end", showFill: true ); _showJudgementToggle = CreateToggleField( ctx, CreateFieldSlot(topRight), "", false, config.ShowJudgementText, LocalizationManager.Get("ui.render_config.show_judgement"), "toggle_show_judgement", val => { config.ShowJudgementText = val; MarkConfigModified(); }, generateLabel: false ); // 4. Video Encoding Config (Collapsible Card) var (_, videoContent) = O5Factory.Card( ctx, scrollContent, LocalizationManager.Get("ui.render_config.video_encoding"), activeValue: true, onActiveChanged: null, onDeleteClick: null, showDeleteButton: false, showActiveToggle: false ); var (videoLeft, videoRight) = CreateTwoColumnPanel(videoContent); var codecs = (Codec[])Enum.GetValues(typeof(Codec)); _codecDropdown = CreateDropdownField( ctx, CreateFieldSlot(videoLeft), LocalizationManager.Get("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(); }); _bitrateInput = CreateInputField( ctx, CreateFieldSlot(videoLeft), LocalizationManager.Get("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), LocalizationManager.Get("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), LocalizationManager.Get("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), LocalizationManager.Get("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), "", false, config.VideoConfig.ForceSoftwareEncoder, LocalizationManager.Get("ui.render_config.force_software"), "toggle_force_software", val => { config.VideoConfig.ForceSoftwareEncoder = val; MarkConfigModified(); }, generateLabel: true ); _keyframeIntervalSlider = O5Factory.Slider( ctx: ctx, parent: videoContent, defaultValue: 2, min: 0.1, max: 10, value: 2, format: "F1", clampMode: ClampMode.All, sliderFilter: null, inputFilter: null, onComplete: null, onChanged: val => { config.VideoConfig.KeyframeInterval = val; MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.keyframe_interval"), id: "slider_keyframe_interval", showFill: true ); // 5. Audio Encoding Config (Collapsible Card) var (_, audioContent) = O5Factory.Card( ctx, scrollContent, LocalizationManager.Get("ui.render_config.audio_encoding"), activeValue: true, onActiveChanged: null, onDeleteClick: null, showDeleteButton: false, showActiveToggle: false ); var (audioLeft, audioRight) = CreateTwoColumnPanel(audioContent); _renderAudioToggle = CreateToggleField( ctx, CreateFieldSlot(audioLeft), "", true, config.AudioConfig.RenderAudio, LocalizationManager.Get("ui.render_config.render_audio"), "toggle_render_audio", val => { config.AudioConfig.RenderAudio = val; MarkConfigModified(); }, generateLabel: false ); _audioBitrateSlider = O5Factory.Slider( ctx: ctx, parent: CreateFieldSlot(audioRight), defaultValue: 256, min: 64, max: 384, value: 256, format: "F0", clampMode: ClampMode.All, sliderFilter: value => Snap(audioBitrateSnap, value), inputFilter: null, onComplete: null, onChanged: val => { config.AudioConfig.AudioBitrate = (int)Math.Round(val) * 1000; MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.audio_bitrate"), id: "slider_audio_bitrate", showFill: true ); var channels = new[] { 1, 2 }; _channelsDropdown = CreateDropdownField( ctx, CreateFieldSlot(audioLeft), LocalizationManager.Get("ui.render_config.channels"), 2, config.AudioConfig.AudioChannels, channels, c => c == 1 ? "1 (Mono)" : "2 (Stereo)", "dd_channels", c => { config.AudioConfig.AudioChannels = c; MarkConfigModified(); } ); _sampleRateDropdown = CreateDropdownField( ctx, CreateFieldSlot(audioRight), LocalizationManager.Get("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(); } ); // 6. Global Settings (Collapsible Card) var (_, globalContent) = O5Factory.Card( ctx, scrollContent, LocalizationManager.Get("ui.render_config.global_settings"), activeValue: true, onActiveChanged: null, onDeleteClick: null, showDeleteButton: false, showActiveToggle: false ); _outputDirInput = CreateFullWidthInputField( ctx, globalContent, LocalizationManager.Get("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.OrdinalIgnoreCase)) { GlobalSettings.Current.OutputDirectory = null; } else { GlobalSettings.Current.OutputDirectory = trimmed; } } ); var (globalLeft, globalRight) = CreateTwoColumnPanel(globalContent); O5Factory.Button( ctx, globalLeft, () => { 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(); } }); }, LocalizationManager.Get("ui.render_config.browse"), "btn_browse_output" ); O5Factory.Button( ctx, globalRight, () => { _outputDirInput.Set(GlobalSettings.DefaultOutputDirectory); GlobalSettings.Current.OutputDirectory = null; GlobalSettings.Save(); }, LocalizationManager.Get("ui.render_config.reset_default"), "btn_reset_output" ); // 7. Advanced Settings (Collapsible Card) var (_, advContent) = O5Factory.Card( ctx, scrollContent, LocalizationManager.Get("ui.render_config.advanced_settings"), activeValue: true, onActiveChanged: null, onDeleteClick: null, showDeleteButton: false, showActiveToggle: false ); var (advLeft, advRight) = CreateTwoColumnPanel(advContent); var extensions = new[] { "mkv", "mp4", "mov", "webm", "avi" }; _fileExtensionDropdown = CreateDropdownField( ctx, CreateFieldSlot(advLeft), LocalizationManager.Get("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(); } ); _processAudioToggle = CreateToggleField( ctx, CreateFieldSlot(advLeft), "", true, config.AdvancedConfig.ProcessAudio, LocalizationManager.Get("ui.render_config.process_audio"), "toggle_process_audio", val => { config.AdvancedConfig.ProcessAudio = val; MarkConfigModified(); }, generateLabel: false ); _randomSeedInput = CreateInputField( ctx, CreateFieldSlot(advRight), LocalizationManager.Get("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(); } ); _nonTileToggle = CreateToggleField( ctx, CreateFieldSlot(advRight), "", false, config.AdvancedConfig.NonTile, LocalizationManager.Get("ui.render_config.non_tile"), "toggle_non_tile", val => { config.AdvancedConfig.NonTile = val; MarkConfigModified(); }, generateLabel: false ); // 7-1. Planet Color Settings (Optional) var planetColorsLabel = O5Factory.ControlText(ctx, advContent, 13f, noPad: true); planetColorsLabel.text = LocalizationManager.Get("ui.render_config.planet_colors"); planetColorsLabel.color = ctx.Theme.TextDim; // 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, "", false, config.AdvancedConfig.firePlanetColor.HasValue, LocalizationManager.Get("ui.render_config.fire_planet"), "toggle_fire_planet", 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(); } } ); if (!config.AdvancedConfig.firePlanetColor.HasValue) { _firePlanetPicker.SetBlocked(true, true); } var iceSlot = CreateFieldSlot(iceCol); _icePlanetToggle = CreateToggleField( ctx, iceSlot, "", false, config.AdvancedConfig.icePlanetColor.HasValue, LocalizationManager.Get("ui.render_config.ice_planet"), "toggle_ice_planet", 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, "", false, config.AdvancedConfig.windPlanetColor.HasValue, LocalizationManager.Get("ui.render_config.wind_planet"), "toggle_wind_planet", 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); } // 8. Go Render Button O5Factory.Button( ctx, scrollContent, TryGoRender, LocalizationManager.Get("ui.render_config.go_render"), "btn_go_render", height: 48f ); // 9. Version Label var versionLabel = O5Factory.ControlText(ctx, scrollContent, 12f, noPad: true); var versionText = $"ADOFAI Renderer v{Melon.Instance.Info.Version}"; if (DateTime.UtcNow >= RenderMod.ExpirationTime) versionText += " " + LocalizationManager.Get("ui.render_config.expired"); versionLabel.text = versionText; 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; double Snap(double[] array, double value) => array.OrderBy(n => Math.Abs(n - value)).First(); } 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; } private static void ApplyConfigToUI(RenderConfig config) { EnsureDefaultConfig(config); _renderFpsSlider?.Set((float) config.RenderFPS, false); _videoFPSSlider?.Set((float) config.VideoFPS, false); _renderWidthSlider?.Set(config.RenderWidth, false); _renderHeightSlider?.Set(config.RenderHeight, false); _delayAfterEndSlider?.Set((float) config.DelayAfterEnd, false); _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); _forceSoftwareToggle?.Set(config.VideoConfig.ForceSoftwareEncoder, false); _renderAudioToggle?.Set(config.AudioConfig.RenderAudio, false); _sampleRateDropdown?.Set(config.AudioConfig.AudioSampleRate, false); _channelsDropdown?.Set(config.AudioConfig.AudioChannels, false); _audioBitrateSlider?.Set(config.AudioConfig.AudioBitrate / 1000.0, false); _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); 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); UpdateVideoCodecUI(config.VideoConfig.Codec, config); UpdateRateControlUI(config.VideoConfig.RateControlMode, noAnimate: true); if (!string.IsNullOrEmpty(GlobalSettings.Current.LastPresetName)) { _presetDropdown?.Set(GlobalSettings.Current.LastPresetName, false); _lastLoadedPresetName = GlobalSettings.Current.LastPresetName; UpdateDeleteButtonState(); } } private static void TryGoRender() { var config = RenderManager.Instance.Configuration; SyncUItoConfig(config); // Sync Output Directory if (_outputDirInput != null) { var path = _outputDirInput.Value.Trim(); if (string.IsNullOrWhiteSpace(path) || string.Equals(path, GlobalSettings.DefaultOutputDirectory, StringComparison.OrdinalIgnoreCase)) { 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); } private static void SyncUItoConfig(RenderConfig target) { if (_renderFpsSlider != null) target.RenderFPS = _renderFpsSlider.Value; if (_videoFPSSlider != null) target.VideoFPS = _videoFPSSlider.Value; 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; // 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; } private static string CustomPresetLabel => LocalizationManager.Get("ui.render_config.preset_custom"); 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(); } private static void OnPresetSelected(string? selectedName, RenderConfig config) { 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(); } private static void OnSavePresetClicked(RenderConfig config) { SyncUItoConfig(config); var name = _presetNameInput?.Value?.Trim(); if (string.IsNullOrWhiteSpace(name) || name == CustomPresetLabel) { 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); var existing = PresetManager.GetPresets().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); if (existing != null && !isCurrentPreset) { CommonWarningDialogUI.ShowDialog( LocalizationManager.Get("ui.render_config.preset_overwrite_title"), LocalizationManager.Get("ui.render_config.preset_overwrite_desc", name), new DialogButton( LocalizationManager.Get("ui.render_config.confirm"), () => DoSavePreset(name, config) ), new DialogButton( LocalizationManager.Get("ui.render_config.cancel"), () => { } ) ); } else { DoSavePreset(name, config); } } private static void DoSavePreset(string name, RenderConfig config) { PresetManager.SavePreset(name, config); GlobalSettings.Current.LastPresetName = name; GlobalSettings.Save(); _lastLoadedPresetName = name; ReloadPresetDropdown(selectName: name); } 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; } } var ctx = RendererUIManager.Context; CommonWarningDialogUI.ShowDialog( LocalizationManager.Get("ui.render_config.preset_delete_title"), LocalizationManager.Get("ui.render_config.preset_delete_desc", targetPresetName), new DialogButton( LocalizationManager.Get("ui.render_config.confirm"), () => DoDeletePreset(targetPresetName, config), ctx.Theme.SoftRed ), new DialogButton( LocalizationManager.Get("ui.render_config.cancel"), () => { } ) ); } 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); } } private static void ReloadPresetDropdown(string? selectName = null) { 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(); } 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); } private static void ShowValidationError(string message) { Melon.Logger.Error(message); CommonWarningDialogUI.ShowDialog( LocalizationManager.Get("ui.render_config.validation_title"), message, new DialogButton("OK", () => { }) ); } // 카드(또는 상단 섹션)당 하나의 다열 컨테이너를 만들어 // 가로 한 줄 각 필드 사이 중앙이 행마다 어긋나지 않도록 한다. // - 패널 1개 = 균등 분할 (행마다 HLG를 만들던 기존 방식의 어긋남 방지) // - 각 컬럼은 preferredWidth=0 + flexibleWidth=1 로 항상 1:1:...:1 균등 분할 // - 각 필드는 Field 슬롯(label 18 + 간격 3 + 컨트롤 50)으로 높이를 고정해 // 좌/우 스택을 써도 행 단위 Y 정렬이 유지되도록 한다. 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; } 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]); } 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 // 핵심: 좌/우 선호 너비를 0으로 강제해 항상 정확히 절반씩 나누도록 한다. // (기존 행마다 HLG 방식은 라벨/값 텍스트 길이에 따라 preferred가 달라져 // 중앙이 (W + P1 - P2) / 2 로 행마다 틀어졌음) var colLE = colObj.AddComponent(); colLE.minWidth = 0f; colLE.preferredWidth = 0f; colLE.flexibleWidth = 1f; return colRect; } 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; } private static O5InputField CreateInputField( O5Context ctx, Transform colParent, string labelText, string? defaultValue, string value, string id, TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None, Action? onChanged = null) { return CreateInputField(ctx, colParent, labelText, defaultValue, value, id, out _, characterValidation, onChanged); } private static O5InputField CreateInputField( O5Context ctx, Transform colParent, string labelText, string? defaultValue, string value, string id, out TMPro.TextMeshProUGUI label, TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None, Action? onChanged = null) { label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); label.text = labelText; label.color = ctx.Theme.TextDim; // 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: labelText, icon: null, id: id ); inputField.InputField.characterValidation = characterValidation; return inputField; } 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); } } 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); } 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; } } private static O5InputField CreateFullWidthInputField( O5Context ctx, Transform parent, string labelText, 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 = labelText; label.color = ctx.Theme.TextDim; // 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: labelText, icon: null, id: id ); return input; } private static O5Toggle CreateToggleField( O5Context ctx, Transform colParent, string labelText, bool? defaultValue, bool value, string toggleText, string id, Action? onChanged = null, bool generateLabel = true) { if (generateLabel) { var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); label.text = labelText; label.color = ctx.Theme.TextDim; // ReSharper disable once InconsistentNaming var labelLE = label.gameObject.AddComponent(); labelLE.preferredHeight = 18f; labelLE.minHeight = 18f; } return O5Factory.Toggle( ctx: ctx, parent: colParent, defaultValue: defaultValue, value: value, onChanged: onChanged, text: toggleText, id: id ); } private static O5Dropdown CreateDropdownField( O5Context ctx, Transform colParent, string labelText, T defaultValue, T value, IReadOnlyList values, Func display, string id, Action? onChanged = null) { var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); label.text = labelText; label.color = ctx.Theme.TextDim; // 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 ); } 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 ); // 3열 등 다열 레이아웃 환경에 맞춰 O5ColorPicker 헤더 내부 비율 및 폰트 크기 조정 // O5Kit 기본값(Preview: 0~0.5, HexInput: 0.62~1.0, fontSize: 19)은 너비가 좁을 때 // Hex 문자열(8자리 RGBA) 앞부분이 잘려 보이는 현상이 발생하므로 공간을 재분배합니다. 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; } private static void SetWindowActive(bool enabled) { if (!enabled) { CloseAllPickers(); } _configWindow?.Rect.gameObject.SetActive(enabled); } private static void CloseAllPickers() { _firePlanetPicker?.SetExpanded(false, true); _icePlanetPicker?.SetExpanded(false, true); _windPlanetPicker?.SetExpanded(false, true); } }