diff --git a/TASKS.md b/TASKS.md index 9820278..cd169ba 100644 --- a/TASKS.md +++ b/TASKS.md @@ -45,7 +45,7 @@ The Godot frontend (src/ReactorMaintenance.Godot) has a complete UX scaffold (sp **Goal:** Replace the grid placeholder with a functional, interactive tile-based viewport. -- [ ] **Task 2.1: Create GridViewport control** +- [x] **Task 2.1: Create GridViewport control** - Location: src/ReactorMaintenance.Godot/Controls/GridViewport.cs - Custom Control that renders the level grid via CanvasItem._draw() - Supports configurable tile size (default 48px) @@ -53,35 +53,35 @@ The Godot frontend (src/ReactorMaintenance.Godot) has a complete UX scaffold (sp - Exposes SelectedCell, RobotPosition, HoveredCell properties - Fires OnCellSelected, OnCellHovered, OnGridClicked signals -- [ ] **Task 2.2: Implement terrain rendering** +- [x] **Task 2.2: Implement terrain rendering** - Draw floor tiles with graphite/steel palette per ART.md - Draw wall tiles with darker steel, reinforced silhouettes - Apply subtle hue shifts between adjacent floor plates (no checkerboard) - Support layer opacity rules (surface full / underground 25% or 50%) -- [ ] **Task 2.3: Implement prop rendering** +- [x] **Task 2.3: Implement prop rendering** - Draw props at cell centers with semantic colors - Show state indicators (enabled/disabled, producing/starved) - Use generated asset textures where available, fall back to procedural badges - Support prop highlight for selected cell -- [ ] **Task 2.4: Implement robot marker** +- [x] **Task 2.4: Implement robot marker** - Draw robot sprite at current RobotPosition - Show direction indicator - Use maintenance_robot.png from Assets/Characters - Animate on position change (optional, low priority) -- [ ] **Task 2.5: Implement hazard rendering** +- [x] **Task 2.5: Implement hazard rendering** - Draw surface hazards on affected floor cells - Use semantic colors: fuel (red slicks), coolant (blue-cyan), electricity (yellow arcs), heat (orange-white) - Show hazard intensity via saturation/opacity -- [ ] **Task 2.6: Implement cell selection highlight** +- [x] **Task 2.6: Implement cell selection highlight** - Draw selection rectangle around selected cell - Draw reachable/actionable hints on valid move targets - Support hover highlight on non-selected cells -- [ ] **Task 2.7: Implement underground network visualization** +- [x] **Task 2.7: Implement underground network visualization** - Draw carrier-colored network lines between adjacent cells with positive flow - Fuel = red, Coolant = blue, Electricity = yellow - Render sources as large centered dots diff --git a/src/ReactorMaintenance.Godot/Assets/Terrain/terrain_tilemap.png b/src/ReactorMaintenance.Godot/Assets/Terrain/terrain_tilemap.png new file mode 100644 index 0000000..9ca784c Binary files /dev/null and b/src/ReactorMaintenance.Godot/Assets/Terrain/terrain_tilemap.png differ diff --git a/src/ReactorMaintenance.Godot/Assets/Terrain/terrain_tilemap.png.import b/src/ReactorMaintenance.Godot/Assets/Terrain/terrain_tilemap.png.import new file mode 100644 index 0000000..cca53b6 --- /dev/null +++ b/src/ReactorMaintenance.Godot/Assets/Terrain/terrain_tilemap.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dlisn0ksvlt2v" +path="res://.godot/imported/terrain_tilemap.png-37fc7f48e81d272b617dcdd0ccab8c0e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/Terrain/terrain_tilemap.png" +dest_files=["res://.godot/imported/terrain_tilemap.png-37fc7f48e81d272b617dcdd0ccab8c0e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/ReactorMaintenance.Godot/Controls/FrontendAssets.cs b/src/ReactorMaintenance.Godot/Controls/FrontendAssets.cs index 1f61a03..f3e7cc2 100644 --- a/src/ReactorMaintenance.Godot/Controls/FrontendAssets.cs +++ b/src/ReactorMaintenance.Godot/Controls/FrontendAssets.cs @@ -1,4 +1,4 @@ -using Godot; +using Godot; namespace ReactorMaintenance.Godot.Controls; @@ -28,4 +28,5 @@ internal static class FrontendAssets public const string PrimaryButtonAccent = "res://Assets/Ui/primary_button_accent.png"; public const string ScannerEyeIcon = "res://Assets/Ui/scanner_eye_icon.png"; public const string StateBadgeFrame = "res://Assets/Ui/state_badge_frame.png"; + public const string TerrainTilemap = "res://Assets/Terrain/terrain_tilemap.png"; } \ No newline at end of file diff --git a/src/ReactorMaintenance.Godot/Controls/GridViewport.cs b/src/ReactorMaintenance.Godot/Controls/GridViewport.cs new file mode 100644 index 0000000..6787e94 --- /dev/null +++ b/src/ReactorMaintenance.Godot/Controls/GridViewport.cs @@ -0,0 +1,633 @@ +using Godot; +using ReactorMaintenance.Simulation; + +namespace ReactorMaintenance.Godot.Controls; + +public partial class GridViewport : Control +{ + private readonly record struct SGridLayout(float CellSize, Vector2 Origin) + { + public Rect2 CellRect(GridPosition position) + { + return new(Origin + new Vector2(position.X * CellSize, position.Y * CellSize), new(CellSize, CellSize)); + } + + public Rect2 DualTileRect(int x, int y) + { + return new(Origin + new Vector2((x - 0.5f) * CellSize, (y - 0.5f) * CellSize), new(CellSize, CellSize)); + } + + public Vector2 CellCenter(GridPosition position) + { + return CellRect(position).GetCenter(); + } + } + + public override void _Ready() + { + MouseFilter = MouseFilterEnum.Stop; + FocusMode = FocusModeEnum.Click; + ClipContents = true; + m_TerrainTilemap = FrontendAssets.LoadTexture(FrontendAssets.TerrainTilemap); + m_RobotTexture = FrontendAssets.LoadTexture(FrontendAssets.MaintenanceRobot); + } + + public override void _Draw() + { + DrawRect(new(Vector2.Zero, Size), c_BackgroundColor); + if (m_LevelState is null) + return; + + var layout = GetLayout(); + DrawTerrain(layout, SurfaceOpacity()); + DrawUnderground(layout); + DrawSurfaceHazards(layout, SurfaceOpacity()); + DrawDoors(layout, SurfaceOpacity()); + DrawProps(layout, SurfaceOpacity()); + DrawLeaks(layout, SurfaceOpacity()); + DrawReachableHints(layout); + DrawRobot(layout, SurfaceOpacity()); + DrawGridOverlays(layout); + } + + public override void _GuiInput(InputEvent @event) + { + if (m_LevelState is null) + return; + + if (@event is InputEventMouseMotion motion) + { + if (m_IsPanning) + { + PanOffset += motion.Relative; + QueueRedraw(); + return; + } + + SetHoveredCell(ScreenToCell(motion.Position)); + return; + } + + if (@event is not InputEventMouseButton mouseButton) + return; + + if (mouseButton.ButtonIndex is MouseButton.WheelUp or MouseButton.WheelDown && mouseButton.Pressed) + { + ZoomAt(mouseButton.Position, mouseButton.ButtonIndex == MouseButton.WheelUp ? 1.12f : 1 / 1.12f); + AcceptEvent(); + return; + } + + if (mouseButton.ButtonIndex == MouseButton.Middle) + { + m_IsPanning = mouseButton.Pressed; + AcceptEvent(); + return; + } + + if (mouseButton.ButtonIndex == MouseButton.Left && mouseButton.Pressed) + { + if (ScreenToCell(mouseButton.Position) is { } cell) + { + SelectedCell = cell; + EmitSignal(SignalName.OnCellSelected, cell); + EmitSignal(SignalName.OnGridClicked, cell); + QueueRedraw(); + } + + AcceptEvent(); + } + } + + public void SetLevelState(LevelState levelState) + { + m_LevelState = levelState; + RobotPosition = ToVector(levelState.Robot.Position); + if (!IsValidCell(SelectedCell)) + SelectedCell = RobotPosition; + + QueueRedraw(); + } + + private void DrawTerrain(SGridLayout layout, float opacity) + { + if (m_LevelState is null) + return; + + for (var y = 0; y <= m_LevelState.Height; y++) + { + for (var x = 0; x <= m_LevelState.Width; x++) + DrawDualTerrainTile(layout.DualTileRect(x, y), GetDualTileFloorMask(x, y), opacity); + } + } + + private void DrawDualTerrainTile(Rect2 rect, int floorMask, float opacity) + { + if (m_TerrainTilemap is null) + { + DrawFallbackTerrainTile(rect, floorMask, opacity); + return; + } + + var wallMask = c_AllCorners ^ floorMask; + DrawTextureRectRegion(m_TerrainTilemap, rect, TilemapSourceRect(wallMask), new(1, 1, 1, opacity)); + } + + private void DrawFallbackTerrainTile(Rect2 rect, int floorMask, float opacity) + { + var color = floorMask == c_AllCorners ? new Color(0.13f, 0.16f, 0.17f, opacity) : new Color(0.18f, 0.20f, 0.22f, opacity); + DrawRect(rect, color); + } + + private void DrawUnderground(SGridLayout layout) + { + foreach (var carrier in OrderedUndergroundLayers()) + DrawUndergroundLayer(layout, carrier, CarrierColor(carrier), UndergroundOpacity(carrier)); + } + + private IEnumerable OrderedUndergroundLayers() + { + var carriers = new[] { ECarrierType.Fuel, ECarrierType.Coolant, ECarrierType.Electricity }.Where(IsLayerVisible).ToArray(); + return ActiveUndergroundLayer is { } activeCarrier && carriers.Contains(activeCarrier) + ? carriers.Where(carrier => carrier != activeCarrier).Append(activeCarrier) + : carriers; + } + + private void DrawUndergroundLayer(SGridLayout layout, ECarrierType carrier, Color color, float opacity) + { + if (m_LevelState is null) + return; + + var layerColor = WithOpacity(color, opacity); + var lineWidth = Math.Max(4, layout.CellSize * 0.16f); + var cellDotRadius = Math.Max(2, layout.CellSize * 0.08f); + var sourceDotRadius = Math.Max(5, layout.CellSize * 0.22f); + foreach (var position in AllPositions()) + { + var cell = m_LevelState.GetUnderground(position, carrier); + if (!cell.IsPresent) + continue; + + var center = layout.CellCenter(position); + DrawNetworkConnection(layout, carrier, position, new(position.X + 1, position.Y), layerColor, lineWidth); + DrawNetworkConnection(layout, carrier, position, new(position.X, position.Y + 1), layerColor, lineWidth); + DrawCircle(center, cellDotRadius, layerColor); + + if (cell.State == EUndergroundState.Leaking) + DrawArc(center, sourceDotRadius * 0.7f, 0, Mathf.Tau, 32, c_LeakColor, Math.Max(2, lineWidth * 0.25f)); + + var prop = m_LevelState.GetProp(position); + if (prop is { Type: EPropType.Flow } && prop.Carrier == carrier) + DrawCircle(center, sourceDotRadius, layerColor); + } + } + + private void DrawNetworkConnection(SGridLayout layout, ECarrierType carrier, GridPosition position, GridPosition neighbor, Color color, float lineWidth) + { + if (m_LevelState is null || !m_LevelState.InBounds(neighbor) || !m_LevelState.GetUnderground(neighbor, carrier).IsPresent) + return; + + DrawLine(layout.CellCenter(position), layout.CellCenter(neighbor), color, lineWidth); + } + + private void DrawSurfaceHazards(SGridLayout layout, float opacity) + { + if (m_LevelState is null) + return; + + foreach (var position in AllPositions().Where(m_LevelState.IsFloor)) + { + var surface = m_LevelState.GetSurface(position); + var rect = layout.CellRect(position); + FillHazard(rect, surface.Fuel, c_FuelColor, 0.08f, opacity, Balancing.Current.FuelCaution, Balancing.Current.FuelCritical); + FillHazard(rect, surface.Coolant, c_CoolantColor, 0.18f, opacity, Balancing.Current.CoolantCaution, Balancing.Current.CoolantCritical); + FillHazard(rect, surface.Electricity, c_ElectricityColor, 0.28f, opacity, Balancing.Current.ElectricityCaution, Balancing.Current.ElectricityCritical); + FillHazard(rect, surface.Heat, c_HeatColor, 0.34f, opacity, Balancing.Current.HeatCaution, Balancing.Current.HeatCritical); + } + } + + private void FillHazard(Rect2 rect, float amount, Color color, float inset, float opacity, float caution, float critical) + { + var overlayOpacity = SurfaceOverlayOpacity(amount, caution, critical); + if (overlayOpacity <= 0) + return; + + DrawRect(Inset(rect, inset), WithOpacity(color, overlayOpacity * opacity * 0.68f)); + } + + private void DrawDoors(SGridLayout layout, float opacity) + { + if (m_LevelState is null) + return; + + foreach (var position in AllPositions()) + { + var prop = m_LevelState.GetProp(position); + if (prop.Type != EPropType.Door) + continue; + + var rect = layout.CellRect(position); + var center = layout.CellCenter(position); + var color = WithOpacity(prop.DoorState == EDoorState.Open ? c_ReadyColor : c_LeakColor, opacity); + var width = Math.Max(3, layout.CellSize * 0.1f); + if (IsWall(new(position.X, position.Y - 1)) && IsWall(new(position.X, position.Y + 1))) + DrawLine(new(center.X, rect.Position.Y), new(center.X, rect.End.Y), color, width); + else + DrawLine(new(rect.Position.X, center.Y), new(rect.End.X, center.Y), color, width); + } + } + + private void DrawProps(SGridLayout layout, float opacity) + { + if (m_LevelState is null) + return; + + foreach (var position in AllPositions()) + { + var prop = m_LevelState.GetProp(position); + if (prop.Type == EPropType.None || prop.Type == EPropType.Door) + continue; + + DrawPropBadge(Inset(layout.CellRect(position), 0.18f), prop, opacity); + } + } + + private void DrawPropBadge(Rect2 rect, PropState prop, float opacity) + { + var color = WithOpacity(PropColor(prop), opacity); + DrawRect(rect, color); + DrawRect(rect, WithOpacity(Colors.White, opacity * 0.4f), false, Math.Max(1, rect.Size.X * 0.04f)); + DrawString(ThemeDB.FallbackFont, rect.GetCenter() + new Vector2(-rect.Size.X * 0.3f, rect.Size.Y * 0.09f), PropLabel(prop), HorizontalAlignment.Center, rect.Size.X * 0.6f, (int)Math.Max(9, rect.Size.Y * 0.24f), WithOpacity(Colors.White, opacity)); + + if (prop.Type is EPropType.Flow or EPropType.Consumer) + { + var indicatorColor = prop.IsEnabled ? c_ReadyColor : c_DisabledColor; + DrawCircle(rect.Position + new Vector2(rect.Size.X * 0.82f, rect.Size.Y * 0.18f), Math.Max(3, rect.Size.X * 0.08f), WithOpacity(indicatorColor, opacity)); + } + } + + private void DrawLeaks(SGridLayout layout, float opacity) + { + if (m_LevelState is null) + return; + + foreach (var leak in m_LevelState.Leaks.Where(leak => !leak.Repaired)) + { + var rect = Inset(layout.CellRect(leak.AccessPosition), 0.12f); + DrawArc(rect.GetCenter(), rect.Size.X * 0.32f, 0, Mathf.Tau, 24, WithOpacity(CarrierColor(leak.Carrier), opacity), Math.Max(3, rect.Size.X * 0.08f)); + DrawLine(rect.Position + new Vector2(rect.Size.X * 0.25f, rect.Size.Y * 0.75f), rect.Position + new Vector2(rect.Size.X * 0.75f, rect.Size.Y * 0.25f), WithOpacity(c_LeakColor, opacity), Math.Max(3, rect.Size.X * 0.07f)); + } + } + + private void DrawReachableHints(SGridLayout layout) + { + if (m_LevelState is null || !IsValidCell(RobotPosition)) + return; + + var robot = ToGridPosition(RobotPosition); + foreach (var neighbor in robot.Neighbors().Where(m_LevelState.IsFloor)) + DrawRect(Inset(layout.CellRect(neighbor), 0.29f), c_ReachableColor); + } + + private void DrawRobot(SGridLayout layout, float opacity) + { + if (m_LevelState is null) + return; + + var rect = Inset(layout.CellRect(m_LevelState.Robot.Position), 0.04f); + if (m_RobotTexture is not null) + { + DrawTextureRect(m_RobotTexture, rect, false, new(1, 1, 1, opacity)); + return; + } + + DrawRect(rect, WithOpacity(Colors.White, opacity)); + DrawString(ThemeDB.FallbackFont, rect.GetCenter() + new Vector2(-rect.Size.X * 0.28f, rect.Size.Y * 0.08f), "BOT", HorizontalAlignment.Center, rect.Size.X * 0.56f, (int)Math.Max(10, rect.Size.Y * 0.25f), c_BackgroundColor); + } + + private void DrawGridOverlays(SGridLayout layout) + { + if (m_LevelState is null) + return; + + foreach (var position in AllPositions()) + DrawRect(layout.CellRect(position), c_GridLineColor, false, 1); + + if (IsValidCell(HoveredCell) && HoveredCell != SelectedCell) + DrawRect(layout.CellRect(ToGridPosition(HoveredCell)), c_HoverColor, false, 2); + + if (IsValidCell(SelectedCell)) + DrawRect(layout.CellRect(ToGridPosition(SelectedCell)), c_SelectedColor, false, 3); + } + + private int GetDualTileFloorMask(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) + { + if (m_LevelState is null) + return ECellTerrain.Wall; + + var position = new GridPosition(x, y); + return m_LevelState.InBounds(position) ? m_LevelState.GetTerrain(position) : ECellTerrain.Wall; + } + + private bool IsWall(GridPosition position) + { + return m_LevelState is not null && m_LevelState.InBounds(position) && m_LevelState.GetTerrain(position) == ECellTerrain.Wall; + } + + private Rect2 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(3, 3), + _ => Vector2I.Zero + }; + + return new(tilePosition * c_TilemapTileSize, new Vector2I(c_TilemapTileSize, c_TilemapTileSize)); + } + + private SGridLayout GetLayout() + { + if (m_LevelState is null) + return new(TileSize * Zoom, Vector2.Zero); + + var cellSize = TileSize * Zoom; + var contentSize = new Vector2(m_LevelState.Width * cellSize, m_LevelState.Height * cellSize); + var origin = ((Size - contentSize) / 2) + PanOffset; + return new(cellSize, origin); + } + + private Vector2I? ScreenToCell(Vector2 point) + { + if (m_LevelState is null) + return null; + + var layout = GetLayout(); + var x = Mathf.FloorToInt((point.X - layout.Origin.X) / layout.CellSize); + var y = Mathf.FloorToInt((point.Y - layout.Origin.Y) / layout.CellSize); + var cell = new Vector2I(x, y); + return IsValidCell(cell) ? cell : null; + } + + private void ZoomAt(Vector2 point, float factor) + { + var oldLayout = GetLayout(); + var cell = (point - oldLayout.Origin) / oldLayout.CellSize; + m_Zoom = Math.Clamp(m_Zoom * factor, c_MinZoom, c_MaxZoom); + var newCellSize = TileSize * m_Zoom; + var contentSize = m_LevelState is null ? Vector2.Zero : new(m_LevelState.Width * newCellSize, m_LevelState.Height * newCellSize); + var centeredOrigin = (Size - contentSize) / 2; + m_PanOffset = point - centeredOrigin - (cell * newCellSize); + QueueRedraw(); + } + + private void SetHoveredCell(Vector2I? cell) + { + var next = cell ?? c_InvalidCell; + if (next == HoveredCell) + return; + + HoveredCell = next; + EmitSignal(SignalName.OnCellHovered, HoveredCell); + QueueRedraw(); + } + + private bool IsLayerVisible(ECarrierType carrier) + { + return carrier switch { + ECarrierType.Fuel => ShowFuelLayer, + ECarrierType.Coolant => ShowCoolantLayer, + ECarrierType.Electricity => ShowElectricityLayer, + _ => false + }; + } + + private float SurfaceOpacity() + { + return ActiveUndergroundLayer is null ? 1.0f : 0.5f; + } + + private float UndergroundOpacity(ECarrierType carrier) + { + if (ActiveUndergroundLayer is null) + return 0.25f; + + return ActiveUndergroundLayer == carrier ? 1.0f : 0.25f; + } + + private IEnumerable AllPositions() + { + if (m_LevelState is null) + yield break; + + for (var y = 0; y < m_LevelState.Height; y++) + { + for (var x = 0; x < m_LevelState.Width; x++) + yield return new(x, y); + } + } + + private bool IsValidCell(Vector2I cell) + { + return m_LevelState is not null && cell.X >= 0 && cell.Y >= 0 && cell.X < m_LevelState.Width && cell.Y < m_LevelState.Height; + } + + private static Vector2I ToVector(GridPosition position) + { + return new(position.X, position.Y); + } + + private static GridPosition ToGridPosition(Vector2I cell) + { + return new(cell.X, cell.Y); + } + + private static Rect2 Inset(Rect2 rect, float fraction) + { + var inset = rect.Size.X * fraction; + return new(rect.Position + new Vector2(inset, inset), rect.Size - new Vector2(inset * 2, inset * 2)); + } + + private static float SurfaceOverlayOpacity(float amount, float caution, float critical) + { + if (amount < caution) + return 0; + + if (amount >= critical) + return 0.9f; + + var cautionRange = Math.Max(0.001f, critical - caution); + var t = (amount - caution) / cautionRange; + return 0.3f + (t * 0.35f); + } + + private static Color WithOpacity(Color color, float opacity) + { + return new(color.R, color.G, color.B, color.A * Math.Clamp(opacity, 0, 1)); + } + + 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 Color PropColor(PropState prop) + { + return prop.Type switch { + EPropType.Flow => CarrierColor(prop.Carrier), + EPropType.Consumer => new(0.36f, 0.48f, 0.67f), + EPropType.Junction => new(0.56f, 0.44f, 0.70f), + EPropType.AllSeeingEyeTerminal => new(0.33f, 0.59f, 0.61f), + EPropType.RemedySupply => new(0.30f, 0.57f, 0.34f), + EPropType.ReactorControl => new(0.69f, 0.28f, 0.29f), + _ => Colors.Gray + }; + } + + private static string PropLabel(PropState prop) + { + return prop.Type switch { + EPropType.Flow => $"{CarrierShort(prop.Carrier)} SRC", + EPropType.Consumer => "CON", + EPropType.Junction => $"J {prop.JunctionMode}", + 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" + }; + } + + public int TileSize + { + get => m_TileSize; + set + { + m_TileSize = Math.Max(12, value); + QueueRedraw(); + } + } + + public float Zoom + { + get => m_Zoom; + set + { + m_Zoom = Math.Clamp(value, c_MinZoom, c_MaxZoom); + QueueRedraw(); + } + } + + public Vector2 PanOffset + { + get => m_PanOffset; + set + { + m_PanOffset = value; + QueueRedraw(); + } + } + + public Vector2I SelectedCell { get; set; } = c_InvalidCell; + public Vector2I HoveredCell { get; private set; } = c_InvalidCell; + public Vector2I RobotPosition { get; private set; } = c_InvalidCell; + public bool ShowFuelLayer { get; set; } = true; + public bool ShowCoolantLayer { get; set; } = true; + public bool ShowElectricityLayer { get; set; } = true; + public ECarrierType? ActiveUndergroundLayer { get; set; } + + [Signal] + public delegate void OnCellHoveredEventHandler(Vector2I cell); + + [Signal] + public delegate void OnCellSelectedEventHandler(Vector2I cell); + + [Signal] + public delegate void OnGridClickedEventHandler(Vector2I cell); + + private const float c_MinZoom = 0.5f; + private const float c_MaxZoom = 3.0f; + private const int c_TilemapTileSize = 512; + 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 static readonly Vector2I c_InvalidCell = new(-1, -1); + private static readonly Color c_BackgroundColor = new(0.06f, 0.07f, 0.08f); + private static readonly Color c_CoolantColor = new(0.20f, 0.78f, 0.92f); + private static readonly Color c_DisabledColor = new(0.32f, 0.34f, 0.35f); + private static readonly Color c_ElectricityColor = new(0.96f, 0.78f, 0.20f); + private static readonly Color c_FuelColor = new(0.86f, 0.20f, 0.18f); + private static readonly Color c_GridLineColor = new(0.36f, 0.41f, 0.45f, 0.35f); + private static readonly Color c_HeatColor = new(1.0f, 0.42f, 0.12f); + private static readonly Color c_HoverColor = new(0.60f, 0.74f, 0.86f, 0.72f); + private static readonly Color c_LeakColor = new(1.0f, 0.27f, 0.16f); + private static readonly Color c_ReachableColor = new(0.78f, 0.96f, 0.84f, 0.20f); + private static readonly Color c_ReadyColor = new(0.46f, 0.95f, 0.52f); + private static readonly Color c_SelectedColor = new(1.0f, 1.0f, 1.0f, 0.95f); + + private bool m_IsPanning; + private LevelState? m_LevelState; + private Vector2 m_PanOffset; + private Texture2D? m_RobotTexture; + private Texture2D? m_TerrainTilemap; + private int m_TileSize = 48; + private float m_Zoom = 1; +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Godot/Data/GameSession.cs b/src/ReactorMaintenance.Godot/Data/GameSession.cs index 5f24143..d2b63a2 100644 --- a/src/ReactorMaintenance.Godot/Data/GameSession.cs +++ b/src/ReactorMaintenance.Godot/Data/GameSession.cs @@ -18,15 +18,16 @@ public sealed class GameSession public bool MoveRobot(GridPosition destination) { - if (LevelState.Terrain[(destination.Y * LevelState.Width) + destination.X] == ECellTerrain.Wall) - return false; - - if (destination.X < 0 || destination.X >= LevelState.Width || - destination.Y < 0 || destination.Y >= LevelState.Height) + if (!LevelState.InBounds(destination) || !LevelState.IsFloor(destination) || LevelState.Robot.Position.ManhattanDistance(destination) != 1) return false; + var previousPosition = LevelState.Robot.Position; LevelState = m_Engine.MoveRobot(LevelState, destination); + if (LevelState.Robot.Position == previousPosition) + return false; + RobotMoved?.Invoke(this); + LevelStateChanged?.Invoke(this); return true; } diff --git a/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs b/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs index 91eb461..e987354 100644 --- a/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs +++ b/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs @@ -12,10 +12,13 @@ public partial class LevelScreen : ScreenBase m_App = app; m_Session = session; m_Level = level; + m_LevelCount = levelCount; + m_LevelNumber = levelNumber; m_OutcomeVisible = false; var body = CreatePage(string.Empty); var header = new LevelHeader(); + m_Header = header; body.AddChild(header); header.SetLevel(level.Name, levelNumber, levelCount, session.LevelState.Global.LevelState); @@ -26,8 +29,15 @@ public partial class LevelScreen : ScreenBase var split = new HSplitContainer { SizeFlagsVertical = SizeFlags.ExpandFill }; body.AddChild(split); - m_GridPanel = CreateGridContainer(); - split.AddChild(m_GridPanel); + var gridColumn = new VBoxContainer { + CustomMinimumSize = new(560, 360), + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill + }; + m_GridViewport = CreateGridViewport(); + gridColumn.AddChild(m_GridViewport); + gridColumn.AddChild(CreateLayerControls()); + split.AddChild(gridColumn); m_Inspector = new(); m_ForecastList = new(); @@ -45,16 +55,74 @@ public partial class LevelScreen : ScreenBase UpdateUI(); m_Session.LevelStateChanged += OnLevelStateChanged; + m_Session.RobotMoved += OnRobotMoved; m_Session.TurnAdvanced += OnTurnAdvanced; } - private PanelContainer CreateGridContainer() + private GridViewport CreateGridViewport() { - return new() { + var viewport = new GridViewport { CustomMinimumSize = new(560, 360), SizeFlagsHorizontal = SizeFlags.ExpandFill, SizeFlagsVertical = SizeFlags.ExpandFill }; + viewport.SetLevelState(m_Session!.LevelState); + viewport.OnCellSelected += OnCellSelected; + viewport.OnCellHovered += OnCellHovered; + viewport.OnGridClicked += OnGridClicked; + return viewport; + } + + private Control CreateLayerControls() + { + var controls = new HBoxContainer(); + controls.AddChild(CreateLayerToggle("Fuel", true, pressed => { + if (m_GridViewport is null) return; + + m_GridViewport.ShowFuelLayer = pressed; + m_GridViewport.QueueRedraw(); + })); + controls.AddChild(CreateLayerToggle("Coolant", true, pressed => { + if (m_GridViewport is null) return; + + m_GridViewport.ShowCoolantLayer = pressed; + m_GridViewport.QueueRedraw(); + })); + controls.AddChild(CreateLayerToggle("Electricity", true, pressed => { + if (m_GridViewport is null) return; + + m_GridViewport.ShowElectricityLayer = pressed; + m_GridViewport.QueueRedraw(); + })); + + var activeLayer = new OptionButton(); + activeLayer.AddItem("Surface", 0); + activeLayer.AddItem("Fuel", 1); + activeLayer.AddItem("Coolant", 2); + activeLayer.AddItem("Electricity", 3); + activeLayer.ItemSelected += index => { + if (m_GridViewport is null) return; + + m_GridViewport.ActiveUndergroundLayer = index switch { + 1 => ECarrierType.Fuel, + 2 => ECarrierType.Coolant, + 3 => ECarrierType.Electricity, + _ => null + }; + m_GridViewport.QueueRedraw(); + }; + controls.AddChild(activeLayer); + return controls; + } + + private static CheckBox CreateLayerToggle(string text, bool pressed, Action toggled) + { + var toggle = new CheckBox { + Text = text, + ButtonPressed = pressed + }; + toggle.Toggled += pressedState => toggled(pressedState); + return toggle; } private HBoxContainer CreateActionBar() @@ -111,20 +179,9 @@ public partial class LevelScreen : ScreenBase return; var ls = m_Session.LevelState; - var robotPos = m_Session.RobotPosition; - var index = (robotPos.Y * ls.Width) + robotPos.X; - var surface = ls.Surface[index]; - var prop = index < ls.Props.Length ? ls.Props[index] : new(); - - m_Inspector.SetCellInfo( - robotPos, - ls.Terrain[index], - prop.Type, - prop.ServiceState, - surface.Fuel, - surface.Coolant, - surface.Electricity, - surface.Heat); + m_Header?.SetLevel(m_Level?.Name ?? ls.Name, m_LevelNumber, m_LevelCount, ls.Global.LevelState); + m_GridViewport?.SetLevelState(ls); + UpdateInspector(m_GridViewport?.SelectedCell ?? new Vector2I(ls.Robot.Position.X, ls.Robot.Position.Y)); m_ForecastList.SetForecasts(ls.Forecasts); @@ -135,11 +192,42 @@ public partial class LevelScreen : ScreenBase m_Session.LevelState.Robot.HeatShields); } + private void UpdateInspector(Vector2I cell) + { + if (m_Session is null || m_Inspector is null) + return; + + var ls = m_Session.LevelState; + var position = new GridPosition(cell.X, cell.Y); + if (!ls.InBounds(position)) + { + m_Inspector.SetCellInfo(null, ECellTerrain.Wall, EPropType.None, EConsumerServiceState.Unknown, 0, 0, 0, 0); + return; + } + + var surface = ls.GetSurface(position); + var prop = ls.GetProp(position); + m_Inspector.SetCellInfo( + position, + ls.GetTerrain(position), + prop.Type, + prop.ServiceState, + surface.Fuel, + surface.Coolant, + surface.Electricity, + surface.Heat); + } + private void OnLevelStateChanged(GameSession sender) { UpdateUI(); } + private void OnRobotMoved(GameSession sender) + { + UpdateUI(); + } + private void OnTurnAdvanced(GameSession sender) { UpdateUI(); @@ -176,12 +264,36 @@ public partial class LevelScreen : ScreenBase m_OverlayLayer.AddChild(center); } + private void OnCellSelected(Vector2I cell) + { + UpdateInspector(cell); + } + + private void OnCellHovered(Vector2I cell) + { + if (cell.X >= 0 && cell.Y >= 0) + UpdateInspector(cell); + } + + private void OnGridClicked(Vector2I cell) + { + if (m_Session is null) + return; + + var destination = new GridPosition(cell.X, cell.Y); + if (m_Session.RobotPosition.ManhattanDistance(destination) == 1) + m_Session.MoveRobot(destination); + } + private AppController? m_App; private ForecastList? m_ForecastList; - private PanelContainer? m_GridPanel; + private GridViewport? m_GridViewport; + private LevelHeader? m_Header; private CellInspector? m_Inspector; private InventoryStrip? m_InventoryStrip; private CampaignLevel? m_Level; + private int m_LevelCount; + private int m_LevelNumber; private bool m_OutcomeVisible; private CanvasLayer? m_OverlayLayer; private GameSession? m_Session;