Implement Godot UX scene scaffold

This commit is contained in:
2026-05-12 21:06:48 +02:00
parent 8cf554574b
commit 33859d2cf6
74 changed files with 1060 additions and 8 deletions

View File

@@ -1,11 +1,11 @@
# Reactor Maintenance # Reactor Maintenance
C# simulation with WinUI 3 + Win2D editor and an empty Godot frontend shell for the deterministic grid simulation described in `docs/design.md`. C# simulation with WinUI 3 + Win2D editor and a Godot frontend shell for the deterministic grid simulation described in `docs/design.md`.
## Projects ## Projects
- `src/ReactorMaintenance.Simulation`: UI-independent level model, editor operations, validation, forecasts, simulation turns, versioned JSON serialization, and deterministic balancing defaults. - `src/ReactorMaintenance.Simulation`: UI-independent level model, editor operations, validation, forecasts, simulation turns, versioned JSON serialization, and deterministic balancing defaults.
- `src/ReactorMaintenance.Godot`: empty Godot 4.5 .NET frontend project shell referencing the simulation core. - `src/ReactorMaintenance.Godot`: Godot 4.5 .NET frontend project with scene routing, UX blueprint screens, reusable UI controls, and a mock campaign manifest referencing future level JSON files.
- `src/ReactorMaintenance.Win2D`: Win2D editor app for authoring terrain, underground fuel/coolant/electricity networks, props, explicit leak access faces, door edges, reactor consumer bindings, rule events, surface hazards, robot start, loading/saving levels, ending turns, interacting with props, and activating a ready reactor. - `src/ReactorMaintenance.Win2D`: Win2D editor app for authoring terrain, underground fuel/coolant/electricity networks, props, explicit leak access faces, door edges, reactor consumer bindings, rule events, surface hazards, robot start, loading/saving levels, ending turns, interacting with props, and activating a ready reactor.
- `tests/ReactorMaintenance.Simulation.Tests`: unit tests for deterministic simulation behavior, validation, serialization, and editor operations. - `tests/ReactorMaintenance.Simulation.Tests`: unit tests for deterministic simulation behavior, validation, serialization, and editor operations.
@@ -29,3 +29,9 @@ dotnet run --project src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.cspro
The WinUI/XAML compiler is Windows-specific. On Linux, the simulation tests run normally, but the Win2D app build must be verified in a Windows-capable environment. The WinUI/XAML compiler is Windows-specific. On Linux, the simulation tests run normally, but the Win2D app build must be verified in a Windows-capable environment.
## Godot Frontend
The current Godot frontend is a navigable UX scaffold. It starts at a splash screen, routes through the main menu, campaign intro, random generation placeholder, level screen, win/loss overlays, options, tutorial, game over, and campaign complete screens.
The mock campaign manifest lives at `src\ReactorMaintenance.Godot\Data\default_campaign_manifest.json`. Each entry includes the future serialized simulation level path; those JSON files are intentionally placeholders for authored levels that will use the simulation `LevelSerializer` format.

View File

@@ -19,7 +19,7 @@ SplashScreen
MainMenu MainMenu
-> New Campaign -> CampaignIntro or LevelScreen -> New Campaign -> CampaignIntro or LevelScreen
-> Continue Campaign -> LevelScreen -> Continue Campaign -> LevelScreen
-> Play Random Level -> LevelScreen -> Play Random Level -> GenerationScreen -> LevelScreen
-> OptionsScreen -> OptionsScreen
-> TutorialScreen -> TutorialScreen

View File

@@ -0,0 +1,15 @@
using Godot;
namespace ReactorMaintenance.Godot.Controls;
public partial class CellInspector : PanelContainer
{
public override void _Ready()
{
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;
}
private readonly Label m_Text = new();
}

View File

@@ -0,0 +1 @@
uid://d0d3kglv6s32m

View File

