Simulation bridge

This commit is contained in:
2026-05-13 01:56:50 +02:00
parent 251cfa5016
commit b939246ba4
16 changed files with 10000 additions and 198 deletions

View File

@@ -1,5 +1,6 @@
# Platform and documentation # Platform and documentation
Find out which platform you're running on.
If this is a linux environment, read `AGENTS.linux.md`. If this is a linux environment, read `AGENTS.linux.md`.
If this is a windows environment, read `AGENTS.windows.md`. If this is a windows environment, read `AGENTS.windows.md`.
Follow the guidelines laid out in `CODESTYLE.md`. Follow the guidelines laid out in `CODESTYLE.md`.

View File

@@ -1,87 +1,4 @@
# Reactor Maintenance Rewrite Tasks # Reactor Maintenance Tasks
## Current State
- Approved design iteration targets the simulation model, rule removal, action economy, reactor requirements, and editor layer workflow.
- Work is proceeding on branch `design-iteration-structural-editor` in methodical commits.
- Completed commits:
- `787f1e5` Document approved design iteration.
- `3d40617` Restore complete design system documentation.
- `e1ac56d` Rework simulation rules.
- Design documentation must preserve every existing system-level rule unless a change explicitly supersedes it. Superseded sections must document the replacement behavior with equal detail.
- The next implementation iteration is the Win2D editor overhaul.
## Completed Work
- Created the approved implementation plan for:
- single multi-service consumers,
- count-based reactor requirements,
- cell-derived doors,
- 0-10 structural integrity,
- fixed automatic rule systems,
- quick/lengthy action economy,
- all-seeing-eye viewing without persistent unlocking,
- layer-aware editor visualization and tools.
- Repaired the design documentation after the hazard-interaction regression:
- restored the complete surface hazard interaction matrix,
- documented that leaked coolant plus leaked fuel directly holds unless mediated by heat/electricity,
- expanded structural integrity, consumer, reactor, all-seeing-eye, and action-economy details.
- Reworked simulation state and systems:
- removed data-driven rule predicates/effects/events from runtime state, validation, forecasts, serialization, and tests,
- replaced explicit reactor consumer bindings with unbound reactor controls plus required fuel/coolant/electricity consumer counts,
- made consumer props carrier-agnostic with per-carrier service state derived from networks beneath the cell,
- moved doors from explicit edge state to door props on floor cells with orientation inferred from opposing wall cells,
- added underground structural integrity, high-pressure degradation, automatic leak creation, structural forecasts, and repair-to-max behavior,
- removed action budgets and made movement quick while mutating interactions resolve one simulation step,
- removed persistent all-seeing-eye unlocking from simulation state,
- bumped serialized level schema to version 3.
- Updated simulation tests for the replacement systems:
- consumer derivation and disabled consumer service state,
- count-based reactor readiness and reactor-under-network positive-flow requirement,
- quick movement versus lengthy door interaction,
- inferred door blocking,
- structural degradation, automatic leaks, repair integrity reset,
- schema version 3 round-tripping and old-schema rejection.
- Kept the Win2D project compiling after the simulation model changes with narrow compatibility edits. Full editor workflow/rendering work remains outstanding.
- Verified after the simulation rework:
- `dotnet test tests\ReactorMaintenance.Simulation.Tests\ReactorMaintenance.Simulation.Tests.csproj` passed with 23 tests,
- `dotnet build ReactorMaintenance.slnx` passed with 0 warnings.
- Reworked the Win2D editor workflow:
- added the Surface/Electricity/Fuel/Coolant layer combobox,
- filtered tools by active layer and fixed exclusive tool selection,
- rendered underground networks as carrier-colored centerline networks with source dots and layer opacity rules,
- removed Rule Events, Reactor Binding, and pending workflow panels from the editor UI,
- replaced two-click electricity leak authoring with electric-layer leak access cycling,
- made Shift+left drag pan in all tools and Cursor drag move the robot or props.
- Added editor-helper tests for electricity leak access cycling and cursor drag movement behavior.
- Verified after the editor overhaul:
- `dotnet test tests\ReactorMaintenance.Simulation.Tests\ReactorMaintenance.Simulation.Tests.csproj` passed with 26 tests,
- `dotnet build ReactorMaintenance.slnx` passed with 0 warnings.
## Current Work
- Editor overhaul implementation is complete; commit is pending.
## Editor Overhaul Requirements
- Add a layer combobox with Surface, Electricity, Fuel, and Coolant.
- When Surface is active, draw the surface layer at full opacity and all underground layers at 25% opacity.
- When an underground layer is active, draw the surface layer at 50% opacity, other underground layers at 25% opacity, and the active underground layer at full opacity.
- Render coolant blue, fuel red, and electricity yellow.
- Render networks as thick lines connecting adjacent cell centers; render sources as large centered dots.
- Make tools layer-aware:
- Cursor is always available.
- Heat, Floor, Walls, Props, Consumers, Hazards, and Doors are only available for Surface.
- Network painting and Sources are only available on their respective underground layers.
- Selecting a tool must deselect all other tools. The current two-way binding can leave multiple tools selected.
- Shift+LMB should pan the view in all tools, including Cursor mode.
- Cursor LMB drag should move any prop or robot from one cell to another.
- Remove Rule Events UI.
- Remove Reactor Binding UI.
- Remove editor workflow and pending actions.
- Door cells are redesigned as single prop cells.
- Electricity leak neighbour should be toggled by using the electric leak tool on an existing electric leak cell.
## Future Work
- Add authored sample levels once the new schema stabilizes.
- Tune structural integrity balancing after playtesting.
- Extend UI affordances for inspecting per-carrier consumer service state.
## Godot Frontend Integration ## Godot Frontend Integration
@@ -91,7 +8,7 @@ The Godot frontend (src/ReactorMaintenance.Godot) has a complete UX scaffold (sp
**Goal:** Connect LevelScreen to SimulationEngine so gameplay state flows between simulation and UI. **Goal:** Connect LevelScreen to SimulationEngine so gameplay state flows between simulation and UI.
- [ ] **Task 1.1: Create GameSession class** - [x] **Task 1.1: Create GameSession class**
- Location: src/ReactorMaintenance.Godot/Data/GameSession.cs - Location: src/ReactorMaintenance.Godot/Data/GameSession.cs
- Wraps SimulationEngine and holds the current LevelState - Wraps SimulationEngine and holds the current LevelState
- Loads LevelState from JSON via LevelSerializer.Deserialize() using Godot FileAccess - Loads LevelState from JSON via LevelSerializer.Deserialize() using Godot FileAccess
@@ -101,7 +18,7 @@ The Godot frontend (src/ReactorMaintenance.Godot) has a complete UX scaffold (sp
- Validates actions before committing (rejects invalid moves) - Validates actions before committing (rejects invalid moves)
- Handles level start snapshot for retry - Handles level start snapshot for retry
- [ ] **Task 1.2: Create LevelStateLoader helper** - [x] **Task 1.2: Create LevelStateLoader helper**
- Location: src/ReactorMaintenance.Godot/Data/LevelStateLoader.cs - Location: src/ReactorMaintenance.Godot/Data/LevelStateLoader.cs
- Static helper that takes a res://Data/Levels/... path - Static helper that takes a res://Data/Levels/... path
- Uses Godot FileAccess to read JSON string - Uses Godot FileAccess to read JSON string
@@ -109,7 +26,7 @@ The Godot frontend (src/ReactorMaintenance.Godot) has a complete UX scaffold (sp
- Throws descriptive exceptions for missing files or schema errors - Throws descriptive exceptions for missing files or schema errors
- Supports both res:// and user:// paths - Supports both res:// and user:// paths
- [ ] **Task 1.3: Wire LevelScreen to GameSession** - [x] **Task 1.3: Wire LevelScreen to GameSession**
- Update LevelScreen.Configure() to accept GameSession instead of raw CampaignLevel - Update LevelScreen.Configure() to accept GameSession instead of raw CampaignLevel
- Replace placeholder grid with real viewport - Replace placeholder grid with real viewport
- Wire CellInspector.SetCellInfo() to display live selected cell data - Wire CellInspector.SetCellInfo() to display live selected cell data
@@ -118,7 +35,7 @@ The Godot frontend (src/ReactorMaintenance.Godot) has a complete UX scaffold (sp
- Update LevelHeader with live global state badge (Stable/Caution/Critical/Ready) - Update LevelHeader with live global state badge (Stable/Caution/Critical/Ready)
- Update InventoryStrip with live remedy/heat shield counts - Update InventoryStrip with live remedy/heat shield counts
- [ ] **Task 1.4: Create res://Data/Levels/ directory and level files** - [x] **Task 1.4: Create res://Data/Levels/ directory and level files**
- Create placeholder level files for the 3 campaign levels - Create placeholder level files for the 3 campaign levels
- Use Win2D editor to export v3 JSON schema for each level - Use Win2D editor to export v3 JSON schema for each level
- Files: coolant_restart.json, fuel_bleed.json, black_start.json - Files: coolant_restart.json, fuel_bleed.json, black_start.json

View File

@@ -1,4 +1,6 @@
using Godot; using Godot;
using ReactorMaintenance.Simulation;
using System.Text;
namespace ReactorMaintenance.Godot.Controls; namespace ReactorMaintenance.Godot.Controls;
@@ -18,9 +20,31 @@ public partial class CellInspector : PanelContainer
body.AddChild(header); body.AddChild(header);
body.AddChild(m_Text); body.AddChild(m_Text);
m_Text.Text = "Selected Cell: 0,0\nTerrain: service floor\nProp: none\nHazards: none\nUnderground: hidden";
m_Text.AutowrapMode = TextServer.AutowrapMode.WordSmart; m_Text.AutowrapMode = TextServer.AutowrapMode.WordSmart;
} }
public void SetCellInfo(GridPosition? position, ECellTerrain terrain, EPropType prop, EConsumerServiceState serviceState, float fuelHazard, float coolantHazard, float electricityHazard, float heatHazard)
{
var pos = position ?? new(-1, -1);
var sb = new StringBuilder();
sb.AppendLine($"Selected Cell: {pos.X},{pos.Y}");
sb.AppendLine($"Terrain: {terrain}");
sb.AppendLine($"Prop: {prop}");
if (prop != EPropType.None)
sb.AppendLine($"Service: {serviceState}");
sb.AppendLine($"Hazards: {FormatHazards(fuelHazard, coolantHazard, electricityHazard, heatHazard)}");
m_Text.Text = sb.ToString();
}
private static string FormatHazards(float fuel, float coolant, float electricity, float heat)
{
var parts = new List<string>();
if (fuel > 0) parts.Add($"fuel {fuel:F1}");
if (coolant > 0) parts.Add($"coolant {coolant:F1}");
if (electricity > 0) parts.Add($"electricity {electricity:F1}");
if (heat > 0) parts.Add($"heat {heat:F1}");
return parts.Count > 0 ? string.Join(", ", parts) : "none";
}
private readonly Label m_Text = new(); private readonly Label m_Text = new();
} }

