Add Godot level editing save path

This commit is contained in:
2026-05-14 10:59:14 +02:00
parent 6699b3b891
commit 542c0cdc19
5 changed files with 165 additions and 5 deletions

View File

@@ -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

View File

@@ -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<Forecast> Forecasts => LevelState.Forecasts;
public IReadOnlyList<LeakState> Leaks => LevelState.Leaks;
public string LevelPath { get; private set; } = string.Empty;
public IReadOnlyList<ReactorState> Reactors => LevelState.Reactors;
public IReadOnlyList<PropState> 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!;
}

View File

@@ -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));
}
}

View File

@@ -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<EEditorTool>())
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<ECarrierType>())
m_CarrierSelect.AddItem(carrier.ToString(), (int)carrier);
controls.AddChild(m_CarrierSelect);
m_RemedySelect = new();
foreach (var remedy in Enum.GetValues<ERemedyType>())
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<TEnum>(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;
}

View File

@@ -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<LevelScreen>("res://Scenes/LevelScreen.tscn", screen => screen.Configure(this, gameSession, level, m_Session.LevelNumber, m_Session.LevelCount));
}