@@ -0,0 +1,33 @@
using Godot;
namespace ReactorMaintenance.Godot.Controls;
public partial class ConfirmDialog : PanelContainer
{
public event Action? Confirmed;
public event Action? Canceled;
public void Configure(string title, string message, string confirmText = "Confirm")
{
foreach (var child in GetChildren())
child.QueueFree();
var body = new VBoxContainer();
AddChild(body);
body.AddChild(new Label { Text = title, HorizontalAlignment = HorizontalAlignment.Center });
body.AddChild(new Label { Text = message, AutowrapMode = TextServer.AutowrapMode.WordSmart });
var actions = new HBoxContainer();
body.AddChild(actions);
var confirm = new PrimaryButton();
confirm.Configure(confirmText);
confirm.Pressed += () => Confirmed?.Invoke();
actions.AddChild(confirm);
var cancel = new PrimaryButton();
cancel.Configure("Cancel");
cancel.Pressed += () => Canceled?.Invoke();
actions.AddChild(cancel);
}
}

View File

@@ -0,0 +1 @@
uid://cp5qe2n4nwb32

View File

@@ -0,0 +1,24 @@
using Godot;
namespace ReactorMaintenance.Godot.Controls;
public partial class ForecastList : PanelContainer
{
public override void _Ready()
{
AddChild(m_Items);
SetForecasts(["Turn +1: Pressure stable", "Turn +2: No forecast warnings"]);
}
public void SetForecasts(IReadOnlyList<string> 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 });
}
private readonly VBoxContainer m_Items = new();
}

View File

@@ -0,0 +1 @@
uid://bswy75n15jxl5

View File

@@ -0,0 +1,23 @@
using Godot;
namespace ReactorMaintenance.Godot.Controls;
public partial class InventoryStrip : HBoxContainer
{
public override void _Ready()
{
AddChild(CreateItem("Fuel Neutralizer", 2));
AddChild(CreateItem("Coolant Neutralizer", 2));
AddChild(CreateItem("Electric Neutralizer", 1));
AddChild(CreateItem("Heat Shield", 1));
}
private static Label CreateItem(string name, int count)
{
return new() {
Text = $"{name}: {count}",
HorizontalAlignment = HorizontalAlignment.Center,
SizeFlagsHorizontal = SizeFlags.ExpandFill
};
}
}

View File

@@ -0,0 +1 @@
uid://d1pdwv570am3v

View File

@@ -0,0 +1,33 @@
using Godot;
using ReactorMaintenance.Godot.Data;
namespace ReactorMaintenance.Godot.Controls;
public partial class LevelHeader : HBoxContainer
{
public override void _Ready()
{
AddChild(m_Title);
AddChild(m_Progress);
AddChild(m_Badge);
AddChild(m_Summary);
m_Title.SizeFlagsHorizontal = SizeFlags.ExpandFill;
m_Title.AddThemeFontSizeOverride("font_size", 24);
m_Progress.HorizontalAlignment = HorizontalAlignment.Center;
m_Summary.HorizontalAlignment = HorizontalAlignment.Right;
}
public void SetLevel(CampaignLevel level, int levelNumber, int levelCount, string state)
{
m_Title.Text = level.Name;
m_Progress.Text = $"Level {levelNumber} / {levelCount}";
m_Badge.SetState(state);
m_Summary.Text = "Heat nominal | Reactor offline";
}
private readonly StateBadge m_Badge = new();
private readonly Label m_Progress = new();
private readonly Label m_Summary = new();
private readonly Label m_Title = new();
}

View File

@@ -0,0 +1 @@
uid://cp50vj0rystlk

View File

@@ -0,0 +1,41 @@
using Godot;
namespace ReactorMaintenance.Godot.Controls;
public partial class OutcomeOverlay : PanelContainer
{
public override void _Ready()
{
CustomMinimumSize = new(420, 220);
AddChild(m_Body);
m_Body.AddChild(m_Title);
m_Body.AddChild(m_Message);
m_Body.AddChild(m_Actions);
m_Title.HorizontalAlignment = HorizontalAlignment.Center;
m_Title.AddThemeFontSizeOverride("font_size", 26);
m_Message.AutowrapMode = TextServer.AutowrapMode.WordSmart;
m_Message.HorizontalAlignment = HorizontalAlignment.Center;
}
protected void Configure(string title, string message, IReadOnlyList<(string Text, Action Pressed)> actions)
{
m_Title.Text = title;
m_Message.Text = message;
foreach (var child in m_Actions.GetChildren())
child.QueueFree();
foreach (var action in actions)
{
var button = new PrimaryButton();
button.Configure(action.Text);
button.Pressed += action.Pressed;
m_Actions.AddChild(button);
}
}
private readonly HBoxContainer m_Actions = new();
private readonly VBoxContainer m_Body = new();
private readonly Label m_Message = new();
private readonly Label m_Title = new();
}

