When placing a building, highlight the tiles under the cursor and show placement validity #37

Closed
opened 2026-08-24 05:45:27 +01:00 by liamjd · 0 comments
Owner

When placing a building, highlight the tiles under the cursor and show whether the site is valid: green if the ground is flat enough to build on, red if it is not.

This issue owns the tile highlight and the placement validity rule. The ghost mesh and placement mode are #39 (done); rotating the footprint is #44; committing a building to the map is not yet filed.

Prerequisites

  • #39 — PlacementController already computes the hovered anchor cell and iterates BuildingType.footprint.cells() every physics frame. This issue reuses that anchor rather than raycasting again.

Decisions

Flatness is measured within the footprint, not by TerrainGrid.is_sloped()

is_sloped(cell) asks a different question: it compares a cell against its eight neighbours and returns true if any differs by more than max_step_levels. That is a pathfinding query — "is there a cliff next to this tile" — and it is wrong here twice over:

  • it fails a perfectly flat 3x3 pad that happens to sit beside a cliff, and
  • for a 1x1 building it consults only tiles the building does not occupy.

The rule this issue wants is the level spread over the occupied cells:

spread = max(level_at(c) for c in cells) - min(level_at(c) for c in cells)
valid  = spread <= max_level_spread

Expose max_level_spread: int as an @export on PlacementController so it can be tuned by eye, default 0.

On "test how likely a threshold of 0 is": for a 1x1 footprint the spread is always 0, so the check is degenerate and every dry tile is green. It only starts biting at 2x2 and above. sensor_tower and colony_centre are 1x1 today, so testing this needs a multi-tile footprint — use the 3x3 or the L-shape from entities/buildings/resources/footprints/.

Water and off-map are invalid regardless of flatness

Flatness alone is not validity. A footprint is invalid if any occupied cell is:

  • out of bounds — map.in_bounds(cell) is false, or
  • water — map.is_walkable(cell) is false.

Both must be checked before the spread, because level_at() on an out-of-bounds cell silently returns _datum_index rather than erroring, which would quietly compute a spread against a fictional tile.


Gotchas found in the current code

These are the three ways a naive implementation goes wrong here. All three are real, in the code as it stands today.

1. world_to_cell() clamps, so it can never report off-map

