Complete Godot pulse UX integration

This commit is contained in:
2026-05-14 11:04:36 +02:00
parent 542c0cdc19
commit ad5445b09d
7 changed files with 315 additions and 43 deletions

View File

@@ -5,7 +5,7 @@ This backlog tracks what must change so the implementation matches `docs/design.
## Audit Snapshot ## Audit Snapshot
- Current simulation tests pass: `60/60` in `tests/ReactorMaintenance.Simulation.Tests`. - Current simulation tests pass: `60/60` in `tests/ReactorMaintenance.Simulation.Tests`.
- Godot has a usable UX scaffold and grid renderer; the full pulse playback, terminal-gated layer controls, and campaign content pass remain in later tasks. - Godot level play includes pulse step playback, terminal-gated layer controls, campaign loading, and in-game level editing with JSON save.
- Campaign data follows the tutorial plus six-group campaign from `docs/CAMPAIGN.md`. - Campaign data follows the tutorial plus six-group campaign from `docs/CAMPAIGN.md`.
- Unrelated Godot metadata or generated `.uid` files are not part of this backlog unless a later implementation task intentionally touches them. - Unrelated Godot metadata or generated `.uid` files are not part of this backlog unless a later implementation task intentionally touches them.
@@ -160,20 +160,20 @@ This backlog tracks what must change so the implementation matches `docs/design.
- Use `PulseAdvanced` or equivalent instead of `TurnAdvanced`. - Use `PulseAdvanced` or equivalent instead of `TurnAdvanced`.
- Ensure accepted no-op powered-prop interactions still notify pulse playback. - Ensure accepted no-op powered-prop interactions still notify pulse playback.
- Ensure refused invalid actions do not mutate state or trigger pulse playback. - Ensure refused invalid actions do not mutate state or trigger pulse playback.
- [ ] Animate `Pulse` playback as a short cascade of `Step`s. - [x] Animate `Pulse` playback as a short cascade of `Step`s.
- Show leak growth, sprinkler discharge, evaporation, quenching, ignition, wet conduction, and readiness updates. - Show leak growth, sprinkler discharge, evaporation, quenching, ignition, wet conduction, and readiness updates.
- Disable conflicting inputs during playback. - Disable conflicting inputs during playback.
- End playback on the final post-pulse decision state. - End playback on the final post-pulse decision state.
- [ ] Gate underground layer controls and forecasts. - [x] Gate underground layer controls and forecasts.
- Hide or disable layer toggles unless the robot is at an active and powered `AllSeeingEyeTerminal`. - Hide or disable layer toggles unless the robot is at an active and powered `AllSeeingEyeTerminal`.
- Show why terminal access is unavailable when unpowered or away from the terminal. - Show why terminal access is unavailable when unpowered or away from the terminal.
- Use `Pulse +N` wording in forecast UI. - Use `Pulse +N` wording in forecast UI.
- [ ] Update grid rendering and inspector text. - [x] Update grid rendering and inspector text.
- Render `Water` separately from underground `water`. - Render `Water` separately from underground `water`.
- Render `Unsafe` cells with a distinct movement warning treatment. - Render `Unsafe` cells with a distinct movement warning treatment.
- Render isolation valve state, sprinkler control state, wall-mounted sprinkler outlet, powered door state, and powered terminal state. - Render isolation valve state, sprinkler control state, wall-mounted sprinkler outlet, powered door state, and powered terminal state.
- Inspector should display visible surface values, prop state, consumer per-carrier state, and underground values only when terminal access allows it. - Inspector should display visible surface values, prop state, consumer per-carrier state, and underground values only when terminal access allows it.
- [ ] Update action affordances. - [x] Update action affordances.
- Movement remains direct and quick. - Movement remains direct and quick.
- Prop interactions should explain unavailable power, invalid position, missing remedy, depleted supply, and reactor-not-ready causes. - Prop interactions should explain unavailable power, invalid position, missing remedy, depleted supply, and reactor-not-ready causes.
- Disabled actions remain inspectable so players can understand why an action is unavailable. - Disabled actions remain inspectable so players can understand why an action is unavailable.
@@ -183,7 +183,6 @@ This backlog tracks what must change so the implementation matches `docs/design.
- [ ] Add concise pulse-result feedback for major outcomes: isolated leak, restored pressure, downstream starvation, reactor ready, wet-electric risk, and terminal heat danger. - [ ] 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 campaign completion flow for the final Group 6 level.
- [ ] Add loading and malformed-level error states for campaign level loading. - [ ] Add loading and malformed-level error states for campaign level loading.
- [ ] Verify Win2D editor export and Godot loader compatibility for the new schema.
- [ ] Revisit art labels/icons for `Water`, `Unsafe`, powered props, and sprinkler controls after mechanics are implemented. - [ ] Revisit art labels/icons for `Water`, `Unsafe`, powered props, and sprinkler controls after mechanics are implemented.
## Verification Rules ## Verification Rules

View File

@@ -23,19 +23,76 @@ public partial class CellInspector : PanelContainer
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 waterHazard, float electricityHazard, float heatHazard) public void SetCellInfo(LevelState? level, GridPosition? position, bool canSeeUnderground)
{ {
var pos = position ?? new(-1, -1); var pos = position ?? new(-1, -1);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine($"Selected Cell: {pos.X},{pos.Y}"); sb.AppendLine($"Selected Cell: {pos.X},{pos.Y}");
if (level is null || position is null || !level.InBounds(pos))
{
m_Text.Text = sb.ToString();
return;
}
var terrain = level.GetTerrain(pos);
var prop = level.GetProp(pos);
var surface = level.GetSurface(pos);
sb.AppendLine($"Terrain: {terrain}"); sb.AppendLine($"Terrain: {terrain}");
sb.AppendLine($"Prop: {prop}"); sb.AppendLine($"Prop: {FormatProp(prop)}");
if (prop != EPropType.None) if (prop.Type != EPropType.None)
sb.AppendLine($"Service: {serviceState}"); sb.AppendLine($"Service: {FormatService(prop)}");
sb.AppendLine($"Hazards: {FormatHazards(fuelHazard, waterHazard, electricityHazard, heatHazard)}"); sb.AppendLine($"Surface: {FormatHazards(surface.Fuel, surface.Water, surface.Electricity, surface.Heat)}");
sb.AppendLine(surface.IsUnsafe() ? "Movement: Unsafe" : "Movement: Safe");
if (canSeeUnderground)
sb.AppendLine($"Underground: {FormatUnderground(level, pos)}");
else
sb.AppendLine("Underground: terminal access required");
m_Text.Text = sb.ToString(); m_Text.Text = sb.ToString();
} }
private static string FormatProp(PropState prop)
{
return prop.Type switch {
EPropType.Flow => $"{prop.Carrier} Flow {prop.SwitchState}",
EPropType.Consumer => $"Consumer {prop.SwitchState}",
EPropType.IsolationValve => $"{prop.Carrier} Valve {(prop.IsOpen ? "Open" : "Closed")}",
EPropType.SprinklerControl => $"Sprinkler Control {prop.SwitchState}",
EPropType.SprinklerValve => $"Sprinkler Valve -> {FormatPosition(prop.OutletPosition)}",
EPropType.Door => $"Door {prop.DoorState}",
EPropType.AllSeeingEyeTerminal => prop.Active ? "All-Seeing-Eye Active" : "All-Seeing-Eye",
EPropType.ReactorControl => $"Reactor Control {prop.ReactorId}",
EPropType.RemedySupply => prop.Depleted ? $"{prop.RemedyType} Empty" : $"{prop.RemedyType} Supply",
_ => prop.Type.ToString()
};
}
private static string FormatService(PropState prop)
{
if (prop.Type == EPropType.Consumer)
return $"fuel {prop.FuelServiceState}, water {prop.WaterServiceState}, electricity {prop.ElectricityServiceState}";
return prop.ServiceState.ToString();
}
private static string FormatUnderground(LevelState level, GridPosition position)
{
var parts = new List<string>();
foreach (var carrier in Enum.GetValues<ECarrierType>())
{
var cell = level.GetUnderground(position, carrier);
if (cell.IsPresent)
parts.Add($"{carrier} {cell.State} {cell.Amount:F1}/{cell.Intensity:F1}");
}
return parts.Count > 0 ? string.Join(", ", parts) : "none";
}
private static string FormatPosition(GridPosition? position)
{
return position is { } p ? $"{p.X},{p.Y}" : "unlinked";
}
private static string FormatHazards(float fuel, float water, float electricity, float heat) private static string FormatHazards(float fuel, float water, float electricity, float heat)
{ {
var parts = new List<string>(); var parts = new List<string>();

View File

@@ -28,6 +28,18 @@ public partial class ForecastList : PanelContainer
} }
} }
public void SetUnavailable(string reason)
{
foreach (var child in m_Items.GetChildren())
child.QueueFree();
m_Items.AddChild(new Label { Text = "Forecasts" });
m_Items.AddChild(new Label {
Text = reason,
AutowrapMode = TextServer.AutowrapMode.WordSmart
});
}
private static string FormatForecast(Forecast forecast) private static string FormatForecast(Forecast forecast)
{ {
var pos = forecast.Position; var pos = forecast.Position;

View File

@@ -42,6 +42,7 @@ public partial class GridViewport : Control
DrawTerrain(layout, SurfaceOpacity()); DrawTerrain(layout, SurfaceOpacity());
DrawUnderground(layout); DrawUnderground(layout);
DrawSurfaceHazards(layout, SurfaceOpacity()); DrawSurfaceHazards(layout, SurfaceOpacity());
DrawUnsafeWarnings(layout, SurfaceOpacity());
DrawDoors(layout, SurfaceOpacity()); DrawDoors(layout, SurfaceOpacity());
DrawProps(layout, SurfaceOpacity()); DrawProps(layout, SurfaceOpacity());
DrawLeaks(layout, SurfaceOpacity()); DrawLeaks(layout, SurfaceOpacity());
@@ -206,6 +207,21 @@ public partial class GridViewport : Control
} }
} }
private void DrawUnsafeWarnings(SGridLayout layout, float opacity)
{
if (m_LevelState is null)
return;
foreach (var position in AllPositions().Where(m_LevelState.IsFloor))
{
if (!m_LevelState.GetSurface(position).IsUnsafe())
continue;
var rect = Inset(layout.CellRect(position), 0.08f);
DrawRect(rect, WithOpacity(c_UnsafeColor, opacity), false, Math.Max(2, layout.CellSize * 0.07f));
}
}
private void FillHazard(Rect2 rect, float amount, Color color, float inset, float opacity, float caution, float critical) private void FillHazard(Rect2 rect, float amount, Color color, float inset, float opacity, float caution, float critical)
{ {
var overlayOpacity = SurfaceOverlayOpacity(amount, caution, critical); var overlayOpacity = SurfaceOverlayOpacity(amount, caution, critical);
@@ -628,6 +644,7 @@ public partial class GridViewport : Control
private static readonly Color c_ReachableColor = new(0.78f, 0.96f, 0.84f, 0.20f); private static readonly Color c_ReachableColor = new(0.78f, 0.96f, 0.84f, 0.20f);
private static readonly Color c_ReadyColor = new(0.46f, 0.95f, 0.52f); private static readonly Color c_ReadyColor = new(0.46f, 0.95f, 0.52f);
private static readonly Color c_SelectedColor = new(1.0f, 1.0f, 1.0f, 0.95f); private static readonly Color c_SelectedColor = new(1.0f, 1.0f, 1.0f, 0.95f);
private static readonly Color c_UnsafeColor = new(1.0f, 0.9f, 0.25f, 0.9f);
private bool m_IsPanning; private bool m_IsPanning;
private LevelState? m_LevelState; private LevelState? m_LevelState;

View File

@@ -37,7 +37,16 @@ public sealed class GameSession
if (LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won) if (LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won)
return false; return false;
LevelState = m_Engine.InteractProp(LevelState); var pulse = LevelState.Global.Pulse;
var trace = m_Engine.InteractPropWithPulseTrace(LevelState);
LevelState = trace.FinalState;
LastPulseSteps = trace.Steps;
if (LevelState.Global.Pulse == pulse)
{
LevelStateChanged?.Invoke(this);
return false;
}
OnPulseAdvanced(); OnPulseAdvanced();
return true; return true;
} }
@@ -47,7 +56,16 @@ public sealed class GameSession
if (LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won) if (LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won)
return false; return false;
LevelState = m_Engine.InteractLeak(LevelState, carrier, useRemedy); var pulse = LevelState.Global.Pulse;
var trace = m_Engine.InteractLeakWithPulseTrace(LevelState, carrier, useRemedy);
LevelState = trace.FinalState;
LastPulseSteps = trace.Steps;
if (LevelState.Global.Pulse == pulse)
{
LevelStateChanged?.Invoke(this);
return false;
}
OnPulseAdvanced(); OnPulseAdvanced();
return true; return true;
} }
@@ -57,7 +75,16 @@ public sealed class GameSession
if (LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won) if (LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won)
return false; return false;
LevelState = m_Engine.ApplyHeatShield(LevelState); var pulse = LevelState.Global.Pulse;
var trace = m_Engine.ApplyHeatShieldWithPulseTrace(LevelState);
LevelState = trace.FinalState;
LastPulseSteps = trace.Steps;
if (LevelState.Global.Pulse == pulse)
{
LevelStateChanged?.Invoke(this);
return false;
}
OnPulseAdvanced(); OnPulseAdvanced();
return true; return true;
} }
@@ -80,6 +107,7 @@ public sealed class GameSession
public void Retry() public void Retry()
{ {
LevelState = m_StartSnapshot; LevelState = m_StartSnapshot;
LastPulseSteps = Array.Empty<LevelState>();
LevelStateChanged?.Invoke(this); LevelStateChanged?.Invoke(this);
} }
@@ -122,6 +150,7 @@ public sealed class GameSession
public GridPosition RobotPosition => LevelState.Robot.Position; public GridPosition RobotPosition => LevelState.Robot.Position;
public GlobalState GlobalState => LevelState.Global; public GlobalState GlobalState => LevelState.Global;
public IReadOnlyList<Forecast> Forecasts => LevelState.Forecasts; public IReadOnlyList<Forecast> Forecasts => LevelState.Forecasts;
public IReadOnlyList<LevelState> LastPulseSteps { get; private set; } = Array.Empty<LevelState>();
public IReadOnlyList<LeakState> Leaks => LevelState.Leaks; public IReadOnlyList<LeakState> Leaks => LevelState.Leaks;
public string LevelPath { get; private set; } = string.Empty; public string LevelPath { get; private set; } = string.Empty;
public IReadOnlyList<ReactorState> Reactors => LevelState.Reactors; public IReadOnlyList<ReactorState> Reactors => LevelState.Reactors;

View File

@@ -2,6 +2,7 @@
using ReactorMaintenance.Godot.Controls; using ReactorMaintenance.Godot.Controls;
using ReactorMaintenance.Godot.Data; using ReactorMaintenance.Godot.Data;
using ReactorMaintenance.Simulation; using ReactorMaintenance.Simulation;
using Timer = Godot.Timer;
namespace ReactorMaintenance.Godot.Screens; namespace ReactorMaintenance.Godot.Screens;
@@ -77,24 +78,27 @@ public partial class LevelScreen : ScreenBase
private Control CreateLayerControls() private Control CreateLayerControls()
{ {
var controls = new HBoxContainer(); var controls = new HBoxContainer();
controls.AddChild(CreateLayerToggle("Fuel", true, pressed => { m_FuelLayerToggle = CreateLayerToggle("Fuel", true, pressed => {
if (m_GridViewport is null) return; if (m_GridViewport is null) return;
m_GridViewport.ShowFuelLayer = pressed; m_GridViewport.ShowFuelLayer = pressed;
m_GridViewport.QueueRedraw(); m_GridViewport.QueueRedraw();
})); });
controls.AddChild(CreateLayerToggle("Water", true, pressed => { controls.AddChild(m_FuelLayerToggle);
m_WaterLayerToggle = CreateLayerToggle("Water", true, pressed => {
if (m_GridViewport is null) return; if (m_GridViewport is null) return;
m_GridViewport.ShowWaterLayer = pressed; m_GridViewport.ShowWaterLayer = pressed;
m_GridViewport.QueueRedraw(); m_GridViewport.QueueRedraw();
})); });
controls.AddChild(CreateLayerToggle("Electricity", true, pressed => { controls.AddChild(m_WaterLayerToggle);
m_ElectricityLayerToggle = CreateLayerToggle("Electricity", true, pressed => {
if (m_GridViewport is null) return; if (m_GridViewport is null) return;
m_GridViewport.ShowElectricityLayer = pressed; m_GridViewport.ShowElectricityLayer = pressed;
m_GridViewport.QueueRedraw(); m_GridViewport.QueueRedraw();
})); });
controls.AddChild(m_ElectricityLayerToggle);
var activeLayer = new OptionButton(); var activeLayer = new OptionButton();
activeLayer.AddItem("Surface", 0); activeLayer.AddItem("Surface", 0);
@@ -113,6 +117,7 @@ public partial class LevelScreen : ScreenBase
m_GridViewport.QueueRedraw(); m_GridViewport.QueueRedraw();
}; };
controls.AddChild(activeLayer); controls.AddChild(activeLayer);
m_ActiveLayerSelect = activeLayer;
return controls; return controls;
} }
@@ -132,41 +137,70 @@ public partial class LevelScreen : ScreenBase
actions.AddChild(CreateButton("Move", OnMoveAction, "Move robot to adjacent floor cell")); 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("Interact", OnInteractAction, "Interact with prop at robot position"));
actions.AddChild(CreateButton("Repair", OnRepairAction, "Repair leak at robot position")); actions.AddChild(CreateButton("Repair", OnRepairAction, "Repair leak at robot position"));
actions.AddChild(CreateButton("Heat Shield", OnHeatShieldAction, "Spend one heat shield for temporary movement safety"));
actions.AddChild(CreateButton("Activate", OnActivateAction, "Activate a ready reactor control at the robot position"));
actions.AddChild(CreateButton("Main Menu", () => m_App?.ShowMainMenu())); actions.AddChild(CreateButton("Main Menu", () => m_App?.ShowMainMenu()));
return actions; return actions;
} }
private void OnMoveAction() private void OnMoveAction()
{ {
if (m_Session is null || m_EditMode) return; if (m_Session is null || m_EditMode || m_InputLocked) return;
var current = m_Session.RobotPosition; var current = m_Session.RobotPosition;
var next = current with { X = current.X + 1 }; var next = current with { X = current.X + 1 };
if (!m_Session.MoveRobot(next)) if (!m_Session.MoveRobot(next))
{ {
next = current with { Y = current.Y + 1 }; next = current with { Y = current.Y + 1 };
m_Session.MoveRobot(next); if (!m_Session.MoveRobot(next))
SetEditorStatus("No quick move target to the east or south.");
} }
} }
private void OnInteractAction() private void OnInteractAction()
{ {
if (!m_EditMode) if (!m_EditMode && !m_InputLocked)
m_Session?.InteractProp(); RunAction(() => m_Session?.InteractProp() ?? false);
} }
private void OnRepairAction() private void OnRepairAction()
{ {
if (m_Session is null || m_EditMode) return; if (m_Session is null || m_EditMode || m_InputLocked) return;
foreach (var leak in m_Session.Leaks) foreach (var leak in m_Session.Leaks)
{ {
if (leak.AccessPosition == m_Session.RobotPosition && !leak.Repaired) if (leak.AccessPosition == m_Session.RobotPosition && !leak.Repaired)
{ {
m_Session.InteractLeak(leak.Carrier, true); RunAction(() => m_Session.InteractLeak(leak.Carrier, true));
return; return;
} }
} }
SetEditorStatus("No reachable leak with available remedy.");
}
private void OnHeatShieldAction()
{
if (m_Session is null || m_EditMode || m_InputLocked)
return;
RunAction(m_Session.ApplyHeatShield);
}
private void OnActivateAction()
{
if (m_Session is null || m_EditMode || m_InputLocked)
return;
var before = m_Session.LevelState.Global.LevelState;
if (!m_Session.ActivateReactor() || before == m_Session.LevelState.Global.LevelState)
SetEditorStatus("Reactor is not ready at this position.");
}
private void RunAction(Func<bool> action)
{
if (!action())
SetEditorStatus(m_Session?.LevelState.Global.Status ?? "Action unavailable.");
} }
private Control CreateEditorControls() private Control CreateEditorControls()
@@ -235,10 +269,14 @@ public partial class LevelScreen : ScreenBase
var ls = m_Session.LevelState; var ls = m_Session.LevelState;
m_Header?.SetLevel(m_Level?.Name ?? ls.Name, m_LevelNumber, m_LevelCount, ls.Global.LevelState); m_Header?.SetLevel(m_Level?.Name ?? ls.Name, m_LevelNumber, m_LevelCount, ls.Global.LevelState);
UpdateTerminalGating(ls);
m_GridViewport?.SetLevelState(ls); m_GridViewport?.SetLevelState(ls);
UpdateInspector(m_GridViewport?.SelectedCell ?? new Vector2I(ls.Robot.Position.X, ls.Robot.Position.Y)); UpdateInspector(m_GridViewport?.SelectedCell ?? new Vector2I(ls.Robot.Position.X, ls.Robot.Position.Y));
m_ForecastList.SetForecasts(ls.Forecasts); if (ls.HasActivePoweredTerminalAccess())
m_ForecastList.SetForecasts(ls.Forecasts);
else
m_ForecastList.SetUnavailable("Terminal access required.");
m_InventoryStrip.SetInventory( m_InventoryStrip.SetInventory(
m_Session.LevelState.Robot.FuelNeutralizers, m_Session.LevelState.Robot.FuelNeutralizers,
@@ -256,21 +294,11 @@ public partial class LevelScreen : ScreenBase
var position = new GridPosition(cell.X, cell.Y); var position = new GridPosition(cell.X, cell.Y);
if (!ls.InBounds(position)) if (!ls.InBounds(position))
{ {
m_Inspector.SetCellInfo(null, ECellTerrain.Wall, EPropType.None, EConsumerServiceState.Unknown, 0, 0, 0, 0); m_Inspector.SetCellInfo(null, null, false);
return; return;
} }
var surface = ls.GetSurface(position); m_Inspector.SetCellInfo(ls, position, ls.HasActivePoweredTerminalAccess());
var prop = ls.GetProp(position);
m_Inspector.SetCellInfo(
position,
ls.GetTerrain(position),
prop.Type,
prop.ServiceState,
surface.Fuel,
surface.Water,
surface.Electricity,
surface.Heat);
} }
private void OnLevelStateChanged(GameSession sender) private void OnLevelStateChanged(GameSession sender)
@@ -285,7 +313,7 @@ public partial class LevelScreen : ScreenBase
private void OnPulseAdvanced(GameSession sender) private void OnPulseAdvanced(GameSession sender)
{ {
UpdateUI(); BeginPulsePlayback(sender.LastPulseSteps);
if (sender.LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won) if (sender.LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won)
{ {
ShowOutcomeOverlay(sender.LevelState.Global.LevelState); ShowOutcomeOverlay(sender.LevelState.Global.LevelState);
@@ -332,7 +360,7 @@ public partial class LevelScreen : ScreenBase
private void OnGridClicked(Vector2I cell) private void OnGridClicked(Vector2I cell)
{ {
if (m_Session is null) if (m_Session is null || m_InputLocked)
return; return;
var destination = new GridPosition(cell.X, cell.Y); var destination = new GridPosition(cell.X, cell.Y);
@@ -347,6 +375,78 @@ public partial class LevelScreen : ScreenBase
m_Session.MoveRobot(destination); m_Session.MoveRobot(destination);
} }
private void UpdateTerminalGating(LevelState level)
{
var canSeeUnderground = level.HasActivePoweredTerminalAccess();
foreach (var toggle in new[] { m_FuelLayerToggle, m_WaterLayerToggle, m_ElectricityLayerToggle })
{
if (toggle is null)
continue;
toggle.Disabled = !canSeeUnderground;
toggle.TooltipText = canSeeUnderground ? "Toggle terminal-visible underground layer" : "Use a powered All-Seeing-Eye terminal to view underground layers";
}
if (m_ActiveLayerSelect is not null)
{
m_ActiveLayerSelect.Disabled = !canSeeUnderground;
if (!canSeeUnderground)
{
m_ActiveLayerSelect.Select(0);
if (m_GridViewport is not null)
m_GridViewport.ActiveUndergroundLayer = null;
}
}
if (m_GridViewport is null)
return;
m_GridViewport.ShowFuelLayer = canSeeUnderground && (m_FuelLayerToggle?.ButtonPressed ?? false);
m_GridViewport.ShowWaterLayer = canSeeUnderground && (m_WaterLayerToggle?.ButtonPressed ?? false);
m_GridViewport.ShowElectricityLayer = canSeeUnderground && (m_ElectricityLayerToggle?.ButtonPressed ?? false);
}
private void BeginPulsePlayback(IReadOnlyList<LevelState> steps)
{
if (steps.Count == 0)
{
UpdateUI();
return;
}
m_InputLocked = true;
SetEditorStatus("Pulse resolving...");
var index = 0;
var timer = new Timer {
OneShot = false,
WaitTime = 0.12
};
timer.Timeout += () => {
var snapshot = steps[Math.Min(index, steps.Count - 1)];
ShowPlaybackSnapshot(snapshot);
index++;
if (index < steps.Count)
return;
timer.Stop();
UpdateUI();
m_InputLocked = false;
SetEditorStatus(m_EditMode ? CurrentValidationText() : $"Pulse {m_Session?.LevelState.Global.Pulse ?? 0}");
timer.QueueFree();
};
AddChild(timer);
timer.Start();
}
private void ShowPlaybackSnapshot(LevelState snapshot)
{
m_GridViewport?.SetLevelState(snapshot);
var cell = m_GridViewport?.SelectedCell ?? new Vector2I(snapshot.Robot.Position.X, snapshot.Robot.Position.Y);
var position = new GridPosition(cell.X, cell.Y);
m_Inspector?.SetCellInfo(snapshot, snapshot.InBounds(position) ? position : snapshot.Robot.Position, snapshot.HasActivePoweredTerminalAccess());
SetEditorStatus($"Pulse {snapshot.Global.Pulse + 1} Step {snapshot.Global.Step}");
}
private EditorToolCommand CurrentEditorCommand() private EditorToolCommand CurrentEditorCommand()
{ {
return new() { return new() {
@@ -390,13 +490,18 @@ public partial class LevelScreen : ScreenBase
}; };
} }
private OptionButton? m_ActiveLayerSelect;
private AppController? m_App; private AppController? m_App;
private OptionButton? m_CarrierSelect; private OptionButton? m_CarrierSelect;
private bool m_EditMode; private bool m_EditMode;
private Label? m_EditorStatus; private Label? m_EditorStatus;
private CheckBox? m_ElectricityLayerToggle;
private ForecastList? m_ForecastList; private ForecastList? m_ForecastList;
private CheckBox? m_FuelLayerToggle;
private GridViewport? m_GridViewport; private GridViewport? m_GridViewport;
private LevelHeader? m_Header; private LevelHeader? m_Header;
private bool m_InputLocked;
private CellInspector? m_Inspector; private CellInspector? m_Inspector;
private InventoryStrip? m_InventoryStrip; private InventoryStrip? m_InventoryStrip;
private CampaignLevel? m_Level; private CampaignLevel? m_Level;
@@ -407,4 +512,5 @@ public partial class LevelScreen : ScreenBase
private OptionButton? m_RemedySelect; private OptionButton? m_RemedySelect;
private GameSession? m_Session; private GameSession? m_Session;
private OptionButton? m_ToolSelect; private OptionButton? m_ToolSelect;
private CheckBox? m_WaterLayerToggle;
} }

View File

@@ -2,6 +2,8 @@
public sealed class SimulationEngine public sealed class SimulationEngine
{ {
public sealed record PulseTrace(LevelState FinalState, IReadOnlyList<LevelState> Steps);
public LevelState MoveRobot(LevelState level, GridPosition destination) public LevelState MoveRobot(LevelState level, GridPosition destination)
{ {
return PlayerActionSystem.MoveRobot(level, destination); return PlayerActionSystem.MoveRobot(level, destination);
@@ -12,16 +14,55 @@ public sealed class SimulationEngine
return PlayerActionSystem.InteractProp(level, ResolvePulse); return PlayerActionSystem.InteractProp(level, ResolvePulse);
} }
public PulseTrace InteractPropWithPulseTrace(LevelState level)
{
PulseTrace? trace = null;
var finalState = PlayerActionSystem.InteractProp(level, Resolve);
return trace ?? new(finalState, Array.Empty<LevelState>());
LevelState Resolve(LevelState accepted)
{
trace = ResolvePulseWithTrace(accepted);
return trace.FinalState;
}
}
public LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy) public LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy)
{ {
return PlayerActionSystem.InteractLeak(level, carrier, useRemedy, ResolvePulse); return PlayerActionSystem.InteractLeak(level, carrier, useRemedy, ResolvePulse);
} }
public PulseTrace InteractLeakWithPulseTrace(LevelState level, ECarrierType carrier, bool useRemedy)
{
PulseTrace? trace = null;
var finalState = PlayerActionSystem.InteractLeak(level, carrier, useRemedy, Resolve);
return trace ?? new(finalState, Array.Empty<LevelState>());
LevelState Resolve(LevelState accepted)
{
trace = ResolvePulseWithTrace(accepted);
return trace.FinalState;
}
}
public LevelState ApplyHeatShield(LevelState level) public LevelState ApplyHeatShield(LevelState level)
{ {
return PlayerActionSystem.ApplyHeatShield(level, ResolvePulse); return PlayerActionSystem.ApplyHeatShield(level, ResolvePulse);
} }
public PulseTrace ApplyHeatShieldWithPulseTrace(LevelState level)
{
PulseTrace? trace = null;
var finalState = PlayerActionSystem.ApplyHeatShield(level, Resolve);
return trace ?? new(finalState, Array.Empty<LevelState>());
LevelState Resolve(LevelState accepted)
{
trace = ResolvePulseWithTrace(accepted);
return trace.FinalState;
}
}
public LevelState ActivateReactor(LevelState level) public LevelState ActivateReactor(LevelState level)
{ {
return ReactorSystem.Activate(level); return ReactorSystem.Activate(level);
@@ -46,13 +87,22 @@ public sealed class SimulationEngine
} }
private LevelState ResolvePulse(LevelState level) private LevelState ResolvePulse(LevelState level)
{
return ResolvePulseWithTrace(level).FinalState;
}
private PulseTrace ResolvePulseWithTrace(LevelState level)
{ {
var next = ValidateAndPropagate(level); var next = ValidateAndPropagate(level);
if (next.Global.LevelState == ELevelState.Lost) if (next.Global.LevelState == ELevelState.Lost)
return next; return new(next, [next]);
var steps = new List<LevelState>();
for (var i = 0; i < Balancing.Current.StepsPerPulse; i++) for (var i = 0; i < Balancing.Current.StepsPerPulse; i++)
{
next = ResolveStepContent(next); next = ResolveStepContent(next);
steps.Add(next);
}
next = ReactorSystem.DeriveState(next); next = ReactorSystem.DeriveState(next);
next = SurfaceInteractionSystem.AdvanceDurations(next); next = SurfaceInteractionSystem.AdvanceDurations(next);
@@ -63,7 +113,9 @@ public sealed class SimulationEngine
} }
}; };
return next with { Forecasts = Forecast(next) }; next = next with { Forecasts = Forecast(next) };
steps.Add(next);
return new(next, steps);
} }
private LevelState ResolveStep(LevelState level, bool refreshForecasts = true) private LevelState ResolveStep(LevelState level, bool refreshForecasts = true)