Add editor image badges and drag moves

This commit is contained in:
2026-05-11 23:03:29 +02:00
parent 884cc4503f
commit dfe0cb3b6a
7 changed files with 538 additions and 46 deletions

View File

@@ -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.

View File

@@ -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))

View File

@@ -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)

View File

@@ -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<string, CanvasBitmap> m_Images = new(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -94,10 +94,52 @@
</Grid>
<TextBlock Text="Global Systems" FontSize="16" FontWeight="SemiBold" Foreground="#F4F1E8" />
<TextBlock x:Name="GlobalText" Foreground="#CCD4DA" TextWrapping="Wrap" />
<ItemsControl x:Name="GlobalGrid">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<ItemsWrapGrid Orientation="Horizontal" MaximumRowsOrColumns="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="#3C4650" BorderThickness="1" Padding="8" Margin="0,0,8,8"
Width="126" CornerRadius="3">
<StackPanel Spacing="2">
<TextBlock Text="{Binding Label}" Foreground="#9EA7AE" FontSize="11"
TextWrapping="Wrap" />
<TextBlock Text="{Binding Value}" Foreground="#F4F1E8" FontSize="15"
TextWrapping="WrapWholeWords" />
<TextBlock Text="{Binding Description}" Foreground="#9EA7AE" FontSize="11"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Selected Cell" FontSize="16" FontWeight="SemiBold" Foreground="#F4F1E8" />
<TextBlock x:Name="CellText" Foreground="#CCD4DA" TextWrapping="Wrap" />
<ItemsControl x:Name="CellGrid">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<ItemsWrapGrid Orientation="Horizontal" MaximumRowsOrColumns="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="#3C4650" BorderThickness="1" Padding="8" Margin="0,0,8,8"
Width="126" CornerRadius="3">
<StackPanel Spacing="2">
<TextBlock Text="{Binding Label}" Foreground="#9EA7AE" FontSize="11"
TextWrapping="Wrap" />
<TextBlock Text="{Binding Value}" Foreground="#F4F1E8" FontSize="14"
TextWrapping="WrapWholeWords" />
<TextBlock Text="{Binding Description}" Foreground="#9EA7AE" FontSize="11"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Forecasts" FontSize="16" FontWeight="SemiBold" Foreground="#F4F1E8" />
<ItemsControl x:Name="ForecastList">

View File

@@ -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<CanvasBitmap> 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;
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<EditorToolViewModel> 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 };

View File

@@ -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);
}
}