From 0afbae9c65e4e037021000a0a85960c1683dde0d Mon Sep 17 00:00:00 2001 From: lamali Date: Mon, 27 Jul 2026 12:46:18 +0200 Subject: [PATCH 1/3] Add mod credit support and custom localization for credits screen --- BaseLib/localization/eng/credits.json | 7 + BaseLibMain.cs | 3 + Patches/UI/NCreditsScreenPatch.cs | 331 ++++++++++++++++++++++++++ Utils/ModCredits.cs | 69 ++++++ 4 files changed, 410 insertions(+) create mode 100644 BaseLib/localization/eng/credits.json create mode 100644 Patches/UI/NCreditsScreenPatch.cs create mode 100644 Utils/ModCredits.cs diff --git a/BaseLib/localization/eng/credits.json b/BaseLib/localization/eng/credits.json new file mode 100644 index 00000000..e9cc49cf --- /dev/null +++ b/BaseLib/localization/eng/credits.json @@ -0,0 +1,7 @@ +{ + "BASELIB-BANNER.name": "— MODS —", + "SLAY_THE_SPIRE": "Slay the Spire 2", + "BASELIB-BASELIB.title": "BaseLib", + "BASELIB-TEAM.header": "Developer", + "BASELIB-TEAM.names": "Alchyr" +} \ No newline at end of file diff --git a/BaseLibMain.cs b/BaseLibMain.cs index 3b13882f..03ffb3a7 100644 --- a/BaseLibMain.cs +++ b/BaseLibMain.cs @@ -66,6 +66,9 @@ public static void Initialize() MainHarmony.TryPatchAll(assembly); CustomLocTableManager.Register("card_modifiers"); + + + ModCredits.Register(ModId, new ModCredits.Section("TEAM")); } //Hopefully temporary fix for linux diff --git a/Patches/UI/NCreditsScreenPatch.cs b/Patches/UI/NCreditsScreenPatch.cs new file mode 100644 index 00000000..81a29ef1 --- /dev/null +++ b/Patches/UI/NCreditsScreenPatch.cs @@ -0,0 +1,331 @@ +using BaseLib.Utils; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.ControllerInput; +using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes.Screens.Credits; + +namespace BaseLib.Patches.UI; + +/// +/// Single Harmony patch class for . Each hook just +/// delegates to . +/// +[HarmonyPatch(typeof(NCreditsScreen))] +public static class NCreditsScreenPatch +{ + /// Renders the modded blocks and side-nav after the screen is ready. + [HarmonyPatch("_Ready"), HarmonyPostfix] + public static void OnReady(NCreditsScreen __instance) + => CreditsNav.Render(__instance); + + /// Redirects default focus onto the side-nav. + [HarmonyPatch("DefaultFocusedControl", MethodType.Getter), HarmonyPostfix] + public static void OnDefaultFocus(ref Control __result) + => CreditsNav.RedirectFocus(ref __result); + + /// Cleans up the confirm binding when the screen leaves the tree. + [HarmonyPatch("_ExitTree"), HarmonyPostfix] + public static void OnExitTree() + => CreditsNav.RemoveBindings(); +} + +/// +/// Rendering and navigation logic for the modded credits screen. Holds all the +/// non-Harmony work: building the credit blocks, the fixed side-nav, jump-scrolling, +/// focus seeding, and the controller confirm binding. Driven by . +/// +internal static class CreditsNav +{ + private static readonly Color BannerColor = new(1f, 0.55f, 0.20f); + private static readonly Color ModNameColor = new(0.53f, 0.81f, 0.92f); + private static readonly Color NavColor = new(1f, 0.965f, 0.886f); + private static readonly Color NavHoverColor = new(0.937f, 0.784f, 0.318f); + + /// Where a jumped-to node lands, measured from the top of the screen. + private const float NavTopPadding = 200f; + + /// Confirm actions bound while the screen is open (last-wins over the screen's blockers). + private static readonly string[] ConfirmActions = [MegaInput.accept, MegaInput.select]; + + /// First side-nav button; used to seed controller/keyboard focus. + private static Button? _firstNavButton; + + /// Confirm binding pushed into the hotkey manager while the screen is open. + private static Action? _confirmBinding; + + /// Focused-button → jump action, so a confirm press triggers the right entry. + private static readonly Dictionary NavActions = []; + + /// Builds the modded credit blocks and the side navigation once the screen is ready. + public static void Render(NCreditsScreen screen) + { + _firstNavButton = null; + if (ModCredits.Entries.Count == 0) return; + + var margin = screen.GetNodeOrNull("%ScreenContents"); + var vbox = margin?.GetNodeOrNull("VBoxContainer"); + if (margin == null || vbox == null) return; + + var headerTpl = vbox.GetNodeOrNull("ModdingSupportHeader"); + var namesTpl = vbox.GetNodeOrNull("ModdingSupportNames"); + var rolesCTpl = vbox.GetNodeOrNull("zhsContainer"); + var multiCTpl = vbox.GetNodeOrNull("PlaytestersContainer"); + if (headerTpl == null || namesTpl == null) return; + + var anchor = vbox.GetNodeOrNull("Spacer3"); + var at = anchor?.GetIndex() ?? vbox.GetChildCount(); + + // Side-nav targets: the vanilla top first, then one per mod. + var navTargets = new List<(string Text, Control Target)>(); + var top = vbox.GetNodeOrNull("StS2Logo") ?? (Control)vbox.GetChild(0); + navTargets.Add(("Slay the Spire 2", top)); + + BuildHeader(vbox, ref at, ModCredits.Resolve("BASELIB-BANNER.name"), BannerColor, headerTpl, "Banner", null, 60); + + var m = 0; + foreach (var e in ModCredits.Entries) + { + var title = ModCredits.Resolve($"{e.ModId}-{e.ModId}.title"); + var titleNode = BuildHeader(vbox, ref at, title, ModNameColor, headerTpl, "Mod" + m, null, 44); + navTargets.Add((title, titleNode)); + + var s = 0; + foreach (var sec in e.Sections) + { + var tag = "M" + m + "S" + s; + var head = ModCredits.Resolve(e.ModId + "-" + sec.Name.ToUpperInvariant() + ".header"); + var body = ModCredits.Resolve(e.ModId + "-" + sec.Name.ToUpperInvariant() + ".names"); + + BuildHeader(vbox, ref at, head, null, headerTpl, tag + "H", 60f); + + switch (sec.Kind) + { + case ModCredits.Layout.Roles when rolesCTpl != null: + BuildRoles(vbox, ref at, body, rolesCTpl, tag); + break; + case ModCredits.Layout.Columns3 when multiCTpl != null: + BuildMulti(vbox, ref at, body, multiCTpl, tag); + break; + case ModCredits.Layout.Names: + default: + BuildNames(vbox, ref at, body, namesTpl, tag); + break; + } + s++; + } + m++; + } + + BuildSideNav(screen, margin, headerTpl, navTargets); + } + + /// + /// Redirects the screen's default focus onto the side-nav, so a controller starts + /// on the navigation list instead of the screen root (which traps directional input). + /// + public static void RedirectFocus(ref Control result) + { + if (_firstNavButton != null && GodotObject.IsInstanceValid(_firstNavButton)) + result = _firstNavButton; + } + + /// Removes the confirm binding when the screen closes, so bindings don't stack. + public static void RemoveBindings() + { + if (_confirmBinding == null) return; + foreach (var a in ConfirmActions) + NHotkeyManager.Instance?.RemoveHotkeyPressedBinding(a, _confirmBinding); + _confirmBinding = null; + } + + /// + /// Builds the fixed left-hand navigation list: click-to-jump buttons, wrapped + /// focus neighbours, seeded focus, and a hotkey-manager confirm binding so a + /// controller press fires the focused entry. + /// + private static void BuildSideNav(GodotObject creditsScreen, Control scrollContent, + Control headerTpl, IReadOnlyList<(string Text, Control Target)> targets) + { + NavActions.Clear(); + + var font = SafeThemeFont(headerTpl); + + var nav = new VBoxContainer { Name = "ModNav" }; + nav.AddThemeConstantOverride("separation", 10); + ((Control)creditsScreen).AddChild(nav); + nav.SetAnchorsPreset(Control.LayoutPreset.TopLeft); + nav.Position = new Vector2(64, 260); + + var buttons = new List