return Vector2i(
    clampi(int(floorf(local.x / tile_size)), 0, grid_size - 1),
    ...

The anchor is always in bounds, but anchor + offset for the other footprint cells is not. Bounds rejection has to be an explicit in_bounds() test per cell — you cannot detect it by checking the anchor.

2. Water is drawn at the datum, not at its own level

_paint_cells() places submerged tiles at _datum_index so the sea reads as one surface, but level_at() still returns the seabed level. So surface_y(level_at(cell)) on a water tile gives a Y below the visible water, and a highlight quad placed there disappears under the sea.

height_at() already does this correction. Either reuse it, or reject water cells before you need their height — the latter is simpler, since a water cell makes the whole site invalid anyway.

3. The highlight is not planar

The ground is terraced, so on a mixed-level site each cell's top face is at a different Y. Draw each quad at its own surface_y(level_at(cell)), not at one shared height for the site. The outline then steps with the terrain, which is correct and reads well.


Implementation notes

Rendering: one ImmediateMesh, not a node per tile

Add a single MeshInstance3D child of PlacementController (sibling of the ghost) holding an ImmediateMesh, rebuilt when the highlighted set changes. This avoids pooling and freeing nodes as the cursor moves.

Everything is drawn into one PRIMITIVE_TRIANGLES surface:

  • fill — one quad per cell, alpha ~0.35
  • border — thin quads along the perimeter, alpha ~0.9

Use quads for the border, not PRIMITIVE_LINES. Line width is not adjustable under gl_compatibility — it is always one pixel, so an outline drawn as lines is invisible when zoomed out and never thickens as you zoom in. A quad ~0.06 m wide gives a border with real world-space thickness.

A cell's edge in direction d is on the perimeter iff cell + d is not in the footprint set. That rule handles L-shapes and holes for free; do not assume the footprint is a rectangle just because bounds() returns one.

One material, colour carried on the vertices. An earlier draft of this issue called for a material per surface and swapping albedo_color between the valid and invalid tints. Use vertex colours instead:

material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.cull_mode = BaseMaterial3D.CULL_DISABLED
material.vertex_color_use_as_albedo = true

The highlight has two independent colour axes — fill vs. border (different alpha) and valid vs. invalid (different hue) — which is four materials handled by albedo_color, or one when ImmediateMesh.surface_set_color() carries it. It also makes per-cell colouring free, which is what a later "show me which tile is the bad one" needs.

Three notes on it:

  • vertex_color_use_as_albedo must be true or the colours are silently ignored and everything draws flat white.
  • albedo_color multiplies with the vertex colour, so leave it white.
  • surface_set_color() is state applied to the next vertex added — call it before surface_add_vertex(). Reversed, every colour lands one vertex late, which reads as a bleed between cells rather than an obvious error.

Build the material and the ImmediateMesh once in _ready(); never allocate either per frame.

Vertices are in the mesh instance's space, not the map's

surface_y() and cell.x * tile_size are map-local, so align the two once in _ready():

grid_placement_mesh.global_transform = map.global_transform

Then emit raw map-local coordinates and no to_global() is needed anywhere. It also means the highlight follows the Map if it ever moves off the origin instead of silently desyncing.

Cell cx spans [cx * tile_size, (cx + 1) * tile_size). GridMap.cell_center_x is true, which sounds like a half-cell offset but is not — verified in 4.7.2: map_to_local(Vector3i(0,0,0)) returns (1.0, …) at tile_size 2, i.e. the centre of a cell spanning 0..2, which agrees with both cell_to_world()'s +0.5 and world_to_cell()'s floor().

Offset the quads ~0.02 m above surface_y to avoid z-fighting with the tile top.

Rebuild only when the anchor or the footprint changes

_physics_process runs 60 times a second and the cursor usually sits over the same tile across many frames. Cache the last anchor (and the selected type) and return early when neither has changed. Rebuilding an ImmediateMesh per frame for a 3x3 is affordable, but the early-out is one line and removes the question.

Tint the ghost to match

The ghost's material_override is already built in code in PlacementController._ready(), so its albedo_color can move between the valid and invalid tints alongside the highlight. It must stay a material_override and never be written back onto the mesh — BuildingMeshes.build() bakes colour into PrimitiveMesh.material, and #41 will start sharing those meshes between calls.

Separate the rule from the drawing so it can be tested

gl_compatibility renders nothing headless, so none of the drawing is testable — the highlight has to be verified by eye with a headed screenshot. That makes it worth pushing the decision out of the draw code entirely:

# entities/buildings/placement_rules.gd
class_name PlacementRules

enum Reason { VALID, OUT_OF_BOUNDS, WATER, TOO_STEEP }

static func evaluate(
    map: TerrainGrid, anchor: Vector2i, footprint: BuildingFootprint, max_level_spread: int
) -> Reason

Pure apart from the queries it makes on map, and a test double can supply those: a TestTerrain extends TerrainGrid overriding in_bounds(), level_at() and is_walkable() from a hand-authored array gives full control without generating noise or touching the renderer.

Returning a Reason rather than a bool costs nothing now and means a future "why can't I build here?" tooltip does not have to re-derive it.

Tests (test/unit/test_placement_rules.gd)

  • flat 3x3 → VALID
  • 3x3 with one cell a level higher, max_level_spread = 0 → TOO_STEEP; the same site with max_level_spread = 1 → VALID
  • one footprint cell over water → WATER, and it wins over a spread that would also fail
  • anchor one tile from each of the four map edges with a footprint that overhangs → OUT_OF_BOUNDS
  • an L-shaped footprint whose bounding box covers a bad cell that the shape itself does not → VALID (guards against evaluating bounds() instead of cells())
  • 1x1 footprint → always VALID on dry land, whatever the surrounding terrain

Acceptance criteria

  • Hovering with a building selected highlights exactly the tiles the footprint occupies, each at its own terrain height.
  • The highlight is green on a valid site and red on an invalid one, and the ghost mesh tints to match.
  • Overhanging the map edge, or covering water, shows red rather than a highlight in the wrong place.
  • The highlight disappears with the ghost on right-click cancel and when the cursor is over UI.
  • PlacementRules is covered by the tests above and the suite is green headless.
  • Verified by eye from a headed screenshot on a multi-level site — tools/screenshot_buildings.gd is the working template for the wait-N-frames-then-save pattern.
When placing a building, highlight the tiles under the cursor and show whether the site is valid: green if the ground is flat enough to build on, red if it is not. This issue owns **the tile highlight and the placement validity rule**. The ghost mesh and placement mode are #39 (done); rotating the footprint is #44; committing a building to the map is not yet filed. ## Prerequisites - #39 — `PlacementController` already computes the hovered anchor cell and iterates `BuildingType.footprint.cells()` every physics frame. This issue reuses that anchor rather than raycasting again. --- ## Decisions ### Flatness is measured *within* the footprint, not by `TerrainGrid.is_sloped()` `is_sloped(cell)` asks a different question: it compares a cell against its **eight neighbours** and returns true if any differs by more than `max_step_levels`. That is a pathfinding query — "is there a cliff next to this tile" — and it is wrong here twice over: - it fails a perfectly flat 3x3 pad that happens to sit beside a cliff, and - for a 1x1 building it consults only tiles the building does not occupy. The rule this issue wants is the **level spread over the occupied cells**: ``` spread = max(level_at(c) for c in cells) - min(level_at(c) for c in cells) valid = spread <= max_level_spread ``` Expose `max_level_spread: int` as an `@export` on `PlacementController` so it can be tuned by eye, default `0`. **On "test how likely a threshold of 0 is":** for a 1x1 footprint the spread is always 0, so the check is degenerate and every dry tile is green. It only starts biting at 2x2 and above. `sensor_tower` and `colony_centre` are 1x1 today, so testing this needs a multi-tile footprint — use the 3x3 or the L-shape from `entities/buildings/resources/footprints/`. ### Water and off-map are invalid regardless of flatness Flatness alone is not validity. A footprint is invalid if **any** occupied cell is: - out of bounds — `map.in_bounds(cell)` is false, or - water — `map.is_walkable(cell)` is false. Both must be checked *before* the spread, because `level_at()` on an out-of-bounds cell silently returns `_datum_index` rather than erroring, which would quietly compute a spread against a fictional tile. --- ## Gotchas found in the current code These are the three ways a naive implementation goes wrong here. All three are real, in the code as it stands today. ### 1. `world_to_cell()` clamps, so it can never report off-map ```gdscript return Vector2i( clampi(int(floorf(local.x / tile_size)), 0, grid_size - 1), ... ``` The *anchor* is always in bounds, but `anchor + offset` for the other footprint cells is not. Bounds rejection has to be an explicit `in_bounds()` test per cell — you cannot detect it by checking the anchor. ### 2. Water is drawn at the datum, not at its own level `_paint_cells()` places submerged tiles at `_datum_index` so the sea reads as one surface, but `level_at()` still returns the seabed level. So `surface_y(level_at(cell))` on a water tile gives a Y **below the visible water**, and a highlight quad placed there disappears under the sea. `height_at()` already does this correction. Either reuse it, or reject water cells before you need their height — the latter is simpler, since a water cell makes the whole site invalid anyway. ### 3. The highlight is not planar The ground is terraced, so on a mixed-level site each cell's top face is at a different Y. Draw each quad at **its own** `surface_y(level_at(cell))`, not at one shared height for the site. The outline then steps with the terrain, which is correct and reads well. --- ## Implementation notes ### Rendering: one `ImmediateMesh`, not a node per tile Add a single `MeshInstance3D` child of `PlacementController` (sibling of the ghost) holding an `ImmediateMesh`, rebuilt when the highlighted set changes. This avoids pooling and freeing nodes as the cursor moves. Everything is drawn into one `PRIMITIVE_TRIANGLES` surface: - **fill** — one quad per cell, alpha ~0.35 - **border** — thin quads along the perimeter, alpha ~0.9 Use quads for the border, not `PRIMITIVE_LINES`. Line width is not adjustable under `gl_compatibility` — it is always one pixel, so an outline drawn as lines is invisible when zoomed out and never thickens as you zoom in. A quad ~0.06 m wide gives a border with real world-space thickness. A cell's edge in direction `d` is on the perimeter iff `cell + d` is **not** in the footprint set. That rule handles L-shapes and holes for free; do not assume the footprint is a rectangle just because `bounds()` returns one. **One material, colour carried on the vertices.** An earlier draft of this issue called for a material per surface and swapping `albedo_color` between the valid and invalid tints. Use vertex colours instead: ```gdscript material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA material.cull_mode = BaseMaterial3D.CULL_DISABLED material.vertex_color_use_as_albedo = true ``` The highlight has two independent colour axes — fill vs. border (different alpha) and valid vs. invalid (different hue) — which is four materials handled by `albedo_color`, or one when `ImmediateMesh.surface_set_color()` carries it. It also makes per-cell colouring free, which is what a later "show me *which* tile is the bad one" needs. Three notes on it: - `vertex_color_use_as_albedo` must be `true` or the colours are silently ignored and everything draws flat white. - `albedo_color` multiplies with the vertex colour, so leave it white. - `surface_set_color()` is state applied to the **next** vertex added — call it before `surface_add_vertex()`. Reversed, every colour lands one vertex late, which reads as a bleed between cells rather than an obvious error. Build the material and the `ImmediateMesh` once in `_ready()`; never allocate either per frame. ### Vertices are in the mesh instance's space, not the map's `surface_y()` and `cell.x * tile_size` are map-local, so align the two once in `_ready()`: ```gdscript grid_placement_mesh.global_transform = map.global_transform ``` Then emit raw map-local coordinates and no `to_global()` is needed anywhere. It also means the highlight follows the Map if it ever moves off the origin instead of silently desyncing. Cell `cx` spans `[cx * tile_size, (cx + 1) * tile_size)`. `GridMap.cell_center_x` is `true`, which sounds like a half-cell offset but is not — verified in 4.7.2: `map_to_local(Vector3i(0,0,0))` returns `(1.0, …)` at `tile_size` 2, i.e. the *centre* of a cell spanning 0..2, which agrees with both `cell_to_world()`'s `+0.5` and `world_to_cell()`'s `floor()`. Offset the quads ~0.02 m above `surface_y` to avoid z-fighting with the tile top. ### Rebuild only when the anchor or the footprint changes `_physics_process` runs 60 times a second and the cursor usually sits over the same tile across many frames. Cache the last anchor (and the selected type) and return early when neither has changed. Rebuilding an `ImmediateMesh` per frame for a 3x3 is affordable, but the early-out is one line and removes the question. ### Tint the ghost to match The ghost's `material_override` is already built in code in `PlacementController._ready()`, so its `albedo_color` can move between the valid and invalid tints alongside the highlight. It must stay a `material_override` and never be written back onto the mesh — `BuildingMeshes.build()` bakes colour into `PrimitiveMesh.material`, and #41 will start sharing those meshes between calls. ### Separate the rule from the drawing so it can be tested `gl_compatibility` renders nothing headless, so **none of the drawing is testable** — the highlight has to be verified by eye with a headed screenshot. That makes it worth pushing the decision out of the draw code entirely: ```gdscript # entities/buildings/placement_rules.gd class_name PlacementRules enum Reason { VALID, OUT_OF_BOUNDS, WATER, TOO_STEEP } static func evaluate( map: TerrainGrid, anchor: Vector2i, footprint: BuildingFootprint, max_level_spread: int ) -> Reason ``` Pure apart from the queries it makes on `map`, and a test double can supply those: a `TestTerrain extends TerrainGrid` overriding `in_bounds()`, `level_at()` and `is_walkable()` from a hand-authored array gives full control without generating noise or touching the renderer. Returning a `Reason` rather than a `bool` costs nothing now and means a future "why can't I build here?" tooltip does not have to re-derive it. ## Tests (`test/unit/test_placement_rules.gd`) - flat 3x3 → `VALID` - 3x3 with one cell a level higher, `max_level_spread = 0` → `TOO_STEEP`; the same site with `max_level_spread = 1` → `VALID` - one footprint cell over water → `WATER`, and it wins over a spread that would also fail - anchor one tile from each of the four map edges with a footprint that overhangs → `OUT_OF_BOUNDS` - an L-shaped footprint whose *bounding box* covers a bad cell that the shape itself does not → `VALID` (guards against evaluating `bounds()` instead of `cells()`) - 1x1 footprint → always `VALID` on dry land, whatever the surrounding terrain ## Acceptance criteria - Hovering with a building selected highlights exactly the tiles the footprint occupies, each at its own terrain height. - The highlight is green on a valid site and red on an invalid one, and the ghost mesh tints to match. - Overhanging the map edge, or covering water, shows red rather than a highlight in the wrong place. - The highlight disappears with the ghost on right-click cancel and when the cursor is over UI. - `PlacementRules` is covered by the tests above and the suite is green headless. - Verified by eye from a headed screenshot on a multi-level site — `tools/screenshot_buildings.gd` is the working template for the wait-N-frames-then-save pattern.
liamjd changed title from When placing a building, highlight the titles under the cursor from the building footprint to When placing a building, highlight the tiles under the cursor and show placement validity 2026-08-29 12:41:26 +01:00
liamjd referenced this issue from a commit 2026-08-29 15:22:56 +01:00
Sign in to join this conversation.
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
liamjd/UntitledColonyBuilder#37
No description provided.