Show a ghost of the selected building type when placing building #39

Open
opened 2026-08-24 19:51:21 +01:00 by liamjd · 0 comments
Owner

When placing a building, show a ghost of the building mesh under the mouse cursor. The ghost should be semi-transparent and follow the cursor, snapped to the tile grid.

Right-clicking cancels the placement, clears the ghost and unsets the button in the building panel.

This issue owns the ghost and the placement mode state. The meshes it displays, the BuildingFootprint resource and the BuildingType fields are #36 — a prerequisite. Validity feedback (green/red tinting, slope checks) is #37; actually committing a building to the map is not yet filed.

Prerequisites

  • #36BuildingType.mesh, BuildingType.footprint, BuildingType.height_levels, the BuildingFootprint resource, and the three meshes.

Current gaps this has to close

  • BuildingPanel builds its cards into a ButtonGroup with allow_unpress = true, but nothing is connected to the press signal — selection currently goes nowhere.
  • There is no notion of a current mouse mode or a selected building type anywhere in the project.

Decisions

Taken 2026-08-24.

Placement state lives on a PlacementController node, not GameState

enum Mode { SELECT, PLACE_BUILDING } and the selected BuildingType belong to a new PlacementController node in game.tscn, not on the GameState autoload as originally sketched in #36.

Mouse mode is a per-scene concern. On the autoload it would survive a return to the main menu and be mutable from anywhere; on a scene node it dies with the scene and has one owner.

Right-click: cancel on tap, rotate on drag

CameraRig._unhandled_input() already binds MOUSE_BUTTON_RIGHT to free-rotate drag. Rather than surrendering rotation while placing — which is exactly when you most want to spin the view to check a site — distinguish click from drag:

  • record event.position on right-press
  • on right-release, cancel placement only if the mouse moved less than ~4 px

Do not call set_input_as_handled() on that release. CameraRig clears _rotating_with_mouse on the same event; swallowing it strands the camera in permanent rotate mode.


Implementation notes

Node placement

PlacementController goes in game.tscn after CameraRig in the tree — _unhandled_input propagates last-child-first, so it sees events before the camera does. It exports a NodePath to Map and to BuildingPanel, creates one child MeshInstance3D in _ready() and shows/hides it. Create the ghost once; never re-instantiate per frame.

Wiring the selection

Connect each card's toggled(toggled_on) signal in BuildingPanel._ready() and re-emit a panel-level signal building_type_selected(type: BuildingType), passing null on untoggle.

ButtonGroup.pressed only fires on press — with allow_unpress = true it gives no clean deselection event, which is why toggled is the right hook.

The controller connects to the panel, not the reverse; the panel stays ignorant of placement. For cancel, the panel also needs a clear_selection() method (button_group.get_pressed_button().button_pressed = false).

Snap maths