View File

@@ -0,0 +1 @@
uid://65iow3r0egvo

View File

@@ -0,0 +1,20 @@
using Godot;
namespace ReactorMaintenance.Godot.Controls;
public partial class PrimaryButton : Button
{
public override void _Ready()
{
FocusMode = FocusModeEnum.All;
CustomMinimumSize = new(220, 44);
SizeFlagsHorizontal = SizeFlags.ExpandFill;
}
public void Configure(string text, string tooltip = "", bool disabled = false)
{
Text = text;
TooltipText = tooltip;
Disabled = disabled;
}
}

View File

@@ -0,0 +1 @@
uid://bxk3mw5bybqxr

View File

@@ -0,0 +1,30 @@
using Godot;
namespace ReactorMaintenance.Godot.Controls;
public partial class StateBadge : Label
{
public override void _Ready()
{
HorizontalAlignment = HorizontalAlignment.Center;
VerticalAlignment = VerticalAlignment.Center;
CustomMinimumSize = new(96, 28);
}
public void SetState(string state)
{
Text = state;
AddThemeColorOverride("font_color", GetColor(state));
}
private static Color GetColor(string state)
{
return state.ToLowerInvariant() switch {
"stable" => new(0.72f, 0.86f, 0.76f),
"caution" => new(1.0f, 0.76f, 0.24f),
"critical" or "lost" => new(1.0f, 0.36f, 0.32f),
"ready" or "won" => new(0.45f, 1.0f, 0.58f),
_ => Colors.White
};
}
}

View File

@@ -0,0 +1 @@
uid://y7qn5eixkr7j

View File

@@ -0,0 +1,9 @@
namespace ReactorMaintenance.Godot.Data;
public sealed record CampaignLevel
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string FlavorText { get; init; } = string.Empty;
public string LevelPath { get; init; } = string.Empty;
}

View File

@@ -0,0 +1 @@
uid://uis6i7yfkrty

View File

@@ -0,0 +1,6 @@
namespace ReactorMaintenance.Godot.Data;
public sealed record CampaignManifest
{
public IReadOnlyList<CampaignLevel> Levels { get; init; } = Array.Empty<CampaignLevel>();
}

View File

@@ -0,0 +1 @@
uid://bnyc3qlyksmua

View File

@@ -0,0 +1,39 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using FileAccess = Godot.FileAccess;
namespace ReactorMaintenance.Godot.Data;
public static class CampaignRepository
{
public static CampaignManifest LoadDefault()
{
var json = FileAccess.GetFileAsString(c_DefaultManifestPath);
if (string.IsNullOrWhiteSpace(json))
return CreateFallback();
var manifest = JsonSerializer.Deserialize<CampaignManifest>(json, s_Options);
return manifest is { Levels.Count: > 0 } ? manifest : CreateFallback();
}
private static CampaignManifest CreateFallback()
{
return new() {
Levels = [
new() {
Id = "fallback",
Name = "Fallback Reactor",
FlavorText = "A placeholder level loaded because the campaign manifest was unavailable.",
LevelPath = "res://Data/Levels/fallback.json"
}
]
};
}
private const string c_DefaultManifestPath = "res://Data/default_campaign_manifest.json";
private static readonly JsonSerializerOptions s_Options = new() {
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() }
};
}

View File

@@ -0,0 +1 @@
uid://bufo8npdbreb1

View File

