Render the entrance arrow during ghost placement #61

Closed
opened 2026-09-05 08:35:35 +01:00 by liamjd · 0 comments
Owner

Split out of #55, which now covers only the data model: BuildingFootprint parses an arrow (< > ^ v) into an anchor-relative doorstep(steps) and a unit entrance_direction(steps), both rotating with the cells via rotate_offset().

During ghost placement mode, render an arrow pointing at the building's entrance so the player can see which way the door faces while rotating.

Approach

  • Add a third MeshInstance3D to PlacementController as a sibling of the ghost mesh, not a child. The ghost is positioned at the centre of its rotated bounds, so a child's offset would be in mesh-local space and coupled to the AABB. As a sibling it can be positioned directly from the grid, the same way GhostMesh and GridPlacementMesh already are.
  • Position: map.cell_to_world(anchor + footprint.doorstep(steps)), plus a small upward lift. Note that TerrainGrid.cell_to_world() already returns the tile centre sitting on its top face (surface_y(level_at(cell)), globalised) — there is no separate surface-height lookup to do.
    • The lift should be its own @export, not the ghost's mesh_hover_height. That is 0.75, tuned for a floating building box; an arrow that high above a bare tile reads as detached rather than as a marker on the ground. The highlight quads sit at +0.02 for comparison.
  • Rotation: derive rotation.y from footprint.entrance_direction(steps) rather than from the step count, so the arrow and the footprint cannot disagree.
    • Grid y maps to world z, and Godot's +Y rotation is counter-clockwise viewed from above — which is why BuildingSite.rotation_for() negates. For a mesh pointing along -Z at yaw 0, the yaw is atan2(-float(dir.x), -float(dir.y)). Checks out on all four: (0,-1) -> 0, (1,0) -> -90 deg, (0,1) -> 180 deg, (-1,0) -> +90 deg.
    • Put this beside rotation_for() as a second static on BuildingSite. That class already exists to hold the pure site maths both the ghost and the placed building call, and a static is testable headless — which the rendering is not. It is the only part of this issue that can have real automated coverage.
  • Mesh: neither a PrismMesh nor a zero-top-radius CylinderMesh works directly — both point along +Y, so an arrow lying flat in the ground plane would need a pitch as well as a yaw, and rotation.x + rotation.y on one node compose in Godot's YXZ Euler order. That is the "plausible-but-wrong orientation" failure CLAUDE.md warns about. Two ways out, both keeping rotation.y as the only thing ever set:
    • Bake the pitch into a child node — EntranceArrow carries the yaw, a MeshInstance3D child carries rotation_degrees = Vector3(-90, 0, 0). Authored as rotation_degrees, never a hand-computed Transform3D.
    • Preferred: build the mesh already flat as an ImmediateMesh triangle/chevron in the XZ plane pointing along -Z, the way _rebuild_highlight() already generates its quads. More consistent with the surrounding code, and a flat chevron reads better than a cone under an orthographic top-down camera.

Not what the first draft of this issue said

Two corrections, recorded so they are not re-derived:

  • "The existing cache check in PlacementController already includes _rotation_steps, so the arrow only needs updating when that check fires." This misreads the code. The ghost's own position and rotation are set every frame, outside that block; the cache exists solely because _rebuild_highlight() regenerates an ImmediateMesh from scratch. Setting a Vector3 and a float per frame costs nothing — update the arrow unconditionally, next to the ghost. What should be built once, in _ready(), is the arrow mesh: unlike the ghost mesh it does not vary by building type, so it never needs rebuilding.
  • "Position ... seated on that tile's surface height plus the same hover lift the ghost uses." Both halves wrong, per the Position bullet above.

Easy to miss

  • _clear_hover() must hide the arrow, and _cancel_placement() must clear it. Every early return in _physics_process() funnels through _clear_hover(), and its docstring already warns about exactly this class of bug.
  • Guard the empty footprint. BuildingSite.position_for() bails on offsets.is_empty() specifically so a failed parse does not push errors 60x/second. The arrow needs the same guard: on a shape that failed to parse, _doorstep is left at Vector2i.MAX and the arrow would be flung to an absurd cell.

Out of scope

  • Placed buildings do not show the arrow by default. Showing it on selection is a later issue.
  • Whether the doorstep tile participates in placement validity (a door against a cliff, off the map edge, or against another building) is a separate rule for PlacementRules, and its own issue. Note that until then a doorstep can fall outside the grid: level_at() clamps out-of-bounds cells to the datum level, so the arrow will render off the map edge rather than error.

Verification

Headless renders nothing under gl_compatibility, so this needs a headed run with a screenshot for each of the four rotations of an asymmetric footprint such as one_by_three_L.

tools/screenshot_buildings.gd is the harness to copy — headed SceneTree, builds a plains map, waits for frames, save_png. Its header marks it THROWAWAY for #36, so write a sibling for this issue rather than editing it.

The BuildingSite yaw static above should get unit tests in the normal GUT suite; it is the one piece here that does not need eyes on a screenshot.

