From 30963a9bdee7a51e4c95b74ba3bbf3c56c4a2186 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 10 May 2026 18:59:00 +0200 Subject: [PATCH] Rework Win2D editor for design model --- README.md | 36 +- TASKS.md | 17 +- .../Balancing.cs | 2 +- .../Difficulties/NormalBalancing.cs | 2 +- .../LevelEditor.cs | 2 +- .../LevelSerializer.cs | 2 +- .../LevelValidator.cs | 2 +- src/ReactorMaintenance.Simulation/Models.cs | 3 +- .../SimulationEngine.cs | 20 +- src/ReactorMaintenance.Win2D/App.xaml.cs | 36 +- src/ReactorMaintenance.Win2D/MainWindow.xaml | 51 +- .../MainWindow.xaml.cs | 1450 ++++++++--------- .../ReactorMaintenance.Win2D.csproj | 8 +- .../SimulationEngineTests.cs | 2 +- 14 files changed, 817 insertions(+), 816 deletions(-) diff --git a/README.md b/README.md index 6a69046..882d094 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ -# Reactor Maintenance - -C# WinUI 3 + Win2D level editor for the deterministic grid simulation described in `design.md`. - -## Projects - -- `src/ReactorMaintenance.Simulation`: UI-independent level model, editor operations, forecasts, simulation turns, versioned JSON serialization, and swappable difficulty balancing profiles. -- `src/ReactorMaintenance.Win2D`: Win2D editor app for painting floor/wall terrain plus cell props, loading/saving levels, advancing simulation turns, and activating the reactor. -- `tests/ReactorMaintenance.Simulation.Tests`: unit tests for deterministic simulation behavior. - -## Commands - -```powershell -dotnet test tests\ReactorMaintenance.Simulation.Tests\ReactorMaintenance.Simulation.Tests.csproj -dotnet build src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.csproj -p:Platform=x64 -dotnet run --project src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.csproj -p:Platform=x64 -``` +# Reactor Maintenance + +C# WinUI 3 + Win2D level editor for the deterministic grid simulation described in `docs/design.md`. + +## Projects + +- `src/ReactorMaintenance.Simulation`: UI-independent level model, editor operations, validation, forecasts, simulation turns, versioned JSON serialization, and deterministic balancing defaults. +- `src/ReactorMaintenance.Win2D`: Win2D editor app for authoring terrain, underground fuel/coolant/electricity networks, props, leaks, doors, surface hazards, robot start, loading/saving levels, ending turns, interacting with props, and activating a ready reactor. +- `tests/ReactorMaintenance.Simulation.Tests`: unit tests for deterministic simulation behavior. + +## Commands + +```powershell +dotnet test tests\ReactorMaintenance.Simulation.Tests\ReactorMaintenance.Simulation.Tests.csproj +dotnet build src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.csproj -p:Platform=x64 -p:EnableWindowsTargeting=true +dotnet run --project src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.csproj -p:Platform=x64 +``` + +The WinUI/XAML compiler is Windows-specific. On Linux, the simulation tests run normally, but the Win2D app build must be verified in a Windows-capable environment. diff --git a/TASKS.md b/TASKS.md index 812e0be..56570b9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -6,7 +6,9 @@ - Scope approved: implement `docs/design.md` end-to-end with deterministic defaults and no backward compatibility. - Simulation core has been replaced with the first design-native model and deterministic engine slice. - Simulation and test projects now target `net10.0` because this Linux environment only has the .NET 10 runtime. -- Win2D editor still references the removed legacy model and is the next major implementation area. +- Win2D editor has been rewritten against the new design model. +- Win2D project now targets `net10.0-windows10.0.19041.0` to match the simulation project. +- Linux can restore and compile the referenced simulation project, but full WinUI/XAML compilation still requires a Windows-capable XAML compiler environment. ## Completed Work @@ -24,16 +26,23 @@ - Attempted `dotnet jb cleanupcode --build=False ...`; unavailable in this environment because `dotnet-jb` is not installed. - Reviewed the first slice and fixed an action-resolution maintainability issue before commit. - Verified `git diff --check` reports no whitespace errors. +- Ran `dotnet jb cleanupcode --build=False ...` successfully after ReSharper install and normalized line endings back to LF. +- Reworked the Win2D editor for the new model: full tool list, layer-aware painting, terrain, underground carriers, surface hazards, props, doors, leaks, robot, forecasts, save validation, starter level, and simple play actions. +- Removed old editor dependencies on legacy props, pressure pipes, smoke, fire, and global power/cooling/core-stability fields. +- Verified `dotnet test tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj` passes after the editor rewrite: 11 passed. +- Attempted Win2D build on Linux with `dotnet build src/ReactorMaintenance.Win2D/ReactorMaintenance.Win2D.csproj -p:EnableWindowsTargeting=true -p:Platform=x64`; it fails at Windows `XamlCompiler.exe` with exec format error. +- Attempted managed XAML compiler path with `-p:UseXamlCompilerExecutable=false`; it fails loading the WinUI XAML compiler task dependency under this Linux/.NET 10 setup. +- Updated `README.md` for the new design-model editor, .NET 10 target, and Linux/Windows build expectations. ## Current Work -- Commit the first simulation-core rewrite slice. +- Commit the Win2D editor rewrite slice. ## Future Work 1. Expand simulation fidelity where the first slice is intentionally simplified: junction branch inference, ambiguity validation, complete pair table coverage, richer rule predicates/effects, and stronger forecast proof cases. -2. Update the Win2D editor for all authored layers and new runtime inspection. -3. Add editor workflows for reactor bindings, door edge selection, electricity wall leak faces, rule events, and layer-specific painting. +2. Add advanced editor workflows for explicit reactor binding selection, explicit door edge selection, electricity wall leak face selection, and rule event authoring. +3. Verify and polish the Win2D app on Windows where the XAML compiler can run. 4. Update README and any affected docs to reflect the new schema, .NET target, editor controls, and deterministic defaults. 5. Build the Win2D project on a Windows-capable environment after the editor rewrite. 6. Add broader tests for junction ratios, ambiguous junctions, all rule event families, serialization edge cases, and editor operations. diff --git a/src/ReactorMaintenance.Simulation/Balancing.cs b/src/ReactorMaintenance.Simulation/Balancing.cs index b1b4e5e..cf0590c 100644 --- a/src/ReactorMaintenance.Simulation/Balancing.cs +++ b/src/ReactorMaintenance.Simulation/Balancing.cs @@ -69,4 +69,4 @@ public abstract class Balancing public abstract int RemedyBlockTurns { get; } public abstract int HeatShieldSteps { get; } public abstract int InventoryCapacityPerRemedy { get; } -} +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs b/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs index f8d9c13..9c17301 100644 --- a/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs +++ b/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs @@ -52,4 +52,4 @@ public class NormalBalancing : Balancing public override int RemedyBlockTurns => 2; public override int HeatShieldSteps => 3; public override int InventoryCapacityPerRemedy => 3; -} +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/LevelEditor.cs b/src/ReactorMaintenance.Simulation/LevelEditor.cs index c7091c0..2a269ea 100644 --- a/src/ReactorMaintenance.Simulation/LevelEditor.cs +++ b/src/ReactorMaintenance.Simulation/LevelEditor.cs @@ -162,4 +162,4 @@ public static class LevelEditor ] }; } -} +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/LevelSerializer.cs b/src/ReactorMaintenance.Simulation/LevelSerializer.cs index 0fe62f8..0c9f8a1 100644 --- a/src/ReactorMaintenance.Simulation/LevelSerializer.cs +++ b/src/ReactorMaintenance.Simulation/LevelSerializer.cs @@ -39,4 +39,4 @@ public static class LevelSerializer public int Version { get; init; } public LevelState? Level { get; init; } } -} +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/LevelValidator.cs b/src/ReactorMaintenance.Simulation/LevelValidator.cs index 4b875c6..96121b0 100644 --- a/src/ReactorMaintenance.Simulation/LevelValidator.cs +++ b/src/ReactorMaintenance.Simulation/LevelValidator.cs @@ -200,4 +200,4 @@ public sealed class LevelValidator { return level.InBounds(position) && level.GetProp(position).Type == propType; } -} +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Models.cs b/src/ReactorMaintenance.Simulation/Models.cs index 20d3d6a..512b50f 100644 --- a/src/ReactorMaintenance.Simulation/Models.cs +++ b/src/ReactorMaintenance.Simulation/Models.cs @@ -331,7 +331,6 @@ public sealed record RuleEventState } public sealed record Forecast(EForecastKind Kind, GridPosition? Position, int Turns, string Message); - public sealed record ValidationIssue(string Message, GridPosition? Position = null); public sealed record ValidationReport @@ -532,4 +531,4 @@ public sealed record LevelState public RobotState Robot { get; init; } = new(); public GlobalState Global { get; init; } = new(); public IReadOnlyList Forecasts { get; init; } = Array.Empty(); -} +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/SimulationEngine.cs b/src/ReactorMaintenance.Simulation/SimulationEngine.cs index b212d4b..ad3126f 100644 --- a/src/ReactorMaintenance.Simulation/SimulationEngine.cs +++ b/src/ReactorMaintenance.Simulation/SimulationEngine.cs @@ -454,16 +454,17 @@ public sealed class SimulationEngine var hasCritical = level.Surface.Any(surface => BandFuel(surface.Fuel) == EBand.Critical || BandCoolant(surface.Coolant) == EBand.Critical || BandElectricity(surface.Electricity) == EBand.Critical || BandHeat(surface.Heat) == EBand.Critical); var hasCaution = hasCritical || level.Props.Any(prop => prop.ServiceState is EConsumerServiceState.Starved or EConsumerServiceState.Disabled) || level.Leaks.Any(leak => !leak.Repaired); - var state = hasCritical ? ELevelState.Critical : hasCaution ? ELevelState.Caution : ELevelState.Stable; + var state = hasCritical ? ELevelState.Critical : + hasCaution ? ELevelState.Caution : ELevelState.Stable; return level with { Reactors = reactors, Global = level.Global with { LevelState = state, Status = state.ToString().ToUpperInvariant() } }; } private static bool IsReactorReady(LevelState level, ReactorBinding reactor) { return HasProducingConsumer(level, reactor.FuelConsumerPosition, ECarrierType.Fuel) - && HasProducingConsumer(level, reactor.CoolantConsumerPosition, ECarrierType.Coolant) - && HasProducingConsumer(level, reactor.ElectricityConsumerPosition, ECarrierType.Electricity) - && level.GetSurface(reactor.ControlPosition).Heat < Balancing.Current.TerminalHeat; + && HasProducingConsumer(level, reactor.CoolantConsumerPosition, ECarrierType.Coolant) + && HasProducingConsumer(level, reactor.ElectricityConsumerPosition, ECarrierType.Electricity) + && level.GetSurface(reactor.ControlPosition).Heat < Balancing.Current.TerminalHeat; } private static bool HasProducingConsumer(LevelState level, GridPosition position, ECarrierType carrier) @@ -535,10 +536,11 @@ public sealed class SimulationEngine private LevelState AdvanceDurations(LevelState level) { var surface = level.Surface.Select(cell => cell with { - FuelBlockTurns = Math.Max(0, cell.FuelBlockTurns - 1), - CoolantBlockTurns = Math.Max(0, cell.CoolantBlockTurns - 1), - ElectricityBlockTurns = Math.Max(0, cell.ElectricityBlockTurns - 1) - }).ToArray(); + FuelBlockTurns = Math.Max(0, cell.FuelBlockTurns - 1), + CoolantBlockTurns = Math.Max(0, cell.CoolantBlockTurns - 1), + ElectricityBlockTurns = Math.Max(0, cell.ElectricityBlockTurns - 1) + }) + .ToArray(); return level with { Surface = surface }; } @@ -732,4 +734,4 @@ public sealed class SimulationEngine } private readonly LevelValidator m_Validator = new(); -} +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Win2D/App.xaml.cs b/src/ReactorMaintenance.Win2D/App.xaml.cs index 631d641..3bb964e 100644 --- a/src/ReactorMaintenance.Win2D/App.xaml.cs +++ b/src/ReactorMaintenance.Win2D/App.xaml.cs @@ -1,19 +1,19 @@ -using Microsoft.UI.Xaml; - -namespace ReactorMaintenance.Win2D; - -public partial class App -{ - public App() - { - InitializeComponent(); - } - - protected override void OnLaunched(LaunchActivatedEventArgs args) - { - m_Window = new MainWindow(); - m_Window.Activate(); - } - - private Window? m_Window; +using Microsoft.UI.Xaml; + +namespace ReactorMaintenance.Win2D; + +public partial class App +{ + public App() + { + InitializeComponent(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + m_Window = new MainWindow(); + m_Window.Activate(); + } + + private Window? m_Window; } \ No newline at end of file diff --git a/src/ReactorMaintenance.Win2D/MainWindow.xaml b/src/ReactorMaintenance.Win2D/MainWindow.xaml index 5cf8599..6b712aa 100644 --- a/src/ReactorMaintenance.Win2D/MainWindow.xaml +++ b/src/ReactorMaintenance.Win2D/MainWindow.xaml @@ -15,8 +15,10 @@ - - + + + + @@ -37,19 +39,22 @@ - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - + + + + diff --git a/src/ReactorMaintenance.Win2D/MainWindow.xaml.cs b/src/ReactorMaintenance.Win2D/MainWindow.xaml.cs index a179563..20d9688 100644 --- a/src/ReactorMaintenance.Win2D/MainWindow.xaml.cs +++ b/src/ReactorMaintenance.Win2D/MainWindow.xaml.cs @@ -1,729 +1,721 @@ -using Microsoft.Graphics.Canvas; -using Microsoft.Graphics.Canvas.UI; -using Microsoft.Graphics.Canvas.UI.Xaml; -using Microsoft.UI; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Input; -using Microsoft.UI.Xaml.Media.Imaging; -using ReactorMaintenance.Simulation; -using System.Globalization; -using Windows.Foundation; -using Windows.Storage; -using Windows.Storage.Pickers; -using Windows.UI; -using Windows.UI.Popups; -using WinRT.Interop; - -namespace ReactorMaintenance.Win2D; - -public sealed partial class MainWindow -{ - private sealed record CanvasLayout(double CellSize, double OriginX, double OriginY) - { - public Rect CellRect(int x, int y) - { - return new(OriginX + (x * CellSize), OriginY + (y * CellSize), CellSize, CellSize); - } - - public Rect DualTileRect(int x, int y) - { - return new(OriginX + ((x - 0.5) * CellSize), OriginY + ((y - 0.5) * CellSize), CellSize, CellSize); - } - } - - private sealed record ForecastViewModel(BitmapImage Icon, string Message); - - private sealed class EditorToolViewModel(EEditorTool tool, BitmapImage? icon, string label) - { - public EEditorTool Tool { get; } = tool; - public BitmapImage? Icon { get; } = icon; - public string Label { get; } = label; - public bool IsSelected { get; set; } - } - - public MainWindow() - { - InitializeComponent(); - - m_Level = BuildStarterLevel(); - m_EditorTools = Enum.GetValues().Select(tool => new EditorToolViewModel(tool, EditorToolIcon(tool), tool.ToString()) { IsSelected = tool == m_SelectedTool }).ToArray(); - ToolPicker.ItemsSource = m_EditorTools; - RefreshInspector(); - } - - private void LevelCanvas_CreateResources(CanvasControl sender, CanvasCreateResourcesEventArgs args) - { - args.TrackAsyncAction(LoadImagesAsync(sender).AsAsyncAction()); - } - - private async Task LoadImagesAsync(CanvasControl 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_FireSprite = await LoadCanvasBitmapAsync(sender, "Images", "Props", "fire.png"); - - m_PropSprites[ECellProp.Reactor] = await LoadCanvasBitmapAsync(sender, "Images", "Props", "reactor.png"); - m_PropSprites[ECellProp.CoolingPump] = await LoadCanvasBitmapAsync(sender, "Images", "Props", "cooling-pump.png"); - m_PropSprites[ECellProp.Generator] = await LoadCanvasBitmapAsync(sender, "Images", "Props", "generator.png"); - m_PropSprites[ECellProp.PressureRegulator] = await LoadCanvasBitmapAsync(sender, "Images", "Props", "pressure-regulator.png"); - m_PropSprites[ECellProp.DiagnosticTerminal] = await LoadCanvasBitmapAsync(sender, "Images", "Props", "diagnostic-terminal.png"); - m_PropSprites[ECellProp.ControlTerminal] = await LoadCanvasBitmapAsync(sender, "Images", "Props", "control-terminal.png"); - - m_PipeTilemaps[EPipeMedium.Pressure] = await LoadCanvasBitmapAsync(sender, "Images", "Pipes", "pipe-pressure-tilemap.png"); - m_PipeTilemaps[EPipeMedium.Coolant] = await LoadCanvasBitmapAsync(sender, "Images", "Pipes", "pipe-coolant-tilemap.png"); - m_PipeTilemaps[EPipeMedium.Fuel] = await LoadCanvasBitmapAsync(sender, "Images", "Pipes", "pipe-fuel-tilemap.png"); - } - - private static async Task LoadCanvasBitmapAsync(CanvasControl sender, params string[] pathParts) - { - var path = Path.Combine([AppContext.BaseDirectory, .. pathParts]); - return await CanvasBitmap.LoadAsync(sender, path); - } - - private void ToolToggle_Checked(object sender, RoutedEventArgs e) - { - if ((sender as FrameworkElement)?.DataContext is EditorToolViewModel tool) - { - m_SelectedTool = tool.Tool; - foreach (var editorTool in m_EditorTools) - editorTool.IsSelected = editorTool == tool; - } - } - - private void New_Click(object sender, RoutedEventArgs e) - { - m_Level = BuildStarterLevel(); - m_CurrentFile = null; - m_SelectedCell = null; - RefreshInspector(); - LevelCanvas.Invalidate(); - } - - private async void Open_Click(object sender, RoutedEventArgs args) - { - try - { - var picker = new FileOpenPicker(); - InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this)); - picker.FileTypeFilter.Add(".json"); - - var file = await picker.PickSingleFileAsync(); - if (file is null) - return; - - var json = await FileIO.ReadTextAsync(file); - m_Level = LevelSerializer.Deserialize(json); - m_Level = m_Level with { Forecasts = m_Simulation.Forecast(m_Level) }; - m_CurrentFile = file; - m_SelectedCell = null; - RefreshInspector(); - LevelCanvas.Invalidate(); - } - catch (Exception e) - { - var messageDialog = new MessageDialog(e.Message); - _ = await messageDialog.ShowAsync(); - } - } - - private async void Save_Click(object sender, RoutedEventArgs args) - { - try - { - var file = m_CurrentFile; - if (file is null) - { - var picker = new FileSavePicker(); - InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this)); - picker.SuggestedFileName = m_Level.Name.Replace(' ', '-').ToLowerInvariant(); - picker.FileTypeChoices.Add("Reactor level", [".json"]); - file = await picker.PickSaveFileAsync(); - } - - if (file is null) - return; - - await FileIO.WriteTextAsync(file, LevelSerializer.Serialize(m_Level)); - m_CurrentFile = file; - } - catch (Exception e) - { - var messageDialog = new MessageDialog(e.Message); - _ = await messageDialog.ShowAsync(); - } - } - - private void Simulate_Click(object sender, RoutedEventArgs e) - { - m_Level = m_Simulation.AdvanceTurn(m_Level); - RefreshInspector(); - LevelCanvas.Invalidate(); - } - - private void Activate_Click(object sender, RoutedEventArgs e) - { - m_Level = m_Simulation.ActivateReactor(m_Level); - RefreshInspector(); - LevelCanvas.Invalidate(); - } - - private void LevelCanvas_PointerPressed(object sender, PointerRoutedEventArgs e) - { - var point = e.GetCurrentPoint(LevelCanvas); - if (point.Properties.IsRightButtonPressed) - { - RemovePropAt(point.Position); - e.Handled = true; - return; - } - - if (point.Properties.IsLeftButtonPressed) - { - _ = LevelCanvas.CapturePointer(e.Pointer); - m_LeftPointerDown = true; - m_LeftPointerDownPoint = point.Position; - m_LastPanPoint = point.Position; - m_DragExceededClickThreshold = false; - e.Handled = true; - } - } - - private void LevelCanvas_PointerMoved(object sender, PointerRoutedEventArgs e) - { - var point = e.GetCurrentPoint(LevelCanvas); - if (m_LeftPointerDown) - { - var deltaX = point.Position.X - m_LastPanPoint.X; - var deltaY = point.Position.Y - m_LastPanPoint.Y; - m_LastPanPoint = point.Position; - - var totalDeltaX = point.Position.X - m_LeftPointerDownPoint.X; - var totalDeltaY = point.Position.Y - m_LeftPointerDownPoint.Y; - if (Math.Sqrt((totalDeltaX * totalDeltaX) + (totalDeltaY * totalDeltaY)) > c_ClickPixelThreshold) - m_DragExceededClickThreshold = true; - - m_PanX += deltaX; - m_PanY += deltaY; - ClampPan(); - LevelCanvas.Invalidate(); - e.Handled = true; - return; - } - } - - private void LevelCanvas_PointerReleased(object sender, PointerRoutedEventArgs e) - { - var point = e.GetCurrentPoint(LevelCanvas); - if (m_LeftPointerDown && !m_DragExceededClickThreshold) - SelectOrPaintAt(point.Position); - - m_LeftPointerDown = false; - m_DragExceededClickThreshold = false; - LevelCanvas.ReleasePointerCapture(e.Pointer); - e.Handled = true; - } - - private void LevelCanvas_PointerWheelChanged(object sender, PointerRoutedEventArgs e) - { - var point = e.GetCurrentPoint(LevelCanvas); - var wheelDelta = point.Properties.MouseWheelDelta; - if (wheelDelta == 0) - return; - - ZoomAt(point.Position, wheelDelta > 0 ? c_ZoomStep : 1 / c_ZoomStep); - e.Handled = true; - } - - private void ZoomAt(Point point, double zoomFactor) - { - var oldLayout = GetLayout(); - var cellX = (point.X - oldLayout.OriginX) / oldLayout.CellSize; - var cellY = (point.Y - oldLayout.OriginY) / oldLayout.CellSize; - - m_Zoom = Math.Clamp(m_Zoom * zoomFactor, c_MinZoom, c_MaxZoom); - var newCellSize = GetBaseCellSize() * m_Zoom; - var originWithoutPan = GetCenteredOrigin(newCellSize); - m_PanX = point.X - originWithoutPan.X - (cellX * newCellSize); - m_PanY = point.Y - originWithoutPan.Y - (cellY * newCellSize); - ClampPan(); - LevelCanvas.Invalidate(); - } - - private void SelectOrPaintAt(Point point) - { - if (m_SelectedTool == EEditorTool.Cursor) - SelectAt(point); - else - PaintAt(point); - } - - private void SelectAt(Point point) - { - if (!TryGetGridPosition(point, out var position)) - return; - - m_SelectedCell = position; - RefreshInspector(); - LevelCanvas.Invalidate(); - } - - private void RemovePropAt(Point point) - { - if (!TryGetGridPosition(point, out var position)) - return; - - var cell = m_Level.GetCell(position); - m_SelectedCell = position; - m_Level = m_Level.SetCell(position, cell with { Prop = ECellProp.None }); - m_Level = m_Level with { Forecasts = m_Simulation.Forecast(m_Level) }; - RefreshInspector(); - LevelCanvas.Invalidate(); - } - - private void PaintAt(Point point) - { - if (!TryGetGridPosition(point, out var position)) - return; - - m_SelectedCell = position; - m_Level = LevelEditor.Apply(m_Level, position, m_SelectedTool); - m_Level = m_Level with { Forecasts = m_Simulation.Forecast(m_Level) }; - RefreshInspector(); - LevelCanvas.Invalidate(); - } - - private void LevelCanvas_Draw(CanvasControl sender, CanvasDrawEventArgs args) - { - var drawing = args.DrawingSession; - var layout = GetLayout(); - - drawing.Clear(ColorHelper.FromArgb(255, 16, 18, 21)); - DrawTerrain(drawing, layout); - DrawCellOverlays(drawing, layout); - //DrawGrid(drawing, layout); - DrawRobot(drawing, layout); - } - - private void DrawTerrain(CanvasDrawingSession drawing, CanvasLayout layout) - { - for (var y = 0; y <= m_Level.Height; y++) - { - for (var x = 0; x <= m_Level.Width; x++) - { - DrawDualTerrainTile(drawing, layout.DualTileRect(x, y), GetDualTileMask(x, y)); - } - } - } - - private void DrawCellOverlays(CanvasDrawingSession drawing, CanvasLayout layout) - { - for (var y = 0; y < m_Level.Height; y++) - { - for (var x = 0; x < m_Level.Width; x++) - { - var position = new GridPosition(x, y); - var cell = m_Level.GetCell(position); - var rect = layout.CellRect(x, y); - - DrawPipe(drawing, position, cell, rect); - - if (cell.LeakRate > 0) - DrawImage(drawing, m_LeakSprite, Inset(rect, 0.12)); - - if (cell.Hazards.Heat > 0) - DrawImage(drawing, m_HeatSprite, Inset(rect, 0.08), Math.Clamp(cell.Hazards.Heat / 10.0f, 0.35f, 0.9f)); - - if (cell.Hazards.Fire) - DrawImage(drawing, m_FireSprite, Inset(rect, 0.08)); - - if (m_SelectedCell == position) - drawing.DrawRectangle(rect, Colors.White, 3); - - DrawCellProp(drawing, cell, rect); - } - } - } - - private void DrawPipe(CanvasDrawingSession drawing, GridPosition position, CellState cell, Rect rect) - { - if (!cell.HasPipe || !m_PipeTilemaps.TryGetValue(cell.Pipe, out var tilemap)) - return; - - var sourceRect = PipeTileSourceRect(GetPipeConnectionMask(position, cell.Pipe)); - drawing.DrawImage(tilemap, rect, sourceRect, 1.0f, CanvasImageInterpolation.HighQualityCubic); - } - - private int GetPipeConnectionMask(GridPosition position, EPipeMedium medium) - { - var mask = 0; - if (HasMatchingPipe(position with { Y = position.Y - 1 }, medium)) - mask |= c_NorthConnection; - - if (HasMatchingPipe(position with { X = position.X + 1 }, medium)) - mask |= c_EastConnection; - - if (HasMatchingPipe(position with { Y = position.Y + 1 }, medium)) - mask |= c_SouthConnection; - - if (HasMatchingPipe(position with { X = position.X - 1 }, medium)) - mask |= c_WestConnection; - - return mask; - } - - private bool HasMatchingPipe(GridPosition position, EPipeMedium medium) - { - return m_Level.InBounds(position) && m_Level.GetCell(position).Pipe == medium; - } - - private static Rect PipeTileSourceRect(int connectionMask) - { - var tileIndex = connectionMask switch { - 0 => 0, - c_NorthConnection => 1, - c_EastConnection => 2, - c_SouthConnection => 3, - c_WestConnection => 4, - c_NorthConnection | c_EastConnection => 5, - c_EastConnection | c_SouthConnection => 6, - c_SouthConnection | c_WestConnection => 7, - c_WestConnection | c_NorthConnection => 8, - c_NorthConnection | c_SouthConnection => 9, - c_EastConnection | c_WestConnection => 10, - c_NorthConnection | c_EastConnection | c_SouthConnection => 11, - c_EastConnection | c_SouthConnection | c_WestConnection => 12, - c_SouthConnection | c_WestConnection | c_NorthConnection => 13, - c_WestConnection | c_NorthConnection | c_EastConnection => 14, - c_NorthConnection | c_EastConnection | c_SouthConnection | c_WestConnection => 15, - _ => throw new ArgumentOutOfRangeException(nameof(connectionMask), connectionMask, "Unsupported pipe connection mask.") - }; - - return new( - tileIndex % c_PipeTilemapColumns * c_PipeTilemapTileSize, - tileIndex / c_PipeTilemapColumns * c_PipeTilemapTileSize, - c_PipeTilemapTileSize, - c_PipeTilemapTileSize); - } - - private static void DrawImage(CanvasDrawingSession drawing, CanvasBitmap? image, Rect rect, float opacity = 1) - { - if (image is not null) - drawing.DrawImage(image, rect, image.Bounds, opacity, CanvasImageInterpolation.HighQualityCubic); - } - - private static Rect Inset(Rect rect, double fraction) - { - var inset = rect.Width * fraction; - return new(rect.X + inset, rect.Y + inset, rect.Width - inset * 2, rect.Height - inset * 2); - } - - private void DrawDualTerrainTile(CanvasDrawingSession drawing, Rect rect, int floorMask) - { - if (m_TerrainTilemap is null) - return; - - var wallMask = c_AllCorners ^ floorMask; - var sourceRect = TilemapSourceRect(wallMask); - drawing.DrawImage(m_TerrainTilemap, rect, sourceRect, 1.0f, CanvasImageInterpolation.HighQualityCubic); - } - - private static Rect TilemapSourceRect(int wallMask) - { - var tilePosition = wallMask switch { - c_BottomLeftCorner => new(0, 0), - c_TopRightCorner | c_BottomRightCorner => new(1, 0), - c_TopLeftCorner | c_BottomLeftCorner | c_BottomRightCorner => new(2, 0), - c_BottomLeftCorner | c_BottomRightCorner => new(3, 0), - c_TopLeftCorner | c_BottomRightCorner => new(0, 1), - c_BottomLeftCorner | c_TopRightCorner | c_BottomRightCorner => new(1, 1), - c_AllCorners => new(2, 1), - c_TopLeftCorner | c_BottomLeftCorner | c_TopRightCorner => new(3, 1), - c_TopRightCorner => new(0, 2), - c_TopLeftCorner | c_TopRightCorner => new(1, 2), - c_TopLeftCorner | c_TopRightCorner | c_BottomRightCorner => new(2, 2), - c_BottomLeftCorner | c_TopLeftCorner => new(3, 2), - 0 => new(0, 3), - c_BottomRightCorner => new(1, 3), - c_BottomLeftCorner | c_TopRightCorner => new(2, 3), - c_TopLeftCorner => new GridPosition(3, 3), - _ => throw new ArgumentOutOfRangeException(nameof(wallMask), wallMask, "Unsupported tile mask.") - }; - - return new( - tilePosition.X * c_TilemapTileSize, - tilePosition.Y * c_TilemapTileSize, - c_TilemapTileSize, - c_TilemapTileSize); - } - - private int GetDualTileMask(int x, int y) - { - var mask = 0; - if (GetTerrainOrWall(x - 1, y - 1) == ECellTerrain.Floor) - mask |= c_TopLeftCorner; - - if (GetTerrainOrWall(x, y - 1) == ECellTerrain.Floor) - mask |= c_TopRightCorner; - - if (GetTerrainOrWall(x - 1, y) == ECellTerrain.Floor) - mask |= c_BottomLeftCorner; - - if (GetTerrainOrWall(x, y) == ECellTerrain.Floor) - mask |= c_BottomRightCorner; - - return mask; - } - - private ECellTerrain GetTerrainOrWall(int x, int y) - { - var position = new GridPosition(x, y); - return m_Level.InBounds(position) ? m_Level.GetCell(position).Terrain : ECellTerrain.Wall; - } - - private void DrawCellProp(CanvasDrawingSession drawing, CellState cell, Rect rect) - { - if (m_PropSprites.TryGetValue(cell.Prop, out var sprite)) - drawing.DrawImage(sprite, rect, sprite.Bounds, 1.0f, CanvasImageInterpolation.HighQualityCubic); - } - - private void DrawGrid(CanvasDrawingSession drawing, CanvasLayout layout) - { - for (var x = 0; x <= m_Level.Width; x++) - { - var xPos = (float)(layout.OriginX + (x * layout.CellSize)); - drawing.DrawLine(xPos, (float)layout.OriginY, xPos, (float)(layout.OriginY + (m_Level.Height * layout.CellSize)), ColorHelper.FromArgb(120, 91, 104, 115), 1); - } - - for (var y = 0; y <= m_Level.Height; y++) - { - var yPos = (float)(layout.OriginY + (y * layout.CellSize)); - drawing.DrawLine((float)layout.OriginX, yPos, (float)(layout.OriginX + (m_Level.Width * layout.CellSize)), yPos, ColorHelper.FromArgb(120, 91, 104, 115), 1); - } - } - - private void DrawRobot(CanvasDrawingSession drawing, CanvasLayout layout) - { - var rect = layout.CellRect(m_Level.Robot.X, m_Level.Robot.Y); - DrawImage(drawing, m_RobotSprite, rect); - } - - 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); - position = new(x, y); - return m_Level.InBounds(position); - } - - private CanvasLayout GetLayout() - { - ClampPan(); - var cellSize = GetBaseCellSize() * m_Zoom; - var centeredOrigin = GetCenteredOrigin(cellSize); - return new(cellSize, centeredOrigin.X + m_PanX, centeredOrigin.Y + m_PanY); - } - - private double GetBaseCellSize() - { - var availableWidth = Math.Max(1, LevelCanvas.ActualWidth); - var availableHeight = Math.Max(1, LevelCanvas.ActualHeight); - var size = Math.Floor(Math.Min(availableWidth / m_Level.Width, availableHeight / m_Level.Height)); - return Math.Max(20, size); - } - - private Point GetCenteredOrigin(double cellSize) - { - var availableWidth = Math.Max(1, LevelCanvas.ActualWidth); - var availableHeight = Math.Max(1, LevelCanvas.ActualHeight); - return new((availableWidth - (cellSize * m_Level.Width)) / 2, (availableHeight - (cellSize * m_Level.Height)) / 2); - } - - private void ClampPan() - { - var cellSize = GetBaseCellSize() * m_Zoom; - var contentWidth = cellSize * m_Level.Width; - var contentHeight = cellSize * m_Level.Height; - var availableWidth = Math.Max(1, LevelCanvas.ActualWidth); - var availableHeight = Math.Max(1, LevelCanvas.ActualHeight); - - m_PanX = ClampAxisPan(m_PanX, contentWidth, availableWidth); - m_PanY = ClampAxisPan(m_PanY, contentHeight, availableHeight); - } - - private static double ClampAxisPan(double pan, double contentSize, double availableSize) - { - if (contentSize <= availableSize) - return 0; - - var maxPan = (contentSize - availableSize) / 2; - return Math.Clamp(pan, -maxPan, maxPan); - } - - private void RefreshInspector() - { - LevelNameText.Text = m_Level.Name; - TurnText.Text = m_Level.Global.Turn.ToString(CultureInfo.InvariantCulture); - StatusText.Text = m_Level.Global.Status; - GlobalText.Text = $"Power: {m_Level.Global.Power}/10\n" + $"Cooling: {m_Level.Global.Cooling}/10\n" + $"Core Heat: {m_Level.Global.CoreHeat}/10\n" + $"Facility Stability: {m_Level.Global.FacilityStability}/10"; - - if (m_SelectedCell is { } position && m_Level.InBounds(position)) - { - var cell = m_Level.GetCell(position); - CellText.Text = $"Position: {position.X},{position.Y}\n" + $"Terrain: {cell.Terrain}\n" + $"Prop: {cell.Prop}\n" + $"Pipe: {cell.Pipe}\n" + $"Flow: {cell.Flow}, Pressure: {cell.Pressure}\n" + $"Integrity: {cell.Integrity}, Leak: {cell.LeakRate}\n" + $"Heat: {cell.Hazards.Heat}, Smoke: {cell.Hazards.Smoke}\n" + $"Fuel Vapor: {cell.Hazards.FuelVapor}, Fuel: {cell.Hazards.LiquidFuel}\n" + $"Coolant: {cell.Hazards.CoolantPooling}, Charge: {cell.Hazards.ElectricalCharge}"; - } - else - CellText.Text = "No cell selected."; - - ForecastList.ItemsSource = m_Level.Forecasts.Select(forecast => new ForecastViewModel(FailureIcon(forecast.Kind), forecast.Message)).ToArray(); - } - - private static BitmapImage FailureIcon(EFailureKind kind) - { - return ImageFromOutputPath("Images", "Failures", FailureIconFileName(kind)); - } - - private static string FailureIconFileName(EFailureKind kind) - { - return kind switch { - EFailureKind.PipeBurst => "failure-pipe-burst.png", - EFailureKind.Ignition => "failure-ignition.png", - EFailureKind.Meltdown => "failure-meltdown.png", - EFailureKind.StabilityCollapse => "failure-stability-collapse.png", - EFailureKind.ReactorReady => "failure-reactor-ready.png", - _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unsupported failure kind.") - }; - } - - private static BitmapImage? EditorToolIcon(EEditorTool tool) - { - return tool switch { - EEditorTool.Cursor => PropImage("cursor.png"), - EEditorTool.Floor => PropImage("floor.png"), - EEditorTool.Wall => PropImage("wall.png"), - EEditorTool.Reactor => PropImage("reactor.png"), - EEditorTool.CoolingPump => PropImage("cooling-pump.png"), - EEditorTool.Generator => PropImage("generator.png"), - EEditorTool.PressureRegulator => PropImage("pressure-regulator.png"), - EEditorTool.DiagnosticTerminal => PropImage("diagnostic-terminal.png"), - EEditorTool.ControlTerminal => PropImage("control-terminal.png"), - EEditorTool.CoolantPipe => PipeImage("pipe-coolant-tilemap.png"), - EEditorTool.FuelPipe => PipeImage("pipe-fuel-tilemap.png"), - EEditorTool.PressurePipe => PipeImage("pipe-pressure-tilemap.png"), - EEditorTool.Leak => PropImage("leak.png"), - EEditorTool.Repair => PropImage("repair.png"), - EEditorTool.Heat => PropImage("heat.png"), - EEditorTool.Fire => PropImage("fire.png"), - EEditorTool.Robot => PropImage("robot.png"), - _ => throw new ArgumentOutOfRangeException(nameof(tool), tool, "Unsupported editor tool.") - }; - } - - private static BitmapImage PropImage(string fileName) - { - return ImageFromOutputPath("Images", "Props", fileName); - } - - private static BitmapImage PipeImage(string fileName) - { - return ImageFromOutputPath("Images", "Pipes", fileName); - } - - private static BitmapImage ImageFromOutputPath(params string[] pathParts) - { - return new(new(Path.Combine([AppContext.BaseDirectory, .. pathParts]))); - } - - private static LevelState BuildStarterLevel() - { - var level = LevelState.Create("Cooling Sector B", 16, 12); - level = level.SetCell(new(3, 5), new() { - Prop = ECellProp.CoolingPump, - Pipe = EPipeMedium.Coolant, - Flow = 5, - Pressure = 5, - Powered = true - }); - level = level.SetCell(new(4, 5), new() { - Pipe = EPipeMedium.Coolant, - Flow = 5, - Pressure = 7 - }); - level = level.SetCell(new(5, 5), new() { - Pipe = EPipeMedium.Coolant, - Flow = 3, - Pressure = 8, - LeakRate = 2, - Integrity = 4 - }); - level = level.SetCell(new(6, 5), new() { - Pipe = EPipeMedium.Coolant, - Flow = 3, - Pressure = 7 - }); - level = level.SetCell(new(8, 5), new() { - Prop = ECellProp.Reactor, - Hazards = new() { - Heat = 6, - Stability = 8 - } - }); - level = level.SetCell(new(2, 8), new() { - Prop = ECellProp.Generator, - Pipe = EPipeMedium.Fuel, - Flow = 4, - Pressure = 6, - Powered = true - }); - level = level.SetCell(new(11, 4), new() { - Prop = ECellProp.DiagnosticTerminal, - Powered = true - }); - level = level.SetCell(new(12, 8), new() { - Prop = ECellProp.ControlTerminal, - Powered = true - }); - return level with { Forecasts = new SimulationEngine().Forecast(level) }; - } - - private const int c_TilemapTileSize = 512; - private const int c_PipeTilemapTileSize = 256; - private const int c_PipeTilemapColumns = 4; - private const int c_TopLeftCorner = 1; - private const int c_TopRightCorner = 2; - private const int c_BottomLeftCorner = 4; - private const int c_BottomRightCorner = 8; - private const int c_AllCorners = c_TopLeftCorner | c_TopRightCorner | c_BottomLeftCorner | c_BottomRightCorner; - private const int c_NorthConnection = 1; - private const int c_EastConnection = 2; - private const int c_SouthConnection = 4; - private const int c_WestConnection = 8; - private const double c_MinZoom = 0.5; - private const double c_MaxZoom = 4; - private const double c_ZoomStep = 1.15; - private const double c_ClickPixelThreshold = 10; - - private readonly SimulationEngine m_Simulation = new(); - private readonly Dictionary m_PropSprites = []; - private readonly Dictionary m_PipeTilemaps = []; - private StorageFile? m_CurrentFile; - private LevelState m_Level; - private IReadOnlyList m_EditorTools = []; - private bool m_LeftPointerDown; - private bool m_DragExceededClickThreshold; - private Point m_LeftPointerDownPoint; - private Point m_LastPanPoint; - private GridPosition? m_SelectedCell; - private EEditorTool m_SelectedTool = EEditorTool.Cursor; - private double m_Zoom = 1; - private double m_PanX; - private double m_PanY; - private CanvasBitmap? m_TerrainTilemap; - private CanvasBitmap? m_RobotSprite; - private CanvasBitmap? m_LeakSprite; - private CanvasBitmap? m_HeatSprite; - private CanvasBitmap? m_FireSprite; -} +using Microsoft.Graphics.Canvas; +using Microsoft.Graphics.Canvas.Text; +using Microsoft.Graphics.Canvas.UI.Xaml; +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using ReactorMaintenance.Simulation; +using System.Globalization; +using Windows.Foundation; +using Windows.Storage; +using Windows.Storage.Pickers; +using Windows.UI; +using Windows.UI.Popups; +using WinRT.Interop; + +namespace ReactorMaintenance.Win2D; + +public sealed partial class MainWindow +{ + private sealed record CanvasLayout(double CellSize, double OriginX, double OriginY) + { + public Rect CellRect(GridPosition position) + { + return new(OriginX + position.X * CellSize, OriginY + position.Y * CellSize, CellSize, CellSize); + } + } + + private sealed record ForecastViewModel(string Message); + + private sealed class EditorToolViewModel(EEditorTool tool, string label) + { + public EEditorTool Tool { get; } = tool; + public string Label { get; } = label; + public bool IsSelected { get; set; } + } + + public MainWindow() + { + InitializeComponent(); + + m_Level = BuildStarterLevel(); + m_EditorTools = Enum.GetValues().Select(tool => new EditorToolViewModel(tool, ToolLabel(tool)) { IsSelected = tool == m_SelectedTool }).ToArray(); + ToolPicker.ItemsSource = m_EditorTools; + RefreshInspector(); + } + + private void LevelCanvas_CreateResources(CanvasControl sender, CanvasCreateResourcesEventArgs args) + { + args.TrackAsyncAction(LoadImagesAsync(sender).AsAsyncAction()); + } + + private async Task LoadImagesAsync(CanvasControl 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"); + } + + private static async Task LoadCanvasBitmapAsync(CanvasControl sender, params string[] pathParts) + { + var path = Path.Combine([AppContext.BaseDirectory, .. pathParts]); + return await CanvasBitmap.LoadAsync(sender, path); + } + + private void ToolToggle_Checked(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.DataContext is not EditorToolViewModel tool) + return; + + m_SelectedTool = tool.Tool; + foreach (var editorTool in m_EditorTools) + editorTool.IsSelected = editorTool == tool; + } + + private void New_Click(object sender, RoutedEventArgs e) + { + m_Level = BuildStarterLevel(); + m_CurrentFile = null; + m_SelectedCell = null; + RefreshInspector(); + LevelCanvas.Invalidate(); + } + + private async void Open_Click(object sender, RoutedEventArgs args) + { + try + { + var picker = new FileOpenPicker(); + InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this)); + picker.FileTypeFilter.Add(".json"); + + var file = await picker.PickSingleFileAsync(); + if (file is null) + return; + + var json = await FileIO.ReadTextAsync(file); + var loaded = LevelSerializer.Deserialize(json); + m_Level = loaded with { Forecasts = m_Simulation.Forecast(loaded) }; + m_CurrentFile = file; + m_SelectedCell = null; + RefreshInspector(); + LevelCanvas.Invalidate(); + } + catch (Exception e) + { + var messageDialog = new MessageDialog(e.Message); + _ = await messageDialog.ShowAsync(); + } + } + + private async void Save_Click(object sender, RoutedEventArgs args) + { + try + { + var report = new LevelValidator().Validate(m_Level); + if (!report.IsValid) + throw new InvalidOperationException(report.Errors[0].Message); + + var file = m_CurrentFile; + if (file is null) + { + var picker = new FileSavePicker(); + InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this)); + picker.SuggestedFileName = m_Level.Name.Replace(' ', '-').ToLowerInvariant(); + picker.FileTypeChoices.Add("Reactor level", [".json"]); + file = await picker.PickSaveFileAsync(); + } + + if (file is null) + return; + + await FileIO.WriteTextAsync(file, LevelSerializer.Serialize(m_Level)); + m_CurrentFile = file; + } + catch (Exception e) + { + var messageDialog = new MessageDialog(e.Message); + _ = await messageDialog.ShowAsync(); + } + } + + private void EndTurn_Click(object sender, RoutedEventArgs e) + { + m_Level = m_Simulation.EndTurn(m_Level); + RefreshInspector(); + LevelCanvas.Invalidate(); + } + + private void Interact_Click(object sender, RoutedEventArgs e) + { + m_Level = m_Simulation.InteractProp(m_Level); + RefreshInspector(); + LevelCanvas.Invalidate(); + } + + private void HeatShield_Click(object sender, RoutedEventArgs e) + { + m_Level = m_Simulation.ApplyHeatShield(m_Level); + RefreshInspector(); + LevelCanvas.Invalidate(); + } + + private void Activate_Click(object sender, RoutedEventArgs e) + { + m_Level = m_Simulation.ActivateReactor(m_Level); + RefreshInspector(); + LevelCanvas.Invalidate(); + } + + private void LevelCanvas_PointerPressed(object sender, PointerRoutedEventArgs e) + { + var point = e.GetCurrentPoint(LevelCanvas); + if (point.Properties.IsRightButtonPressed) + { + ClearAt(point.Position); + e.Handled = true; + return; + } + + if (!point.Properties.IsLeftButtonPressed) + return; + + _ = LevelCanvas.CapturePointer(e.Pointer); + m_LeftPointerDown = true; + m_LeftPointerDownPoint = point.Position; + m_LastPanPoint = point.Position; + m_DragExceededClickThreshold = false; + e.Handled = true; + } + + private void LevelCanvas_PointerMoved(object sender, PointerRoutedEventArgs e) + { + 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; + + var totalDeltaX = point.Position.X - m_LeftPointerDownPoint.X; + var totalDeltaY = point.Position.Y - m_LeftPointerDownPoint.Y; + if (Math.Sqrt(totalDeltaX * totalDeltaX + totalDeltaY * totalDeltaY) > c_ClickPixelThreshold) + m_DragExceededClickThreshold = true; + + m_PanX += deltaX; + m_PanY += deltaY; + ClampPan(); + LevelCanvas.Invalidate(); + e.Handled = true; + } + + private void LevelCanvas_PointerReleased(object sender, PointerRoutedEventArgs e) + { + var point = e.GetCurrentPoint(LevelCanvas); + if (m_LeftPointerDown && !m_DragExceededClickThreshold) + SelectOrPaintAt(point.Position); + + m_LeftPointerDown = false; + m_DragExceededClickThreshold = false; + LevelCanvas.ReleasePointerCapture(e.Pointer); + e.Handled = true; + } + + private void LevelCanvas_PointerExited(object sender, PointerRoutedEventArgs e) + { + m_LeftPointerDown = false; + m_DragExceededClickThreshold = false; + } + + private void LevelCanvas_PointerWheelChanged(object sender, PointerRoutedEventArgs e) + { + var point = e.GetCurrentPoint(LevelCanvas); + var wheelDelta = point.Properties.MouseWheelDelta; + if (wheelDelta == 0) + return; + + ZoomAt(point.Position, wheelDelta > 0 ? c_ZoomStep : 1 / c_ZoomStep); + e.Handled = true; + } + + private void SelectOrPaintAt(Point point) + { + if (!TryGetGridPosition(point, out var position)) + return; + + m_SelectedCell = position; + if (m_SelectedTool != EEditorTool.Cursor) + { + m_Level = LevelEditor.Apply(m_Level, position, m_SelectedTool); + m_Level = AutoBindReactors(m_Level); + m_Level = m_Level with { Forecasts = m_Simulation.Forecast(m_Level) }; + } + + RefreshInspector(); + LevelCanvas.Invalidate(); + } + + private void ClearAt(Point point) + { + if (!TryGetGridPosition(point, out var position)) + return; + + m_SelectedCell = position; + m_Level = m_Level.SetProp(position, new()).SetSurface(position, new()) with { + Leaks = m_Level.Leaks.Where(leak => leak.AccessPosition != position && leak.UndergroundPosition != position).ToArray(), + Doors = m_Level.Doors.Where(door => door.A != position && door.B != position).ToArray(), + Reactors = m_Level.Reactors.Where(reactor => reactor.ControlPosition != position).ToArray() + }; + m_Level = m_Level with { Forecasts = m_Simulation.Forecast(m_Level) }; + RefreshInspector(); + LevelCanvas.Invalidate(); + } + + private void LevelCanvas_Draw(CanvasControl sender, CanvasDrawEventArgs args) + { + var drawing = args.DrawingSession; + var layout = GetLayout(); + + drawing.Clear(ColorHelper.FromArgb(255, 16, 18, 21)); + DrawTerrain(drawing, layout); + DrawUnderground(drawing, layout); + DrawSurface(drawing, layout); + DrawDoors(drawing, layout); + DrawProps(drawing, layout); + DrawLeaks(drawing, layout); + DrawRobot(drawing, layout); + DrawGrid(drawing, layout); + } + + private void DrawTerrain(CanvasDrawingSession drawing, CanvasLayout layout) + { + foreach (var position in AllPositions()) + { + var rect = layout.CellRect(position); + var color = m_Level.GetTerrain(position) == ECellTerrain.Wall ? ColorHelper.FromArgb(255, 41, 47, 52) : ColorHelper.FromArgb(255, 32, 38, 42); + drawing.FillRectangle(rect, color); + } + } + + private void DrawUnderground(CanvasDrawingSession drawing, CanvasLayout layout) + { + foreach (var position in AllPositions()) + { + var rect = Inset(layout.CellRect(position), 0.18); + DrawUndergroundCell(drawing, rect, position, ECarrierType.Fuel, c_FuelColor); + DrawUndergroundCell(drawing, Inset(rect, -0.08), position, ECarrierType.Coolant, c_CoolantColor); + DrawUndergroundCell(drawing, Inset(rect, -0.16), position, ECarrierType.Electricity, c_ElectricityColor); + } + } + + private void DrawUndergroundCell(CanvasDrawingSession drawing, Rect rect, GridPosition position, ECarrierType carrier, Color color) + { + var cell = m_Level.GetUnderground(position, carrier); + if (!cell.IsPresent) + return; + + drawing.DrawRectangle(rect, color, cell.State == EUndergroundState.Leaking ? 4 : 2); + if (cell.Amount > 0 || cell.Intensity > 0) + drawing.FillCircle((float)(rect.X + rect.Width / 2), (float)(rect.Y + rect.Height / 2), (float)Math.Max(2, rect.Width * 0.08), color); + } + + private void DrawSurface(CanvasDrawingSession drawing, CanvasLayout layout) + { + foreach (var position in AllPositions().Where(m_Level.IsFloor)) + { + var surface = m_Level.GetSurface(position); + var rect = layout.CellRect(position); + FillHazard(drawing, rect, surface.Fuel, c_FuelColor, 0.08); + FillHazard(drawing, rect, surface.Coolant, c_CoolantColor, 0.18); + FillHazard(drawing, rect, surface.Electricity, c_ElectricityColor, 0.28); + if (surface.Heat > 0) + DrawImage(drawing, m_HeatSprite, Inset(rect, 0.18), Math.Clamp(surface.Heat / Balancing.Current.MaxValue, 0.25f, 0.9f)); + } + } + + private static void FillHazard(CanvasDrawingSession drawing, Rect rect, float amount, Color color, double inset) + { + if (amount <= 0) + return; + + var alpha = (byte)Math.Clamp(40 + amount / Balancing.Current.MaxValue * 130, 40, 170); + drawing.FillRectangle(Inset(rect, inset), ColorHelper.FromArgb(alpha, color.R, color.G, color.B)); + } + + private void DrawDoors(CanvasDrawingSession drawing, CanvasLayout layout) + { + foreach (var door in m_Level.Doors) + { + var centerA = Center(layout.CellRect(door.A)); + var centerB = Center(layout.CellRect(door.B)); + drawing.DrawLine((float)centerA.X, (float)centerA.Y, (float)centerB.X, (float)centerB.Y, door.State == EDoorState.Open ? Colors.LightGreen : Colors.OrangeRed, 5); + } + } + + private void DrawProps(CanvasDrawingSession drawing, CanvasLayout layout) + { + foreach (var position in AllPositions()) + { + var prop = m_Level.GetProp(position); + if (prop.Type == EPropType.None) + continue; + + var rect = Inset(layout.CellRect(position), 0.18); + drawing.FillRoundedRectangle(rect, 4, 4, PropColor(prop)); + DrawCenteredText(drawing, PropLabel(prop), rect, Colors.White, Math.Max(10, (float)(layout.CellSize * 0.22))); + } + } + + private void DrawLeaks(CanvasDrawingSession drawing, CanvasLayout layout) + { + foreach (var leak in m_Level.Leaks.Where(leak => !leak.Repaired)) + DrawImage(drawing, m_LeakSprite, Inset(layout.CellRect(leak.AccessPosition), 0.1)); + } + + private void DrawRobot(CanvasDrawingSession drawing, CanvasLayout layout) + { + DrawImage(drawing, m_RobotSprite, Inset(layout.CellRect(m_Level.Robot.Position), 0.04)); + } + + private void DrawGrid(CanvasDrawingSession drawing, CanvasLayout layout) + { + foreach (var position in AllPositions()) + { + var rect = layout.CellRect(position); + drawing.DrawRectangle(rect, ColorHelper.FromArgb(90, 91, 104, 115), 1); + if (m_SelectedCell == position) + drawing.DrawRectangle(rect, Colors.White, 3); + } + } + + private static void DrawCenteredText(CanvasDrawingSession drawing, string text, Rect rect, Color color, float fontSize) + { + using var format = new CanvasTextFormat { + FontSize = fontSize, + HorizontalAlignment = CanvasHorizontalAlignment.Center, + VerticalAlignment = CanvasVerticalAlignment.Center, + WordWrapping = CanvasWordWrapping.NoWrap + }; + drawing.DrawText(text, rect, color, format); + } + + 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); + position = new(x, y); + return m_Level.InBounds(position); + } + + private CanvasLayout GetLayout() + { + ClampPan(); + var cellSize = GetBaseCellSize() * m_Zoom; + var centeredOrigin = GetCenteredOrigin(cellSize); + return new(cellSize, centeredOrigin.X + m_PanX, centeredOrigin.Y + m_PanY); + } + + private double GetBaseCellSize() + { + var availableWidth = Math.Max(1, LevelCanvas.ActualWidth); + var availableHeight = Math.Max(1, LevelCanvas.ActualHeight); + var size = Math.Floor(Math.Min(availableWidth / m_Level.Width, availableHeight / m_Level.Height)); + return Math.Max(20, size); + } + + private Point GetCenteredOrigin(double cellSize) + { + var availableWidth = Math.Max(1, LevelCanvas.ActualWidth); + var availableHeight = Math.Max(1, LevelCanvas.ActualHeight); + return new((availableWidth - cellSize * m_Level.Width) / 2, (availableHeight - cellSize * m_Level.Height) / 2); + } + + private void ClampPan() + { + var cellSize = GetBaseCellSize() * m_Zoom; + m_PanX = ClampAxisPan(m_PanX, cellSize * m_Level.Width, Math.Max(1, LevelCanvas.ActualWidth)); + m_PanY = ClampAxisPan(m_PanY, cellSize * m_Level.Height, Math.Max(1, LevelCanvas.ActualHeight)); + } + + private static double ClampAxisPan(double pan, double contentSize, double availableSize) + { + if (contentSize <= availableSize) + return 0; + + var maxPan = (contentSize - availableSize) / 2; + return Math.Clamp(pan, -maxPan, maxPan); + } + + private void ZoomAt(Point point, double zoomFactor) + { + var oldLayout = GetLayout(); + var cellX = (point.X - oldLayout.OriginX) / oldLayout.CellSize; + var cellY = (point.Y - oldLayout.OriginY) / oldLayout.CellSize; + + m_Zoom = Math.Clamp(m_Zoom * zoomFactor, c_MinZoom, c_MaxZoom); + var newCellSize = GetBaseCellSize() * m_Zoom; + var originWithoutPan = GetCenteredOrigin(newCellSize); + m_PanX = point.X - originWithoutPan.X - cellX * newCellSize; + m_PanY = point.Y - originWithoutPan.Y - cellY * newCellSize; + ClampPan(); + LevelCanvas.Invalidate(); + } + + private void RefreshInspector() + { + LevelNameText.Text = m_Level.Name; + TurnText.Text = m_Level.Global.Turn.ToString(CultureInfo.InvariantCulture); + StatusText.Text = $"{m_Level.Global.LevelState}: {m_Level.Global.Status}"; + GlobalText.Text = $"Actions: {m_Level.Global.ActionsRemaining}/{Balancing.Current.ActionsPerTurn}\n" + + $"All-seeing-eye: {(m_Level.Global.AllSeeingEyeUnlocked ? "unlocked" : "locked")}\n" + + $"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" + + $"Reactors: {m_Level.Reactors.Count}, Leaks: {m_Level.Leaks.Count}, Doors: {m_Level.Doors.Count}"; + + CellText.Text = m_SelectedCell is { } position && m_Level.InBounds(position) ? CellInspectionText(position) : "No cell selected."; + ForecastList.ItemsSource = m_Level.Forecasts.Select(forecast => new ForecastViewModel($"{forecast.Turns}: {forecast.Message}")).ToArray(); + } + + private string CellInspectionText(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.Carrier} {prop.SwitchState} {prop.ServiceState}\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}"; + } + + private static string UndergroundText(UndergroundCell cell) + { + return $"{cell.State} amount {Format(cell.Amount)} intensity {Format(cell.Intensity)}"; + } + + private static string Format(float value) + { + return value.ToString("0.0", CultureInfo.InvariantCulture); + } + + private static LevelState BuildStarterLevel() + { + var level = LevelState.Create("Cooling Sector B", 16, 12); + level = AddNetwork(level, ECarrierType.Fuel, new(2, 3), new(5, 3)); + level = AddNetwork(level, ECarrierType.Coolant, new(2, 5), new(5, 5)); + level = AddNetwork(level, ECarrierType.Electricity, new(2, 7), new(5, 7)); + level = level.SetProp(new(2, 3), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel }); + level = level.SetProp(new(2, 5), new() { Type = EPropType.Flow, Carrier = ECarrierType.Coolant }); + level = level.SetProp(new(2, 7), new() { Type = EPropType.Flow, Carrier = ECarrierType.Electricity }); + level = level.SetProp(new(5, 3), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Fuel }); + level = level.SetProp(new(5, 5), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Coolant }); + level = level.SetProp(new(5, 7), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Electricity }); + level = level.SetProp(new(10, 5), new() { Type = EPropType.ReactorControl, ReactorId = 1 }); + level = level.SetProp(new(11, 4), new() { Type = EPropType.AllSeeingEyeTerminal }); + level = level.SetProp(new(11, 6), new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.HeatShield }); + level = level.SetUnderground(new(4, 5), ECarrierType.Coolant, new() { State = EUndergroundState.Leaking }) with { + Leaks = [new LeakState { Carrier = ECarrierType.Coolant, UndergroundPosition = new(4, 5), AccessPosition = new(4, 5) }], + Doors = [new DoorState { A = new(8, 5), B = new(9, 5), State = EDoorState.Closed }], + Robot = new() { Position = new(10, 5) }, + Reactors = [ + new ReactorBinding { + ReactorId = 1, + ControlPosition = new(10, 5), + FuelConsumerPosition = new(5, 3), + CoolantConsumerPosition = new(5, 5), + ElectricityConsumerPosition = new(5, 7) + } + ] + }; + + return level with { Forecasts = new SimulationEngine().Forecast(level) }; + } + + private static LevelState AddNetwork(LevelState level, ECarrierType carrier, GridPosition start, GridPosition end) + { + var minX = Math.Min(start.X, end.X); + var maxX = Math.Max(start.X, end.X); + var minY = Math.Min(start.Y, end.Y); + var maxY = Math.Max(start.Y, end.Y); + for (var y = minY; y <= maxY; y++) + { + for (var x = minX; x <= maxX; x++) + level = level.SetUnderground(new(x, y), carrier, new() { State = EUndergroundState.Intact }); + } + + return level; + } + + private static LevelState AutoBindReactors(LevelState level) + { + if (level.Reactors.Count == 0) + return level; + + var fuel = FirstConsumer(level, ECarrierType.Fuel); + var coolant = FirstConsumer(level, ECarrierType.Coolant); + var electricity = FirstConsumer(level, ECarrierType.Electricity); + var reactors = level.Reactors.Select(reactor => reactor with { + FuelConsumerPosition = fuel ?? reactor.FuelConsumerPosition, + CoolantConsumerPosition = coolant ?? reactor.CoolantConsumerPosition, + ElectricityConsumerPosition = electricity ?? reactor.ElectricityConsumerPosition + }) + .ToArray(); + return level with { Reactors = reactors }; + } + + private static GridPosition? FirstConsumer(LevelState level, ECarrierType carrier) + { + return AllPositions(level).FirstOrDefault(position => level.GetProp(position) is { Type: EPropType.Consumer, Carrier: var propCarrier } && propCarrier == carrier); + } + + private IEnumerable AllPositions() + { + return AllPositions(m_Level); + } + + private static IEnumerable AllPositions(LevelState level) + { + for (var y = 0; y < level.Height; y++) + { + for (var x = 0; x < level.Width; x++) + yield return new(x, y); + } + } + + private static Rect Inset(Rect rect, double fraction) + { + var inset = rect.Width * fraction; + return new(rect.X + inset, rect.Y + inset, rect.Width - inset * 2, rect.Height - inset * 2); + } + + private static Point Center(Rect rect) + { + return new(rect.X + rect.Width / 2, rect.Y + rect.Height / 2); + } + + private static void DrawImage(CanvasDrawingSession drawing, CanvasBitmap? image, Rect rect, float opacity = 1) + { + if (image is not null) + drawing.DrawImage(image, rect, image.Bounds, opacity); + } + + private static Color PropColor(PropState prop) + { + return prop.Type switch { + EPropType.Flow => CarrierColor(prop.Carrier), + EPropType.Consumer => ColorHelper.FromArgb(255, 93, 123, 170), + EPropType.TJunction or EPropType.CrossJunction => ColorHelper.FromArgb(255, 143, 111, 178), + EPropType.Door => ColorHelper.FromArgb(255, 187, 119, 55), + EPropType.AllSeeingEyeTerminal => ColorHelper.FromArgb(255, 85, 151, 156), + EPropType.RemedySupply => ColorHelper.FromArgb(255, 76, 145, 86), + EPropType.ReactorControl => ColorHelper.FromArgb(255, 177, 72, 73), + _ => Colors.Gray + }; + } + + private static Color CarrierColor(ECarrierType carrier) + { + return carrier switch { + ECarrierType.Fuel => c_FuelColor, + ECarrierType.Coolant => c_CoolantColor, + ECarrierType.Electricity => c_ElectricityColor, + _ => Colors.White + }; + } + + private static string PropLabel(PropState prop) + { + return prop.Type switch { + EPropType.Flow => $"{CarrierShort(prop.Carrier)} SRC", + EPropType.Consumer => $"{CarrierShort(prop.Carrier)} CON", + EPropType.TJunction => $"T {prop.TJunctionMode}", + EPropType.CrossJunction => $"X {prop.CrossJunctionMode}", + EPropType.Door => "DOOR", + EPropType.AllSeeingEyeTerminal => "EYE", + EPropType.RemedySupply => RemedyShort(prop.RemedyType), + EPropType.ReactorControl => "REACT", + _ => string.Empty + }; + } + + private static string CarrierShort(ECarrierType carrier) + { + return carrier switch { + ECarrierType.Fuel => "F", + ECarrierType.Coolant => "C", + ECarrierType.Electricity => "E", + _ => "?" + }; + } + + private static string RemedyShort(ERemedyType remedy) + { + return remedy switch { + ERemedyType.FuelNeutralizer => "F REM", + ERemedyType.CoolantNeutralizer => "C REM", + ERemedyType.ElectricityNeutralizer => "E REM", + ERemedyType.HeatShield => "H SHD", + _ => "REM" + }; + } + + private static string ToolLabel(EEditorTool tool) + { + return tool switch { + EEditorTool.FuelUnderground => "Fuel Net", + EEditorTool.CoolantUnderground => "Coolant Net", + EEditorTool.ElectricityUnderground => "Electric Net", + EEditorTool.FuelFlow => "Fuel Source", + EEditorTool.CoolantFlow => "Coolant Source", + EEditorTool.ElectricityFlow => "Electric Source", + EEditorTool.FuelConsumer => "Fuel Consumer", + EEditorTool.CoolantConsumer => "Coolant Consumer", + EEditorTool.ElectricityConsumer => "Electric Consumer", + EEditorTool.AllSeeingEyeTerminal => "Eye Terminal", + EEditorTool.FuelRemedySupply => "Fuel Remedy", + EEditorTool.CoolantRemedySupply => "Coolant Remedy", + EEditorTool.ElectricityRemedySupply => "Electric Remedy", + EEditorTool.HeatRemedySupply => "Heat Shield", + EEditorTool.ReactorControl => "Reactor", + EEditorTool.FuelHazard => "Fuel Hazard", + EEditorTool.CoolantHazard => "Coolant Hazard", + EEditorTool.ElectricityHazard => "Electric Hazard", + _ => tool.ToString() + }; + } + + private const double c_MinZoom = 0.5; + private const double c_MaxZoom = 4; + private const double c_ZoomStep = 1.15; + private const double c_ClickPixelThreshold = 10; + private static readonly Color c_FuelColor = ColorHelper.FromArgb(255, 220, 170, 68); + 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 SimulationEngine m_Simulation = new(); + private StorageFile? m_CurrentFile; + private LevelState m_Level; + private IReadOnlyList m_EditorTools = []; + private bool m_LeftPointerDown; + private bool m_DragExceededClickThreshold; + private Point m_LeftPointerDownPoint; + private Point m_LastPanPoint; + private GridPosition? m_SelectedCell; + private EEditorTool m_SelectedTool = EEditorTool.Cursor; + private double m_Zoom = 1; + private double m_PanX; + private double m_PanY; + private CanvasBitmap? m_TerrainTilemap; + private CanvasBitmap? m_RobotSprite; + private CanvasBitmap? m_LeakSprite; + private CanvasBitmap? m_HeatSprite; +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Win2D/ReactorMaintenance.Win2D.csproj b/src/ReactorMaintenance.Win2D/ReactorMaintenance.Win2D.csproj index 3d8ca5a..8eeddc1 100644 --- a/src/ReactorMaintenance.Win2D/ReactorMaintenance.Win2D.csproj +++ b/src/ReactorMaintenance.Win2D/ReactorMaintenance.Win2D.csproj @@ -1,7 +1,7 @@  WinExe - net8.0-windows10.0.19041.0 + net10.0-windows10.0.19041.0 10.0.17763.0 ReactorMaintenance.Win2D app.manifest @@ -16,9 +16,9 @@ - - - + + + diff --git a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs index 465a09b..94a45ab 100644 --- a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs +++ b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs @@ -200,4 +200,4 @@ public sealed class SimulationEngineTests } private readonly SimulationEngine m_Engine = new(); -} +} \ No newline at end of file