SHA256
2202 lines
99 KiB
C#
2202 lines
99 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The "Render Config" window: edits a <see cref="RenderConfig"/> and the global output directory, manages presets,
|
|
/// and starts a render ("Go Render").
|
|
/// <para>
|
|
/// Opened from the level editor through <c>RenderManager</c> (after the unsaved-changes check) with
|
|
/// <see cref="CreateWindow"/>. The window is built once and then hidden/shown; its state lives in static fields for the
|
|
/// whole game session. All members run on the Unity main thread.
|
|
/// </para>
|
|
/// <para>
|
|
/// Data flow:
|
|
/// <list type="bullet">
|
|
/// <item>Each control's change callback writes its value into <see cref="UIConfig"/> right away and calls
|
|
/// <see cref="MarkConfigModified"/> (the preset dropdown then shows the Custom label).</item>
|
|
/// <item><see cref="ApplyConfigToUI"/> writes a config back into every control without invoking the callbacks
|
|
/// (preset selection, window reopen).</item>
|
|
/// <item><see cref="SyncUItoConfig"/> 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.</item>
|
|
/// <item>The output directory is stored in <c>GlobalSettings.Current.OutputDirectory</c> (<c>null</c> = default folder),
|
|
/// not in the render config, and is saved to the global settings file.</item>
|
|
/// </list>
|
|
/// </para>
|
|
/// <para>
|
|
/// File layout: fields; <see cref="CreateWindow"/>; <see cref="BuildUI"/> (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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
internal static class RenderConfigWindow
|
|
{
|
|
// ===================================================================================================
|
|
// Fields
|
|
// ===================================================================================================
|
|
|
|
/// <summary>The window, created by the first <see cref="CreateWindow"/> call and reused (hidden, not destroyed, on close).</summary>
|
|
private static O5Window? _configWindow;
|
|
|
|
// Presets controls
|
|
/// <summary>Preset selector: preset names, plus the Custom label at index 0 while the values differ from the preset.</summary>
|
|
private static O5Dropdown<string>? _presetDropdown;
|
|
/// <summary>Name used by the Save button; set to the selected preset's name when a preset is loaded.</summary>
|
|
private static O5InputField? _presetNameInput;
|
|
/// <summary>Saves the current values under the name in <see cref="_presetNameInput"/>.</summary>
|
|
private static O5Button? _savePresetButton;
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static O5Button? _deletePresetButton;
|
|
/// <summary>Layout element of the delete button; preferred width 90 units normally, 110 while armed.</summary>
|
|
// ReSharper disable once InconsistentNaming
|
|
private static LayoutElement? _deletePresetButtonLE;
|
|
/// <summary>True while the delete button is armed and waiting for the confirming click.</summary>
|
|
private static bool _isDeletePending;
|
|
/// <summary>Coroutine (on RendererUIManager) that disarms the delete button after 3 seconds.</summary>
|
|
private static Coroutine? _deleteResetCoroutine;
|
|
/// <summary>
|
|
/// 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. <c>null</c> when no presets exist.
|
|
/// </summary>
|
|
private static string? _lastLoadedPresetName;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static RenderConfig? _uiConfig;
|
|
|
|
/// <summary>
|
|
/// <see cref="_uiConfig"/>, created on first access with the <see cref="RenderConfig"/> initializer values
|
|
/// (RenderFPS, VideoFPS, RenderWidth and RenderHeight start at 0; <see cref="EnsureDefaultConfig"/> fills them).
|
|
/// </summary>
|
|
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<Codec>? _codecDropdown;
|
|
private static O5Dropdown<RateControlMode>? _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<int>? _sampleRateDropdown;
|
|
private static O5Dropdown<int>? _channelsDropdown;
|
|
private static O5Slider? _audioBitrateSlider;
|
|
|
|
/// <summary>
|
|
/// Output directory input. Bound to <c>GlobalSettings.Current.OutputDirectory</c>, not to a RenderConfig property.
|
|
/// </summary>
|
|
private static O5InputField? _outputDirInput;
|
|
|
|
private static O5Dropdown<string>? _fileExtensionDropdown;
|
|
private static O5InputField? _randomSeedInput;
|
|
private static O5Toggle? _processAudioToggle;
|
|
private static O5Toggle? _nonTileToggle;
|
|
|
|
// Planet color overrides: the toggle enables the override, the picker holds the color. A disabled toggle stores
|
|
// null in AdvancedConfig (no override is applied by PersistencePatch) and blocks the picker.
|
|
private static O5Toggle? _firePlanetToggle;
|
|
private static O5ColorPicker? _firePlanetPicker;
|
|
private static O5Toggle? _icePlanetToggle;
|
|
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
|
|
// ===================================================================================================
|
|
|
|
/// <summary>
|
|
/// Shows the window, building it on the first call.
|
|
/// <list type="bullet">
|
|
/// <item>First call: creates a 900x600 resizable, draggable window with a close button, builds the controls
|
|
/// (<see cref="BuildUI"/>) and subscribes <see cref="OnLanguageChanged"/>.</item>
|
|
/// <item>Later calls: reloads the preset list from disk and selects <c>GlobalSettings.Current.LastPresetName</c>
|
|
/// (or the first preset).</item>
|
|
/// </list>
|
|
/// Then writes <see cref="UIConfig"/> 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).
|
|
/// </summary>
|
|
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
|
|
// ===================================================================================================
|
|
|
|
/// <summary>
|
|
/// Builds every control of the window inside a vertical scroll view (10 units between items, 8 units padding).
|
|
/// <para>
|
|
/// The initial preset is <c>GlobalSettings.Current.LastPresetName</c> if it exists, otherwise the first preset whose
|
|
/// name starts with "QHD", otherwise the first preset. Its values are copied into <see cref="UIConfig"/> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// Sliders use a <c>sliderFilter</c> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="content">Content rect of the window.</param>
|
|
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<RectTransform>();
|
|
var actionsHLayout = actionsRowObj.AddComponent<HorizontalLayoutGroup>();
|
|
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<LayoutElement>();
|
|
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<LayoutElement>() ?? _presetNameInput.Rect.gameObject.AddComponent<LayoutElement>();
|
|
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<LayoutElement>() ?? _savePresetButton.Rect.gameObject.AddComponent<LayoutElement>();
|
|
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<LayoutElement>() ?? _deletePresetButton.Rect.gameObject.AddComponent<LayoutElement>();
|
|
_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.OrdinalIgnoreCase))
|
|
{
|
|
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 Unity main 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<RenderMod>.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
|
|
// "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
|
|
);
|
|
|
|
// 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
|
|
);
|
|
|
|
// 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<LayoutElement>();
|
|
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<version>" 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<LayoutElement>();
|
|
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
|
|
// ===================================================================================================
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes every value of <paramref name="config"/> (after <see cref="EnsureDefaultConfig"/>) 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 <c>GlobalSettings.EffectiveOutputDirectory</c>.
|
|
/// </summary>
|
|
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);
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compares two configs by their serialized JSON (the same form as the preset files), so every saved value is compared.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Go Render button handler: reads the controls into <see cref="UIConfig"/>, validates, saves the global settings,
|
|
/// hides the window and starts a <see cref="RenderSession"/> for <c>RenderManager.Instance.levelPath</c>.
|
|
/// <para>
|
|
/// 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 (<c>RenderSession.Start</c>).
|
|
/// </para>
|
|
/// </summary>
|
|
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.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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads every control into <paramref name="target"/>, 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.
|
|
/// </summary>
|
|
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;
|
|
|
|
// 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
|
|
// ===================================================================================================
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static bool IsCustomPresetLabel(string text) =>
|
|
text == CustomPresetLabel || text == "(Modified)" || text == "(수정됨)";
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
private static string CustomPresetLabel => LocalizationManager.Get("ui.render_config.preset_custom");
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string>(currentValues);
|
|
list.Insert(0, customLabel);
|
|
_presetDropdown.SetValues(list);
|
|
}
|
|
|
|
_presetDropdown.Set(customLabel, false);
|
|
UpdateDeleteButtonState();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Preset dropdown callback: loads the selected preset into <paramref name="config"/> 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Save button handler: reads the controls into <paramref name="config"/> and saves it under the trimmed name from
|
|
/// the name input.
|
|
/// <list type="bullet">
|
|
/// <item>Empty name, or a name equal to a Custom label: a validation dialog is shown, nothing is saved.</item>
|
|
/// <item>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.</item>
|
|
/// <item>Otherwise the preset is saved directly (re-saving the loaded preset needs no confirmation).</item>
|
|
/// </list>
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the preset file (<c>PresetManager.SavePreset</c>), 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).
|
|
/// </summary>
|
|
private static void DoSavePreset(string name, RenderConfig config)
|
|
{
|
|
PresetManager.SavePreset(name, config);
|
|
GlobalSettings.Current.LastPresetName = name;
|
|
GlobalSettings.Save();
|
|
_lastLoadedPresetName = name;
|
|
ReloadPresetDropdown(selectName: name);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disarms the delete button after 3 seconds. Uses <see cref="WaitForSeconds"/>, i.e. scaled game time, run on
|
|
/// RendererUIManager.
|
|
/// </summary>
|
|
private static System.Collections.IEnumerator ResetDeletePendingAfterDelay()
|
|
{
|
|
yield return new WaitForSeconds(3f);
|
|
ResetDeletePendingState();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <paramref name="config"/>, the controls and the name input.
|
|
/// With no presets left the config and controls keep their values.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rebuilds the dropdown from the presets on disk and selects <paramref name="selectName"/> (or the first preset
|
|
/// when it is null or missing), recording the selection as <see cref="_lastLoadedPresetName"/>. With no presets the
|
|
/// dropdown holds only the Custom label and the last preset is cleared. Does not load preset values into the config.
|
|
/// </summary>
|
|
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<string> { 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enables the delete button only when the target preset exists on disk. The target is
|
|
/// <paramref name="presetName"/>, else the dropdown value, and the last loaded preset while the Custom label is
|
|
/// selected.
|
|
/// </summary>
|
|
/// <param name="presetName">Explicit target; every current caller passes none.</param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <see cref="LocalizationManager.OnLanguageChanged"/> handler: rebuilds the preset dropdown so that the Custom label
|
|
/// appears in the new language. All other texts follow the language through their <see cref="LocalizedText"/> bindings.
|
|
/// </summary>
|
|
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<string> { 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Version label text: "ADOFAI Renderer v" + the MelonInfo version, followed by the localized "(Expired)" once the
|
|
/// current UTC time reaches <c>RenderMod.ExpirationTime</c>.
|
|
/// </summary>
|
|
private static string GetVersionText()
|
|
{
|
|
var versionText = $"ADOFAI Renderer v{Melon<RenderMod>.Instance.Info.Version}";
|
|
if (DateTime.UtcNow >= RenderMod.ExpirationTime)
|
|
versionText += " " + LocalizationManager.Get("ui.render_config.expired");
|
|
return versionText;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logs <paramref name="message"/> as an error and shows it in a dialog titled "Invalid Settings" (localized) with a
|
|
/// single "OK" button (fixed English text).
|
|
/// </summary>
|
|
private static void ShowValidationError(string message)
|
|
{
|
|
Melon<RenderMod>.Logger.Error(message);
|
|
CommonWarningDialogUI.ShowDialog(
|
|
LocalizationManager.Get("ui.render_config.validation_title"),
|
|
message,
|
|
new DialogButton("OK", () => { })
|
|
);
|
|
}
|
|
|
|
// ===================================================================================================
|
|
// Layout helpers: column panels, field slots, cards
|
|
// ===================================================================================================
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// <list type="bullet">
|
|
/// <item>The panel is a <see cref="HorizontalLayoutGroup"/> that stretches to the parent width; the columns are
|
|
/// <paramref name="spacing"/> units apart.</item>
|
|
/// <item>Every column has preferredWidth 0 and flexibleWidth 1 (see <see cref="CreateColumn"/>), 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.</item>
|
|
/// <item>Rows line up across columns when the fields in the same row have the same height: put each field in a
|
|
/// <see cref="CreateFieldSlot"/> (label 18 + spacing 3 + control; dropdown controls are 50 units high).</item>
|
|
/// </list>
|
|
/// </summary>
|
|
/// <param name="parent">Card content or scroll content.</param>
|
|
/// <param name="columnCount">Number of columns, at least 1.</param>
|
|
/// <param name="spacing">Horizontal gap between columns, in units.</param>
|
|
/// <param name="columnSpacing">Vertical gap between fields inside a column, in units.</param>
|
|
/// <returns>The column rects, left to right.</returns>
|
|
/// <exception cref="ArgumentOutOfRangeException"><paramref name="columnCount"/> is less than 1.</exception>
|
|
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<RectTransform>();
|
|
|
|
var hLayout = panelObj.AddComponent<HorizontalLayoutGroup>();
|
|
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<LayoutElement>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>Two-column form of <see cref="CreateMultiColumnPanel"/>; returns the left and right column.</summary>
|
|
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]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates one column: a <see cref="VerticalLayoutGroup"/> (children stretched to the column width, <paramref name="spacing"/>
|
|
/// units apart) with a layout element of min/preferred width 0 and flexible width 1.
|
|
/// </summary>
|
|
private static RectTransform CreateColumn(Transform parent, string name, float spacing)
|
|
{
|
|
var colObj = new GameObject(name);
|
|
colObj.transform.SetParent(parent, false);
|
|
var colRect = colObj.AddComponent<RectTransform>();
|
|
|
|
var colLayout = colObj.AddComponent<VerticalLayoutGroup>();
|
|
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<LayoutElement>();
|
|
colLE.minWidth = 0f;
|
|
colLE.preferredWidth = 0f;
|
|
colLE.flexibleWidth = 1f;
|
|
|
|
return colRect;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static RectTransform CreateFieldSlot(Transform column)
|
|
{
|
|
var slotObj = new GameObject("Field");
|
|
slotObj.transform.SetParent(column, false);
|
|
var slotRect = slotObj.AddComponent<RectTransform>();
|
|
|
|
var slotLayout = slotObj.AddComponent<VerticalLayoutGroup>();
|
|
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<LayoutElement>();
|
|
slotLE.flexibleWidth = 1f;
|
|
|
|
return slotRect;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a collapsible O5Kit card (<c>O5Factory.Card</c>) with a localized title. The title text
|
|
/// (child "Header/TitleTrigger/TitleText", or any descendant named "TitleText") is set to 18 pt and bound to
|
|
/// <paramref name="titleKey"/> so it follows language changes. The remaining parameters are passed to O5Kit unchanged.
|
|
/// </summary>
|
|
/// <returns>The card root and the content rect that receives the card's fields.</returns>
|
|
// ReSharper disable once UnusedTupleComponentInReturnValue
|
|
private static (RectTransform card, RectTransform content) CreateCard(
|
|
O5Context ctx,
|
|
Transform parent,
|
|
string titleKey,
|
|
bool activeValue = true,
|
|
Action<bool>? 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<TMPro.TextMeshProUGUI>()
|
|
?? card.GetComponentsInChildren<TMPro.TextMeshProUGUI>(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)
|
|
// ===================================================================================================
|
|
|
|
/// <summary>
|
|
/// Creates a localized text label (default 13 pt, theme dim text color unless <paramref name="color"/> is given).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an O5Kit slider with a localized label (the slider's own label; no separate label row).
|
|
/// </summary>
|
|
/// <param name="ctx">O5Kit context.</param>
|
|
/// <param name="parent">Field slot or column that receives the slider.</param>
|
|
/// <param name="labelKey">Localization key of the slider label.</param>
|
|
/// <param name="id">O5Kit control id.</param>
|
|
/// <param name="defaultValue">Value restored by middle click.</param>
|
|
/// <param name="min">Minimum value.</param>
|
|
/// <param name="max">Maximum value.</param>
|
|
/// <param name="value">Initial value.</param>
|
|
/// <param name="format">Numeric display format, e.g. "F0", "F3".</param>
|
|
/// <param name="clampMode">How values outside min..max are clamped (O5Kit <see cref="ClampMode"/>).</param>
|
|
/// <param name="sliderFilter">Applied to dragged values (used for snapping); typed values are not filtered by it.</param>
|
|
/// <param name="inputFilter">Applied to values typed into the slider's input box.</param>
|
|
/// <param name="onChanged">Called with the new value on every change.</param>
|
|
/// <param name="onComplete">Called when an edit is completed.</param>
|
|
/// <param name="showFill">Whether the filled part of the track is drawn.</param>
|
|
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<double, double>? sliderFilter = null,
|
|
Func<double, double>? inputFilter = null,
|
|
Action<double>? onChanged = null,
|
|
Action<double>? 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an O5Kit button with a localized label. <paramref name="height"/> null uses the theme control height.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Same as the CreateInputField overload with an <c>out</c> label parameter, without returning the label.</summary>
|
|
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<string>? onChanged = null)
|
|
{
|
|
return CreateInputField(ctx, colParent, labelKey, defaultValue, value, id, out _, characterValidation, onChanged);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a labeled text input in <paramref name="colParent"/> (normally a field slot): an 18-unit label row and an
|
|
/// O5Kit input below it. The label and the placeholder are both bound to <paramref name="labelKey"/>.
|
|
/// </summary>
|
|
/// <param name="ctx">O5Kit context.</param>
|
|
/// <param name="colParent">Field slot or column that receives the label and the input.</param>
|
|
/// <param name="labelKey">Localization key of the label and the placeholder.</param>
|
|
/// <param name="defaultValue">Value restored by middle click; <c>null</c> for none.</param>
|
|
/// <param name="value">Initial text.</param>
|
|
/// <param name="id">O5Kit control id.</param>
|
|
/// <param name="label">Receives the label text component (used to dim the label of a blocked field).</param>
|
|
/// <param name="characterValidation">TextMeshPro character filter for typed text, e.g. Integer.</param>
|
|
/// <param name="onChanged">Called with the raw text on every change; parsing and validation are up to the caller.</param>
|
|
/// <returns>The input field.</returns>
|
|
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<string>? 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<LayoutElement>();
|
|
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)
|
|
// ===================================================================================================
|
|
|
|
/// <summary>
|
|
/// 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 <paramref name="noAnimate"/>.
|
|
/// </summary>
|
|
/// <param name="input">Input to block; may be null.</param>
|
|
/// <param name="label">Label above the input; may be null.</param>
|
|
/// <param name="tweenHandle">Stored tween handle of this label, replaced by the new tween.</param>
|
|
/// <param name="blocked">True to block (disable) the field.</param>
|
|
/// <param name="noAnimate">True to apply the change without animation.</param>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replaces the sample rate choices with those supported by the audio codec paired with <paramref name="codec"/>.
|
|
/// 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 <paramref name="config"/> when one is given.
|
|
/// </summary>
|
|
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
|
|
// ===================================================================================================
|
|
|
|
/// <summary>
|
|
/// Creates a labeled text input that spans the full width of <paramref name="parent"/>: 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.
|
|
/// </summary>
|
|
private static O5InputField CreateFullWidthInputField(
|
|
O5Context ctx,
|
|
Transform parent,
|
|
string labelKey,
|
|
string? defaultValue,
|
|
string value,
|
|
string id,
|
|
Action<string>? onChanged = null)
|
|
{
|
|
var container = new GameObject($"FullWidth_{id}");
|
|
container.transform.SetParent(parent, false);
|
|
|
|
var containerRect = container.AddComponent<RectTransform>();
|
|
var layout = container.AddComponent<VerticalLayoutGroup>();
|
|
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<LayoutElement>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an O5Kit toggle whose text is localized with <paramref name="toggleTextKey"/>. With
|
|
/// <paramref name="generateLabel"/> an 18-unit label row is added above it (text from <paramref name="labelKey"/>,
|
|
/// empty when the key is empty), which keeps the toggle aligned with labeled fields in the neighboring column.
|
|
/// </summary>
|
|
private static O5Toggle CreateToggleField(
|
|
O5Context ctx,
|
|
Transform colParent,
|
|
string labelKey,
|
|
bool? defaultValue,
|
|
bool value,
|
|
string toggleTextKey,
|
|
string id,
|
|
Action<bool>? 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<LayoutElement>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a labeled dropdown: an 18-unit label row and a container of preferred height 50 units holding the O5Kit
|
|
/// dropdown. <paramref name="display"/> converts values to their shown text. The change callback is not invoked for
|
|
/// a null value.
|
|
/// </summary>
|
|
private static O5Dropdown<T> CreateDropdownField<T>(
|
|
O5Context ctx,
|
|
Transform colParent,
|
|
string labelKey,
|
|
T defaultValue,
|
|
T value,
|
|
IReadOnlyList<T> values,
|
|
Func<T, string> display,
|
|
string id,
|
|
Action<T>? 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<LayoutElement>();
|
|
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<LayoutElement>();
|
|
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
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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. <paramref name="onChanged"/> is used both while
|
|
/// editing and on completion. The header is re-proportioned for narrow (three-column) layouts, see below.
|
|
/// </summary>
|
|
private static O5ColorPicker CreateColorPickerField(
|
|
O5Context ctx,
|
|
Transform colParent,
|
|
Color? defaultValue,
|
|
Color value,
|
|
string id,
|
|
Action<Color>? onChanged = null)
|
|
{
|
|
var cpContainer = new GameObject($"CP_{id}");
|
|
cpContainer.transform.SetParent(colParent, false);
|
|
|
|
// ReSharper disable once InconsistentNaming
|
|
var cpLE = cpContainer.AddComponent<LayoutElement>();
|
|
var height = ctx.Theme.ControlHeightFor(ctx.Theme.FontSizeBody);
|
|
cpLE.preferredHeight = height;
|
|
cpLE.minHeight = height;
|
|
|
|
var canvasRect = RendererUIManager.Instance.WindowManager.Canvas.Root.GetComponent<RectTransform>();
|
|
|
|
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<TMPro.TMP_InputField>();
|
|
if (tmpInput != null && tmpInput.textComponent != null)
|
|
{
|
|
tmpInput.textComponent.fontSize = 15f;
|
|
}
|
|
}
|
|
}
|
|
|
|
return picker;
|
|
}
|
|
|
|
// ===================================================================================================
|
|
// Window visibility
|
|
// ===================================================================================================
|
|
|
|
/// <summary>Shows or hides the window; hiding first collapses all open color pickers.</summary>
|
|
private static void SetWindowActive(bool enabled)
|
|
{
|
|
if (!enabled)
|
|
{
|
|
CloseAllPickers();
|
|
}
|
|
_configWindow?.Rect.gameObject.SetActive(enabled);
|
|
}
|
|
|
|
/// <summary>Collapses the three planet color pickers without animation.</summary>
|
|
private static void CloseAllPickers()
|
|
{
|
|
_firePlanetPicker?.SetExpanded(false, true);
|
|
_icePlanetPicker?.SetExpanded(false, true);
|
|
_windPlanetPicker?.SetExpanded(false, true);
|
|
}
|
|
} |