From dfe0cb3b6af561010ac01d8c4831a6fac0da0b7f Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Mon, 11 May 2026 23:03:29 +0200 Subject: [PATCH] Add editor image badges and drag moves --- docs/design.md | 2 + .../LevelEditor.cs | 111 +++++++- .../LevelStateExtensions.cs | 5 +- .../EditorImageRegistry.cs | 64 +++++ src/ReactorMaintenance.Win2D/MainWindow.xaml | 46 ++- .../MainWindow.xaml.cs | 265 +++++++++++++++--- .../LevelEditorTests.cs | 91 ++++++ 7 files changed, 538 insertions(+), 46 deletions(-) create mode 100644 src/ReactorMaintenance.Win2D/EditorImageRegistry.cs diff --git a/docs/design.md b/docs/design.md index 2897743..d2a5ebb 100644 --- a/docs/design.md +++ b/docs/design.md @@ -455,6 +455,8 @@ The editor includes layer selection for Surface, Electricity, Fuel, and Coolant: - Networks render as thick lines connecting adjacent cell centers; sources render as large centered dots. - Tools are layer-aware. Cursor is always available. Surface terrain, props, consumers, hazards, doors, and heat tools are available only on Surface. Network painting and sources are available only on their matching underground layer. +Editor tool badges and drag previews use stable semantic image keys when assets are available. Assets may be added under `Images/Badges` or `Images/Elements` with filenames such as `tool-door.png`, `prop-reactor.png`, `carrier-fuel-source.png`, `leak-electricity.png`, or `robot.png`; missing assets fall back to compact procedural badges and text labels. + The serialized level schema stores level metadata, dimensions, terrain, underground layers including structural integrity, props and prop state, required reactor consumer counts, leaks, robot state, inventory, forecasts, and dynamic state when saving active play. The loader accepts only schema-valid level data and returns clear errors for malformed data. diff --git a/src/ReactorMaintenance.Simulation/LevelEditor.cs b/src/ReactorMaintenance.Simulation/LevelEditor.cs index 4c79209..09058ff 100644 --- a/src/ReactorMaintenance.Simulation/LevelEditor.cs +++ b/src/ReactorMaintenance.Simulation/LevelEditor.cs @@ -2,26 +2,106 @@ public static class LevelEditor { + public sealed record MoveOccupantResult(bool Success, LevelState Level, string Reason) + { + public static MoveOccupantResult Succeeded(LevelState level) + { + return new(true, level, string.Empty); + } + + public static MoveOccupantResult Failed(LevelState level, string reason) + { + return new(false, level, reason); + } + } + public static LevelState MoveOccupant(LevelState level, GridPosition source, GridPosition destination) { - if (!level.InBounds(source) || !level.IsFloor(destination) || source == destination) - return level; + return TryMoveOccupant(level, source, destination).Level; + } + + public static MoveOccupantResult TryMoveOccupant(LevelState level, GridPosition source, GridPosition destination) + { + if (!level.InBounds(source)) + return MoveOccupantResult.Failed(level, "Drag start is outside the level."); + + if (!level.InBounds(destination)) + return MoveOccupantResult.Failed(level, "Drop target is outside the level."); + + if (source == destination) + return MoveOccupantResult.Failed(level, "Drop target is the same cell."); var prop = level.GetProp(source); - if (prop.Type == EPropType.None) - return level.Robot.Position == source ? level with { Robot = level.Robot with { Position = destination } } : level; + if (prop.Type != EPropType.None) + return TryMoveProp(level, source, destination, prop); + + var leak = level.Leaks.FirstOrDefault(leak => !leak.Repaired && (leak.AccessPosition == source || leak.UndergroundPosition == source)); + if (leak is not null) + return TryMoveLeak(level, leak, destination); + + return level.Robot.Position == source + ? TryMoveRobot(level, destination) + : MoveOccupantResult.Failed(level, "No movable robot, prop, source, or leak starts here."); + } + + private static MoveOccupantResult TryMoveRobot(LevelState level, GridPosition destination) + { + if (!level.IsFloor(destination)) + return MoveOccupantResult.Failed(level, "Robot destination must be a floor cell."); + + return MoveOccupantResult.Succeeded(level with { Robot = level.Robot with { Position = destination } }); + } + + private static MoveOccupantResult TryMoveProp(LevelState level, GridPosition source, GridPosition destination, PropState prop) + { + if (!level.IsFloor(destination)) + return MoveOccupantResult.Failed(level, "Prop destination must be a floor cell."); if (level.GetProp(destination).Type != EPropType.None) - return level; + return MoveOccupantResult.Failed(level, "Prop destination is already occupied."); var next = level.SetProp(source, new()).SetProp(destination, prop); if (prop.Type != EPropType.ReactorControl) - return next; + return MoveOccupantResult.Succeeded(next); - return next with { + return MoveOccupantResult.Succeeded(next with { Reactors = next.Reactors .Select(reactor => reactor.ControlPosition == source ? reactor with { ControlPosition = destination } : reactor) .ToArray() + }); + } + + private static MoveOccupantResult TryMoveLeak(LevelState level, LeakState leak, GridPosition destination) + { + if (leak.Carrier is ECarrierType.Fuel or ECarrierType.Coolant) + { + if (!level.IsFloor(destination)) + return MoveOccupantResult.Failed(level, "Fuel and coolant leaks must move to a floor cell."); + + var next = ClearLeak(level, leak) + .SetUnderground(leak.UndergroundPosition, leak.Carrier, new()); + return MoveOccupantResult.Succeeded(SetLeak(next, destination, destination, leak.Carrier)); + } + + if (leak.Carrier == ECarrierType.Electricity) + { + if (!level.IsFloor(destination)) + return MoveOccupantResult.Failed(level, "Electric leak destination must be an adjacent floor access cell."); + + var undergroundPosition = leak.UndergroundPosition; + if (undergroundPosition.ManhattanDistance(destination) != 1) + return MoveOccupantResult.Failed(level, "Electric leak destination must stay adjacent to its underground wall cell."); + + return MoveOccupantResult.Succeeded(SetLeak(ClearLeak(level, leak), undergroundPosition, destination, leak.Carrier)); + } + + return MoveOccupantResult.Failed(level, "Unsupported leak carrier."); + } + + private static LevelState ClearLeak(LevelState level, LeakState leak) + { + return level with { + Leaks = level.Leaks.Where(candidate => candidate != leak).ToArray() }; } @@ -61,7 +141,7 @@ public static class LevelEditor EEditorTool.Flow => SetCarrierProp(level, position, EPropType.Flow, command.Carrier), EEditorTool.Consumer => SetFloorProp(level, position, new() { Type = EPropType.Consumer, SwitchState = EPropSwitchState.Enabled }), EEditorTool.Junction => SetFloorProp(level, position, new() { Type = EPropType.Junction }), - EEditorTool.Door => SetFloorProp(level, position, new() { Type = EPropType.Door }), + EEditorTool.Door => ToggleOrSetDoor(level, position), EEditorTool.AllSeeingEyeTerminal => SetFloorProp(level, position, new() { Type = EPropType.AllSeeingEyeTerminal }), EEditorTool.RemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = command.RemedyType }), EEditorTool.ReactorControl => SetReactorControl(level, position), @@ -129,6 +209,21 @@ public static class LevelEditor return level.IsFloor(position) ? level.SetProp(position, prop) : level; } + private static LevelState ToggleOrSetDoor(LevelState level, GridPosition position) + { + if (!level.IsFloor(position)) + return level; + + var prop = level.GetProp(position); + if (prop.Type == EPropType.Door) + { + var nextState = prop.DoorState == EDoorState.Open ? EDoorState.Closed : EDoorState.Open; + return level.SetProp(position, prop with { DoorState = nextState }); + } + + return level.SetProp(position, new() { Type = EPropType.Door, DoorState = EDoorState.Closed }); + } + private static LevelState SetReactorControl(LevelState level, GridPosition position) { if (!level.IsFloor(position)) diff --git a/src/ReactorMaintenance.Simulation/LevelStateExtensions.cs b/src/ReactorMaintenance.Simulation/LevelStateExtensions.cs index c5ae60f..222b48d 100644 --- a/src/ReactorMaintenance.Simulation/LevelStateExtensions.cs +++ b/src/ReactorMaintenance.Simulation/LevelStateExtensions.cs @@ -103,10 +103,7 @@ public static class LevelStateExtensions private static LevelState ClearFloorOnlyState(this LevelState level, GridPosition position) { return level.SetSurface(position, new()) - .SetProp(position, new()) - .SetUnderground(position, ECarrierType.Fuel, new()) - .SetUnderground(position, ECarrierType.Coolant, new()) - .SetUnderground(position, ECarrierType.Electricity, new()); + .SetProp(position, new()); } private static bool DoorBlocksEdge(LevelState level, GridPosition doorPosition, GridPosition neighbor) diff --git a/src/ReactorMaintenance.Win2D/EditorImageRegistry.cs b/src/ReactorMaintenance.Win2D/EditorImageRegistry.cs new file mode 100644 index 0000000..2005367 --- /dev/null +++ b/src/ReactorMaintenance.Win2D/EditorImageRegistry.cs @@ -0,0 +1,64 @@ +using Microsoft.Graphics.Canvas; +using Microsoft.Graphics.Canvas.UI.Xaml; + +namespace ReactorMaintenance.Win2D; + +public sealed class EditorImageRegistry +{ + public async Task LoadAsync(CanvasControl sender) + { + m_Images.Clear(); + await LoadFolderAsync(sender, "Images", "Props"); + await LoadFolderAsync(sender, "Images", "Pipes"); + await LoadFolderAsync(sender, "Images", "Badges"); + await LoadFolderAsync(sender, "Images", "Elements"); + AddAlias("tool-floor", "floor"); + AddAlias("tool-wall", "wall"); + AddAlias("tool-heat", "heat"); + AddAlias("tool-robot", "robot"); + AddAlias("robot", "robot"); + AddAlias("prop-reactor", "reactor"); + AddAlias("prop-consumer", "generator"); + AddAlias("prop-flow", "generator"); + AddAlias("carrier-fuel-source", "generator"); + AddAlias("carrier-coolant-source", "cooling-pump"); + AddAlias("carrier-electricity-source", "generator"); + AddAlias("prop-junction", "pressure-regulator"); + AddAlias("prop-door", "wall"); + AddAlias("prop-eye-terminal", "diagnostic-terminal"); + AddAlias("prop-remedy", "repair"); + AddAlias("leak-fuel", "leak"); + AddAlias("leak-coolant", "leak"); + AddAlias("leak-electricity", "leak"); + AddAlias("hazard-heat", "heat"); + AddAlias("hazard-fuel", "fire"); + AddAlias("hazard-coolant", "leak"); + AddAlias("hazard-electricity", "heat"); + } + + public CanvasBitmap? Get(string key) + { + return m_Images.GetValueOrDefault(key); + } + + private async Task LoadFolderAsync(CanvasControl sender, params string[] pathParts) + { + var folder = Path.Combine([AppContext.BaseDirectory, .. pathParts]); + if (!Directory.Exists(folder)) + return; + + foreach (var path in Directory.EnumerateFiles(folder, "*.png")) + { + var key = Path.GetFileNameWithoutExtension(path); + m_Images[key] = await CanvasBitmap.LoadAsync(sender, path); + } + } + + private void AddAlias(string alias, string key) + { + if (!m_Images.ContainsKey(alias) && m_Images.TryGetValue(key, out var image)) + m_Images[alias] = image; + } + + private readonly Dictionary m_Images = new(StringComparer.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Win2D/MainWindow.xaml b/src/ReactorMaintenance.Win2D/MainWindow.xaml index d60904a..8868156 100644 --- a/src/ReactorMaintenance.Win2D/MainWindow.xaml +++ b/src/ReactorMaintenance.Win2D/MainWindow.xaml @@ -94,10 +94,52 @@ - + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + diff --git a/src/ReactorMaintenance.Win2D/MainWindow.xaml.cs b/src/ReactorMaintenance.Win2D/MainWindow.xaml.cs index d59e4c5..a1cd7e1 100644 --- a/src/ReactorMaintenance.Win2D/MainWindow.xaml.cs +++ b/src/ReactorMaintenance.Win2D/MainWindow.xaml.cs @@ -44,6 +44,7 @@ public sealed partial class MainWindow } private sealed record ForecastViewModel(string Message); + private sealed record InspectorItemViewModel(string Label, string Value, string Description); private sealed class EditorToolViewModel(EditorToolCommand command, string label) : INotifyPropertyChanged { @@ -92,10 +93,10 @@ public sealed partial class MainWindow private async Task LoadImagesAsync(CanvasControl sender) { + await m_Images.LoadAsync(sender); m_TerrainTilemap = await LoadCanvasBitmapAsync(sender, "Images", "tilemap.png"); - m_RobotSprite = await LoadCanvasBitmapAsync(sender, "Images", "Props", "robot.png"); - m_LeakSprite = await LoadCanvasBitmapAsync(sender, "Images", "Props", "leak.png"); - m_HeatSprite = await LoadCanvasBitmapAsync(sender, "Images", "Props", "heat.png"); + m_LeakSprite = m_Images.Get("leak"); + m_HeatSprite = m_Images.Get("heat"); } private static async Task LoadCanvasBitmapAsync(CanvasControl sender, params string[] pathParts) @@ -259,18 +260,28 @@ public sealed partial class MainWindow m_DragExceededClickThreshold = false; m_IsPanning = e.KeyModifiers.HasFlag(VirtualKeyModifiers.Shift); m_CursorDragStartCell = null; + m_DragPreviewDestination = null; + m_InvalidDragCell = null; + m_EditorFeedback = string.Empty; + m_LastPaintedCell = null; if (!m_IsPanning && m_SelectedTool.Tool == EEditorTool.Cursor && TryGetGridPosition(point.Position, out var position)) m_CursorDragStartCell = position; + else if (!m_IsPanning && IsDragPaintTool(m_SelectedTool.Tool) && TryGetGridPosition(point.Position, out position)) + { + m_LastPaintedCell = position; + PaintDraggedCell(position); + } e.Handled = true; } private void LevelCanvas_PointerMoved(object sender, PointerRoutedEventArgs e) { + var point = e.GetCurrentPoint(LevelCanvas); + UpdateHoverCell(point.Position); if (!m_LeftPointerDown) return; - var point = e.GetCurrentPoint(LevelCanvas); var deltaX = point.Position.X - m_LastPanPoint.X; var deltaY = point.Position.Y - m_LastPanPoint.Y; m_LastPanPoint = point.Position; @@ -287,6 +298,16 @@ public sealed partial class MainWindow ClampPan(); LevelCanvas.Invalidate(); } + else if (IsDragPaintTool(m_SelectedTool.Tool) && TryGetGridPosition(point.Position, out var paintPosition) && paintPosition != m_LastPaintedCell) + { + m_LastPaintedCell = paintPosition; + PaintDraggedCell(paintPosition); + } + else if (m_CursorDragStartCell is not null && m_DragExceededClickThreshold && TryGetGridPosition(point.Position, out var destination)) + { + m_DragPreviewDestination = destination; + LevelCanvas.Invalidate(); + } e.Handled = true; } @@ -302,14 +323,21 @@ public sealed partial class MainWindow } else if (m_CursorDragStartCell is { } source && m_DragExceededClickThreshold && TryGetGridPosition(point.Position, out var destination)) { - m_Level = LevelEditor.MoveOccupant(m_Level, source, destination); - m_SelectedCell = destination; - SelectReactorFromCell(destination); - RefreshForecasts(); + var result = LevelEditor.TryMoveOccupant(m_Level, source, destination); + m_Level = result.Level; + m_SelectedCell = result.Success ? destination : source; + m_InvalidDragCell = result.Success ? null : destination; + m_EditorFeedback = result.Reason; + if (result.Success) + { + SelectReactorFromCell(destination); + RefreshForecasts(); + } + RefreshInspector(); LevelCanvas.Invalidate(); } - else if (!m_DragExceededClickThreshold) + else if (!m_DragExceededClickThreshold && !IsDragPaintTool(m_SelectedTool.Tool)) { SelectOrPaintAt(point.Position); } @@ -318,6 +346,8 @@ public sealed partial class MainWindow m_LeftPointerDown = false; m_IsPanning = false; m_CursorDragStartCell = null; + m_DragPreviewDestination = null; + m_LastPaintedCell = null; m_DragExceededClickThreshold = false; LevelCanvas.ReleasePointerCapture(e.Pointer); e.Handled = true; @@ -328,6 +358,8 @@ public sealed partial class MainWindow m_LeftPointerDown = false; m_IsPanning = false; m_CursorDragStartCell = null; + m_DragPreviewDestination = null; + m_LastPaintedCell = null; m_DragExceededClickThreshold = false; } @@ -361,6 +393,29 @@ public sealed partial class MainWindow LevelCanvas.Invalidate(); } + private void PaintDraggedCell(GridPosition position) + { + m_SelectedCell = position; + ApplySelectedTool(position); + RefreshInspector(); + LevelCanvas.Invalidate(); + } + + private void UpdateHoverCell(Point point) + { + var nextHover = TryGetGridPosition(point, out var position) ? position : null; + if (nextHover == m_HoverCell) + return; + + m_HoverCell = nextHover; + LevelCanvas.Invalidate(); + } + + private static bool IsDragPaintTool(EEditorTool tool) + { + return tool is EEditorTool.Floor or EEditorTool.Wall; + } + private void ClearAt(Point point) { if (!TryGetGridPosition(point, out var position)) @@ -390,6 +445,7 @@ public sealed partial class MainWindow DrawLeaks(drawing, layout, SurfaceOpacity()); DrawRobot(drawing, layout, SurfaceOpacity()); DrawGrid(drawing, layout); + DrawEditorOverlays(drawing, layout); } private void DrawTerrain(CanvasDrawingSession drawing, CanvasLayout layout, float opacity) @@ -569,9 +625,11 @@ public sealed partial class MainWindow var center = Center(rect); var color = WithOpacity(prop.DoorState == EDoorState.Open ? Colors.LightGreen : Colors.OrangeRed, opacity); if (IsWall(new(position.X, position.Y - 1)) && IsWall(new(position.X, position.Y + 1))) + drawing.DrawLine((float)center.X, (float)rect.Top, (float)center.X, (float)rect.Bottom, color, 5); + else if (IsWall(new(position.X - 1, position.Y)) && IsWall(new(position.X + 1, position.Y))) drawing.DrawLine((float)rect.Left, (float)center.Y, (float)rect.Right, (float)center.Y, color, 5); else - drawing.DrawLine((float)center.X, (float)rect.Top, (float)center.X, (float)rect.Bottom, color, 5); + drawing.DrawLine((float)rect.Left, (float)center.Y, (float)rect.Right, (float)center.Y, color, 5); } } @@ -584,8 +642,7 @@ public sealed partial class MainWindow continue; var rect = Inset(layout.CellRect(position), 0.18); - drawing.FillRoundedRectangle(rect, 4, 4, WithOpacity(PropColor(prop), opacity)); - DrawCenteredText(drawing, PropLabel(prop), rect, WithOpacity(Colors.White, opacity), Math.Max(10, (float)(layout.CellSize * 0.22))); + DrawBadge(drawing, rect, ImageKey(prop), PropColor(prop), PropLabel(prop), opacity); } } @@ -597,7 +654,42 @@ public sealed partial class MainWindow private void DrawRobot(CanvasDrawingSession drawing, CanvasLayout layout, float opacity) { - DrawImage(drawing, m_RobotSprite, Inset(layout.CellRect(m_Level.Robot.Position), 0.04), opacity); + DrawBadge(drawing, Inset(layout.CellRect(m_Level.Robot.Position), 0.04), "robot", Colors.White, "BOT", opacity); + } + + private void DrawEditorOverlays(CanvasDrawingSession drawing, CanvasLayout layout) + { + if (m_DragPreviewDestination is { } destination) + { + var rect = Inset(layout.CellRect(destination), 0.12); + var key = m_CursorDragStartCell is { } source ? MovableImageKey(source) : null; + DrawBadge(drawing, rect, key, ColorHelper.FromArgb(255, 104, 168, 222), "MOVE", 0.55f); + } + + if (m_InvalidDragCell is { } invalidCell) + { + var rect = layout.CellRect(invalidCell); + drawing.DrawRectangle(rect, Colors.OrangeRed, 4); + DrawStatusBadge(drawing, rect, m_EditorFeedback); + } + + if (m_HoverCell is { } hover && m_SelectedTool.Tool != EEditorTool.Cursor) + { + var rect = layout.CellRect(hover); + var size = Math.Max(26, rect.Width * 0.42); + var badgeRect = new Rect(rect.Right - size - 4, rect.Top + 4, size, size); + DrawBadge(drawing, badgeRect, ImageKey(m_SelectedTool), ToolColor(m_SelectedTool), ToolShortLabel(m_SelectedTool), 0.9f); + } + } + + private static void DrawStatusBadge(CanvasDrawingSession drawing, Rect cellRect, string text) + { + if (string.IsNullOrWhiteSpace(text)) + return; + + var rect = new Rect(cellRect.Left, cellRect.Bottom + 4, Math.Max(cellRect.Width * 2.6, 160), 28); + drawing.FillRoundedRectangle(rect, 4, 4, ColorHelper.FromArgb(230, 96, 32, 32)); + DrawCenteredText(drawing, text, rect, Colors.White, 11); } private void DrawGrid(CanvasDrawingSession drawing, CanvasLayout layout) @@ -625,8 +717,8 @@ public sealed partial class MainWindow private bool TryGetGridPosition(Point point, out GridPosition position) { var layout = GetLayout(); - var x = (int)((point.X - layout.OriginX) / layout.CellSize); - var y = (int)((point.Y - layout.OriginY) / layout.CellSize); + var x = (int)Math.Floor((point.X - layout.OriginX) / layout.CellSize); + var y = (int)Math.Floor((point.Y - layout.OriginY) / layout.CellSize); position = new(x, y); return m_Level.InBounds(position); } @@ -689,14 +781,20 @@ public sealed partial class MainWindow { LevelNameText.Text = m_Level.Name; TurnText.Text = m_Level.Global.Turn.ToString(CultureInfo.InvariantCulture); - StatusText.Text = $"{m_Level.Global.LevelState}: {m_Level.Global.Status}"; + StatusText.Text = string.IsNullOrWhiteSpace(m_EditorFeedback) + ? $"{m_Level.Global.LevelState}: {m_Level.Global.Status}" + : $"{m_Level.Global.LevelState}: {m_EditorFeedback}"; var doorCount = m_Level.Props.Count(prop => prop.Type == EPropType.Door); - GlobalText.Text = $"Inventory: F {m_Level.Robot.FuelNeutralizers}, C {m_Level.Robot.CoolantNeutralizers}, E {m_Level.Robot.ElectricityNeutralizers}, H {m_Level.Robot.HeatShields}\n" - + $"Heat immunity steps: {m_Level.Robot.HeatImmunitySteps}\n" - + $"Required consumers F/C/E: {m_Level.RequiredFuelConsumers}/{m_Level.RequiredCoolantConsumers}/{m_Level.RequiredElectricityConsumers}\n" - + $"Reactors: {m_Level.Reactors.Count}, Leaks: {m_Level.Leaks.Count}, Doors: {doorCount}"; + GlobalGrid.ItemsSource = new[] { + new InspectorItemViewModel("Inventory", $"F {m_Level.Robot.FuelNeutralizers} / C {m_Level.Robot.CoolantNeutralizers} / E {m_Level.Robot.ElectricityNeutralizers} / H {m_Level.Robot.HeatShields}", "Remedies carried by the robot."), + new InspectorItemViewModel("Heat Shield", m_Level.Robot.HeatImmunitySteps.ToString(CultureInfo.InvariantCulture), "Remaining protected movement steps."), + new InspectorItemViewModel("Required F/C/E", $"{m_Level.RequiredFuelConsumers}/{m_Level.RequiredCoolantConsumers}/{m_Level.RequiredElectricityConsumers}", "Producing consumers needed for readiness."), + new InspectorItemViewModel("Objects", $"R {m_Level.Reactors.Count} / L {m_Level.Leaks.Count} / D {doorCount}", "Reactors, active leaks, and doors.") + }; - CellText.Text = m_SelectedCell is { } position && m_Level.InBounds(position) ? CellInspectionText(position) : "No cell selected."; + CellGrid.ItemsSource = m_SelectedCell is { } position && m_Level.InBounds(position) + ? CellInspectionItems(position) + : new[] { new InspectorItemViewModel("Selection", "None", "Select a grid cell to inspect it.") }; ForecastList.ItemsSource = m_Level.Forecasts.Select(forecast => new ForecastViewModel($"{forecast.Turns}: {forecast.Message}")).ToArray(); } @@ -725,22 +823,24 @@ public sealed partial class MainWindow RefreshForecasts(); } - private string CellInspectionText(GridPosition position) + private InspectorItemViewModel[] CellInspectionItems(GridPosition position) { var prop = m_Level.GetProp(position); var surface = m_Level.GetSurface(position); var fuel = m_Level.GetUnderground(position, ECarrierType.Fuel); var coolant = m_Level.GetUnderground(position, ECarrierType.Coolant); var electricity = m_Level.GetUnderground(position, ECarrierType.Electricity); - return $"Position: {position.X},{position.Y}\n" - + $"Terrain: {m_Level.GetTerrain(position)}\n" - + $"Prop: {prop.Type} {prop.SwitchState} {prop.ServiceState}\n" - + $"Consumer F/C/E: {prop.FuelServiceState} / {prop.CoolantServiceState} / {prop.ElectricityServiceState}\n" - + $"Fuel: {UndergroundText(fuel)}\n" - + $"Coolant: {UndergroundText(coolant)}\n" - + $"Electricity: {UndergroundText(electricity)}\n" - + $"Surface fuel/coolant/electricity/heat: {Format(surface.Fuel)} / {Format(surface.Coolant)} / {Format(surface.Electricity)} / {Format(surface.Heat)}\n" - + $"Blocks F/C/E: {surface.FuelBlockTurns}/{surface.CoolantBlockTurns}/{surface.ElectricityBlockTurns}"; + return [ + new("Position", $"{position.X},{position.Y}", "Grid coordinate."), + new("Terrain", m_Level.GetTerrain(position).ToString(), "Static surface cell type."), + new("Prop", $"{prop.Type} {prop.SwitchState}", "Placed surface object and switch state."), + new("Services F/C/E", $"{prop.FuelServiceState}/{prop.CoolantServiceState}/{prop.ElectricityServiceState}", "Consumer service status."), + new("Fuel Net", UndergroundText(fuel), "Amount, intensity, and integrity."), + new("Coolant Net", UndergroundText(coolant), "Amount, intensity, and integrity."), + new("Electric Net", UndergroundText(electricity), "Amount, intensity, and integrity."), + new("Surface F/C/E/H", $"{Format(surface.Fuel)} / {Format(surface.Coolant)} / {Format(surface.Electricity)} / {Format(surface.Heat)}", "Visible hazards and heat."), + new("Blocks F/C/E", $"{surface.FuelBlockTurns}/{surface.CoolantBlockTurns}/{surface.ElectricityBlockTurns}", "Temporary remedy entry blocks.") + ]; } private static string UndergroundText(UndergroundCell cell) @@ -831,6 +931,32 @@ public sealed partial class MainWindow drawing.DrawImage(image, rect, image.Bounds, opacity); } + private void DrawBadge(CanvasDrawingSession drawing, Rect rect, string? imageKey, Color fallbackColor, string fallbackLabel, float opacity) + { + var image = imageKey is null ? null : m_Images.Get(imageKey); + if (image is not null) + { + DrawImage(drawing, image, rect, opacity); + return; + } + + drawing.FillRoundedRectangle(rect, 4, 4, WithOpacity(fallbackColor, opacity)); + DrawCenteredText(drawing, fallbackLabel, rect, WithOpacity(Colors.White, opacity), Math.Max(9, (float)(rect.Height * 0.24))); + } + + private string? MovableImageKey(GridPosition source) + { + var prop = m_Level.GetProp(source); + if (prop.Type != EPropType.None) + return ImageKey(prop); + + var leak = m_Level.Leaks.FirstOrDefault(leak => !leak.Repaired && (leak.AccessPosition == source || leak.UndergroundPosition == source)); + if (leak is not null) + return $"leak-{leak.Carrier.ToString().ToLowerInvariant()}"; + + return m_Level.Robot.Position == source ? "robot" : null; + } + private float SurfaceOpacity() { return m_ActiveLayer == EEditorLayer.Surface ? 1.0f : 0.5f; @@ -873,6 +999,61 @@ public sealed partial class MainWindow }; } + private static Color ToolColor(EditorToolCommand command) + { + return command.Tool switch { + EEditorTool.Floor => ColorHelper.FromArgb(255, 62, 76, 67), + EEditorTool.Wall => ColorHelper.FromArgb(255, 78, 86, 94), + EEditorTool.Underground or EEditorTool.Flow or EEditorTool.Leak or EEditorTool.SurfaceHazard => CarrierColor(command.Carrier), + EEditorTool.Heat => ColorHelper.FromArgb(255, 221, 120, 55), + EEditorTool.Robot => ColorHelper.FromArgb(255, 215, 219, 224), + _ => PropColor(new() { Type = ToolPropType(command.Tool), Carrier = command.Carrier, RemedyType = command.RemedyType }) + }; + } + + private static EPropType ToolPropType(EEditorTool tool) + { + return tool switch { + EEditorTool.Flow => EPropType.Flow, + EEditorTool.Consumer => EPropType.Consumer, + EEditorTool.Junction => EPropType.Junction, + EEditorTool.Door => EPropType.Door, + EEditorTool.AllSeeingEyeTerminal => EPropType.AllSeeingEyeTerminal, + EEditorTool.RemedySupply => EPropType.RemedySupply, + EEditorTool.ReactorControl => EPropType.ReactorControl, + _ => EPropType.None + }; + } + + private static string ImageKey(PropState prop) + { + return prop.Type switch { + EPropType.Flow => $"carrier-{prop.Carrier.ToString().ToLowerInvariant()}-source", + EPropType.Consumer => "prop-consumer", + EPropType.Junction => "prop-junction", + EPropType.Door => "prop-door", + EPropType.AllSeeingEyeTerminal => "prop-eye-terminal", + EPropType.RemedySupply => "prop-remedy", + EPropType.ReactorControl => "prop-reactor", + _ => "prop" + }; + } + + private static string ImageKey(EditorToolCommand command) + { + return command.Tool switch { + EEditorTool.Floor => "tool-floor", + EEditorTool.Wall => "tool-wall", + EEditorTool.Underground => $"carrier-{command.Carrier.ToString().ToLowerInvariant()}", + EEditorTool.Flow => $"carrier-{command.Carrier.ToString().ToLowerInvariant()}-source", + EEditorTool.Leak => $"leak-{command.Carrier.ToString().ToLowerInvariant()}", + EEditorTool.SurfaceHazard => $"hazard-{command.Carrier.ToString().ToLowerInvariant()}", + EEditorTool.Heat => "hazard-heat", + EEditorTool.Robot => "robot", + _ => ImageKey(new PropState { Type = ToolPropType(command.Tool), Carrier = command.Carrier, RemedyType = command.RemedyType }) + }; + } + private static Color CarrierColor(ECarrierType carrier) { return carrier switch { @@ -897,6 +1078,21 @@ public sealed partial class MainWindow }; } + private static string ToolShortLabel(EditorToolCommand command) + { + return command.Tool switch { + EEditorTool.Floor => "FLR", + EEditorTool.Wall => "WALL", + EEditorTool.Underground => $"{CarrierShort(command.Carrier)} NET", + EEditorTool.Flow => $"{CarrierShort(command.Carrier)} SRC", + EEditorTool.Leak => $"{CarrierShort(command.Carrier)} LEAK", + EEditorTool.SurfaceHazard => $"{CarrierShort(command.Carrier)} HAZ", + EEditorTool.Heat => "HEAT", + EEditorTool.Robot => "BOT", + _ => PropLabel(new() { Type = ToolPropType(command.Tool), Carrier = command.Carrier, RemedyType = command.RemedyType }) + }; + } + private static string CarrierShort(ECarrierType carrier) { return carrier switch { @@ -1015,14 +1211,20 @@ public sealed partial class MainWindow private static readonly Color c_CoolantColor = ColorHelper.FromArgb(255, 57, 174, 196); private static readonly Color c_ElectricityColor = ColorHelper.FromArgb(255, 236, 226, 82); private readonly IReadOnlyList m_EditorTools = []; + private readonly EditorImageRegistry m_Images = new(); private readonly SimulationEngine m_Simulation = new(); private EEditorLayer m_ActiveLayer = EEditorLayer.Surface; private StorageFile? m_CurrentFile; private GridPosition? m_CursorDragStartCell; private bool m_DragExceededClickThreshold; + private GridPosition? m_DragPreviewDestination; + private string m_EditorFeedback = string.Empty; private CanvasBitmap? m_HeatSprite; + private GridPosition? m_HoverCell; + private GridPosition? m_InvalidDragCell; private bool m_IsPanning; + private GridPosition? m_LastPaintedCell; private Point m_LastPanPoint; private CanvasBitmap? m_LeakSprite; private bool m_LeftPointerDown; @@ -1030,7 +1232,6 @@ public sealed partial class MainWindow private LevelState m_Level; private double m_PanX; private double m_PanY; - private CanvasBitmap? m_RobotSprite; private GridPosition? m_SelectedCell; private int? m_SelectedReactorId = 1; private EditorToolCommand m_SelectedTool = new() { Tool = EEditorTool.Cursor }; diff --git a/tests/ReactorMaintenance.Simulation.Tests/LevelEditorTests.cs b/tests/ReactorMaintenance.Simulation.Tests/LevelEditorTests.cs index ff289a9..b41bae2 100644 --- a/tests/ReactorMaintenance.Simulation.Tests/LevelEditorTests.cs +++ b/tests/ReactorMaintenance.Simulation.Tests/LevelEditorTests.cs @@ -13,6 +13,35 @@ public sealed class LevelEditorTests Assert.Equal(EDoorState.Closed, next.GetProp(new(2, 2)).DoorState); } + [Fact] + public void DoorToolTogglesExistingDoorState() + { + var level = LevelState.Create("Door editor", 6, 6); + level = LevelEditor.Apply(level, new(2, 2), new() { Tool = EEditorTool.Door }); + + var opened = LevelEditor.Apply(level, new(2, 2), new() { Tool = EEditorTool.Door }); + var closed = LevelEditor.Apply(opened, new(2, 2), new() { Tool = EEditorTool.Door }); + + Assert.Equal(EDoorState.Open, opened.GetProp(new(2, 2)).DoorState); + Assert.Equal(EDoorState.Closed, closed.GetProp(new(2, 2)).DoorState); + } + + [Fact] + public void WallToolPreservesUndergroundNetworks() + { + var level = LevelState.Create("Wall editor", 6, 6); + var position = new GridPosition(2, 2); + level = LevelEditor.Apply(level, position, new() { Tool = EEditorTool.Underground, Carrier = ECarrierType.Fuel }); + level = LevelEditor.Apply(level, position, new() { Tool = EEditorTool.Underground, Carrier = ECarrierType.Coolant }); + level = LevelEditor.Apply(level, position, new() { Tool = EEditorTool.Underground, Carrier = ECarrierType.Electricity }); + + var next = LevelEditor.Apply(level, position, new() { Tool = EEditorTool.Wall }); + + Assert.Equal(EUndergroundState.Intact, next.GetUnderground(position, ECarrierType.Fuel).State); + Assert.Equal(EUndergroundState.Intact, next.GetUnderground(position, ECarrierType.Coolant).State); + Assert.Equal(EUndergroundState.Intact, next.GetUnderground(position, ECarrierType.Electricity).State); + } + [Fact] public void ConsumerToolPlacesCarrierAgnosticConsumer() { @@ -92,4 +121,66 @@ public sealed class LevelEditorTests Assert.Equal(EPropType.ReactorControl, next.GetProp(new(3, 3)).Type); Assert.Equal(new(3, 3), next.Reactors[0].ControlPosition); } + + [Fact] + public void MoveOccupantMovesSourcesAsProps() + { + var level = LevelState.Create("Move editor", 6, 6); + level = LevelEditor.Apply(level, new(1, 1), new() { Tool = EEditorTool.Flow, Carrier = ECarrierType.Fuel }); + + var result = LevelEditor.TryMoveOccupant(level, new(1, 1), new(3, 3)); + + Assert.True(result.Success, result.Reason); + Assert.Equal(EPropType.None, result.Level.GetProp(new(1, 1)).Type); + Assert.Equal(EPropType.Flow, result.Level.GetProp(new(3, 3)).Type); + Assert.Equal(ECarrierType.Fuel, result.Level.GetProp(new(3, 3)).Carrier); + } + + [Fact] + public void MoveOccupantMovesFuelLeakToFloorDestination() + { + var level = LevelState.Create("Move editor", 6, 6); + level = LevelEditor.SetLeak(level, new(1, 1), new(1, 1), ECarrierType.Fuel); + + var result = LevelEditor.TryMoveOccupant(level, new(1, 1), new(3, 3)); + + Assert.True(result.Success, result.Reason); + Assert.Single(result.Level.Leaks); + Assert.Equal(new(3, 3), result.Level.Leaks[0].UndergroundPosition); + Assert.Equal(new(3, 3), result.Level.Leaks[0].AccessPosition); + Assert.Equal(EUndergroundState.Leaking, result.Level.GetUnderground(new(3, 3), ECarrierType.Fuel).State); + Assert.Equal(EUndergroundState.Absent, result.Level.GetUnderground(new(1, 1), ECarrierType.Fuel).State); + } + + [Fact] + public void MoveOccupantMovesElectricityLeakAccessFace() + { + var level = LevelState.Create("Move editor", 6, 6); + level = level.SetTerrain(new(2, 2), ECellTerrain.Wall); + level = LevelEditor.SetLeak(level, new(2, 2), new(2, 3), ECarrierType.Electricity); + + var result = LevelEditor.TryMoveOccupant(level, new(2, 3), new(3, 2)); + + Assert.True(result.Success, result.Reason); + Assert.Single(result.Level.Leaks); + Assert.Equal(new(2, 2), result.Level.Leaks[0].UndergroundPosition); + Assert.Equal(new(3, 2), result.Level.Leaks[0].AccessPosition); + } + + [Fact] + public void MoveOccupantReportsInvalidStartAndDestinationReasons() + { + var level = LevelState.Create("Move editor", 6, 6); + level = level.SetTerrain(new(3, 3), ECellTerrain.Wall); + level = level.SetProp(new(1, 1), new() { Type = EPropType.Consumer }); + level = level.SetProp(new(2, 2), new() { Type = EPropType.Consumer }); + + var invalidStart = LevelEditor.TryMoveOccupant(level, new(4, 4), new(5, 5)); + var invalidDestination = LevelEditor.TryMoveOccupant(level, new(1, 1), new(2, 2)); + + Assert.False(invalidStart.Success); + Assert.Contains("No movable", invalidStart.Reason); + Assert.False(invalidDestination.Success); + Assert.Contains("occupied", invalidDestination.Reason); + } } \ No newline at end of file