diff --git a/content/ModTemplate/ModTemplate.csproj b/content/ModTemplate/ModTemplate.csproj index f801eb4..c88fcbc 100644 --- a/content/ModTemplate/ModTemplate.csproj +++ b/content/ModTemplate/ModTemplate.csproj @@ -3,7 +3,7 @@ net9.0 true - {NullableChecks} + enable true $(MSBuildProjectDirectory)\ @@ -28,7 +28,7 @@ - + diff --git a/content/ModTemplate/ModTemplate.json b/content/ModTemplate/ModTemplate.json index e670dfc..c87362c 100644 --- a/content/ModTemplate/ModTemplate.json +++ b/content/ModTemplate/ModTemplate.json @@ -1,14 +1,14 @@ -{ +{ "id": "ModTemplate", - "name": "ModTemplate", - "author": "{ModAuthor}", - "description": "Slay the Spire 2 mod created from a template for use with BaseLib", - "version": "v0.0.0", + "name": "Checkpoint", + "author": "Misachu", + "description": "Allows you to go back to any floor in a run", + "version": "v1.0.0", "min_game_version": "0.107.0", - "has_pck": true, + "has_pck": false, "has_dll": true, "dependencies": [ - {"id": "BaseLib", "min_version": "3.3.0"} + {"id": "BaseLib", "min_version": "3.3.2"} ], "affects_gameplay": true } diff --git a/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointData.cs b/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointData.cs new file mode 100644 index 0000000..172ebae --- /dev/null +++ b/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointData.cs @@ -0,0 +1,7 @@ +namespace ModTemplate.ModTemplateCode.Checkpoints; + +public class RunCheckpoint +{ + public int Floor { get; set; } + public DateTime SavedAt { get; set; } = DateTime.UtcNow; +} diff --git a/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointManager.cs b/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointManager.cs new file mode 100644 index 0000000..979d6b8 --- /dev/null +++ b/content/ModTemplate/ModTemplateCode/Checkpoints/CheckpointManager.cs @@ -0,0 +1,205 @@ +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Nodes; +using MegaCrit.Sts2.Core.Runs; +using MegaCrit.Sts2.Core.Saves; + +namespace ModTemplate.ModTemplateCode.Checkpoints; + +public static class CheckpointManager +{ + public static int CheckpointCount => _checkpoints.Count; + + // Floor → (complete run state at that floor, time it was captured) + private static readonly Dictionary _checkpoints = new(); + + private static bool _isLoadingCheckpoint; + + private static string CheckpointRoot => Path.Combine(OS.GetUserDataDir(), "mod_checkpoints"); + private static string ActiveDir => Path.Combine(CheckpointRoot, "active"); + + // ── Run lifecycle ───────────────────────────────────────────────────────── + + public static void OnRunStart() + { + _checkpoints.Clear(); + if (Directory.Exists(ActiveDir)) + Directory.Delete(ActiveDir, recursive: true); + Directory.CreateDirectory(ActiveDir); + MainFile.Logger.Info("[Checkpoint] New run started."); + } + + public static void OnRunContinue() + { + if (_isLoadingCheckpoint) return; + _checkpoints.Clear(); + if (Directory.Exists(ActiveDir)) + { + foreach (var dir in Directory.GetDirectories(ActiveDir)) + { + var name = Path.GetFileName(dir); + if (!name.StartsWith("floor_") || !int.TryParse(name[6..], out var floor)) continue; + + var checkpointFile = Path.Combine(dir, "checkpoint.json"); + var metaFile = Path.Combine(dir, "meta.json"); + if (!File.Exists(checkpointFile)) continue; + + try + { + var result = JsonSerializationUtility.FromJson( + File.ReadAllText(checkpointFile)); + if (!result.Success || result.SaveData == null) continue; + var runSave = result.SaveData; + + var savedAt = File.Exists(metaFile) + ? DateTime.Parse(File.ReadAllText(metaFile), null, System.Globalization.DateTimeStyles.RoundtripKind) + : Directory.GetCreationTimeUtc(dir); + + _checkpoints[floor] = (runSave, savedAt); + MainFile.Logger.Info($"[Checkpoint] Restored floor {floor} from disk."); + } + catch (Exception ex) + { + MainFile.Logger.Info($"[Checkpoint] Could not restore floor {floor}: {ex.Message}"); + } + } + } + MainFile.Logger.Info($"[Checkpoint] Continued run | {_checkpoints.Count} checkpoint(s)."); + } + + public static void OnRunEnd() + { + _checkpoints.Clear(); + if (Directory.Exists(ActiveDir)) + { + Directory.Delete(ActiveDir, recursive: true); + MainFile.Logger.Info("[Checkpoint] Run ended, checkpoints cleared."); + } + } + + // ── Save ────────────────────────────────────────────────────────────────── + + public static void Save(int floor) + { + _ = SaveAsync(floor); + } + + private static async Task SaveAsync(int floor) + { + try + { + var saveManager = SaveManager.Instance; + + // Wait for any in-flight save so we read the final committed state. + var pending = saveManager.CurrentRunSaveTask; + if (pending != null) await pending; + + var readResult = saveManager.LoadRunSave(); + if (!readResult.Success || readResult.SaveData == null) + { + MainFile.Logger.Info($"[Checkpoint] Save floor {floor}: LoadRunSave failed (status={readResult.Status})."); + return; + } + + var runSave = readResult.SaveData; + bool isNew = !_checkpoints.ContainsKey(floor); + var savedAt = DateTime.UtcNow; + _checkpoints[floor] = (runSave, savedAt); + + // Persist to disk so checkpoints survive game restarts. + var dir = Path.Combine(ActiveDir, $"floor_{floor:D2}"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "checkpoint.json"), + JsonSerializationUtility.ToJson(runSave)); + File.WriteAllText(Path.Combine(dir, "meta.json"), + savedAt.ToString("O")); // ISO 8601 round-trip format + + MainFile.Logger.Info($"[Checkpoint] {(isNew ? "Saved" : "Overwrote")} floor {floor} ({_checkpoints.Count} total)."); + } + catch (Exception ex) + { + MainFile.Logger.Info($"[Checkpoint] SaveAsync error: {ex}"); + } + } + + // ── Enumerate ───────────────────────────────────────────────────────────── + + public static List LoadAll() => + [.. _checkpoints + .Select(kvp => new RunCheckpoint { Floor = kvp.Key, SavedAt = kvp.Value.SavedAt }) + .OrderByDescending(s => s.Floor)]; + + // ── Load ────────────────────────────────────────────────────────────────── + + public static void LoadCheckpoint(RunCheckpoint checkpoint) + { + if (!_checkpoints.TryGetValue(checkpoint.Floor, out var entry)) + { + MainFile.Logger.Info($"[Checkpoint] Load floor {checkpoint.Floor}: not found in memory."); + return; + } + MainFile.Logger.Info($"[Checkpoint] Starting load for floor {checkpoint.Floor}."); + _ = LoadCheckpointAsync(checkpoint.Floor, entry.RunSave); + } + + private static async Task LoadCheckpointAsync(int floor, SerializableRun runSave) + { + try + { + var game = NGame.Instance; + var saveManager = SaveManager.Instance; + var runManager = RunManager.Instance; + + if (game == null || saveManager == null || runManager == null) + { + MainFile.Logger.Info("[Checkpoint] LoadAsync: required instances not available."); + return; + } + + // Wait for any pending save so we don't race against an in-flight write. + var pending = saveManager.CurrentRunSaveTask; + if (pending != null) + { + MainFile.Logger.Info("[Checkpoint] LoadAsync: waiting for pending save..."); + await pending; + } + + runManager.ActionExecutor.Cancel(); + runManager.ActionQueueSet.Reset(); + + // Reconstruct run state directly from the captured object — no file I/O needed. + RunState runState = RunState.FromSerializable(runSave); + + var transition = game.Transition; + if (transition != null) await transition.FadeOut(); + + runManager.CleanUp(); + + // Method name changed capitalisation between game versions. + var setupMethod = + AccessTools.Method(typeof(RunManager), "SetUpSavedSingleplayer", [typeof(RunState), typeof(SerializableRun)]) ?? + AccessTools.Method(typeof(RunManager), "SetUpSavedSinglePlayer", [typeof(RunState), typeof(SerializableRun)]); + if (setupMethod != null) + { + var result = setupMethod.Invoke(runManager, [runState, runSave]); + if (result is Task t) await t; + } + else + { + MainFile.Logger.Info("[Checkpoint] LoadAsync: SetUpSavedSingleplayer not found."); + } + + _isLoadingCheckpoint = true; + try { await game.LoadRun(runState, runSave.PreFinishedRoom); } + finally { _isLoadingCheckpoint = false; } + + if (transition != null) await transition.FadeIn(); + + MainFile.Logger.Info($"[Checkpoint] Load complete for floor {floor}."); + } + catch (Exception ex) + { + MainFile.Logger.Info($"[Checkpoint] LoadAsync error: {ex}"); + } + } +} diff --git a/content/ModTemplate/ModTemplateCode/MainFile.cs b/content/ModTemplate/ModTemplateCode/MainFile.cs index 571445c..d8b6a17 100644 --- a/content/ModTemplate/ModTemplateCode/MainFile.cs +++ b/content/ModTemplate/ModTemplateCode/MainFile.cs @@ -4,21 +4,18 @@ namespace ModTemplate.ModTemplateCode; -//You're recommended but not required to keep all your code in this package and all your assets in the ModTemplate folder. [ModInitializer(nameof(Initialize))] public partial class MainFile : Node { - public const string ModId = "ModTemplate"; //At the moment, this is used only for the Logger and harmony names. + public const string ModId = "Checkpoint"; - public static MegaCrit.Sts2.Core.Logging.Logger Logger { get; } = new(ModId, MegaCrit.Sts2.Core.Logging.LogType.Generic); + public static MegaCrit.Sts2.Core.Logging.Logger Logger { get; } = + new(ModId, MegaCrit.Sts2.Core.Logging.LogType.Generic); public static void Initialize() { - //If you want to use scripts defined in your mod for Godot scenes, uncomment the following line. - //Godot.Bridge.ScriptManagerBridge.LookupScriptsInAssembly(Assembly.GetExecutingAssembly()); - Harmony harmony = new(ModId); - - harmony.PatchAll(); + try { harmony.PatchAll(); } + catch (Exception ex) { Logger.Info($"[Checkpoint] PatchAll error: {ex.Message}"); } } } diff --git a/content/ModTemplate/ModTemplateCode/Nodes/CheckpointUiNode.cs b/content/ModTemplate/ModTemplateCode/Nodes/CheckpointUiNode.cs new file mode 100644 index 0000000..6f0e171 --- /dev/null +++ b/content/ModTemplate/ModTemplateCode/Nodes/CheckpointUiNode.cs @@ -0,0 +1,194 @@ +using Godot; +using ModTemplate.ModTemplateCode.Checkpoints; + +namespace ModTemplate.ModTemplateCode.Nodes; + +// Static UI manager — deliberately NOT a Node subclass. +// Custom partial Node classes crash in mod context because Harmony's MonoMod +// JIT hook fires when Godot tries to JIT-compile InvokeGodotClassMethod and +// throws ArgumentException. Using a static class with built-in Godot nodes +// (CanvasLayer, Label, etc.) avoids this entirely. +// Input polling and HUD updates are driven by a Harmony patch on NRun._Process. +internal static class CheckpointUi +{ + private static Label? _hudLabel; + private static Control? _panel; + private static VBoxContainer? _list; + + private static double _lHeld; + private const double ToggleHold = 0.3; + + // Called from NRunReadyPatch — builds the UI once per run scene. + public static void Initialize(Node sceneRoot) + { + if (sceneRoot.HasNode("CheckpointUiLayer")) return; + + var layer = new CanvasLayer { Layer = 128, Name = "CheckpointUiLayer" }; + sceneRoot.AddChild(layer); + BuildLayout(layer); + MainFile.Logger.Info("[Checkpoint] CheckpointUi initialized."); + } + + // Called every frame from NRunProcessPatch. + public static void Update(double delta) + { + if (_hudLabel == null) return; + + _hudLabel.Text = CheckpointManager.CheckpointCount > 0 + ? $"[Checkpoints: {CheckpointManager.CheckpointCount}] hold L" + : "[Checkpoint] hold L"; + + if (Input.IsKeyPressed(Key.L)) + { + if (_lHeld >= 0) + { + _lHeld += delta; + if (_lHeld >= ToggleHold) + { + _lHeld = double.MinValue; + TogglePanel(); + } + } + } + else + { + _lHeld = 0; + } + + if (_panel?.Visible == true && Input.IsKeyPressed(Key.Escape)) + _panel.Visible = false; + } + + // ── Layout ──────────────────────────────────────────────────────────────── + + private static void BuildLayout(CanvasLayer layer) + { + var root = new Control(); + root.SetAnchorsPreset(Control.LayoutPreset.FullRect); + root.MouseFilter = Control.MouseFilterEnum.Ignore; + layer.AddChild(root); + + _hudLabel = new Label + { + HorizontalAlignment = HorizontalAlignment.Right, + MouseFilter = Control.MouseFilterEnum.Ignore, + }; + _hudLabel.AnchorLeft = 1f; + _hudLabel.AnchorRight = 1f; + _hudLabel.AnchorTop = 0f; + _hudLabel.AnchorBottom = 0f; + _hudLabel.OffsetLeft = -560f; + _hudLabel.OffsetRight = -300f; + _hudLabel.OffsetTop = 8f; + _hudLabel.OffsetBottom = 32f; + root.AddChild(_hudLabel); + + _panel = new Control(); + _panel.SetAnchorsPreset(Control.LayoutPreset.FullRect); + _panel.MouseFilter = Control.MouseFilterEnum.Stop; + _panel.Visible = false; + root.AddChild(_panel); + + var overlay = new ColorRect { Color = new Color(0f, 0f, 0f, 0.7f) }; + overlay.SetAnchorsPreset(Control.LayoutPreset.FullRect); + overlay.GuiInput += e => + { + if (e is InputEventMouseButton { Pressed: true }) _panel.Visible = false; + }; + _panel.AddChild(overlay); + + var bg = new Panel(); + bg.AnchorLeft = 0.10f; + bg.AnchorTop = 0.05f; + bg.AnchorRight = 0.90f; + bg.AnchorBottom = 0.95f; + _panel.AddChild(bg); + + var margin = new MarginContainer(); + margin.SetAnchorsPreset(Control.LayoutPreset.FullRect); + margin.AddThemeConstantOverride("margin_left", 16); + margin.AddThemeConstantOverride("margin_top", 16); + margin.AddThemeConstantOverride("margin_right", 16); + margin.AddThemeConstantOverride("margin_bottom", 16); + bg.AddChild(margin); + + var outer = new VBoxContainer(); + margin.AddChild(outer); + + var header = new HBoxContainer(); + outer.AddChild(header); + header.AddChild(new Label + { + Text = "Checkpoint History (hold L or Esc to close)", + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + }); + var closeBtn = new Button { Text = "X" }; + closeBtn.Pressed += () => _panel.Visible = false; + header.AddChild(closeBtn); + + outer.AddChild(new HSeparator()); + + var cols = new HBoxContainer(); + outer.AddChild(cols); + cols.AddChild(ColLabel("Floor", 80)); + cols.AddChild(ColLabel("Saved at", 150)); + cols.AddChild(new Control { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }); + outer.AddChild(new HSeparator()); + + var scroll = new ScrollContainer(); + scroll.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + outer.AddChild(scroll); + + _list = new VBoxContainer(); + _list.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + scroll.AddChild(_list); + } + + private static Label ColLabel(string text, int minWidth) => new() + { + Text = text, + CustomMinimumSize = new Vector2(minWidth, 0), + }; + + // ── Panel ───────────────────────────────────────────────────────────────── + + private static void TogglePanel() + { + if (_panel == null) return; + if (_panel.Visible) { _panel.Visible = false; return; } + Refresh(); + _panel.Visible = true; + } + + private static void Refresh() + { + if (_list == null) return; + foreach (var child in _list.GetChildren()) child.QueueFree(); + + var checkpoints = CheckpointManager.LoadAll(); + + if (checkpoints.Count == 0) + { + _list.AddChild(new Label { Text = "No checkpoints yet." }); + return; + } + + foreach (var cp in checkpoints) + { + var row = new HBoxContainer(); + _list.AddChild(row); + row.AddChild(new Label { Text = $"Floor {cp.Floor,2}", CustomMinimumSize = new Vector2(80, 0) }); + row.AddChild(new Label { Text = cp.SavedAt.ToLocalTime().ToString("MM/dd HH:mm:ss"), CustomMinimumSize = new Vector2(150, 0) }); + row.AddChild(new Control { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }); + + var btn = new Button { Text = "Load Checkpoint" }; + var captured = cp; + btn.Pressed += () => + { + CheckpointManager.LoadCheckpoint(captured); + if (_panel != null) _panel.Visible = false; + }; + row.AddChild(btn); + } + } +} diff --git a/content/ModTemplate/ModTemplateCode/Patches/CheckpointPatch.cs b/content/ModTemplate/ModTemplateCode/Patches/CheckpointPatch.cs new file mode 100644 index 0000000..d59f58c --- /dev/null +++ b/content/ModTemplate/ModTemplateCode/Patches/CheckpointPatch.cs @@ -0,0 +1,45 @@ +using HarmonyLib; +using MegaCrit.Sts2.Core.Hooks; +using MegaCrit.Sts2.Core.Nodes; +using MegaCrit.Sts2.Core.Rooms; +using MegaCrit.Sts2.Core.Runs; +using ModTemplate.ModTemplateCode.Checkpoints; + +namespace ModTemplate.ModTemplateCode.Patches; + +// Run starts when a new singleplayer run is initiated. +[HarmonyPatch(typeof(NGame), nameof(NGame.StartNewSingleplayerRun))] +static class RunStartPatch +{ + [HarmonyPostfix] + static void Postfix() + { + try { CheckpointManager.OnRunStart(); } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] RunStart error: {ex.Message}"); } + } +} + +// Run continues when the player resumes an existing run from the main menu. +[HarmonyPatch("MegaCrit.Sts2.Core.Nodes.NGame", "LoadRun")] +static class RunContinuePatch +{ + [HarmonyPostfix] + static void Postfix() + { + try { CheckpointManager.OnRunContinue(); } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] RunContinue error: {ex.Message}"); } + } +} + +// Auto-save a checkpoint at the start of every floor. +// Hook.BeforeRoomEntered is the game's canonical hook point fired before any room logic runs. +[HarmonyPatch(typeof(Hook), nameof(Hook.BeforeRoomEntered))] +static class FloorCheckpointPatch +{ + [HarmonyPostfix] + static void Postfix(IRunState runState, AbstractRoom room) + { + try { CheckpointManager.Save(runState.TotalFloor); } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] FloorCheckpoint error: {ex.Message}"); } + } +} diff --git a/content/ModTemplate/ModTemplateCode/Patches/CheckpointUiPatch.cs b/content/ModTemplate/ModTemplateCode/Patches/CheckpointUiPatch.cs new file mode 100644 index 0000000..006ae47 --- /dev/null +++ b/content/ModTemplate/ModTemplateCode/Patches/CheckpointUiPatch.cs @@ -0,0 +1,43 @@ +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Nodes; +using ModTemplate.ModTemplateCode.Checkpoints; +using ModTemplate.ModTemplateCode.Nodes; + +namespace ModTemplate.ModTemplateCode.Patches; + +internal static class CheckpointUiPatch +{ + // ── Initialize UI and load checkpoints when a run scene is ready ────────── + + [HarmonyPatch(typeof(NRun), "_Ready")] + static class NRunReadyPatch + { + [HarmonyPostfix] + static void Postfix(NRun __instance) + { + try + { + var sceneRoot = __instance.GetTree()?.Root; + if (sceneRoot != null) + CheckpointUi.Initialize(sceneRoot); + + CheckpointManager.OnRunContinue(); + } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] NRunReady error: {ex}"); } + } + } + + // ── Drive UI every frame ────────────────────────────────────────────────── + + [HarmonyPatch(typeof(NRun), "_Process")] + static class NRunProcessPatch + { + [HarmonyPostfix] + static void Postfix(double delta) + { + try { CheckpointUi.Update(delta); } + catch (Exception ex) { MainFile.Logger.Info($"[Checkpoint] UI error: {ex.Message}"); } + } + } +}