View File

@@ -1,4 +1,5 @@
using Godot; using Godot;
using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Godot.Controls; namespace ReactorMaintenance.Godot.Controls;
@@ -7,17 +8,44 @@ public partial class ForecastList : PanelContainer
public override void _Ready() public override void _Ready()
{ {
AddChild(m_Items); AddChild(m_Items);
SetForecasts(["Turn +1: Pressure stable", "Turn +2: No forecast warnings"]); SetForecasts(Array.Empty<Forecast>());
} }
public void SetForecasts(IReadOnlyList<string> forecasts) public void SetForecasts(IReadOnlyList<Forecast> forecasts)
{ {
foreach (var child in m_Items.GetChildren()) foreach (var child in m_Items.GetChildren())
child.QueueFree(); child.QueueFree();
m_Items.AddChild(new Label { Text = "Forecasts" }); m_Items.AddChild(new Label { Text = "Forecasts" });
foreach (var forecast in forecasts) foreach (var forecast in forecasts)
m_Items.AddChild(new Label { Text = forecast, AutowrapMode = TextServer.AutowrapMode.WordSmart }); {
var label = new Label {
Text = FormatForecast(forecast),
AutowrapMode = TextServer.AutowrapMode.WordSmart
};
ApplyForecastColor(label, forecast.Kind);
m_Items.AddChild(label);
}
}
private static string FormatForecast(Forecast forecast)
{
var pos = forecast.Position;
var posStr = pos != null ? $" [{pos.X},{pos.Y}]" : "";
return $"Turn +{forecast.Turns}: {forecast.Message}{posStr}";
}
private static void ApplyForecastColor(Label label, EForecastKind kind)
{
var color = kind switch {
EForecastKind.TerminalLoss => new(1.0f, 0.36f, 0.32f),
EForecastKind.ConsumerStarved => new(1.0f, 0.6f, 0.2f),
EForecastKind.HazardGrowth => new(1.0f, 0.8f, 0.2f),
EForecastKind.StructuralIntegrity => new(0.6f, 0.8f, 1.0f),
EForecastKind.ReactorReady => new(0.45f, 1.0f, 0.58f),
_ => Colors.White
};
label.AddThemeColorOverride("font_color", color);
} }
private readonly VBoxContainer m_Items = new(); private readonly VBoxContainer m_Items = new();