Footprints are anchor-relative (see #36), so the tile under the cursor is the anchor and there is no parity maths, no derived centre and no cell_to_world() special-casing:

for offset in type.footprint.cells():
    var cell: Vector2i = hovered_cell + offset

Clamp so every occupied cell stays in bounds — reject the position outright rather than clamping the anchor, or an L-shape slides oddly along the map edge.

For the ghost mesh position, centre on the footprint's bounding box:

var b: Rect2i = type.footprint.bounds()
centre_local.x = (hovered_cell.x + b.position.x + b.size.x * 0.5) * tile_size
centre_local.z = (hovered_cell.y + b.position.y + b.size.y * 0.5) * tile_size

Sanity checks: the 3x3 rocket lands on the anchor tile's centre; the 2x2 mine lands on the corner between the anchor and its +X/+Z neighbour (a consequence of where @ was authored, now visible in the file); an L-shape centres on its bounding box rather than its centre of mass, which is why #36 says to model non-rectangular meshes to fill the bounding box.

Note TerrainGrid.cell_to_world() hardcodes +0.5 and is a 1x1 helper — it is right for finding a tile centre, wrong for positioning a multi-tile ghost.

Ground height and sitting the mesh on it

Take the maximum surface_y(level_at(cell)) across the occupied cells. The minimum sinks the ghost into a hillside; the max floats it, which is the honest read until #37 lands validity.

Then sit the mesh on that ground:

ghost.position.y = ground_y - selected.mesh.get_aabb().position.y

Godot's mesh primitives are centred on their origin and have no offset property of their own (PlaneMesh.center_offset is the sole exception), so the meshes from #36 arrive centred. The get_aabb() subtraction lifts any mesh onto the ground without the caller knowing its height, and still works if a mesh later becomes a hand-built ArrayMesh with its origin already at the base.

Per-frame update, in _physics_process

Update every frame rather than on InputEventMouseMotion — the camera can pan under a stationary cursor, and a motion-driven ghost would lag behind the terrain.

if mode != PLACE_BUILDING: hide; return
if get_viewport().gui_get_hovered_control() != null: hide; return
raycast -> hit position -> world_to_cell() -> snap -> position ghost

The gui_get_hovered_control() guard matters because the raycast will happily hit terrain underneath the building panel. Placement clicks are already safe — _unhandled_input never sees an event a Control consumed.

Use a physics raycast, not CameraRig._ground_point(). That helper intersects a plane at the rig's Y, which is correct for zoom-to-cursor and wrong here — on a hill the ghost lands several tiles off.

PhysicsRayQueryParameters3D.create(
    cam.project_ray_origin(mouse),
    cam.project_ray_origin(mouse) + cam.project_ray_normal(mouse) * cam.far)

CameraRig.camera() exposes the camera. The GridMap's colliders are already a single tile_size cube at the top of each column rather than the full column — TerrainGrid._build_mesh_library() notes that a full-depth collider makes raycasts pick the cliff behind the tile you clicked. That was written for this feature.

Ghost material

Build it in code in _ready() and assign as material_override:

var mat := StandardMaterial3D.new()
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = Color(0.6, 0.8, 1.0, 0.4)
ghost.material_override = mat
ghost.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF

Shadows off — a solid shadow under a translucent ghost reads as a bug.

Build the material rather than preload()ing a .tres. preload()/load() return the shared cached resource, so #37's green/red tinting would otherwise mutate the material for every ghost for the whole session.


Testing

The bounding-box centring and the bounds clamp are pure maths — extract them as standalone functions so they are reachable without instantiating the scene, and cover them in GUT:

  • a 3x3 (odd), a 2x2 (even) and an L-shape
  • all four map edges, for the bounds rejection

BuildingFootprint's own parsing and rotated() tests belong with #36.

The ghost itself is not testable — gl_compatibility renders nothing headless. Verify it headed, by screenshot.

When placing a building, show a ghost of the building mesh under the mouse cursor. The ghost should be semi-transparent and follow the cursor, snapped to the tile grid. Right-clicking cancels the placement, clears the ghost and unsets the button in the building panel. This issue owns **the ghost and the placement mode state**. The meshes it displays, the `BuildingFootprint` resource and the `BuildingType` fields are #36 — a prerequisite. Validity feedback (green/red tinting, slope checks) is #37; actually committing a building to the map is not yet filed. ## Prerequisites - #36 — `BuildingType.mesh`, `BuildingType.footprint`, `BuildingType.height_levels`, the `BuildingFootprint` resource, and the three meshes. ## Current gaps this has to close - `BuildingPanel` builds its cards into a `ButtonGroup` with `allow_unpress = true`, but **nothing is connected to the press signal** — selection currently goes nowhere. - There is no notion of a current mouse mode or a selected building type anywhere in the project. --- ## Decisions Taken 2026-08-24. ### Placement state lives on a `PlacementController` node, not `GameState` `enum Mode { SELECT, PLACE_BUILDING }` and the selected `BuildingType` belong to a new `PlacementController` node in `game.tscn`, **not** on the `GameState` autoload as originally sketched in #36. Mouse mode is a per-scene concern. On the autoload it would survive a return to the main menu and be mutable from anywhere; on a scene node it dies with the scene and has one owner. ### Right-click: cancel on tap, rotate on drag `CameraRig._unhandled_input()` already binds `MOUSE_BUTTON_RIGHT` to free-rotate drag. Rather than surrendering rotation while placing — which is exactly when you most want to spin the view to check a site — distinguish click from drag: - record `event.position` on right-press - on right-release, cancel placement only if the mouse moved less than ~4 px **Do not call `set_input_as_handled()` on that release.** `CameraRig` clears `_rotating_with_mouse` on the same event; swallowing it strands the camera in permanent rotate mode. --- ## Implementation notes ### Node placement `PlacementController` goes in `game.tscn` **after `CameraRig`** in the tree — `_unhandled_input` propagates last-child-first, so it sees events before the camera does. It exports a `NodePath` to `Map` and to `BuildingPanel`, creates one child `MeshInstance3D` in `_ready()` and shows/hides it. Create the ghost once; never re-instantiate per frame. ### Wiring the selection Connect each card's `toggled(toggled_on)` signal in `BuildingPanel._ready()` and re-emit a panel-level `signal building_type_selected(type: BuildingType)`, passing `null` on untoggle. `ButtonGroup.pressed` only fires on press — with `allow_unpress = true` it gives no clean deselection event, which is why `toggled` is the right hook. The controller connects to the panel, not the reverse; the panel stays ignorant of placement. For cancel, the panel also needs a `clear_selection()` method (`button_group.get_pressed_button().button_pressed = false`). ### Snap maths Footprints are anchor-relative (see #36), so **the tile under the cursor is the anchor** and there is no parity maths, no derived centre and no `cell_to_world()` special-casing: ```gdscript for offset in type.footprint.cells(): var cell: Vector2i = hovered_cell + offset ``` Clamp so every occupied cell stays in bounds — reject the position outright rather than clamping the anchor, or an L-shape slides oddly along the map edge. For the **ghost mesh position**, centre on the footprint's bounding box: ``` var b: Rect2i = type.footprint.bounds() centre_local.x = (hovered_cell.x + b.position.x + b.size.x * 0.5) * tile_size centre_local.z = (hovered_cell.y + b.position.y + b.size.y * 0.5) * tile_size ``` Sanity checks: the 3x3 rocket lands on the anchor tile's centre; the 2x2 mine lands on the corner between the anchor and its +X/+Z neighbour (a consequence of where `@` was authored, now visible in the file); an L-shape centres on its bounding box rather than its centre of mass, which is why #36 says to model non-rectangular meshes to fill the bounding box. Note `TerrainGrid.cell_to_world()` hardcodes `+0.5` and is a 1x1 helper — it is right for finding a tile centre, wrong for positioning a multi-tile ghost. ### Ground height and sitting the mesh on it Take the **maximum** `surface_y(level_at(cell))` across the occupied cells. The minimum sinks the ghost into a hillside; the max floats it, which is the honest read until #37 lands validity. Then sit the mesh on that ground: ```gdscript ghost.position.y = ground_y - selected.mesh.get_aabb().position.y ``` Godot's mesh primitives are centred on their origin and have no offset property of their own (`PlaneMesh.center_offset` is the sole exception), so the meshes from #36 arrive centred. The `get_aabb()` subtraction lifts any mesh onto the ground without the caller knowing its height, and still works if a mesh later becomes a hand-built `ArrayMesh` with its origin already at the base. ### Per-frame update, in `_physics_process` Update every frame rather than on `InputEventMouseMotion` — the camera can pan under a stationary cursor, and a motion-driven ghost would lag behind the terrain. ``` if mode != PLACE_BUILDING: hide; return if get_viewport().gui_get_hovered_control() != null: hide; return raycast -> hit position -> world_to_cell() -> snap -> position ghost ``` The `gui_get_hovered_control()` guard matters because the raycast will happily hit terrain underneath the building panel. Placement *clicks* are already safe — `_unhandled_input` never sees an event a Control consumed. Use a physics raycast, **not** `CameraRig._ground_point()`. That helper intersects a plane at the rig's Y, which is correct for zoom-to-cursor and wrong here — on a hill the ghost lands several tiles off. ```gdscript PhysicsRayQueryParameters3D.create( cam.project_ray_origin(mouse), cam.project_ray_origin(mouse) + cam.project_ray_normal(mouse) * cam.far) ``` `CameraRig.camera()` exposes the camera. The GridMap's colliders are already a single `tile_size` cube at the top of each column rather than the full column — `TerrainGrid._build_mesh_library()` notes that a full-depth collider makes raycasts pick the cliff behind the tile you clicked. That was written for this feature. ### Ghost material Build it in code in `_ready()` and assign as `material_override`: ```gdscript var mat := StandardMaterial3D.new() mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.albedo_color = Color(0.6, 0.8, 1.0, 0.4) ghost.material_override = mat ghost.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF ``` Shadows off — a solid shadow under a translucent ghost reads as a bug. Build the material rather than `preload()`ing a `.tres`. `preload()`/`load()` return the **shared cached** resource, so #37's green/red tinting would otherwise mutate the material for every ghost for the whole session. --- ## Testing The bounding-box centring and the bounds clamp are pure maths — extract them as standalone functions so they are reachable without instantiating the scene, and cover them in GUT: - a 3x3 (odd), a 2x2 (even) and an L-shape - all four map edges, for the bounds rejection `BuildingFootprint`'s own parsing and `rotated()` tests belong with #36. The ghost itself is not testable — `gl_compatibility` renders nothing headless. Verify it headed, by 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#39
No description provided.