@@ -0,0 +1,62 @@
namespace ReactorMaintenance.Godot.Data;
public sealed class FrontendSession
{
public FrontendSession(CampaignManifest campaign)
{
m_Campaign = campaign;
}
public void StartNewCampaign()
{
IsRandomLevel = false;
CampaignIndex = 0;
HasContinue = true;
}
public void ContinueCampaign()
{
IsRandomLevel = false;
HasContinue = true;
}
public void StartRandomLevel()
{
IsRandomLevel = true;
}
public void MarkCurrentLevelLoaded()
{
if (!IsRandomLevel)
HasContinue = true;
}
public void AdvanceToNextLevel()
{
if (HasNextLevel)
CampaignIndex++;
}
public void CompleteCampaign()
{
IsRandomLevel = false;
CampaignIndex = 0;
HasContinue = false;
}
public bool HasContinue { get; private set; }
public bool IsRandomLevel { get; private set; }
public int CampaignIndex { get; private set; }
public CampaignLevel CurrentLevel => IsRandomLevel ? m_RandomLevel : m_Campaign.Levels[CampaignIndex];
public int LevelNumber => IsRandomLevel ? 1 : CampaignIndex + 1;
public int LevelCount => IsRandomLevel ? 1 : m_Campaign.Levels.Count;
public bool HasNextLevel => !IsRandomLevel && CampaignIndex + 1 < m_Campaign.Levels.Count;
private readonly CampaignManifest m_Campaign;
private readonly CampaignLevel m_RandomLevel = new() {
Id = "random-maintenance",
Name = "Random Maintenance Shift",
FlavorText = "A generated shift will use authored JSON level data until generation is implemented.",
LevelPath = "res://Data/Levels/random_placeholder.json"
};
}

View File

@@ -0,0 +1 @@
uid://bsbukw3qpo0by

View File

@@ -0,0 +1,22 @@
{
"levels": [
{
"id": "coolant-restart",
"name": "Coolant Restart",
"flavorText": "The lower coolant loop is starving the reactor core. Restore enough service to keep the first startup window alive.",
"levelPath": "res://Data/Levels/coolant_restart.json"
},
{
"id": "fuel-bleed",
"name": "Fuel Bleed",
"flavorText": "A fuel manifold is venting through the maintenance deck. Isolate the leak before pressure cascades into the ignition zone.",
"levelPath": "res://Data/Levels/fuel_bleed.json"
},
{
"id": "black-start",
"name": "Black Start",
"flavorText": "The final reactor needs fuel, coolant, and electricity in balance before the station can carry load again.",
"levelPath": "res://Data/Levels/black_start.json"
}
]
}

View File

