From 542c0cdc19304c1efb9a9024441cb2d5bacb3403 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 14 May 2026 10:59:14 +0200 Subject: [PATCH] Add Godot level editing save path --- TASKS.md | 4 + .../Data/GameSession.cs | 26 +++- .../Data/LevelStateLoader.cs | 16 +++ .../Screens/LevelScreen.cs | 122 +++++++++++++++++- .../Scripts/AppController.cs | 2 +- 5 files changed, 165 insertions(+), 5 deletions(-) diff --git a/TASKS.md b/TASKS.md index 1ee5ba8..2a9677f 100644 --- a/TASKS.md +++ b/TASKS.md @@ -146,6 +146,10 @@ This backlog tracks what must change so the implementation matches `docs/design. - [x] Add test/build helpers for level construction. - Prefer shared builders for linear networks, forks, leaks, wall electricity faces, doors, controls, consumers, and reactors. - Avoid duplicating low-level array setup across tests. +- [x] Add Godot level editing support. + - Expose editor tool, carrier, and remedy selection from the level screen. + - Apply `LevelEditor` commands to grid clicks in edit mode. + - Validate and save edited levels back through `LevelSerializer`. ## P1 Godot UX Integration diff --git a/src/ReactorMaintenance.Godot/Data/GameSession.cs b/src/ReactorMaintenance.Godot/Data/GameSession.cs index 458cf7c..81477c6 100644 --- a/src/ReactorMaintenance.Godot/Data/GameSession.cs +++ b/src/ReactorMaintenance.Godot/Data/GameSession.cs @@ -10,9 +10,10 @@ public sealed class GameSession public event StateChangedHandler? LevelWon; public event StateChangedHandler? LevelLost; - public void Initialize(LevelState levelState) + public void Initialize(LevelState levelState, string levelPath) { LevelState = levelState; + LevelPath = levelPath; m_StartSnapshot = LevelSerializer.Deserialize(LevelSerializer.Serialize(levelState)); } @@ -82,6 +83,27 @@ public sealed class GameSession LevelStateChanged?.Invoke(this); } + public void ApplyEditorTool(GridPosition position, EditorToolCommand command) + { + LevelState = LevelEditor.Apply(LevelState, position, command); + LevelStateChanged?.Invoke(this); + } + + public string ValidateForSave() + { + var report = m_Validator.Validate(LevelState); + if (!report.IsValid) + return report.Errors[0].Message; + + return report.Warnings.Count > 0 ? report.Warnings[0].Message : "Level is valid."; + } + + public void SaveLevel() + { + LevelStateLoader.Save(LevelPath, LevelState); + m_StartSnapshot = LevelSerializer.Deserialize(LevelSerializer.Serialize(LevelState)); + } + private void OnPulseAdvanced() { CheckOutcome(); @@ -101,6 +123,7 @@ public sealed class GameSession public GlobalState GlobalState => LevelState.Global; public IReadOnlyList Forecasts => LevelState.Forecasts; public IReadOnlyList Leaks => LevelState.Leaks; + public string LevelPath { get; private set; } = string.Empty; public IReadOnlyList Reactors => LevelState.Reactors; public IReadOnlyList Props => LevelState.Props; public ECellTerrain[] Terrain => LevelState.Terrain; @@ -111,5 +134,6 @@ public sealed class GameSession public delegate void StateChangedHandler(GameSession sender); private readonly SimulationEngine m_Engine = new(); + private readonly LevelValidator m_Validator = new(); private LevelState m_StartSnapshot = null!; } \ No newline at end of file diff --git a/src/ReactorMaintenance.Godot/Data/LevelStateLoader.cs b/src/ReactorMaintenance.Godot/Data/LevelStateLoader.cs index ddabd8d..e5a74d1 100644 --- a/src/ReactorMaintenance.Godot/Data/LevelStateLoader.cs +++ b/src/ReactorMaintenance.Godot/Data/LevelStateLoader.cs @@ -31,4 +31,20 @@ public static class LevelStateLoader $"Failed to deserialize level from {path}: {ex.Message}", ex); } } + + public static void Save(string path, LevelState level) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Level path must not be null or empty.", nameof(path)); + + var report = new LevelValidator().Validate(level); + if (!report.IsValid) + throw new InvalidOperationException(report.Errors[0].Message); + + using var file = FileAccess.Open(path, FileAccess.ModeFlags.Write); + if (file is null) + throw new IOException($"Could not open level file for writing: {path}"); + + file.StoreString(LevelSerializer.Serialize(level)); + } } \ No newline at end of file diff --git a/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs b/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs index e3b74af..0f892ef 100644 --- a/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs +++ b/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs @@ -37,6 +37,7 @@ public partial class LevelScreen : ScreenBase m_GridViewport = CreateGridViewport(); gridColumn.AddChild(m_GridViewport); gridColumn.AddChild(CreateLayerControls()); + gridColumn.AddChild(CreateEditorControls()); split.AddChild(gridColumn); m_Inspector = new(); @@ -137,7 +138,7 @@ public partial class LevelScreen : ScreenBase private void OnMoveAction() { - if (m_Session is null) return; + if (m_Session is null || m_EditMode) return; var current = m_Session.RobotPosition; var next = current with { X = current.X + 1 }; @@ -150,12 +151,13 @@ public partial class LevelScreen : ScreenBase private void OnInteractAction() { - m_Session?.InteractProp(); + if (!m_EditMode) + m_Session?.InteractProp(); } private void OnRepairAction() { - if (m_Session is null) return; + if (m_Session is null || m_EditMode) return; foreach (var leak in m_Session.Leaks) { @@ -167,6 +169,65 @@ public partial class LevelScreen : ScreenBase } } + private Control CreateEditorControls() + { + var controls = new HBoxContainer(); + + var editToggle = new CheckBox { + Text = "Edit", + ButtonPressed = m_EditMode, + TooltipText = "Apply level editor tools on grid clicks" + }; + editToggle.Toggled += pressed => { + m_EditMode = pressed; + SetEditorStatus(pressed ? CurrentValidationText() : "Play mode"); + }; + controls.AddChild(editToggle); + + m_ToolSelect = new(); + foreach (var tool in Enum.GetValues()) + m_ToolSelect.AddItem(EditorToolLabel(tool), (int)tool); + m_ToolSelect.Select((int)EEditorTool.Cursor); + controls.AddChild(m_ToolSelect); + + m_CarrierSelect = new(); + foreach (var carrier in Enum.GetValues()) + m_CarrierSelect.AddItem(carrier.ToString(), (int)carrier); + controls.AddChild(m_CarrierSelect); + + m_RemedySelect = new(); + foreach (var remedy in Enum.GetValues()) + m_RemedySelect.AddItem(remedy.ToString(), (int)remedy); + controls.AddChild(m_RemedySelect); + + controls.AddChild(CreateButton("Save JSON", SaveEditedLevel, "Validate and save this level JSON")); + + m_EditorStatus = new() { + Text = "Play mode", + AutowrapMode = TextServer.AutowrapMode.WordSmart, + SizeFlagsHorizontal = SizeFlags.ExpandFill + }; + controls.AddChild(m_EditorStatus); + + return controls; + } + + private void SaveEditedLevel() + { + if (m_Session is null) + return; + + try + { + m_Session.SaveLevel(); + SetEditorStatus($"Saved {m_Session.LevelPath}"); + } + catch (Exception ex) + { + SetEditorStatus(ex.Message); + } + } + private void UpdateUI() { if (m_Session is null || m_Inspector is null || m_ForecastList is null || m_InventoryStrip is null) @@ -275,11 +336,64 @@ public partial class LevelScreen : ScreenBase return; var destination = new GridPosition(cell.X, cell.Y); + if (m_EditMode) + { + m_Session.ApplyEditorTool(destination, CurrentEditorCommand()); + SetEditorStatus(CurrentValidationText()); + return; + } + if (m_Session.RobotPosition.ManhattanDistance(destination) == 1) m_Session.MoveRobot(destination); } + private EditorToolCommand CurrentEditorCommand() + { + return new() { + Tool = SelectedEnum(m_ToolSelect, EEditorTool.Cursor), + Carrier = SelectedEnum(m_CarrierSelect, ECarrierType.Fuel), + RemedyType = SelectedEnum(m_RemedySelect, ERemedyType.FuelNeutralizer) + }; + } + + private string CurrentValidationText() + { + return m_Session?.ValidateForSave() ?? "No level loaded."; + } + + private void SetEditorStatus(string text) + { + if (m_EditorStatus is not null) + m_EditorStatus.Text = text; + } + + private static TEnum SelectedEnum(OptionButton? select, TEnum fallback) where TEnum : struct, Enum + { + if (select is null) + return fallback; + + var id = select.GetSelectedId(); + return Enum.IsDefined(typeof(TEnum), id) ? (TEnum)Enum.ToObject(typeof(TEnum), id) : fallback; + } + + private static string EditorToolLabel(EEditorTool tool) + { + return tool switch { + EEditorTool.AllSeeingEyeTerminal => "Eye Terminal", + EEditorTool.IsolationValve => "Isolation Valve", + EEditorTool.RemedySupply => "Remedy Supply", + EEditorTool.ReactorControl => "Reactor Control", + EEditorTool.SprinklerControl => "Sprinkler Control", + EEditorTool.SprinklerValve => "Sprinkler Valve", + EEditorTool.SurfaceHazard => "Surface Hazard", + _ => tool.ToString() + }; + } + private AppController? m_App; + private OptionButton? m_CarrierSelect; + private bool m_EditMode; + private Label? m_EditorStatus; private ForecastList? m_ForecastList; private GridViewport? m_GridViewport; private LevelHeader? m_Header; @@ -290,5 +404,7 @@ public partial class LevelScreen : ScreenBase private int m_LevelNumber; private bool m_OutcomeVisible; private CanvasLayer? m_OverlayLayer; + private OptionButton? m_RemedySelect; private GameSession? m_Session; + private OptionButton? m_ToolSelect; } \ No newline at end of file diff --git a/src/ReactorMaintenance.Godot/Scripts/AppController.cs b/src/ReactorMaintenance.Godot/Scripts/AppController.cs index a97a858..ac260f4 100644 --- a/src/ReactorMaintenance.Godot/Scripts/AppController.cs +++ b/src/ReactorMaintenance.Godot/Scripts/AppController.cs @@ -50,7 +50,7 @@ public partial class AppController : Control var level = m_Session.CurrentLevel; var gameSession = new GameSession(); var levelState = LevelStateLoader.Load(level.LevelPath); - gameSession.Initialize(levelState); + gameSession.Initialize(levelState, level.LevelPath); ShowScreen("res://Scenes/LevelScreen.tscn", screen => screen.Configure(this, gameSession, level, m_Session.LevelNumber, m_Session.LevelCount)); }