Implement Godot grid viewport rendering

This commit is contained in:
2026-05-13 03:43:05 +02:00
parent b939246ba4
commit 4a3fd37ab4
7 changed files with 819 additions and 32 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

View File

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

View File

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

View File

@@ -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<ECarrierType> 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<GridPosition> 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;
}

View File

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

View File

@@ -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<bool> 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;