@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Screens/CampaignIntro.cs" id="1"]
[node name="CampaignIntro" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Controls/CellInspector.cs" id="1"]
[node name="CellInspector" type="PanelContainer"]
script = ExtResource("1")

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Controls/ConfirmDialog.cs" id="1"]
[node name="ConfirmDialog" type="PanelContainer"]
script = ExtResource("1")

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Controls/ForecastList.cs" id="1"]
[node name="ForecastList" type="PanelContainer"]
script = ExtResource("1")

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Controls/InventoryStrip.cs" id="1"]
[node name="InventoryStrip" type="HBoxContainer"]
script = ExtResource("1")

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Controls/LevelHeader.cs" id="1"]
[node name="LevelHeader" type="HBoxContainer"]
script = ExtResource("1")

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Controls/OutcomeOverlay.cs" id="1"]
[node name="OutcomeOverlay" type="PanelContainer"]
script = ExtResource("1")

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Controls/PrimaryButton.cs" id="1"]
[node name="PrimaryButton" type="Button"]
script = ExtResource("1")

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Controls/StateBadge.cs" id="1"]
[node name="StateBadge" type="Label"]
script = ExtResource("1")

View File

@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Screens/GameOverScreen.cs" id="1"]
[node name="GameOverScreen" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

View File

@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Screens/GameWonScreen.cs" id="1"]
[node name="GameWonScreen" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

View File

@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Screens/GenerationScreen.cs" id="1"]
[node name="GenerationScreen" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

View File

@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Screens/LevelScreen.cs" id="1"]
[node name="LevelScreen" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Screens/LoseOverlay.cs" id="1"]
[node name="LoseOverlay" type="PanelContainer"]
script = ExtResource("1")

View File

@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Screens/MainMenu.cs" id="1"]
[node name="MainMenu" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

View File

@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Screens/OptionsScreen.cs" id="1"]
[node name="OptionsScreen" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

View File

@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Screens/SplashScreen.cs" id="1"]
[node name="SplashScreen" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

View File

@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Screens/TutorialScreen.cs" id="1"]
[node name="TutorialScreen" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Screens/WinOverlay.cs" id="1"]
[node name="WinOverlay" type="PanelContainer"]
script = ExtResource("1")

View File

@@ -0,0 +1,20 @@
using Godot;
using ReactorMaintenance.Godot.Data;
namespace ReactorMaintenance.Godot.Screens;
public partial class CampaignIntro : ScreenBase
{
public void Configure(AppController app, CampaignLevel level, int levelNumber, int levelCount)
{
var body = CreatePage(level.Name);
body.Alignment = BoxContainer.AlignmentMode.Center;
body.AddChild(CreateBodyText($"Campaign level {levelNumber} / {levelCount}"));
body.AddChild(CreateBodyText(level.FlavorText));
var actions = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
body.AddChild(actions);
actions.AddChild(CreateButton("Begin", app.LoadCurrentLevel));
actions.AddChild(CreateButton("Back", app.ShowMainMenu));
}
}

View File

@@ -0,0 +1 @@
uid://cd0ffydc3pdc2

View File

@@ -0,0 +1,19 @@
using Godot;
using ReactorMaintenance.Godot.Data;
namespace ReactorMaintenance.Godot.Screens;
public partial class GameOverScreen : ScreenBase
{
public void Configure(AppController app, CampaignLevel level)
{
var body = CreatePage("Game Over");
body.Alignment = BoxContainer.AlignmentMode.Center;
body.AddChild(CreateBodyText($"Failed level: {level.Name}"));
var actions = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
body.AddChild(actions);
actions.AddChild(CreateButton("Retry Current Level", app.RetryCurrentLevel));
actions.AddChild(CreateButton("Main Menu", app.ShowMainMenu));
}
}

View File

@@ -0,0 +1 @@
uid://d1ncb4ysjstdd

View File

@@ -0,0 +1,15 @@
using Godot;
namespace ReactorMaintenance.Godot.Screens;
public partial class GameWonScreen : ScreenBase
{
public void Configure(AppController app, int completedLevels)
{
var body = CreatePage("Campaign Complete");
body.Alignment = BoxContainer.AlignmentMode.Center;
body.AddChild(CreateBodyText($"All {completedLevels} handcrafted reactor incidents are resolved."));
body.AddChild(CreateBodyText("The maintenance crew has restored enough capacity to bring the station back online."));
body.AddChild(CreateButton("Main Menu", app.ShowMainMenu));
}
}

View File

@@ -0,0 +1 @@
uid://cfijlvnlio424

View File

@@ -0,0 +1,20 @@
using Godot;
using ReactorMaintenance.Godot.Data;
namespace ReactorMaintenance.Godot.Screens;
public partial class GenerationScreen : ScreenBase
{
public void Configure(AppController app, CampaignLevel level)
{
var body = CreatePage("Random Level");
body.Alignment = BoxContainer.AlignmentMode.Center;
body.AddChild(CreateBodyText(level.FlavorText));
body.AddChild(CreateBodyText($"Future level JSON: {level.LevelPath}"));
var actions = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
body.AddChild(actions);
actions.AddChild(CreateButton("Begin", app.LoadCurrentLevel));
actions.AddChild(CreateButton("Back", app.ShowMainMenu));
}
}

View File

@@ -0,0 +1 @@
uid://cs63x3a7h8pad

View File

@@ -0,0 +1,107 @@
using Godot;
using ReactorMaintenance.Godot.Controls;
using ReactorMaintenance.Godot.Data;
namespace ReactorMaintenance.Godot.Screens;
public partial class LevelScreen : ScreenBase
{
public void Configure(AppController app, CampaignLevel level, int levelNumber, int levelCount)
{
m_App = app;
m_Level = level;
var body = CreatePage(string.Empty);
var header = new LevelHeader();
body.AddChild(header);
header.SetLevel(level, levelNumber, levelCount, "Stable");
var flavor = CreateBodyText(level.FlavorText);
flavor.HorizontalAlignment = HorizontalAlignment.Left;
body.AddChild(flavor);
var split = new HSplitContainer { SizeFlagsVertical = SizeFlags.ExpandFill };
body.AddChild(split);
split.AddChild(CreateGridPlaceholder());
split.AddChild(CreateSidePanel(level));
body.AddChild(new InventoryStrip());
body.AddChild(CreateActionBar());
m_OverlayLayer = new();
AddChild(m_OverlayLayer);
}
private static PanelContainer CreateGridPlaceholder()
{
var panel = new PanelContainer { CustomMinimumSize = new(560, 360), SizeFlagsHorizontal = SizeFlags.ExpandFill, SizeFlagsVertical = SizeFlags.ExpandFill };
var label = new Label {
Text = "Level Grid Placeholder\nRobot marker, terrain, props, hazards, and underground overlays will render here.",
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
AutowrapMode = TextServer.AutowrapMode.WordSmart
};
panel.AddChild(label);
return panel;
}
private static VBoxContainer CreateSidePanel(CampaignLevel level)
{
var sidePanel = new VBoxContainer { CustomMinimumSize = new(300, 0) };
sidePanel.AddChild(new Label { Text = "Inspector" });
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("Main Menu", () => m_App?.ShowMainMenu()));
return actions;
}
private void ShowLoseOverlay()
{
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);
}
private void ShowWinOverlay()
{
if (m_OutcomeVisible || m_App is null || m_Level is null || m_OverlayLayer is null)
return;
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);
}
private AppController? m_App;
private CampaignLevel? m_Level;
private bool m_OutcomeVisible;
private CanvasLayer? m_OverlayLayer;
}

View File

@@ -0,0 +1 @@
uid://dix1dibqgxr3y

View File

@@ -0,0 +1,19 @@
using ReactorMaintenance.Godot.Controls;
using ReactorMaintenance.Godot.Data;
namespace ReactorMaintenance.Godot.Screens;
public partial class LoseOverlay : OutcomeOverlay
{
public void Configure(CampaignLevel level, Action retryLevel, Action openGameOver, Action mainMenu)
{
Configure(
"Level Lost",
$"{level.Name} has failed. Retry reloads the level from its starting snapshot.",
[
("Retry Level", retryLevel),
("Game Over", openGameOver),
("Main Menu", mainMenu)
]);
}
}

View File

@@ -0,0 +1 @@
uid://bvonbqula7dxq

View File

@@ -0,0 +1,26 @@
using Godot;
namespace ReactorMaintenance.Godot.Screens;
public partial class MainMenu : ScreenBase
{
public void Configure(AppController app, bool hasContinue)
{
var body = CreatePage("Reactor Maintenance");
body.Alignment = BoxContainer.AlignmentMode.Center;
var commands = new VBoxContainer {
CustomMinimumSize = new(320, 0),
SizeFlagsHorizontal = SizeFlags.ShrinkCenter
};
body.AddChild(commands);
commands.AddChild(CreateButton("New Campaign", app.StartNewCampaign));
commands.AddChild(CreateButton("Continue Campaign", app.ContinueCampaign, "No campaign progress exists yet.", !hasContinue));
commands.AddChild(CreateButton("Play Random Level", app.StartRandomLevel));
commands.AddChild(CreateButton("Options", app.ShowOptions));
commands.AddChild(CreateButton("Tutorial", app.ShowTutorial));
body.AddChild(CreateBodyText("Godot frontend prototype"));
}
}

View File

@@ -0,0 +1 @@
uid://dj4he4y5anmdy

View File

@@ -0,0 +1,26 @@
using Godot;
namespace ReactorMaintenance.Godot.Screens;
public partial class OptionsScreen : ScreenBase
{
public void Configure(AppController app)
{
var body = CreatePage("Options");
body.Alignment = BoxContainer.AlignmentMode.Center;
body.AddChild(CreateSlider("Master Volume"));
body.AddChild(CreateSlider("Music Volume"));
body.AddChild(new CheckBox { Text = "Fullscreen" });
body.AddChild(new OptionButton { Text = "UI Scale" });
body.AddChild(CreateButton("Back", app.ShowMainMenu));
}
private static HBoxContainer CreateSlider(string label)
{
var row = new HBoxContainer { CustomMinimumSize = new(360, 40), SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
row.AddChild(new Label { Text = label, CustomMinimumSize = new(140, 0) });
row.AddChild(new HSlider { MinValue = 0, MaxValue = 100, Value = 75, SizeFlagsHorizontal = SizeFlags.ExpandFill });
return row;
}
}

View File

@@ -0,0 +1 @@
uid://bkf0ctd7yk656

View File

@@ -0,0 +1,58 @@
using Godot;
using ReactorMaintenance.Godot.Controls;
namespace ReactorMaintenance.Godot.Screens;
public abstract partial class ScreenBase : Control
{
protected VBoxContainer CreatePage(string title)
{
foreach (var child in GetChildren())
child.QueueFree();
SetAnchorsPreset(LayoutPreset.FullRect);
var margin = new MarginContainer {
AnchorRight = 1,
AnchorBottom = 1
};
margin.AddThemeConstantOverride("margin_left", 48);
margin.AddThemeConstantOverride("margin_top", 36);
margin.AddThemeConstantOverride("margin_right", 48);
margin.AddThemeConstantOverride("margin_bottom", 36);
AddChild(margin);
var body = new VBoxContainer {
SizeFlagsHorizontal = SizeFlags.ExpandFill,
SizeFlagsVertical = SizeFlags.ExpandFill
};
margin.AddChild(body);
var titleLabel = new Label {
Text = title,
HorizontalAlignment = HorizontalAlignment.Center
};
titleLabel.AddThemeFontSizeOverride("font_size", 32);
body.AddChild(titleLabel);
return body;
}
protected static PrimaryButton CreateButton(string text, Action pressed, string tooltip = "", bool disabled = false)
{
var button = new PrimaryButton();
button.Configure(text, tooltip, disabled);
button.Pressed += pressed;
return button;
}
protected static Label CreateBodyText(string text)
{
return new() {
Text = text,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
HorizontalAlignment = HorizontalAlignment.Center,
SizeFlagsHorizontal = SizeFlags.ExpandFill
};
}
}

View File

@@ -0,0 +1 @@
uid://dcwn6wqeikkpn

View File

@@ -0,0 +1,33 @@
using Godot;
namespace ReactorMaintenance.Godot.Screens;
public partial class SplashScreen : ScreenBase
{
public event Action? Started;
public override void _Ready()
{
var body = CreatePage("Reactor Maintenance");
body.Alignment = BoxContainer.AlignmentMode.Center;
body.AddChild(CreateBodyText("Systems booting..."));
GetTree().CreateTimer(1.2).Timeout += Start;
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event.IsPressed())
Start();
}
private void Start()
{
if (m_Transitioned)
return;
m_Transitioned = true;
Started?.Invoke();
}
private bool m_Transitioned;
}

View File

@@ -0,0 +1 @@
uid://yiggx6pgqntg

View File

@@ -0,0 +1,18 @@
using Godot;
namespace ReactorMaintenance.Godot.Screens;
public partial class TutorialScreen : ScreenBase
{
public void Configure(AppController app)
{
var body = CreatePage("Tutorial");
body.Alignment = BoxContainer.AlignmentMode.Center;
body.AddChild(CreateBodyText("Topics: action economy, hazards, forecasts, remedies, heat shields, and reactor activation."));
var actions = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
body.AddChild(actions);
actions.AddChild(CreateButton("Start Tutorial Level", app.StartRandomLevel));
actions.AddChild(CreateButton("Back", app.ShowMainMenu));
}
}

View File

@@ -0,0 +1 @@
uid://xfq61pdayub7

View File

@@ -0,0 +1,18 @@
using ReactorMaintenance.Godot.Controls;
using ReactorMaintenance.Godot.Data;
namespace ReactorMaintenance.Godot.Screens;
public partial class WinOverlay : OutcomeOverlay
{
public void Configure(CampaignLevel level, Action nextLevel, Action mainMenu)
{
Configure(
"Reactor Online",
$"{level.Name} is stabilized. Continue loads the next campaign level or campaign completion screen.",
[
("Continue", nextLevel),
("Main Menu", mainMenu)
]);
}
}

View File

@@ -0,0 +1 @@
uid://cqiqwgr0usmki

View File

@@ -0,0 +1,110 @@
using Godot;
using ReactorMaintenance.Godot.Data;
using ReactorMaintenance.Godot.Screens;
namespace ReactorMaintenance.Godot;
public partial class AppController : Control
{
public override void _Ready()
{
ShowSplash();
}
public void ShowSplash()
{
ShowScreen<SplashScreen>("res://Scenes/SplashScreen.tscn", screen => screen.Started += ShowMainMenu);
}
public void ShowMainMenu()
{
ShowScreen<MainMenu>("res://Scenes/MainMenu.tscn", screen => screen.Configure(this, m_Session.HasContinue));
}
public void StartNewCampaign()
{
m_Session.StartNewCampaign();
ShowCampaignIntro();
}
public void ContinueCampaign()
{
m_Session.ContinueCampaign();
LoadCurrentLevel();
}
public void StartRandomLevel()
{
m_Session.StartRandomLevel();
ShowScreen<GenerationScreen>("res://Scenes/GenerationScreen.tscn", screen => screen.Configure(this, m_Session.CurrentLevel));
}
public void ShowCampaignIntro()
{
ShowScreen<CampaignIntro>("res://Scenes/CampaignIntro.tscn", screen => screen.Configure(this, m_Session.CurrentLevel, m_Session.LevelNumber, m_Session.LevelCount));
}
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));
}
public void RetryCurrentLevel()
{
LoadCurrentLevel();
}
public void CompleteCurrentLevel()
{
if (m_Session.IsRandomLevel)
{
StartRandomLevel();
return;
}
if (m_Session.HasNextLevel)
{
m_Session.AdvanceToNextLevel();
ShowCampaignIntro();
return;
}
m_Session.CompleteCampaign();
ShowGameWon();
}
public void ShowOptions()
{
ShowScreen<OptionsScreen>("res://Scenes/OptionsScreen.tscn", screen => screen.Configure(this));
}
public void ShowTutorial()
{
ShowScreen<TutorialScreen>("res://Scenes/TutorialScreen.tscn", screen => screen.Configure(this));
}
public void ShowGameOver()
{
ShowScreen<GameOverScreen>("res://Scenes/GameOverScreen.tscn", screen => screen.Configure(this, m_Session.CurrentLevel));
}
private void ShowGameWon()
{
ShowScreen<GameWonScreen>("res://Scenes/GameWonScreen.tscn", screen => screen.Configure(this, m_Session.LevelCount));
}
private void ShowScreen<TScreen>(string scenePath, Action<TScreen> configure) where TScreen : Control
{
m_CurrentScreen?.QueueFree();
var packedScene = ResourceLoader.Load<PackedScene>(scenePath);
var screen = packedScene.Instantiate<TScreen>();
m_CurrentScreen = screen;
AddChild(screen);
configure(screen);
}
private readonly FrontendSession m_Session = new(CampaignRepository.LoadDefault());
private Control? m_CurrentScreen;
}

View File

@@ -0,0 +1 @@
uid://exf77g2sqb6d

View File

@@ -1,3 +1,12 @@
[gd_scene format=3 uid="uid://bhhppnvto57ht"] [gd_scene load_steps=2 format=3 uid="uid://bhhppnvto57ht"]
[node name="Node2D" type="Node2D"] [ext_resource type="Script" path="res://Scripts/AppController.cs" id="1"]
[node name="Main" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

View File

@@ -8,12 +8,10 @@
config_version=5 config_version=5
"config_version"=5
[application] [application]
config/name="Reactor Maintenance" config/name="Reactor Maintenance"
run/main_scene="uid://bhhppnvto57ht" run/main_scene="res://main.tscn"
config/features=PackedStringArray("4.5", "C#", "Forward Plus") config/features=PackedStringArray("4.5", "C#", "Forward Plus")
[dotnet] [dotnet]