diff --git a/BaseLib/localization/eng/settings_ui.json b/BaseLib/localization/eng/settings_ui.json index c639e7f..612199d 100644 --- a/BaseLib/localization/eng/settings_ui.json +++ b/BaseLib/localization/eng/settings_ui.json @@ -1,4 +1,6 @@ { + "BASELIB.mod_title": "Example mod configuration", + "BASELIB-FIRST_SECTION.title": "These are just example options", "BASELIB-SECOND_SECTION.title": "Nothing here does anything at all", diff --git a/Config/ModConfig.cs b/Config/ModConfig.cs index db3ae12..117c270 100644 --- a/Config/ModConfig.cs +++ b/Config/ModConfig.cs @@ -4,17 +4,30 @@ using System.Text.RegularExpressions; using BaseLib.Config.UI; using BaseLib.Extensions; -using BaseLib.Utils; using Godot; using HarmonyLib; using MegaCrit.Sts2.addons.mega_text; using MegaCrit.Sts2.Core.Assets; using MegaCrit.Sts2.Core.Helpers; using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; using MegaCrit.Sts2.Core.Nodes.Screens.Settings; namespace BaseLib.Config; +// NMainMenu is recreated every time you return from a run, etc., so this isn't a run-once as it seems. +// We also check for errors when exiting the mod config submenu, as that won't trigger this code. +[HarmonyPatch(typeof(NMainMenu), nameof(NMainMenu._Ready))] +public static class NMainMenu_Ready_Patch +{ + public static void Postfix() + { + if (ModConfig.ModConfigLogger.PendingUserMessages.Count == 0) return; + Callable.From(ModConfig.ShowAndClearPendingErrors).CallDeferred(); + } +} + public abstract partial class ModConfig { private const string SettingsTheme = "res://themes/settings_screen_line_header.tres"; @@ -26,7 +39,7 @@ public abstract partial class ModConfig public event EventHandler? ConfigChanged; private readonly string _path; - protected string ModPrefix { get; private set; } + public string ModPrefix { get; private set; } private readonly string _modConfigName; private bool _savingDisabled; @@ -38,12 +51,17 @@ public static class ModConfigLogger { public static List PendingUserMessages { get; } = []; + /// + /// Show a message in the console, and optionally in the GUI. Only use showInGui=true if truly necessary; + /// players won't enjoy having warnings/errors shoved in their faces unless it's something that truly impacts them. + /// public static void Warn(string message, bool showInGui = false) { MainFile.Logger.Warn(message); if (showInGui && !PendingUserMessages.Contains(message)) PendingUserMessages.Add(message); } + /// public static void Error(string message, bool showInGui = true) { MainFile.Logger.Error(message); @@ -90,7 +108,7 @@ private void CheckConfigProperties() if (!property.CanRead || !property.CanWrite) continue; if (property.GetMethod?.IsStatic != true) { - ModConfigLogger.Warn($"Ignoring {_modConfigName} property {property.Name}: only static properties are supported", true); + ModConfigLogger.Warn($"Ignoring {_modConfigName} property {property.Name}: only static properties are supported"); continue; } @@ -117,22 +135,36 @@ public void Save() { if (_savingDisabled) { - ModConfigLogger.Error($"Skipping save for {_modConfigName} because the config file is currently in a corrupted, read-only state."); + // No GUI error here, because that would've been shown already when _savingDisabled was set. + ModConfigLogger.Warn($"Skipping save for {_modConfigName} because the config file is currently in a corrupted, read-only state."); return; } Dictionary values = []; - foreach (var property in ConfigProperties) + + try { - var value = property.GetValue(null); + foreach (var property in ConfigProperties) + { + var value = property.GetValue(null); - var converter = TypeDescriptor.GetConverter(property.PropertyType); - var stringValue = converter.ConvertToInvariantString(value); + var converter = TypeDescriptor.GetConverter(property.PropertyType); + var stringValue = converter.ConvertToInvariantString(value); - if (stringValue != null) - values.Add(property.Name, stringValue); - else - ModConfigLogger.Warn($"Failed to convert {_modConfigName} property {property.Name} to string for saving; it will be omitted.", true); + if (stringValue != null) + values.Add(property.Name, stringValue); + else + { + ModConfigLogger.Warn( + $"Failed to convert {_modConfigName} property {property.Name} to string for saving; " + + "it will be omitted."); + } + } + } + catch (Exception) + { + // During testing, I have never seen an exception here, but let's avoid a game crash/menu hang, etc. + ModConfigLogger.Error($"Failed to save config {_modConfigName}: unknown error during conversion.", false); } try @@ -186,17 +218,20 @@ public void Load() if (!TryApplyPropertyValue(property, value)) hasSoftErrors = true; } - MainFile.Logger.Info(!hasSoftErrors - ? $"Loaded config {_modConfigName} successfully" - : $"Loaded config {_modConfigName} with some missing or invalid fields."); + if (hasSoftErrors) + ModConfigLogger.Warn($"Loaded config {_modConfigName} with some missing or invalid fields."); } } catch (JsonException jsonEx) { - ModConfigLogger.Error($"Failed to parse config file for {_modConfigName}. The JSON is likely invalid. " + - $"Error: {jsonEx.Message}"); - ModConfigLogger.Warn("Config saving has been DISABLED for this session to protect any manual edits. " + - "Please fix the JSON formatting.", true); + // Unlikely to happen except for people who have modified the file manually, so let's be verbose and show in GUI. + var locationText = jsonEx.LineNumber.HasValue + ? $"Line {jsonEx.LineNumber + 1}, position {jsonEx.BytePositionInLine + 1}" + : "unknown line"; + ModConfigLogger.Error($"Failed to parse config file for {_modConfigName}. The JSON is likely invalid.\n" + + $"File path: {_path}\n" + + $"Error location: {locationText}"); + ModConfigLogger.Warn("Config saving has been DISABLED for this mod to protect any manual edits.", true); _savingDisabled = true; return; } @@ -224,7 +259,7 @@ private static bool TryApplyPropertyValue(PropertyInfo property, string value) if (configVal == null) { ModConfigLogger.Warn($"Failed to load saved config value \"{value}\" for property {property.Name}:" + - "Converter returned null.", true); + "Converter returned null."); return false; } @@ -239,7 +274,7 @@ private static bool TryApplyPropertyValue(PropertyInfo property, string value) catch (Exception ex) { ModConfigLogger.Warn($"Failed to load saved config value \"{value}\" for property {property.Name}. " + - $"Error: {ex.Message}", true); + $"Error: {ex.Message}"); return false; } } @@ -250,27 +285,27 @@ protected string GetLabelText(string labelName) return loc != null ? loc.GetFormattedText() : labelName; } - // Creates a raw toggle control, with no layout (see SimpleModConfig.CreateToggleOption unless you want custom layout) + // Creates a raw toggle control, with no layout (use SimpleModConfig.CreateToggleOption unless you want custom layout) protected NConfigTickbox CreateRawTickboxControl(PropertyInfo property) { - var tickbox = new NConfigTickbox().TransferAllNodes(SceneHelper.GetScenePath("screens/settings_tickbox")); + var tickbox = new NConfigTickbox(); tickbox.Initialize(this, property); return tickbox; } - // Creates a raw slider control, with no layout (see SimpleModConfig.CreateSliderOption unless you want custom layout) + // Creates a raw slider control, with no layout (use SimpleModConfig.CreateSliderOption unless you want custom layout) protected NConfigSlider CreateRawSliderControl(PropertyInfo property) { - var slider = new NConfigSlider().TransferAllNodes(SceneHelper.GetScenePath("screens/settings_slider")); + var slider = new NConfigSlider(); slider.Initialize(this, property); return slider; } - // Creates a raw dropdown control, with no layout (see SimpleModConfig.CreateDropdownOption unless you want custom layout) + // Creates a raw dropdown control, with no layout (use SimpleModConfig.CreateDropdownOption unless you want custom layout) private static readonly FieldInfo DropdownNode = AccessTools.DeclaredField(typeof(NDropdownPositioner), "_dropdownNode"); protected NDropdownPositioner CreateRawDropdownControl(PropertyInfo property) { - var dropdown = new NConfigDropdown().TransferAllNodes(SceneHelper.GetScenePath("screens/settings_dropdown")); + var dropdown = new NConfigDropdown(); var items = CreateDropdownItems(property, out var currentIndex); dropdown.SetItems(items, currentIndex); @@ -323,7 +358,7 @@ protected NDropdownPositioner CreateRawDropdownControl(PropertyInfo property) // Creates a raw label control, with no layout (see SimpleModConfig.Create*Option and CreateSectionHeader for // layout-ready controls) - protected static MegaRichTextLabel CreateRawLabelControl(string labelText, int fontSize) + public static MegaRichTextLabel CreateRawLabelControl(string labelText, int fontSize) { var kreonNormal = PreloadManager.Cache.GetAsset("res://themes/kreon_regular_shared.tres"); var kreonBold = PreloadManager.Cache.GetAsset("res://themes/kreon_bold_shared.tres"); @@ -334,6 +369,7 @@ protected static MegaRichTextLabel CreateRawLabelControl(string labelText, int f Theme = PreloadManager.Cache.GetAsset(SettingsTheme), AutoSizeEnabled = false, MouseFilter = Control.MouseFilterEnum.Ignore, + FocusMode = Control.FocusModeEnum.None, BbcodeEnabled = true, ScrollActive = false, VerticalAlignment = VerticalAlignment.Center, @@ -342,11 +378,7 @@ protected static MegaRichTextLabel CreateRawLabelControl(string labelText, int f label.AddThemeFontOverride("normal_font", kreonNormal); label.AddThemeFontOverride("bold_font", kreonBold); - label.AddThemeFontSizeOverride("normal_font_size", fontSize); - label.AddThemeFontSizeOverride("bold_font_size", fontSize); - label.AddThemeFontSizeOverride("bold_italics_font_size", fontSize); - label.AddThemeFontSizeOverride("italics_font_size", fontSize); - label.AddThemeFontSizeOverride("mono_font_size", fontSize); + label.AddThemeFontSizeOverrideAll(fontSize); return label; } @@ -362,6 +394,23 @@ protected static ColorRect CreateDividerControl() }; } + public static void ShowAndClearPendingErrors() + { + var pendingMessages = ModConfigLogger.PendingUserMessages; + if (pendingMessages.Count <= 0) return; + + var errorPopup = NErrorPopup.Create("Mod configuration error", + string.Join('\n', pendingMessages), false); + if (errorPopup == null || NModalContainer.Instance == null) return; + NModalContainer.Instance.Add(errorPopup); + + var vertPopup = errorPopup.GetNodeOrNull("VerticalPopup"); + if (vertPopup == null) return; + vertPopup.BodyLabel.AddThemeFontSizeOverrideAll(22); + + pendingMessages.Clear(); + } + [GeneratedRegex("[^a-zA-Z0-9_.]")] private static partial Regex SpecialCharRegex(); } \ No newline at end of file diff --git a/Config/SimpleModConfig.cs b/Config/SimpleModConfig.cs index 551c840..cbae80a 100644 --- a/Config/SimpleModConfig.cs +++ b/Config/SimpleModConfig.cs @@ -52,6 +52,7 @@ protected MarginContainer CreateSectionHeader(string labelName, bool alignToTop container.AddThemeConstantOverride("margin_left", 24); container.AddThemeConstantOverride("margin_right", 24); container.MouseFilter = Control.MouseFilterEnum.Ignore; + container.FocusMode = Control.FocusModeEnum.None; var label = CreateRawLabelControl($"[center][b]{GetLabelText(labelName)}[/b][/center]", 40); label.Name = "SectionLabel_" + labelName.Replace(" ", ""); @@ -147,14 +148,12 @@ protected void GenerateOptionsForAllProperties(Control targetContainer) if (previousSetting != null) { - var path = currentSetting.GetPathTo(previousSetting); - if (currentSetting.FocusNeighborLeft?.IsEmpty != false) currentSetting.FocusNeighborLeft = path; - if (currentSetting.FocusNeighborTop?.IsEmpty != false) currentSetting.FocusNeighborTop = path; - - path = previousSetting.GetPathTo(currentSetting); - if (previousSetting.FocusNeighborRight?.IsEmpty != false) previousSetting.FocusNeighborRight = path; - if (previousSetting.FocusNeighborBottom?.IsEmpty != false) previousSetting.FocusNeighborBottom = path; + currentSetting.FocusNeighborTop = currentSetting.GetPathTo(previousSetting); + previousSetting.FocusNeighborBottom = previousSetting.GetPathTo(currentSetting); } + + currentSetting.FocusNeighborLeft = currentSetting.GetPath(); + currentSetting.FocusNeighborRight = currentSetting.GetPath(); } catch (NotSupportedException ex) { diff --git a/Config/UI/NConfigButton.cs b/Config/UI/NConfigButton.cs index c4c7cdb..de18e21 100644 --- a/Config/UI/NConfigButton.cs +++ b/Config/UI/NConfigButton.cs @@ -1,7 +1,10 @@ -using BaseLib.Patches.UI; +using System.Reflection; +using BaseLib.Patches.UI; using BaseLib.Utils; using Godot; +using HarmonyLib; using MegaCrit.Sts2.Core.Assets; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; using MegaCrit.Sts2.Core.Nodes.Screens.ModdingScreen; using MegaCrit.Sts2.Core.Nodes.TopBar; @@ -59,6 +62,7 @@ public override void _Process(double delta) } } + private static readonly FieldInfo ModdingScreenStack = AccessTools.Field(typeof(NModdingScreen), "_stack"); protected override void OnRelease() { base.OnRelease(); @@ -74,17 +78,7 @@ protected override void OnRelease() var modConfig = ModConfigRegistry.Get(mod.manifest?.id); if (modConfig != null) { - // Find NModdingScreen - var parent = GetParent(); - while (parent != null && parent is not NModdingScreen) - { - parent = parent.GetParent(); - } - - if (parent is not NModdingScreen screen) return; - - NModConfigPopup.ShowModConfig(screen, modConfig, this); - IsConfigOpen = true; + OpenModConfigSubmenu(modConfig); } } @@ -105,10 +99,27 @@ public override void OnUnfocus() base.OnUnfocus(); NHoverTipSet.Remove(this); }*/ - protected override bool IsOpen() { return IsConfigOpen; } + + private void OpenModConfigSubmenu(ModConfig modConfig) + { + var stackField = AccessTools.Field(typeof(NSubmenu), "_stack"); + + if (FindParent("ModdingScreen") is not NModdingScreen moddingScreen || + stackField.GetValue(moddingScreen) is not NMainMenuSubmenuStack stackInstance) + { + ModConfig.ModConfigLogger.Error("Unable to locate the game's modding screen!\n" + + "Please report a bug at:\nhttps://github.com/Alchyr/BaseLib-StS2"); + return; + } + + var modConfigSubmenu = stackInstance.PushSubmenuType(); + modConfigSubmenu.LoadModConfig(modConfig, this); + + IsConfigOpen = true; + } } \ No newline at end of file diff --git a/Config/UI/NConfigDropdown.cs b/Config/UI/NConfigDropdown.cs index df3e2cb..17c9f6b 100644 --- a/Config/UI/NConfigDropdown.cs +++ b/Config/UI/NConfigDropdown.cs @@ -1,4 +1,5 @@ using System.Reflection; +using BaseLib.Utils; using Godot; using HarmonyLib; using MegaCrit.Sts2.Core.Helpers; @@ -13,6 +14,7 @@ public partial class NConfigDropdown : NSettingsDropdown private List? _items; private int _currentDisplayIndex = -1; private float _lastGlobalY; + private NodePath _selfNodePath = new("."); private static readonly FieldInfo DropdownContainerField = AccessTools.Field(typeof(NDropdown), "_dropdownContainer"); @@ -22,12 +24,21 @@ public NConfigDropdown() SizeFlagsHorizontal = SizeFlags.ShrinkEnd; SizeFlagsVertical = SizeFlags.Fill; FocusMode = FocusModeEnum.All; + + this.TransferAllNodes(SceneHelper.GetScenePath("screens/settings_dropdown")); } public override void _Process(double delta) { base._Process(delta); + // Hacky, but this is overwritten and causes issues. Setting it in _Ready and on row creation isn't enough. + if (FocusNeighborLeft != _selfNodePath || FocusNeighborRight != _selfNodePath) + { + FocusNeighborLeft = _selfNodePath; + FocusNeighborRight = _selfNodePath; + } + if (DropdownContainerField.GetValue(this) is Control { Visible: true } container) { // Ensure the list of items follows the dropdown itself when the parent container is scrolled. @@ -73,6 +84,11 @@ public override void _Ready() container.VisibilityChanged += () => { container.TopLevel = container.Visible; container.GlobalPosition = GlobalPosition + new Vector2(0, Size.Y); + + // Focus the last selected entry (base class always selects the first) + if (_currentDisplayIndex < 0 || _currentDisplayIndex >= _items.Count) return; + var entry = _dropdownItems.GetChildOrNull(_currentDisplayIndex); + entry?.TryGrabFocus(); }; } } diff --git a/Config/UI/NConfigOptionRow.cs b/Config/UI/NConfigOptionRow.cs index 4849236..f16846d 100644 --- a/Config/UI/NConfigOptionRow.cs +++ b/Config/UI/NConfigOptionRow.cs @@ -28,9 +28,10 @@ public NConfigOptionRow(string modPrefix, PropertyInfo property, Control label, Name = property.Name; SettingControl = settingControl; - AddThemeConstantOverride("margin_left", 24); - AddThemeConstantOverride("margin_right", 24); + AddThemeConstantOverride("margin_left", 12); + AddThemeConstantOverride("margin_right", 12); MouseFilter = MouseFilterEnum.Pass; + FocusMode = FocusModeEnum.None; CustomMinimumSize = new Vector2(0, 64); label.CustomMinimumSize = new Vector2(0, 64); diff --git a/Config/UI/NConfigSlider.cs b/Config/UI/NConfigSlider.cs index a133b04..d4b30e3 100644 --- a/Config/UI/NConfigSlider.cs +++ b/Config/UI/NConfigSlider.cs @@ -1,47 +1,79 @@ using System.Reflection; +using BaseLib.Utils; using Godot; using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.ControllerInput; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Nodes.Combat; +using MegaCrit.Sts2.Core.Nodes.CommonUi; using MegaCrit.Sts2.Core.Nodes.GodotExtensions; namespace BaseLib.Config.UI; -// We don't inherit from NSettingsSlider because it's too rigid +// We don't inherit from NSettingsSlider because it's too rigid (forces % in the format, forces step of 5, fixed width) public partial class NConfigSlider : Control { private ModConfig? _config; private PropertyInfo? _property; private string _displayFormat = "{0}"; + private NSlider _slider; + private MegaLabel _sliderLabel; + private NSelectionReticle _selectionReticle; + // _realMin is a workaround to support negative numbers, without forcing // the underlying NSlider to understand that such things really exist - private NSlider _slider = null!; private double _realMin; + private const int LabelFontSize = 28; + + // Controller hold-to-accelerate stuff + private enum HoldDirection { None, Left, Right } + private HoldDirection _holdDir = HoldDirection.None; + private float _holdTimer; + private float _stepTimer; + private float _currentRepeatRate = 0.1f; + private const float InitialDelay = 0.3f; + private const float StartingRepeatRate = 0.1f; + private float _minRepeatDelay = 0.002f; public NConfigSlider() { - SetCustomMinimumSize(new Vector2(320, 64)); + var targetSize = new Vector2(324, 64); + CustomMinimumSize = targetSize; + Size = targetSize; SizeFlagsHorizontal = SizeFlags.ShrinkEnd; SizeFlagsVertical = SizeFlags.Fill; + FocusMode = FocusModeEnum.All; + + this.TransferAllNodes(SceneHelper.GetScenePath("screens/settings_slider")); + + _slider = GetNode("Slider"); + _sliderLabel = GetNode("SliderValue"); + _selectionReticle = GetNode((NodePath) "SelectionReticle"); } public override void _Ready() { - _slider = GetNode("Slider"); + _slider.FocusMode = FocusModeEnum.None; + var numSteps = (float)((_slider.MaxValue - _slider.MinValue) / _slider.Step); + var dynamicFloor = 1.5f / numSteps; + _minRepeatDelay = Mathf.Max(0.002f, dynamicFloor); - var label = GetNodeOrNull("SliderValue"); - if (label != null) - { - label.AutoSizeEnabled = false; - label.AddThemeFontSizeOverride("font_size", 28); + _sliderLabel.AutoSizeEnabled = false; + _sliderLabel.AddThemeFontSizeOverride("font_size", LabelFontSize); - // Right-align the label and let it overflow, so users can use formats more than a few characters wide - label.GrowHorizontal = GrowDirection.Begin; - label.HorizontalAlignment = HorizontalAlignment.Right; - label.ClipContents = false; - } + // Right-align the label and let it overflow, so users can use formats more than a few characters wide + _sliderLabel.GrowHorizontal = GrowDirection.Begin; + _sliderLabel.HorizontalAlignment = HorizontalAlignment.Right; + _sliderLabel.ClipContents = false; + + _selectionReticle.AnchorRight = 1f; + _selectionReticle.OffsetRight = 10f; SetFromProperty(); _slider.Connect(Godot.Range.SignalName.ValueChanged, Callable.From(OnValueChanged)); + Connect(Godot.Control.SignalName.FocusEntered, Callable.From(OnFocus)); + Connect(Godot.Control.SignalName.FocusExited, Callable.From(OnUnfocus)); } public void Initialize(ModConfig modConfig, PropertyInfo property) @@ -85,7 +117,85 @@ private void OnValueChanged(double proxyValue) private void UpdateLabel(double value) { - var label = GetNodeOrNull("SliderValue"); - label?.SetText(string.Format(_displayFormat, value)); + _sliderLabel.Text = string.Format(_displayFormat, value); + + // Update the reticle; the label size doesn't match the text size, so calculate the correct offset manually + var textWidth = _sliderLabel.GetMinimumSize().X; + var labelRightEdge = _sliderLabel.Position.X + _sliderLabel.Size.X; + var labelLeftEdge = labelRightEdge - textWidth; + _selectionReticle.OffsetLeft = labelLeftEdge - 10f; + } + + private void OnFocus() + { + if (NControllerManager.Instance?.IsUsingController != true) return; + _selectionReticle.OnSelect(); + } + + private void OnUnfocus() + { + _selectionReticle.OnDeselect(); + } + + // The remaining code below is all controller specific, to improve UX by not being forced to tap left/right + // once for every step you want to move the slider. (Technically works with keyboard, too.) + + public override void _GuiInput(InputEvent @event) + { + base._GuiInput(@event); + + if (@event.IsActionPressed(MegaInput.left)) + { + _slider.Value -= _slider.Step; + StartHolding(HoldDirection.Left); + AcceptEvent(); + } + else if (@event.IsActionPressed(MegaInput.right)) + { + _slider.Value += _slider.Step; + StartHolding(HoldDirection.Right); + AcceptEvent(); + } + + else if (@event.IsActionReleased(MegaInput.left) && _holdDir == HoldDirection.Left || + @event.IsActionReleased(MegaInput.right) && _holdDir == HoldDirection.Right) + { + _holdDir = HoldDirection.None; + } + } + + private void StartHolding(HoldDirection dir) + { + _holdDir = dir; + _holdTimer = 0f; + _stepTimer = 0f; + _currentRepeatRate = StartingRepeatRate; + } + + public override void _Process(double delta) + { + base._Process(delta); + + if (_holdDir == HoldDirection.None) return; + if (!HasFocus()) + { + _holdDir = HoldDirection.None; + return; + } + + _holdTimer += (float)delta; + if (_holdTimer < InitialDelay) return; + + _stepTimer += (float)delta; + if (_stepTimer < _currentRepeatRate) return; + + // Time to make a step; accelerate until we reach the limit + _stepTimer = 0f; + _currentRepeatRate = Mathf.Clamp(_currentRepeatRate - 0.01f, _minRepeatDelay, 0.15f); + + if (_holdDir == HoldDirection.Left) + _slider.Value -= _slider.Step; + else + _slider.Value += _slider.Step; } } \ No newline at end of file diff --git a/Config/UI/NConfigTickbox.cs b/Config/UI/NConfigTickbox.cs index 034db40..cc2ecb4 100644 --- a/Config/UI/NConfigTickbox.cs +++ b/Config/UI/NConfigTickbox.cs @@ -1,5 +1,7 @@ using System.Reflection; +using BaseLib.Utils; using Godot; +using MegaCrit.Sts2.Core.Helpers; using MegaCrit.Sts2.Core.Nodes.Screens.Settings; namespace BaseLib.Config.UI; @@ -11,26 +13,19 @@ public partial class NConfigTickbox : NSettingsTickbox public NConfigTickbox() { - SetCustomMinimumSize(new Vector2(64, 64)); + SetCustomMinimumSize(new Vector2(320, 64)); SizeFlagsHorizontal = SizeFlags.ShrinkEnd; SizeFlagsVertical = SizeFlags.Fill; + FocusMode = FocusModeEnum.All; + MouseFilter = MouseFilterEnum.Pass; + + this.TransferAllNodes(SceneHelper.GetScenePath("screens/settings_tickbox")); } public override void _Ready() { if (_property == null) throw new Exception("NConfigTickbox added to tree without an assigned property"); ConnectSignals(); - - var tickboxVisuals = GetNode("%TickboxVisuals"); - tickboxVisuals.SetAnchorsAndOffsetsPreset(LayoutPreset.CenterRight, LayoutPresetMode.KeepSize); - - if (GetParent() is MarginContainer parentContainer) - { - // Hacky, but aligns it properly with dropdowns and sliders. Likely needed due to transparent pixels - // in the graphic. - parentContainer.AddThemeConstantOverride("margin_right", parentContainer.GetThemeConstant("margin_right") - 10); - } - SetFromProperty(); } diff --git a/Config/UI/NModConfigPopup.cs b/Config/UI/NModConfigPopup.cs deleted file mode 100644 index bc650d6..0000000 --- a/Config/UI/NModConfigPopup.cs +++ /dev/null @@ -1,203 +0,0 @@ -using BaseLib.Utils; -using Godot; -using HarmonyLib; -using MegaCrit.Sts2.Core.Assets; -using MegaCrit.Sts2.Core.Helpers; -using MegaCrit.Sts2.Core.Nodes.CommonUi; -using MegaCrit.Sts2.Core.Nodes.GodotExtensions; -using MegaCrit.Sts2.Core.Nodes.Screens.ModdingScreen; - -namespace BaseLib.Config.UI; - -public partial class NModConfigPopup : NClickableControl -{ - public static readonly SpireField ConfigPopup = new(Create); - - [HarmonyPatch(typeof(NModdingScreen), nameof(NModdingScreen._Ready))] - static class NModConfigPatch - { - [HarmonyPostfix] - static void PrepPopup(NModdingScreen __instance) - { - ConfigPopup.Get(__instance); - } - } - - public static void ShowModConfig(NModdingScreen screen, ModConfig config, NConfigButton opener) - { - var popup = ConfigPopup.Get(screen); - if (popup == null) - { - opener.IsConfigOpen = false; - return; - } - - popup?.ShowMod(config, opener); - } - - private ModConfig? _currentConfig; - private NScrollableContainer _optionScrollContainer; - private VBoxContainer _optionContainer; - private NConfigButton? _opener; - private double _saveTimer; //When any config option is changed, starts a timer. If enough time passes with no change, saves. - private const double AutosaveDelay = 5; - - public static NModConfigPopup Create(NModdingScreen screen) - { - NModConfigPopup popup = new(screen); - /*popup.Size = screen.Size; - popup.MouseFilter = MouseFilterEnum.Ignore; - popup.Hide(); - - screen.AddChild(popup); - popup.Owner = screen; - - popup._optionContainer.Size = new(, 1); - popup.Position = */ - return popup; - } - - private NModConfigPopup(Control futureParent) - { - _saveTimer = -1; - - Size = futureParent.Size; - MouseFilter = MouseFilterEnum.Ignore; - - _optionScrollContainer = new(); - _optionScrollContainer.MouseFilter = MouseFilterEnum.Stop; - _optionScrollContainer.Size = new Vector2( - x: Math.Max(480, Size.X * 0.5f), - y: Math.Min(950, Size.Y * 0.95f) - ); - Color back = new Color(0.1f, 0.1f, 0.1f); //Allow mods to change the color of their panel? - Color border = new Color(239/255f, 198/255f, 93/255f); //Allow mods to change the color of their panel? - _optionScrollContainer.Draw += () => - { - _optionScrollContainer - .DrawRect(new Rect2(0, 0, _optionScrollContainer.Size), back); - _optionScrollContainer - .DrawRect(new Rect2(0, 0, _optionScrollContainer.Size), border, false, 2); - }; - - AddChild(_optionScrollContainer); - _optionScrollContainer.Owner = this; - _optionScrollContainer.Position = Size * 0.5f - _optionScrollContainer.Size * 0.5f; - - NScrollbar scrollbar = PreloadManager.Cache.GetScene(SceneHelper.GetScenePath("ui/scrollbar")).Instantiate(); - scrollbar.Name = "Scrollbar"; - _optionScrollContainer.AddChild(scrollbar); - scrollbar.Owner = _optionScrollContainer; - - scrollbar.SetAnchorsPreset(LayoutPreset.RightWide); - scrollbar.OffsetLeft = 0; - scrollbar.OffsetRight = 48; - scrollbar.OffsetTop = 32; - scrollbar.OffsetBottom = -32; - - Control mask = new(); - mask.Name = "Mask"; - mask.Size = _optionScrollContainer.Size; - mask.MouseFilter = MouseFilterEnum.Ignore; - mask.ClipContents = true; - - _optionScrollContainer.AddChild(mask); - mask.Owner = _optionScrollContainer; - - _optionContainer = new VBoxContainer(); - _optionContainer.Name = "Content"; - _optionContainer.CustomMinimumSize = new Vector2(mask.Size.X, 0); - mask.MouseFilter = MouseFilterEnum.Ignore; - - _optionContainer.MinimumSizeChanged += () => - { - _optionContainer.Size = new Vector2(mask.Size.X, _optionContainer.GetMinimumSize().Y); - }; - - mask.AddChild(_optionContainer); - _optionContainer.Owner = mask; - - Hide(); - futureParent.AddChildSafely(this); - } - - public override void _Ready() - { - ConnectSignals(); - } - - private void ShowMod(ModConfig config, NConfigButton opener) - { - _opener = opener; - NHotkeyManager.Instance?.AddBlockingScreen(this); - MouseFilter = MouseFilterEnum.Stop; - - try - { - config.SetupConfigUI(_optionContainer); - _optionScrollContainer.DisableScrollingIfContentFits(); - _optionScrollContainer.InstantlyScrollToTop(); - _currentConfig = config; - config.ConfigChanged += OnConfigChanged; - Show(); - - var pendingMessages = ModConfig.ModConfigLogger.PendingUserMessages; - if (pendingMessages.Count > 0) - { - var popup = NErrorPopup.Create("Mod configuration error", string.Join('\n', pendingMessages), false); - NModalContainer.Instance?.Add(popup!); - pendingMessages.Clear(); - } - } - catch (Exception e) - { - MainFile.Logger.Error(e.ToString()); - ClosePopup(); - } - } - - private void ClosePopup() - { - if (_opener != null) _opener.IsConfigOpen = false; - NHotkeyManager.Instance?.RemoveBlockingScreen(this); - MouseFilter = MouseFilterEnum.Ignore; - if (_currentConfig != null) _currentConfig.ConfigChanged -= OnConfigChanged; - Hide(); - _optionContainer.FreeChildren(); - foreach (var child in _optionContainer.GetParent().GetChildren()) - if (child != _optionContainer) - child.QueueFreeSafely(); - } - - private void OnConfigChanged(object? sender, EventArgs e) - { - _saveTimer = AutosaveDelay; - } - - public override void _Process(double delta) - { - base._Process(delta); - if (_saveTimer > 0) - { - _saveTimer -= delta; - if (_saveTimer <= 0) - { - SaveCurrentConfig(); - } - } - } - - protected override void OnRelease() - { - base.OnRelease(); - - SaveCurrentConfig(); - ClosePopup(); - } - - private void SaveCurrentConfig() - { - _currentConfig?.Save(); - _saveTimer = -1; - } -} \ No newline at end of file diff --git a/Config/UI/NModConfigSubmenu.cs b/Config/UI/NModConfigSubmenu.cs new file mode 100644 index 0000000..3bbe4e7 --- /dev/null +++ b/Config/UI/NModConfigSubmenu.cs @@ -0,0 +1,325 @@ +using BaseLib.Extensions; +using BaseLib.Utils; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.Assets; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes.GodotExtensions; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; + +namespace BaseLib.Config.UI; + +[HarmonyPatch(typeof(NMainMenuSubmenuStack), nameof(NMainMenuSubmenuStack.GetSubmenuType), typeof(Type))] +public static class InjectModConfigSubmenuPatch +{ + private static readonly SpireField SubmenuField = new(CreateSubmenu); + + private static NModConfigSubmenu CreateSubmenu(NMainMenuSubmenuStack stack) + { + var menu = new NModConfigSubmenu(); + menu.Visible = false; + stack.AddChildSafely(menu); + return menu; + } + + public static bool Prefix(NMainMenuSubmenuStack __instance, Type type, ref NSubmenu __result) + { + if (type != typeof(NModConfigSubmenu)) return true; + + __result = SubmenuField.Get(__instance)!; + return false; + } +} + +public partial class NModConfigSubmenu : NSubmenu +{ + private VBoxContainer _optionContainer; + private NScrollableContainer _scrollContainer; + private Control _contentPanel; + private MegaRichTextLabel _modTitle; + private NConfigButton? _opener; + private Tween? _fadeInTween; + + private ModConfig? _currentConfig; + private double _saveTimer = -1; + private const double AutosaveDelay = 5; + + private const float ContentWidth = 1012f; // Same as the base game + private const float ClipperTopOffset = 187f; + private const float ModTitleHeight = 90f; + private const float MinPadding = 30f; + + protected override Control InitialFocusedControl => FindFirstFocusable(_optionContainer) ?? _optionContainer; + + public NModConfigSubmenu() + { + // Basic node structure: + // NModConfigSubmenu > _scrollContainer > mask > clipper > _contentPanel > _optionContainer + // ... where _optionContainer is passed to the mod's ModConfig + AnchorRight = 1f; + AnchorBottom = 1f; + GrowHorizontal = GrowDirection.Both; + GrowVertical = GrowDirection.Both; + + _scrollContainer = new NScrollableContainer + { + Name = "ScrollContainer", + ClipChildren = ClipChildrenMode.Only, + AnchorRight = 1f, + AnchorBottom = 1f, + GrowHorizontal = GrowDirection.Both, + GrowVertical = GrowDirection.Both, + }; + + // Verbose, but basically the same as the fading gradient in the original settings scene + var mask = new TextureRect + { + Name = "Mask", + ClipChildren = ClipChildrenMode.Only, + AnchorRight = 1f, + AnchorBottom = 1f, + GrowHorizontal = GrowDirection.Both, + GrowVertical = GrowDirection.Both, + MouseFilter = MouseFilterEnum.Ignore, + Texture = new GradientTexture2D + { + FillFrom = new Vector2(0f, 1f), + FillTo = Vector2.Zero, + Gradient = new Gradient + { + // Note: these are ordered bottom-to-top! + Offsets = [0f, 0.08f, 0.805f, 0.827f], + Colors = + [ + new Color(1f, 1f, 1f, 0f), + new Color(1f, 1f, 1f), + new Color(1f, 1f, 1f), + new Color(1f, 1f, 1f, 0f) + ], + }, + }, + }; + + var clipper = new Control + { + Name = "Clipper", + ClipContents = true, + AnchorRight = 1f, + AnchorBottom = 1f, + OffsetTop = ClipperTopOffset, + GrowHorizontal = GrowDirection.Both, + GrowVertical = GrowDirection.Both, + MouseFilter = MouseFilterEnum.Ignore, + }; + mask.AddChild(clipper); + + _contentPanel = new Control + { + Name = "ModConfigContent", + AnchorLeft = 0.5f, + AnchorRight = 0.5f, + OffsetLeft = -ContentWidth / 2, + OffsetTop = 24f, + OffsetRight = ContentWidth / 2, + GrowHorizontal = GrowDirection.Both, + MouseFilter = MouseFilterEnum.Ignore, + }; + clipper.AddChild(_contentPanel); + + // The container that we send to the ModConfig to populate + _optionContainer = new VBoxContainer + { + Name = "VBoxContainer", + CustomMinimumSize = new Vector2(ContentWidth, 0f), + AnchorRight = 1f, + GrowHorizontal = GrowDirection.Both, + MouseFilter = MouseFilterEnum.Ignore, + }; + + _optionContainer.MinimumSizeChanged += RefreshSize; + + _contentPanel.AddChild(_optionContainer); + + var scrollbar = PreloadManager.Cache.GetScene(SceneHelper.GetScenePath("ui/scrollbar")) + .Instantiate(); + scrollbar.Name = "Scrollbar"; + const float gap = 54f; + const float width = 48f; + scrollbar.AnchorLeft = 0.5f; + scrollbar.AnchorRight = 0.5f; + scrollbar.AnchorTop = 0f; + scrollbar.AnchorBottom = 1f; + scrollbar.OffsetLeft = ContentWidth / 2 + gap; + scrollbar.OffsetRight = ContentWidth / 2 + gap + width; + + scrollbar.OffsetTop = ClipperTopOffset + 32f; + scrollbar.OffsetBottom = -64f; + + _scrollContainer.AddChild(scrollbar); + _scrollContainer.AddChild(mask); + + // Autosize is on, but we need a value here + _modTitle = ModConfig.CreateRawLabelControl("[center]Unknown mod name[/center]", 36); + _modTitle.Name = "ModTitle"; + _modTitle.AutoSizeEnabled = true; + _modTitle.MaxFontSize = 64; + _modTitle.CustomMinimumSize = new Vector2(ContentWidth, ModTitleHeight); + + _modTitle.SetAnchorsPreset(LayoutPreset.TopWide); + _modTitle.OffsetBottom = ClipperTopOffset - 10; + _modTitle.OffsetTop = _modTitle.OffsetBottom - ModTitleHeight; + } + + public override void _Ready() + { + AddChild(_scrollContainer); + AddChild(_modTitle); + _scrollContainer.SetContent(_contentPanel); + _scrollContainer.DisableScrollingIfContentFits(); + + var backButton = PreloadManager.Cache.GetScene(SceneHelper.GetScenePath("ui/back_button")).Instantiate(); + backButton.Name = "BackButton"; + AddChild(backButton); + + ConnectSignals(); + GetViewport().Connect(Viewport.SignalName.SizeChanged, Callable.From(RefreshSize)); + } + + public void LoadModConfig(ModConfig config, NConfigButton opener) + { + if (_currentConfig != null) _currentConfig.ConfigChanged -= OnConfigChanged; + _currentConfig = config; + _opener = opener; + _optionContainer.FreeChildren(); + _optionContainer.AddThemeConstantOverride("separation", 8); + + try + { + config.SetupConfigUI(_optionContainer); + SetModTitle(config); + config.ConfigChanged += OnConfigChanged; + + _scrollContainer.DisableScrollingIfContentFits(); + _scrollContainer.InstantlyScrollToTop(); + RefreshSize(); + + ModConfig.ShowAndClearPendingErrors(); + + // Note: TryGrabFocus returns if a controller isn't being used + Callable.From( + () => FindFirstFocusable(_optionContainer)?.TryGrabFocus() + ).CallDeferred(); + } + catch (Exception e) + { + ModConfig.ModConfigLogger.Error("An error occurred while loading the mod config screen." + + "Please report a bug at:\nhttps://github.com/Alchyr/BaseLib-StS2"); + MainFile.Logger.Error(e.ToString()); + _stack.Pop(); + } + } + + private void SetModTitle(ModConfig config) + { + var fallbackTitle = config.GetType().GetRootNamespace(); + if (string.IsNullOrWhiteSpace(fallbackTitle)) fallbackTitle = "Unknown mod name"; + + var locKey = $"{config.ModPrefix[..^1]}.mod_title"; + var locStr = LocString.GetIfExists("settings_ui", locKey); + if (locStr == null) + { + ModConfig.ModConfigLogger.Warn( + $"No {locKey} found in localization table, using mod namespace {fallbackTitle} as title"); + } + + var titleText = locStr?.GetFormattedText() ?? fallbackTitle; + _modTitle.SetTextAutoSize($"[center]{titleText}[/center]"); + } + + private void RefreshSize() + { + var clipperSize = _contentPanel.GetParent().Size; + var requiredHeight = _optionContainer.GetMinimumSize().Y; + var paddedHeight = requiredHeight + MinPadding; + + // Emulate the game's menus: add padding below if scrolling is (almost) needed + if (paddedHeight >= clipperSize.Y) + { + paddedHeight += clipperSize.Y * 0.3f; + } + + _contentPanel.CustomMinimumSize = new Vector2(ContentWidth, paddedHeight); + _optionContainer.Size = new Vector2(ContentWidth, requiredHeight); + _scrollContainer.DisableScrollingIfContentFits(); + } + + protected override void OnSubmenuShown() + { + base.OnSubmenuShown(); + + _saveTimer = -1; + + _fadeInTween?.Kill(); + _fadeInTween = CreateTween().SetParallel(); + _fadeInTween.TweenProperty(_contentPanel, "modulate", Colors.White, 0.5f) + .From(new Color(0, 0, 0, 0)) + .SetEase(Tween.EaseType.Out) + .SetTrans(Tween.TransitionType.Cubic); + } + + protected override void OnSubmenuHidden() + { + if (_currentConfig != null) _currentConfig.ConfigChanged -= OnConfigChanged; + if (_opener != null) _opener.IsConfigOpen = false; + SaveCurrentConfig(); + + if (ModConfig.ModConfigLogger.PendingUserMessages.Count > 0) + { + // The main menu will only show this when recreated; if a player goes from settings to play a game, + // that is AFTER finishing the game. We need to show the error now, so let's check here, too. + Callable.From(ModConfig.ShowAndClearPendingErrors).CallDeferred(); + } + + base.OnSubmenuHidden(); + } + + private void OnConfigChanged(object? sender, EventArgs e) + { + _saveTimer = AutosaveDelay; + } + + private static Control? FindFirstFocusable(Node parent) + { + foreach (var child in parent.GetChildren()) + { + if (child is Control { FocusMode: FocusModeEnum.All or FocusModeEnum.Click } control) + return control; + + var nestedFocus = FindFirstFocusable(child); + if (nestedFocus != null) + return nestedFocus; + } + + return null; + } + + public override void _Process(double delta) + { + base._Process(delta); + if (_saveTimer <= 0) return; + _saveTimer -= delta; + if (_saveTimer <= 0) + { + SaveCurrentConfig(); + } + } + + private void SaveCurrentConfig() + { + _currentConfig?.Save(); + _saveTimer = -1; + } +} \ No newline at end of file diff --git a/Extensions/ControlExtensions.cs b/Extensions/ControlExtensions.cs index 28e757c..e662cfb 100644 --- a/Extensions/ControlExtensions.cs +++ b/Extensions/ControlExtensions.cs @@ -18,4 +18,24 @@ public static void DrawDebug(this Control artist, Control child) { artist.DrawRect(new Rect2(child.Position, child.Size), new Color(1, 1, 1, 0.5f)); } -} + + /// + /// Calls AddThemeFontSizeOverride() for all font types: font_size, {normal,bold,italics,bold_italics,mono}_font_size. + /// + public static void AddThemeFontSizeOverrideAll(this Control control, int fontSize) + { + string[] fontTypes = [ + "font_size", + "normal_font_size", + "bold_font_size", + "italics_font_size", + "bold_italics_font_size", + "mono_font_size" + ]; + + foreach (var fontType in fontTypes) + { + control.AddThemeFontSizeOverride(fontType, fontSize); + } + } +} \ No newline at end of file