Tile highlight and placement validity for building placement #48

Merged
liamjd merged 8 commits from 37-building-placement-validity-and-highlight into main 2026-08-30 08:27:08 +01:00
Owner

Highlights the tiles under the cursor during building placement and shows whether the
site is buildable, green for valid and red for not.

Closes #37

What's here

PlacementRules.evaluate() (entities/buildings/placement_rules.gd) — a pure
static rule, separated from the drawing so it can be tested headless. Returns a
Reason rather than a bool so a future "why can't I build here?" tooltip does not
have to re-derive it. Checks run in the order #37 specified: bounds, then water, then
level spread — bounds first because level_at() on an off-map cell silently returns
_datum_index and would otherwise compute a spread against a fictional tile.

Flatness is the spread across the occupied cells, not TerrainGrid.is_sloped().
is_sloped() answers a pathfinding question about a cell's eight neighbours, which
both fails a flat pad sitting beside a cliff and, for a 1x1, consults only tiles the
building does not occupy.

The highlight — one ImmediateMesh on a single MeshInstance3D, one quad per
footprint cell, rebuilt only when the anchor, building type or validity changes. The
mesh instance takes map.global_transform in _ready(), so vertices are emitted in
raw map-local coordinates with no to_global() anywhere. Colour is carried on the
vertices under one unshaded material, which is what makes per-cell tinting free.

Each quad sits at its own surface_y(level_at(cell)) + 0.02, so the highlight
steps with the terraced ground instead of clipping through it.

The ghost mesh tints to match via its material_override, never written back onto
the mesh itself — BuildingMeshes.build() bakes colour into PrimitiveMesh.material
and #41 will start sharing those meshes.

max_level_spread is an @export on PlacementController, default 0, so the
tolerance can be tuned by eye.

Two things that landed while building it

Cells are inset by a proportion of tile_size (highlight_inset_ratio, default
0.15), leaving a small gap between neighbours so the footprint reads as distinct tiles
rather than one blob. This was tried on a whim and kept because it looks better. It is
a ratio rather than a fixed distance so it scales with the grid and cannot invert the
quads on a small tile_size.

The anchor cell draws inverted when the footprint has more than one cell, which
makes it obvious which tile the cursor actually owns on a large footprint. Marked
temporary in the code — it is a stand-in for real per-cell colouring, and it is ugly
against some tints.

Also adds the five_thick_c footprint (a 5-wide block with an indent) and a Warehouse
that uses it, which is what the multi-cell and non-rectangular cases were tested
against.

Verification

  • 97/97 GUT tests passing, headless, in 3.6s.
  • test/unit/test_placement_rules.gd covers every case #37 asked for: flat 3x3 valid;
    one cell raised → TOO_STEEP at spread 0 and VALID at spread 1; water beating a
    spread that would also fail; overhang off each of the four edges; an L-shape whose
    bounding box covers a bad cell the shape itself does not (guards against
    evaluating bounds() instead of cells()); 1x1 always valid on dry land.
  • Checked by eye, headed, on a multi-level site — gl_compatibility renders
    nothing headless, so none of the drawing is covered by the suite and a green run says
    nothing about how it looks. Confirmed manually: per-cell stepping on terraced ground,
    green/red tinting, red rather than a misplaced highlight when overhanging the map
    edge or covering water, and the highlight disappearing with the ghost on right-click
    cancel and when the cursor is over UI.

Left undone

  • The perimeter border is not implemented — #37 asked for fill plus thin border
    quads at higher alpha. Filed as #47 rather than left as a TODO comment. The inset gap
    covers most of what the border was for at zoom, and the inset changes what "border"
    should mean (per-cell frame vs. one outline around the whole plot), which is a
    look-at-it decision rather than one to settle blind.
  • Reason.MISSING_RESOURCE is in the enum and wired to a tint, but evaluate()
    can never return it and no test covers it. It is a deliberate placeholder for the
    resource rule, which is far-future work.
  • The water check will need revisiting if any building type is ever meant to be
    placed on water; today water is unconditionally invalid.
