Render the entrance arrow during ghost placement #63

Merged
liamjd merged 1 commit from 61-render-entrance-placement-arrow into main 2026-09-05 15:19:01 +01:00
Owner

Renders a flat chevron on the doorstep tile during ghost placement, pointing at the building's door, so the player can see which way the entrance faces while rotating with R and T.

Builds on #55, which added the data model: BuildingFootprint parses an arrow into an anchor-relative doorstep(steps) and a unit entrance_direction(steps).

Changes

BuildingSite.yaw_for(direction) — turns a grid-space direction into the Y rotation for a mesh authored pointing along -Z. It sits beside rotation_for() because it is the same kind of thing (pure site maths, no node, no state) and because a static is testable headless, which the rendering is not.

It negates the ints rather than the floats. -float(0) is -0.0, and atan2(-0.0, -0.0) is a half turn rather than none — the float form pointed a failed-parse footprint backwards. A test pins the zero case.

PlacementController — a third MeshInstance3D sibling of the ghost:

  • Geometry built once in _ready(), not per frame. Unlike the placement highlight, which regenerates because its cells change with every hover, the arrow's shape never varies — only where it stands and which way it faces, and those are the node's transform. Two triangles meeting at a notch, wound so the face normal points at +Y, sized as ratios of tile_size via three new exports.
  • Position from cell_to_world(anchor + doorstep(steps)) plus its own arrow_hover_height. cell_to_world() already sits on the tile's top face, so there is no separate height lookup.
  • Yaw from the direction, not the step count, so the arrow and the footprint cannot disagree however the steps are counted.
  • Material UNSHADED and CULL_DISABLED, for the same reasons the highlight's is: ImmediateMesh carries no normals unless asked, and a lit material with none renders black.
  • Guarded on cells.is_empty() — the same bargain BuildingSite.position_for() makes, so a footprint that failed to parse stays quiet rather than reporting itself sixty times a second.

The arrow keeps one colour rather than following the validity tint. Which way the door faces is true whether or not the site is legal, and a second thing changing colour alongside the ghost reads as noise. Easy to reverse if it plays badly.

Verification

Full GUT suite green: 177/177, 1063 asserts, no orphans. Four new tests on yaw_for: the cardinals by hand, the property that Vector3.FORWARD rotated by the yaw lands on the direction asked for, the doorstep invariant at all four rotations of one_by_three_L, and the zero-direction case.

tools/screenshot_entrance_arrow.gd — straight-down orthographic shot of one_by_three_L at all four rotations, footprint tiles in blue with the anchor lighter, arrow in red. In every one the arrow stands on an unoccupied tile and points into the L:

steps doorstep points lands on
0 (7,6) left (6,6) occupied
1 (16,8) up (16,7) occupied
2 (5,17) right (16,7) occupied
3 (14,15) down (14,16) occupied

It draws no building mesh on purpose — the first attempt did, and an opaque box over the tiles in question makes the shot useless.

Confirmed in the running game by hand: the arrow appears in placement mode, tracks the door through all four rotations at the game's own camera angle, and disappears on cancel. The four exports were tuned there.

Noted while doing this

The GUT suite passed 177/177 while placement_controller.gd had a parse error in it — no test loads that script, so nothing noticed. It was caught by booting game.tscn headed for twenty frames. Every controller change is currently unverified by the suite; a boot check promoted into tools/ would close that gap. Worth its own issue.

Closes #61

🤖 Generated with Claude Code

https://claude.ai/code/session_01DtdF9mqzKi95DHBn9Et1pz