Split out of #55, which now covers only the data model: `BuildingFootprint` parses an arrow (`< > ^ v`) into an anchor-relative `doorstep(steps)` and a unit `entrance_direction(steps)`, both rotating with the cells via `rotate_offset()`. During ghost placement mode, render an arrow pointing at the building's entrance so the player can see which way the door faces while rotating. ## Approach - Add a third `MeshInstance3D` to `PlacementController` as a **sibling** of the ghost mesh, not a child. The ghost is positioned at the centre of its rotated bounds, so a child's offset would be in mesh-local space and coupled to the AABB. As a sibling it can be positioned directly from the grid, the same way `GhostMesh` and `GridPlacementMesh` already are. - **Position:** `map.cell_to_world(anchor + footprint.doorstep(steps))`, plus a small upward lift. Note that `TerrainGrid.cell_to_world()` already returns the tile centre sitting on its top face (`surface_y(level_at(cell))`, globalised) — there is no separate surface-height lookup to do. - The lift should be its own `@export`, **not** the ghost's `mesh_hover_height`. That is 0.75, tuned for a floating building box; an arrow that high above a bare tile reads as detached rather than as a marker on the ground. The highlight quads sit at `+0.02` for comparison. - **Rotation:** derive `rotation.y` from `footprint.entrance_direction(steps)` rather than from the step count, so the arrow and the footprint cannot disagree. - Grid `y` maps to world `z`, and Godot's `+Y` rotation is counter-clockwise viewed from above — which is why `BuildingSite.rotation_for()` negates. For a mesh pointing along `-Z` at yaw 0, the yaw is `atan2(-float(dir.x), -float(dir.y))`. Checks out on all four: `(0,-1)` -> 0, `(1,0)` -> -90 deg, `(0,1)` -> 180 deg, `(-1,0)` -> +90 deg. - **Put this beside `rotation_for()` as a second static on `BuildingSite`.** That class already exists to hold the pure site maths both the ghost and the placed building call, and a static is testable **headless** — which the rendering is not. It is the only part of this issue that can have real automated coverage. - **Mesh:** neither a `PrismMesh` nor a zero-top-radius `CylinderMesh` works directly — both point along **+Y**, so an arrow lying flat in the ground plane would need a pitch as well as a yaw, and `rotation.x` + `rotation.y` on one node compose in Godot's YXZ Euler order. That is the "plausible-but-wrong orientation" failure CLAUDE.md warns about. Two ways out, both keeping `rotation.y` as the only thing ever set: - Bake the pitch into a child node — `EntranceArrow` carries the yaw, a `MeshInstance3D` child carries `rotation_degrees = Vector3(-90, 0, 0)`. Authored as `rotation_degrees`, never a hand-computed `Transform3D`. - **Preferred:** build the mesh already flat as an `ImmediateMesh` triangle/chevron in the XZ plane pointing along `-Z`, the way `_rebuild_highlight()` already generates its quads. More consistent with the surrounding code, and a flat chevron reads better than a cone under an orthographic top-down camera. ## Not what the first draft of this issue said Two corrections, recorded so they are not re-derived: - ~~"The existing cache check in `PlacementController` already includes `_rotation_steps`, so the arrow only needs updating when that check fires."~~ This misreads the code. The ghost's own position and rotation are set **every frame**, outside that block; the cache exists solely because `_rebuild_highlight()` regenerates an `ImmediateMesh` from scratch. Setting a `Vector3` and a `float` per frame costs nothing — update the arrow unconditionally, next to the ghost. What *should* be built once, in `_ready()`, is the arrow **mesh**: unlike the ghost mesh it does not vary by building type, so it never needs rebuilding. - ~~"Position ... seated on that tile's surface height plus the same hover lift the ghost uses."~~ Both halves wrong, per the Position bullet above. ## Easy to miss - **`_clear_hover()` must hide the arrow**, and `_cancel_placement()` must clear it. Every early return in `_physics_process()` funnels through `_clear_hover()`, and its docstring already warns about exactly this class of bug. - **Guard the empty footprint.** `BuildingSite.position_for()` bails on `offsets.is_empty()` specifically so a failed parse does not push errors 60x/second. The arrow needs the same guard: on a shape that failed to parse, `_doorstep` is left at `Vector2i.MAX` and the arrow would be flung to an absurd cell. ## Out of scope - Placed buildings do not show the arrow by default. Showing it on selection is a later issue. - Whether the doorstep tile participates in placement validity (a door against a cliff, off the map edge, or against another building) is a separate rule for `PlacementRules`, and its own issue. Note that until then a doorstep can fall outside the grid: `level_at()` clamps out-of-bounds cells to the datum level, so the arrow will render off the map edge rather than error. ## Verification Headless renders nothing under gl_compatibility, so this needs a headed run with a screenshot for each of the four rotations of an asymmetric footprint such as `one_by_three_L`. `tools/screenshot_buildings.gd` is the harness to copy — headed `SceneTree`, builds a plains map, waits for frames, `save_png`. Its header marks it THROWAWAY for #36, so write a sibling for this issue rather than editing it. The `BuildingSite` yaw static above should get unit tests in the normal GUT suite; it is the one piece here that does not need eyes on a screenshot.
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#61
No description provided.