Add editor image badges and drag moves
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
64
src/ReactorMaintenance.Win2D/EditorImageRegistry.cs
Normal file
64
src/ReactorMaintenance.Win2D/EditorImageRegistry.cs
Normal 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);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
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<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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user