View File

@@ -4,20 +4,6 @@ namespace ReactorMaintenance.Godot.Controls;
internal static class FrontendAssets internal static class FrontendAssets
{ {
public const string CoolantIcon = "res://Assets/Ui/coolant_icon.png";
public const string ElectricIcon = "res://Assets/Ui/electric_icon.png";
public const string FuelIcon = "res://Assets/Ui/fuel_icon.png";
public const string HeatShieldIcon = "res://Assets/Ui/heat_shield_icon.png";
public const string MaintenanceRobot = "res://Assets/Characters/maintenance_robot.png";
public const string PrimaryButtonAccent = "res://Assets/Ui/primary_button_accent.png";
public const string ScannerEyeIcon = "res://Assets/Ui/scanner_eye_icon.png";
public const string StateBadgeFrame = "res://Assets/Ui/state_badge_frame.png";
public static Texture2D? LoadTexture(string path)
{
return ResourceLoader.Exists(path) ? ResourceLoader.Load<Texture2D>(path) : null;
}
public static TextureRect CreateIcon(string path, Vector2 size) public static TextureRect CreateIcon(string path, Vector2 size)
{ {
return new() { return new() {
@@ -28,4 +14,18 @@ internal static class FrontendAssets
MouseFilter = Control.MouseFilterEnum.Ignore MouseFilter = Control.MouseFilterEnum.Ignore
}; };
} }
public static Texture2D? LoadTexture(string path)
{
return ResourceLoader.Exists(path) ? ResourceLoader.Load<Texture2D>(path) : null;
}
public const string CoolantIcon = "res://Assets/Ui/coolant_icon.png";
public const string ElectricIcon = "res://Assets/Ui/electric_icon.png";
public const string FuelIcon = "res://Assets/Ui/fuel_icon.png";
public const string HeatShieldIcon = "res://Assets/Ui/heat_shield_icon.png";
public const string MaintenanceRobot = "res://Assets/Characters/maintenance_robot.png";
public const string PrimaryButtonAccent = "res://Assets/Ui/primary_button_accent.png";
public const string ScannerEyeIcon = "res://Assets/Ui/scanner_eye_icon.png";
public const string StateBadgeFrame = "res://Assets/Ui/state_badge_frame.png";
} }

