From 6feaf84e39fb2b8f1e93a9b7460d7d0cc5424d79 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 14 May 2026 11:09:22 +0200 Subject: [PATCH] Finish Godot campaign polish --- TASKS.md | 8 ++--- .../Data/GameSession.cs | 29 +++++++++++++++++++ .../Screens/GameOverScreen.cs | 12 ++++++++ .../Screens/LevelScreen.cs | 11 ++++++- .../Scripts/AppController.cs | 13 ++++++++- 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/TASKS.md b/TASKS.md index 37db963..a49d2ef 100644 --- a/TASKS.md +++ b/TASKS.md @@ -180,10 +180,10 @@ This backlog tracks what must change so the implementation matches `docs/design. ## P2 Polish And Release Tasks -- [ ] Add concise pulse-result feedback for major outcomes: isolated leak, restored pressure, downstream starvation, reactor ready, wet-electric risk, and terminal heat danger. -- [ ] Add campaign completion flow for the final Group 6 level. -- [ ] Add loading and malformed-level error states for campaign level loading. -- [ ] Revisit art labels/icons for `Water`, `Unsafe`, powered props, and sprinkler controls after mechanics are implemented. +- [x] Add concise pulse-result feedback for major outcomes: isolated leak, restored pressure, downstream starvation, reactor ready, wet-electric risk, and terminal heat danger. +- [x] Add campaign completion flow for the final Group 6 level. +- [x] Add loading and malformed-level error states for campaign level loading. +- [x] Revisit art labels/icons for `Water`, `Unsafe`, powered props, and sprinkler controls after mechanics are implemented. ## Verification Rules diff --git a/src/ReactorMaintenance.Godot/Data/GameSession.cs b/src/ReactorMaintenance.Godot/Data/GameSession.cs index 3cd12c3..e259fb0 100644 --- a/src/ReactorMaintenance.Godot/Data/GameSession.cs +++ b/src/ReactorMaintenance.Godot/Data/GameSession.cs @@ -41,6 +41,7 @@ public sealed class GameSession var trace = m_Engine.InteractPropWithPulseTrace(LevelState); LevelState = trace.FinalState; LastPulseSteps = trace.Steps; + LastPulseFeedback = CreatePulseFeedback(LevelState); if (LevelState.Global.Pulse == pulse) { LevelStateChanged?.Invoke(this); @@ -60,6 +61,7 @@ public sealed class GameSession var trace = m_Engine.InteractLeakWithPulseTrace(LevelState, carrier, useRemedy); LevelState = trace.FinalState; LastPulseSteps = trace.Steps; + LastPulseFeedback = CreatePulseFeedback(LevelState); if (LevelState.Global.Pulse == pulse) { LevelStateChanged?.Invoke(this); @@ -79,6 +81,7 @@ public sealed class GameSession var trace = m_Engine.ApplyHeatShieldWithPulseTrace(LevelState); LevelState = trace.FinalState; LastPulseSteps = trace.Steps; + LastPulseFeedback = CreatePulseFeedback(LevelState); if (LevelState.Global.Pulse == pulse) { LevelStateChanged?.Invoke(this); @@ -108,6 +111,7 @@ public sealed class GameSession { LevelState = m_StartSnapshot; LastPulseSteps = Array.Empty(); + LastPulseFeedback = Array.Empty(); LevelStateChanged?.Invoke(this); } @@ -138,6 +142,30 @@ public sealed class GameSession PulseAdvanced?.Invoke(this); } + private static IReadOnlyList CreatePulseFeedback(LevelState level) + { + var feedback = new List(); + if (level.Global.LevelState == ELevelState.Ready) + feedback.Add("Reactor ready"); + + if (level.Props.Any(prop => prop.Type == EPropType.Consumer && prop.ServiceStateFor(ECarrierType.Fuel) == EConsumerServiceState.Starved)) + feedback.Add("Fuel consumer starved"); + + if (level.Props.Any(prop => prop.Type == EPropType.Consumer && prop.ServiceStateFor(ECarrierType.Water) == EConsumerServiceState.Starved)) + feedback.Add("Water consumer starved"); + + if (level.Props.Any(prop => prop.Type == EPropType.Consumer && prop.ServiceStateFor(ECarrierType.Electricity) == EConsumerServiceState.Starved)) + feedback.Add("Electricity consumer starved"); + + if (level.Surface.Any(surface => surface.IsWetElectricUnsafe())) + feedback.Add("Wet-electric risk"); + + if (level.Surface.Any(surface => surface.Heat >= Balancing.Current.RobotHeatSafetyThreshold)) + feedback.Add("Heat danger"); + + return feedback.Count > 0 ? feedback : [level.Global.Status]; + } + private void CheckOutcome() { if (LevelState.Global.LevelState == ELevelState.Won) @@ -150,6 +178,7 @@ public sealed class GameSession public GridPosition RobotPosition => LevelState.Robot.Position; public GlobalState GlobalState => LevelState.Global; public IReadOnlyList Forecasts => LevelState.Forecasts; + public IReadOnlyList LastPulseFeedback { get; private set; } = Array.Empty(); public IReadOnlyList LastPulseSteps { get; private set; } = Array.Empty(); public IReadOnlyList Leaks => LevelState.Leaks; public string LevelPath { get; private set; } = string.Empty; diff --git a/src/ReactorMaintenance.Godot/Screens/GameOverScreen.cs b/src/ReactorMaintenance.Godot/Screens/GameOverScreen.cs index d622684..5e36171 100644 --- a/src/ReactorMaintenance.Godot/Screens/GameOverScreen.cs +++ b/src/ReactorMaintenance.Godot/Screens/GameOverScreen.cs @@ -16,4 +16,16 @@ public partial class GameOverScreen : ScreenBase actions.AddChild(CreateButton("Retry Current Level", app.RetryCurrentLevel)); actions.AddChild(CreateButton("Main Menu", app.ShowMainMenu)); } + + public void ConfigureLoadError(AppController app, CampaignLevel level, string message) + { + var body = CreatePage("Level Load Error"); + body.Alignment = BoxContainer.AlignmentMode.Center; + body.AddChild(CreateBodyText($"{level.Name}: {message}")); + + var actions = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ShrinkCenter }; + body.AddChild(actions); + actions.AddChild(CreateButton("Retry", app.RetryCurrentLevel)); + actions.AddChild(CreateButton("Main Menu", app.ShowMainMenu)); + } } \ No newline at end of file diff --git a/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs b/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs index d742933..cd7ace7 100644 --- a/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs +++ b/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs @@ -431,13 +431,22 @@ public partial class LevelScreen : ScreenBase timer.Stop(); UpdateUI(); m_InputLocked = false; - SetEditorStatus(m_EditMode ? CurrentValidationText() : $"Pulse {m_Session?.LevelState.Global.Pulse ?? 0}"); + SetEditorStatus(m_EditMode ? CurrentValidationText() : CurrentPulseFeedback()); timer.QueueFree(); }; AddChild(timer); timer.Start(); } + private string CurrentPulseFeedback() + { + if (m_Session is null) + return "Play mode"; + + var feedback = m_Session.LastPulseFeedback.Count > 0 ? string.Join(", ", m_Session.LastPulseFeedback) : m_Session.LevelState.Global.Status; + return $"Pulse {m_Session.LevelState.Global.Pulse}: {feedback}"; + } + private void ShowPlaybackSnapshot(LevelState snapshot) { m_GridViewport?.SetLevelState(snapshot); diff --git a/src/ReactorMaintenance.Godot/Scripts/AppController.cs b/src/ReactorMaintenance.Godot/Scripts/AppController.cs index ac260f4..2fb7bb8 100644 --- a/src/ReactorMaintenance.Godot/Scripts/AppController.cs +++ b/src/ReactorMaintenance.Godot/Scripts/AppController.cs @@ -1,6 +1,7 @@ using Godot; using ReactorMaintenance.Godot.Data; using ReactorMaintenance.Godot.Screens; +using ReactorMaintenance.Simulation; namespace ReactorMaintenance.Godot; @@ -49,7 +50,17 @@ public partial class AppController : Control m_Session.MarkCurrentLevelLoaded(); var level = m_Session.CurrentLevel; var gameSession = new GameSession(); - var levelState = LevelStateLoader.Load(level.LevelPath); + LevelState levelState; + try + { + levelState = LevelStateLoader.Load(level.LevelPath); + } + catch (Exception ex) + { + ShowScreen("res://Scenes/GameOverScreen.tscn", screen => screen.ConfigureLoadError(this, level, ex.Message)); + return; + } + gameSession.Initialize(levelState, level.LevelPath); ShowScreen("res://Scenes/LevelScreen.tscn", screen => screen.Configure(this, gameSession, level, m_Session.LevelNumber, m_Session.LevelCount)); }