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,4 +1,6 @@
using Godot;
using Godot;
using ReactorMaintenance.Simulation;
using System.Text;
namespace ReactorMaintenance.Godot.Controls;
@@ -18,9 +20,31 @@ public partial class CellInspector : PanelContainer
body.AddChild(header);
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;
}
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();
}
}

View File

@@ -1,4 +1,5 @@
using Godot;
using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Godot.Controls;
@@ -7,17 +8,44 @@ public partial class ForecastList : PanelContainer
public override void _Ready()
{
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())
child.QueueFree();
m_Items.AddChild(new Label { Text = "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();

View File

@@ -4,20 +4,6 @@ namespace ReactorMaintenance.Godot.Controls;
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)
{
return new() {
@@ -28,4 +14,18 @@ internal static class FrontendAssets
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;
@@ -6,23 +6,43 @@ public partial class InventoryStrip : HBoxContainer
{
public override void _Ready()
{
AddChild(CreateItem("Fuel Neutralizer", 2, FrontendAssets.FuelIcon));
AddChild(CreateItem("Coolant Neutralizer", 2, FrontendAssets.CoolantIcon));
AddChild(CreateItem("Electric Neutralizer", 1, FrontendAssets.ElectricIcon));
AddChild(CreateItem("Heat Shield", 1, FrontendAssets.HeatShieldIcon));
AddChild(CreateItem("Fuel Neutralizer", 2, FrontendAssets.FuelIcon, out var fuelLabel));
AddChild(CreateItem("Coolant Neutralizer", 2, FrontendAssets.CoolantIcon, out var coolantLabel));
AddChild(CreateItem("Electric Neutralizer", 1, FrontendAssets.ElectricIcon, out var electricLabel));
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 {
SizeFlagsHorizontal = SizeFlags.ExpandFill
};
item.AddChild(FrontendAssets.CreateIcon(iconPath, new(30, 30)));
item.AddChild(new Label {
Text = $"{name}: {count}",
countLabel = new() {
Text = $"{count}",
Name = name,
HorizontalAlignment = HorizontalAlignment.Center,
SizeFlagsHorizontal = SizeFlags.ExpandFill
});
};
item.AddChild(countLabel);
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 ReactorMaintenance.Godot.Data;
using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Godot.Controls;
@@ -18,12 +18,25 @@ public partial class LevelHeader : HBoxContainer
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_Badge.SetState(state);
m_Summary.Text = "Heat nominal | Reactor offline";
m_Badge.SetState(StateToString(state));
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();

View File

@@ -21,4 +21,4 @@ public partial class PrimaryButton : Button
TooltipText = tooltip;
Disabled = disabled;
}
}
}

View File

@@ -48,4 +48,4 @@ public partial class StateBadge : PanelContainer
}
private readonly Label m_Text = 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.Data;
using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Godot.Screens;
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_Session = session;
m_Level = level;
m_OutcomeVisible = false;
var body = CreatePage(string.Empty);
var header = new LevelHeader();
body.AddChild(header);
header.SetLevel(level, levelNumber, levelCount, "Stable");
header.SetLevel(level.Name, levelNumber, levelCount, session.LevelState.Global.LevelState);
var flavor = CreateBodyText(level.FlavorText);
flavor.HorizontalAlignment = HorizontalAlignment.Left;
@@ -23,109 +26,163 @@ public partial class LevelScreen : ScreenBase
var split = new HSplitContainer { SizeFlagsVertical = SizeFlags.ExpandFill };
body.AddChild(split);
split.AddChild(CreateGridPlaceholder());
split.AddChild(CreateSidePanel(level));
m_GridPanel = CreateGridContainer();
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());
m_OverlayLayer = new();
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 };
var viewport = new Control {
return new() {
CustomMinimumSize = new(560, 360),
SizeFlagsHorizontal = 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()
{
var actions = new HBoxContainer();
actions.AddChild(CreateButton("Move", () => { }, "Quick action placeholder"));
actions.AddChild(CreateButton("Interact", () => { }, "Lengthy action placeholder"));
actions.AddChild(CreateButton("Repair", () => { }, "Lengthy action placeholder"));
actions.AddChild(CreateButton("Trigger Win", ShowWinOverlay));
actions.AddChild(CreateButton("Trigger Lose", ShowLoseOverlay));
actions.AddChild(CreateButton("Move", OnMoveAction, "Move robot to adjacent floor cell"));
actions.AddChild(CreateButton("Interact", OnInteractAction, "Interact with prop at robot position"));
actions.AddChild(CreateButton("Repair", OnRepairAction, "Repair leak at robot position"));
actions.AddChild(CreateButton("End Turn", OnEndTurnAction));
actions.AddChild(CreateButton("Main Menu", () => m_App?.ShowMainMenu()));
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)
return;
m_OutcomeVisible = true;
var overlay = new LoseOverlay();
overlay.Configure(m_Level, m_App.RetryCurrentLevel, m_App.ShowGameOver, m_App.ShowMainMenu);
AddCenteredOverlay(overlay);
}
Control overlay;
private void ShowWinOverlay()
{
if (m_OutcomeVisible || m_App is null || m_Level is null || m_OverlayLayer is null)
return;
if (state == ELevelState.Won)
{
overlay = new WinOverlay();
((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 {
AnchorRight = 1,
AnchorBottom = 1
};
center.AddChild(overlay);
m_OverlayLayer?.AddChild(center);
m_OverlayLayer.AddChild(center);
}
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 bool m_OutcomeVisible;
private CanvasLayer? m_OverlayLayer;
}
private GameSession? m_Session;
}

View File

@@ -47,7 +47,11 @@ public partial class AppController : Control
public void LoadCurrentLevel()
{
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()