View File

@@ -1,4 +1,4 @@
using Godot; using Godot;
namespace ReactorMaintenance.Godot.Controls; namespace ReactorMaintenance.Godot.Controls;
@@ -6,23 +6,43 @@ public partial class InventoryStrip : HBoxContainer
{ {
public override void _Ready() public override void _Ready()
{ {
AddChild(CreateItem("Fuel Neutralizer", 2, FrontendAssets.FuelIcon)); AddChild(CreateItem("Fuel Neutralizer", 2, FrontendAssets.FuelIcon, out var fuelLabel));
AddChild(CreateItem("Coolant Neutralizer", 2, FrontendAssets.CoolantIcon)); AddChild(CreateItem("Coolant Neutralizer", 2, FrontendAssets.CoolantIcon, out var coolantLabel));
AddChild(CreateItem("Electric Neutralizer", 1, FrontendAssets.ElectricIcon)); AddChild(CreateItem("Electric Neutralizer", 1, FrontendAssets.ElectricIcon, out var electricLabel));
AddChild(CreateItem("Heat Shield", 1, FrontendAssets.HeatShieldIcon)); AddChild(CreateItem("Heat Shield", 1, FrontendAssets.HeatShieldIcon, out var heatLabel));
m_FuelLabel = fuelLabel;
m_CoolantLabel = coolantLabel;
m_ElectricLabel = electricLabel;
m_HeatLabel = heatLabel;
} }
private static HBoxContainer CreateItem(string name, int count, string iconPath) public void SetInventory(int fuelNeutralizers, int coolantNeutralizers, int electricNeutralizers, int heatShields)
{
m_FuelLabel.Text = $"{fuelNeutralizers}";
m_CoolantLabel.Text = $"{coolantNeutralizers}";
m_ElectricLabel.Text = $"{electricNeutralizers}";
m_HeatLabel.Text = $"{heatShields}";
}
private static HBoxContainer CreateItem(string name, int count, string iconPath, out Label countLabel)
{ {
var item = new HBoxContainer { var item = new HBoxContainer {
SizeFlagsHorizontal = SizeFlags.ExpandFill SizeFlagsHorizontal = SizeFlags.ExpandFill
}; };
item.AddChild(FrontendAssets.CreateIcon(iconPath, new(30, 30))); item.AddChild(FrontendAssets.CreateIcon(iconPath, new(30, 30)));
item.AddChild(new Label { countLabel = new() {
Text = $"{name}: {count}", Text = $"{count}",
Name = name,
HorizontalAlignment = HorizontalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center,
SizeFlagsHorizontal = SizeFlags.ExpandFill SizeFlagsHorizontal = SizeFlags.ExpandFill
}); };
item.AddChild(countLabel);
return item; return item;
} }
private Label m_CoolantLabel = null!;
private Label m_ElectricLabel = null!;
private Label m_FuelLabel = null!;
private Label m_HeatLabel = null!;
} }