Highlights the tiles under the cursor during building placement and shows whether the site is buildable, green for valid and red for not. Closes #37 ## What's here **`PlacementRules.evaluate()`** (`entities/buildings/placement_rules.gd`) — a pure static rule, separated from the drawing so it can be tested headless. Returns a `Reason` rather than a `bool` so a future "why can't I build here?" tooltip does not have to re-derive it. Checks run in the order #37 specified: bounds, then water, then level spread — bounds first because `level_at()` on an off-map cell silently returns `_datum_index` and would otherwise compute a spread against a fictional tile. Flatness is the **spread across the occupied cells**, not `TerrainGrid.is_sloped()`. `is_sloped()` answers a pathfinding question about a cell's eight neighbours, which both fails a flat pad sitting beside a cliff and, for a 1x1, consults only tiles the building does not occupy. **The highlight** — one `ImmediateMesh` on a single `MeshInstance3D`, one quad per footprint cell, rebuilt only when the anchor, building type or validity changes. The mesh instance takes `map.global_transform` in `_ready()`, so vertices are emitted in raw map-local coordinates with no `to_global()` anywhere. Colour is carried on the vertices under one unshaded material, which is what makes per-cell tinting free. Each quad sits at its **own** `surface_y(level_at(cell)) + 0.02`, so the highlight steps with the terraced ground instead of clipping through it. **The ghost mesh** tints to match via its `material_override`, never written back onto the mesh itself — `BuildingMeshes.build()` bakes colour into `PrimitiveMesh.material` and #41 will start sharing those meshes. **`max_level_spread`** is an `@export` on `PlacementController`, default `0`, so the tolerance can be tuned by eye. ## Two things that landed while building it **Cells are inset by a proportion of `tile_size`** (`highlight_inset_ratio`, default 0.15), leaving a small gap between neighbours so the footprint reads as distinct tiles rather than one blob. This was tried on a whim and kept because it looks better. It is a ratio rather than a fixed distance so it scales with the grid and cannot invert the quads on a small `tile_size`. **The anchor cell draws inverted** when the footprint has more than one cell, which makes it obvious which tile the cursor actually owns on a large footprint. Marked temporary in the code — it is a stand-in for real per-cell colouring, and it is ugly against some tints. Also adds the `five_thick_c` footprint (a 5-wide block with an indent) and a Warehouse that uses it, which is what the multi-cell and non-rectangular cases were tested against. ## Verification - **97/97 GUT tests passing**, headless, in 3.6s. - `test/unit/test_placement_rules.gd` covers every case #37 asked for: flat 3x3 valid; one cell raised → `TOO_STEEP` at spread 0 and `VALID` at spread 1; water beating a spread that would also fail; overhang off each of the four edges; an L-shape whose *bounding box* covers a bad cell the shape itself does not (guards against evaluating `bounds()` instead of `cells()`); 1x1 always valid on dry land. - **Checked by eye, headed, on a multi-level site** — `gl_compatibility` renders nothing headless, so none of the drawing is covered by the suite and a green run says nothing about how it looks. Confirmed manually: per-cell stepping on terraced ground, green/red tinting, red rather than a misplaced highlight when overhanging the map edge or covering water, and the highlight disappearing with the ghost on right-click cancel and when the cursor is over UI. ## Left undone - **The perimeter border is not implemented** — #37 asked for fill *plus* thin border quads at higher alpha. Filed as #47 rather than left as a TODO comment. The inset gap covers most of what the border was for at zoom, and the inset changes what "border" should mean (per-cell frame vs. one outline around the whole plot), which is a look-at-it decision rather than one to settle blind. - **`Reason.MISSING_RESOURCE`** is in the enum and wired to a tint, but `evaluate()` can never return it and no test covers it. It is a deliberate placeholder for the resource rule, which is far-future work. - **The water check will need revisiting** if any building type is ever meant to be placed on water; today water is unconditionally invalid.
liamjd merged commit 6671992636 into main 2026-08-30 08:27:08 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No project
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!48
No description provided.