Renders a flat chevron on the doorstep tile during ghost placement, pointing at the building's door, so the player can see which way the entrance faces while rotating with R and T. Builds on #55, which added the data model: `BuildingFootprint` parses an arrow into an anchor-relative `doorstep(steps)` and a unit `entrance_direction(steps)`. ## Changes **`BuildingSite.yaw_for(direction)`** — turns a grid-space direction into the Y rotation for a mesh authored pointing along `-Z`. It sits beside `rotation_for()` because it is the same kind of thing (pure site maths, no node, no state) and because a static is testable **headless**, which the rendering is not. It negates the ints rather than the floats. `-float(0)` is `-0.0`, and `atan2(-0.0, -0.0)` is a half turn rather than none — the float form pointed a failed-parse footprint backwards. A test pins the zero case. **`PlacementController`** — a third `MeshInstance3D` sibling of the ghost: - **Geometry built once** in `_ready()`, not per frame. Unlike the placement highlight, which regenerates because its cells change with every hover, the arrow's shape never varies — only where it stands and which way it faces, and those are the node's transform. Two triangles meeting at a notch, wound so the face normal points at `+Y`, sized as ratios of `tile_size` via three new exports. - **Position** from `cell_to_world(anchor + doorstep(steps))` plus its own `arrow_hover_height`. `cell_to_world()` already sits on the tile's top face, so there is no separate height lookup. - **Yaw** from the direction, not the step count, so the arrow and the footprint cannot disagree however the steps are counted. - **Material** `UNSHADED` and `CULL_DISABLED`, for the same reasons the highlight's is: `ImmediateMesh` carries no normals unless asked, and a lit material with none renders black. - **Guarded** on `cells.is_empty()` — the same bargain `BuildingSite.position_for()` makes, so a footprint that failed to parse stays quiet rather than reporting itself sixty times a second. The arrow keeps one colour rather than following the validity tint. Which way the door faces is true whether or not the site is legal, and a second thing changing colour alongside the ghost reads as noise. Easy to reverse if it plays badly. ## Verification Full GUT suite green: **177/177, 1063 asserts**, no orphans. Four new tests on `yaw_for`: the cardinals by hand, the property that `Vector3.FORWARD` rotated by the yaw lands on the direction asked for, the doorstep invariant at all four rotations of `one_by_three_L`, and the zero-direction case. `tools/screenshot_entrance_arrow.gd` — straight-down orthographic shot of `one_by_three_L` at all four rotations, footprint tiles in blue with the anchor lighter, arrow in red. In every one the arrow stands on an unoccupied tile and points into the L: | steps | doorstep | points | lands on | |---|---|---|---| | 0 | (7,6) | left | (6,6) occupied | | 1 | (16,8) | up | (16,7) occupied | | 2 | (5,17) | right | (16,7) occupied | | 3 | (14,15) | down | (14,16) occupied | It draws no building mesh on purpose — the first attempt did, and an opaque box over the tiles in question makes the shot useless. Confirmed in the running game by hand: the arrow appears in placement mode, tracks the door through all four rotations at the game's own camera angle, and disappears on cancel. The four exports were tuned there. ## Noted while doing this The GUT suite passed 177/177 while `placement_controller.gd` had a **parse error** in it — no test loads that script, so nothing noticed. It was caught by booting `game.tscn` headed for twenty frames. Every controller change is currently unverified by the suite; a boot check promoted into `tools/` would close that gap. Worth its own issue. Closes #61 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DtdF9mqzKi95DHBn9Et1pz
Adds a flat chevron that stands on the doorstep tile and points at the
building's door, following the footprint through rotation.

BuildingSite.yaw_for() turns a grid-space direction into the Y rotation for
a mesh authored pointing along -Z. It sits beside rotation_for() because it
is the same kind of thing - pure site maths both the ghost and a placed
building can call - and because a static is testable headless, which the
rendering is not. It negates the ints rather than the floats: -float(0) is
-0.0, and atan2(-0.0, -0.0) is a half turn rather than none, so the float
form pointed a failed-parse footprint backwards.

The arrow's geometry is built once in _ready(), not per frame. Unlike the
placement highlight, which regenerates because its cells change with every
hover, the arrow's shape never varies - only where it stands and which way
it faces, and those are the node's transform. Two triangles meeting at a
notch, wound so the face normal points at +Y, sized as ratios of tile_size.

Its material is UNSHADED and CULL_DISABLED for the same reasons the
highlight's is: ImmediateMesh carries no normals unless asked, and a lit
material with none renders black.

The arrow keeps one colour rather than following the validity tint. Which
way the door faces is true whether or not the site is legal, and a second
thing changing colour alongside the ghost reads as noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtdF9mqzKi95DHBn9Et1pz
liamjd merged commit 72346fb0c7 into main 2026-09-05 15:19:01 +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!63
No description provided.