View File

@@ -1,5 +1,5 @@
using Godot; using Godot;
using ReactorMaintenance.Godot.Data; using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Godot.Controls; namespace ReactorMaintenance.Godot.Controls;
@@ -18,12 +18,25 @@ public partial class LevelHeader : HBoxContainer
m_Summary.HorizontalAlignment = HorizontalAlignment.Right; m_Summary.HorizontalAlignment = HorizontalAlignment.Right;
} }
public void SetLevel(CampaignLevel level, int levelNumber, int levelCount, string state) public void SetLevel(string title, int levelNumber, int levelCount, ELevelState state)
{ {
m_Title.Text = level.Name; m_Title.Text = title;
m_Progress.Text = $"Level {levelNumber} / {levelCount}"; m_Progress.Text = $"Level {levelNumber} / {levelCount}";
m_Badge.SetState(state); m_Badge.SetState(StateToString(state));
m_Summary.Text = "Heat nominal | Reactor offline"; m_Summary.Text = "Heat: nominal | Reactor: offline";
}
private static string StateToString(ELevelState state)
{
return state switch {
ELevelState.Stable => "Stable",
ELevelState.Caution => "Caution",
ELevelState.Critical => "Critical",
ELevelState.Ready => "Ready",
ELevelState.Lost => "Lost",
ELevelState.Won => "Won",
_ => "Unknown"
};
} }
private readonly StateBadge m_Badge = new(); private readonly StateBadge m_Badge = new();

View File

@@ -0,0 +1,121 @@
using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Godot.Data;
public sealed class GameSession
{
public event StateChangedHandler? LevelStateChanged;
public event StateChangedHandler? RobotMoved;
public event StateChangedHandler? TurnAdvanced;
public event StateChangedHandler? LevelWon;
public event StateChangedHandler? LevelLost;
public void Initialize(LevelState levelState)
{
LevelState = levelState;
m_StartSnapshot = LevelSerializer.Deserialize(LevelSerializer.Serialize(levelState));
}
public bool MoveRobot(GridPosition destination)
{
if (LevelState.Terrain[(destination.Y * LevelState.Width) + destination.X] == ECellTerrain.Wall)
return false;
if (destination.X < 0 || destination.X >= LevelState.Width ||
destination.Y < 0 || destination.Y >= LevelState.Height)
return false;
LevelState = m_Engine.MoveRobot(LevelState, destination);
RobotMoved?.Invoke(this);
return true;
}
public LevelState EndTurn()
{
LevelState = m_Engine.EndTurn(LevelState);
OnTurnAdvanced();
return LevelState;
}
public bool InteractProp()
{
if (LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won)
return false;
LevelState = m_Engine.InteractProp(LevelState);
OnTurnAdvanced();
return true;
}
public bool InteractLeak(ECarrierType carrier, bool useRemedy)
{
if (LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won)
return false;
LevelState = m_Engine.InteractLeak(LevelState, carrier, useRemedy);
OnTurnAdvanced();
return true;
}
public bool ApplyHeatShield()
{
if (LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won)
return false;
LevelState = m_Engine.ApplyHeatShield(LevelState);
OnTurnAdvanced();
return true;
}
public bool ActivateReactor()
{
if (LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won)
return false;
LevelState = m_Engine.ActivateReactor(LevelState);
CheckOutcome();
return true;
}
public IReadOnlyList<Forecast> GetForecasts()
{
return m_Engine.Forecast(LevelState);
}
public void Retry()
{
LevelState = m_StartSnapshot;
LevelStateChanged?.Invoke(this);
}
private void OnTurnAdvanced()
{
CheckOutcome();
TurnAdvanced?.Invoke(this);
}
private void CheckOutcome()
{
if (LevelState.Global.LevelState == ELevelState.Won)
LevelWon?.Invoke(this);
else if (LevelState.Global.LevelState == ELevelState.Lost)
LevelLost?.Invoke(this);
}
public LevelState LevelState { get; private set; } = null!;
public GridPosition RobotPosition => LevelState.Robot.Position;
public GlobalState GlobalState => LevelState.Global;
public IReadOnlyList<Forecast> Forecasts => LevelState.Forecasts;
public IReadOnlyList<LeakState> Leaks => LevelState.Leaks;
public IReadOnlyList<ReactorState> Reactors => LevelState.Reactors;
public IReadOnlyList<PropState> Props => LevelState.Props;
public ECellTerrain[] Terrain => LevelState.Terrain;
public SurfaceState[] Surface => LevelState.Surface;
public UndergroundCell[] UndergroundFuel => LevelState.Fuel;
public UndergroundCell[] UndergroundCoolant => LevelState.Coolant;
public UndergroundCell[] UndergroundElectricity => LevelState.Electricity;
public delegate void StateChangedHandler(GameSession sender);
private readonly SimulationEngine m_Engine = new();
private LevelState m_StartSnapshot = null!;
}

