Add Godot level editing save path
This commit is contained in:
4
TASKS.md
4
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.
|
- [x] Add test/build helpers for level construction.
|
||||||
- Prefer shared builders for linear networks, forks, leaks, wall electricity faces, doors, controls, consumers, and reactors.
|
- Prefer shared builders for linear networks, forks, leaks, wall electricity faces, doors, controls, consumers, and reactors.
|
||||||
- Avoid duplicating low-level array setup across tests.
|
- 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
|
## P1 Godot UX Integration
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ public sealed class GameSession
|
|||||||
public event StateChangedHandler? LevelWon;
|
public event StateChangedHandler? LevelWon;
|
||||||
public event StateChangedHandler? LevelLost;
|
public event StateChangedHandler? LevelLost;
|
||||||
|
|
||||||
public void Initialize(LevelState levelState)
|
public void Initialize(LevelState levelState, string levelPath)
|
||||||
{
|
{
|
||||||
LevelState = levelState;
|
LevelState = levelState;
|
||||||
|
LevelPath = levelPath;
|
||||||
m_StartSnapshot = LevelSerializer.Deserialize(LevelSerializer.Serialize(levelState));
|
m_StartSnapshot = LevelSerializer.Deserialize(LevelSerializer.Serialize(levelState));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +83,27 @@ public sealed class GameSession
|
|||||||
LevelStateChanged?.Invoke(this);
|
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()
|
private void OnPulseAdvanced()
|
||||||
{
|
{
|
||||||
CheckOutcome();
|
CheckOutcome();
|
||||||
@@ -101,6 +123,7 @@ public sealed class GameSession
|
|||||||
public GlobalState GlobalState => LevelState.Global;
|
public GlobalState GlobalState => LevelState.Global;
|
||||||
public IReadOnlyList<Forecast> Forecasts => LevelState.Forecasts;
|
public IReadOnlyList<Forecast> Forecasts => LevelState.Forecasts;
|
||||||
public IReadOnlyList<LeakState> Leaks => LevelState.Leaks;
|
public IReadOnlyList<LeakState> Leaks => LevelState.Leaks;
|
||||||
|
public string LevelPath { get; private set; } = string.Empty;
|
||||||
public IReadOnlyList<ReactorState> Reactors => LevelState.Reactors;
|
public IReadOnlyList<ReactorState> Reactors => LevelState.Reactors;
|
||||||
public IReadOnlyList<PropState> Props => LevelState.Props;
|
public IReadOnlyList<PropState> Props => LevelState.Props;
|
||||||
public ECellTerrain[] Terrain => LevelState.Terrain;
|
public ECellTerrain[] Terrain => LevelState.Terrain;
|
||||||
@@ -111,5 +134,6 @@ public sealed class GameSession
|
|||||||
public delegate void StateChangedHandler(GameSession sender);
|
public delegate void StateChangedHandler(GameSession sender);
|
||||||
|
|
||||||
private readonly SimulationEngine m_Engine = new();
|
private readonly SimulationEngine m_Engine = new();
|
||||||
|
private readonly LevelValidator m_Validator = new();
|
||||||
private LevelState m_StartSnapshot = null!;
|
private LevelState m_StartSnapshot = null!;
|
||||||
}
|
}
|
||||||
@@ -31,4 +31,20 @@ public static class LevelStateLoader
|
|||||||
$"Failed to deserialize level from {path}: {ex.Message}", ex);
|
$"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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,7 @@ public partial class LevelScreen : ScreenBase
|
|||||||
m_GridViewport = CreateGridViewport();
|
m_GridViewport = CreateGridViewport();
|
||||||
gridColumn.AddChild(m_GridViewport);
|
gridColumn.AddChild(m_GridViewport);
|
||||||
gridColumn.AddChild(CreateLayerControls());
|
gridColumn.AddChild(CreateLayerControls());
|
||||||
|
gridColumn.AddChild(CreateEditorControls());
|
||||||
split.AddChild(gridColumn);
|
split.AddChild(gridColumn);
|
||||||
|
|
||||||
m_Inspector = new();
|
m_Inspector = new();
|
||||||
@@ -137,7 +138,7 @@ public partial class LevelScreen : ScreenBase
|
|||||||
|
|
||||||
private void OnMoveAction()
|
private void OnMoveAction()
|
||||||
{
|
{
|
||||||
if (m_Session is null) return;
|
if (m_Session is null || m_EditMode) return;
|
||||||
|
|
||||||
var current = m_Session.RobotPosition;
|
var current = m_Session.RobotPosition;
|
||||||
var next = current with { X = current.X + 1 };
|
var next = current with { X = current.X + 1 };
|
||||||
@@ -150,12 +151,13 @@ public partial class LevelScreen : ScreenBase
|
|||||||
|
|
||||||
private void OnInteractAction()
|
private void OnInteractAction()
|
||||||
{
|
{
|
||||||
|
if (!m_EditMode)
|
||||||
m_Session?.InteractProp();
|
m_Session?.InteractProp();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnRepairAction()
|
private void OnRepairAction()
|
||||||
{
|
{
|
||||||
if (m_Session is null) return;
|
if (m_Session is null || m_EditMode) return;
|
||||||
|
|
||||||
foreach (var leak in m_Session.Leaks)
|
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()
|
private void UpdateUI()
|
||||||
{
|
{
|
||||||
if (m_Session is null || m_Inspector is null || m_ForecastList is null || m_InventoryStrip is null)
|
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;
|
return;
|
||||||
|
|
||||||
var destination = new GridPosition(cell.X, cell.Y);
|
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)
|
if (m_Session.RobotPosition.ManhattanDistance(destination) == 1)
|
||||||
m_Session.MoveRobot(destination);
|
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 AppController? m_App;
|
||||||
|
private OptionButton? m_CarrierSelect;
|
||||||
|
private bool m_EditMode;
|
||||||
|
private Label? m_EditorStatus;
|
||||||
private ForecastList? m_ForecastList;
|
private ForecastList? m_ForecastList;
|
||||||
private GridViewport? m_GridViewport;
|
private GridViewport? m_GridViewport;
|
||||||
private LevelHeader? m_Header;
|
private LevelHeader? m_Header;
|
||||||
@@ -290,5 +404,7 @@ public partial class LevelScreen : ScreenBase
|
|||||||
private int m_LevelNumber;
|
private int m_LevelNumber;
|
||||||
private bool m_OutcomeVisible;
|
private bool m_OutcomeVisible;
|
||||||
private CanvasLayer? m_OverlayLayer;
|
private CanvasLayer? m_OverlayLayer;
|
||||||
|
private OptionButton? m_RemedySelect;
|
||||||
private GameSession? m_Session;
|
private GameSession? m_Session;
|
||||||
|
private OptionButton? m_ToolSelect;
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ public partial class AppController : Control
|
|||||||
var level = m_Session.CurrentLevel;
|
var level = m_Session.CurrentLevel;
|
||||||
var gameSession = new GameSession();
|
var gameSession = new GameSession();
|
||||||
var levelState = LevelStateLoader.Load(level.LevelPath);
|
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));
|
ShowScreen<LevelScreen>("res://Scenes/LevelScreen.tscn", screen => screen.Configure(this, gameSession, level, m_Session.LevelNumber, m_Session.LevelCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user