View File

@@ -0,0 +1,34 @@
using ReactorMaintenance.Simulation;
using FileAccess = Godot.FileAccess;
namespace ReactorMaintenance.Godot.Data;
public static class LevelStateLoader
{
public static LevelState Load(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Level path must not be null or empty.", nameof(path));
if (!FileAccess.FileExists(path))
throw new FileNotFoundException($"Level file not found: {path}");
var json = FileAccess.GetFileAsString(path);
if (string.IsNullOrWhiteSpace(json))
throw new InvalidOperationException($"Level file is empty: {path}");
try
{
return LevelSerializer.Deserialize(json);
}
catch (InvalidOperationException)
{
throw;
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Failed to deserialize level from {path}: {ex.Message}", ex);
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,23 @@
using Godot; using Godot;
using ReactorMaintenance.Godot.Controls; using ReactorMaintenance.Godot.Controls;
using ReactorMaintenance.Godot.Data; using ReactorMaintenance.Godot.Data;
using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Godot.Screens; namespace ReactorMaintenance.Godot.Screens;
public partial class LevelScreen : ScreenBase public partial class LevelScreen : ScreenBase
{ {
public void Configure(AppController app, CampaignLevel level, int levelNumber, int levelCount) public void Configure(AppController app, GameSession session, CampaignLevel level, int levelNumber, int levelCount)
{ {
m_App = app; m_App = app;
m_Session = session;
m_Level = level; m_Level = level;
m_OutcomeVisible = false;
var body = CreatePage(string.Empty); var body = CreatePage(string.Empty);
var header = new LevelHeader(); var header = new LevelHeader();
body.AddChild(header); body.AddChild(header);
header.SetLevel(level, levelNumber, levelCount, "Stable"); header.SetLevel(level.Name, levelNumber, levelCount, session.LevelState.Global.LevelState);
var flavor = CreateBodyText(level.FlavorText); var flavor = CreateBodyText(level.FlavorText);
flavor.HorizontalAlignment = HorizontalAlignment.Left; flavor.HorizontalAlignment = HorizontalAlignment.Left;
@@ -23,109 +26,163 @@ public partial class LevelScreen : ScreenBase
var split = new HSplitContainer { SizeFlagsVertical = SizeFlags.ExpandFill }; var split = new HSplitContainer { SizeFlagsVertical = SizeFlags.ExpandFill };
body.AddChild(split); body.AddChild(split);
split.AddChild(CreateGridPlaceholder()); m_GridPanel = CreateGridContainer();
split.AddChild(CreateSidePanel(level)); split.AddChild(m_GridPanel);
body.AddChild(new InventoryStrip()); m_Inspector = new();
m_ForecastList = new();
var sidePanel = new VBoxContainer { CustomMinimumSize = new(300, 0) };
sidePanel.AddChild(m_Inspector);
sidePanel.AddChild(m_ForecastList);
split.AddChild(sidePanel);
m_InventoryStrip = new();
body.AddChild(m_InventoryStrip);
body.AddChild(CreateActionBar()); body.AddChild(CreateActionBar());
m_OverlayLayer = new(); m_OverlayLayer = new();
AddChild(m_OverlayLayer); AddChild(m_OverlayLayer);
UpdateUI();
m_Session.LevelStateChanged += OnLevelStateChanged;
m_Session.TurnAdvanced += OnTurnAdvanced;
} }
private static PanelContainer CreateGridPlaceholder() private PanelContainer CreateGridContainer()
{ {
var panel = new PanelContainer { CustomMinimumSize = new(560, 360), SizeFlagsHorizontal = SizeFlags.ExpandFill, SizeFlagsVertical = SizeFlags.ExpandFill }; return new() {
var viewport = new Control { CustomMinimumSize = new(560, 360),
SizeFlagsHorizontal = SizeFlags.ExpandFill, SizeFlagsHorizontal = SizeFlags.ExpandFill,
SizeFlagsVertical = SizeFlags.ExpandFill SizeFlagsVertical = SizeFlags.ExpandFill
}; };
panel.AddChild(viewport);
var label = new Label {
Text = "Level Grid Placeholder\nRobot marker, terrain, props, hazards, and underground overlays will render here.",
AnchorRight = 1,
AnchorBottom = 1,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
AutowrapMode = TextServer.AutowrapMode.WordSmart
};
viewport.AddChild(label);
var robot = new TextureRect {
Texture = FrontendAssets.LoadTexture(FrontendAssets.MaintenanceRobot),
AnchorLeft = 0.5f,
AnchorTop = 0.5f,
AnchorRight = 0.5f,
AnchorBottom = 0.5f,
OffsetLeft = -48,
OffsetTop = -32,
OffsetRight = 48,
OffsetBottom = 64,
ExpandMode = TextureRect.ExpandModeEnum.FitWidthProportional,
StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered,
MouseFilter = Control.MouseFilterEnum.Ignore
};
viewport.AddChild(robot);
return panel;
}
private static VBoxContainer CreateSidePanel(CampaignLevel level)
{
var sidePanel = new VBoxContainer { CustomMinimumSize = new(300, 0) };
sidePanel.AddChild(new CellInspector());
sidePanel.AddChild(new ForecastList());
sidePanel.AddChild(new Label { Text = $"Level JSON: {level.LevelPath}", AutowrapMode = TextServer.AutowrapMode.WordSmart });
return sidePanel;
} }
private HBoxContainer CreateActionBar() private HBoxContainer CreateActionBar()
{ {
var actions = new HBoxContainer(); var actions = new HBoxContainer();
actions.AddChild(CreateButton("Move", () => { }, "Quick action placeholder")); actions.AddChild(CreateButton("Move", OnMoveAction, "Move robot to adjacent floor cell"));
actions.AddChild(CreateButton("Interact", () => { }, "Lengthy action placeholder")); actions.AddChild(CreateButton("Interact", OnInteractAction, "Interact with prop at robot position"));
actions.AddChild(CreateButton("Repair", () => { }, "Lengthy action placeholder")); actions.AddChild(CreateButton("Repair", OnRepairAction, "Repair leak at robot position"));
actions.AddChild(CreateButton("Trigger Win", ShowWinOverlay)); actions.AddChild(CreateButton("End Turn", OnEndTurnAction));
actions.AddChild(CreateButton("Trigger Lose", ShowLoseOverlay));
actions.AddChild(CreateButton("Main Menu", () => m_App?.ShowMainMenu())); actions.AddChild(CreateButton("Main Menu", () => m_App?.ShowMainMenu()));
return actions; return actions;
} }
private void ShowLoseOverlay() private void OnMoveAction()
{
if (m_Session is null) return;
var current = m_Session.RobotPosition;
var next = current with { X = current.X + 1 };
if (!m_Session.MoveRobot(next))
{
next = current with { Y = current.Y + 1 };
m_Session.MoveRobot(next);
}
}
private void OnInteractAction()
{
m_Session?.InteractProp();
}
private void OnRepairAction()
{
if (m_Session is null) return;
foreach (var leak in m_Session.Leaks)
{
if (leak.AccessPosition == m_Session.RobotPosition && !leak.Repaired)
{
m_Session.InteractLeak(leak.Carrier, true);
return;
}
}
}
private void OnEndTurnAction()
{
m_Session?.EndTurn();
}
private void UpdateUI()
{
if (m_Session is null || m_Inspector is null || m_ForecastList is null || m_InventoryStrip is null)
return;
var ls = m_Session.LevelState;
var robotPos = m_Session.RobotPosition;
var index = (robotPos.Y * ls.Width) + robotPos.X;
var surface = ls.Surface[index];
var prop = index < ls.Props.Length ? ls.Props[index] : new();
m_Inspector.SetCellInfo(
robotPos,
ls.Terrain[index],
prop.Type,
prop.ServiceState,
surface.Fuel,
surface.Coolant,
surface.Electricity,
surface.Heat);
m_ForecastList.SetForecasts(ls.Forecasts);
m_InventoryStrip.SetInventory(
m_Session.LevelState.Robot.FuelNeutralizers,
m_Session.LevelState.Robot.CoolantNeutralizers,
m_Session.LevelState.Robot.ElectricityNeutralizers,
m_Session.LevelState.Robot.HeatShields);
}
private void OnLevelStateChanged(GameSession sender)
{
UpdateUI();
}
private void OnTurnAdvanced(GameSession sender)
{
UpdateUI();
if (sender.LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won)
{
ShowOutcomeOverlay(sender.LevelState.Global.LevelState);
}
}
private void ShowOutcomeOverlay(ELevelState state)
{ {
if (m_OutcomeVisible || m_App is null || m_Level is null || m_OverlayLayer is null) if (m_OutcomeVisible || m_App is null || m_Level is null || m_OverlayLayer is null)
return; return;
m_OutcomeVisible = true; m_OutcomeVisible = true;
var overlay = new LoseOverlay(); Control overlay;
overlay.Configure(m_Level, m_App.RetryCurrentLevel, m_App.ShowGameOver, m_App.ShowMainMenu);
AddCenteredOverlay(overlay);
}
private void ShowWinOverlay() if (state == ELevelState.Won)
{ {
if (m_OutcomeVisible || m_App is null || m_Level is null || m_OverlayLayer is null) overlay = new WinOverlay();
return; ((WinOverlay)overlay).Configure(m_Level, m_App.CompleteCurrentLevel, m_App.ShowMainMenu);
}
else
{
overlay = new LoseOverlay();
((LoseOverlay)overlay).Configure(m_Level, m_App.RetryCurrentLevel, m_App.ShowGameOver, m_App.ShowMainMenu);
}
m_OutcomeVisible = true;
var overlay = new WinOverlay();
overlay.Configure(m_Level, m_App.CompleteCurrentLevel, m_App.ShowMainMenu);
AddCenteredOverlay(overlay);
}
private void AddCenteredOverlay(Control overlay)
{
var center = new CenterContainer { var center = new CenterContainer {
AnchorRight = 1, AnchorRight = 1,
AnchorBottom = 1 AnchorBottom = 1
}; };
center.AddChild(overlay); center.AddChild(overlay);
m_OverlayLayer?.AddChild(center); m_OverlayLayer.AddChild(center);
} }
private AppController? m_App; private AppController? m_App;
private ForecastList? m_ForecastList;
private PanelContainer? m_GridPanel;
private CellInspector? m_Inspector;
private InventoryStrip? m_InventoryStrip;
private CampaignLevel? m_Level; private CampaignLevel? m_Level;
private bool m_OutcomeVisible; private bool m_OutcomeVisible;
private CanvasLayer? m_OverlayLayer; private CanvasLayer? m_OverlayLayer;
private GameSession? m_Session;
} }

View File

@@ -47,7 +47,11 @@ public partial class AppController : Control
public void LoadCurrentLevel() public void LoadCurrentLevel()
{ {
m_Session.MarkCurrentLevelLoaded(); m_Session.MarkCurrentLevelLoaded();
ShowScreen<LevelScreen>("res://Scenes/LevelScreen.tscn", screen => screen.Configure(this, m_Session.CurrentLevel, m_Session.LevelNumber, m_Session.LevelCount)); var level = m_Session.CurrentLevel;
var gameSession = new GameSession();
var levelState = LevelStateLoader.Load(level.LevelPath);
gameSession.Initialize(levelState);
ShowScreen<LevelScreen>("res://Scenes/LevelScreen.tscn", screen => screen.Configure(this, gameSession, level, m_Session.LevelNumber, m_Session.LevelCount));
} }
public void RetryCurrentLevel() public void RetryCurrentLevel()