Select a pawn or building on the map #78

Closed
opened 2026-09-07 20:56:29 +01:00 by liamjd · 2 comments
Owner

Part of milestone #6 (Selection and inspection). Depends on #77 (closed) and #72 (closed).

Rewritten 2026-09-30 after #72 replaced the per-mode booleans with GameState.interaction_mode, and the scope widened from "select a pawn" to "select a pawn or a building". The earlier design (a GameState.placing flag, building_placement_canceled, a command_move action, selected_pawn_id) is superseded by what follows.

Summary

The player can left-click a pawn or a building on the map to select it. The selection is one shared piece of state on GameState. This ticket builds the selection model and the picking on the map. The rest of milestone #6 builds on it: the pawn list (#140) and building list (#141) select through the same state, and the inspector panel (#142) displays it.

Design

Mode arbitration comes from interaction_mode, not a new flag.

  • Selecting on the map is what a click means in NONE mode. This was decided 2026-09-30, and it matches how RTS games usually behave: with nothing armed, clicking selects.
  • InteractionMode.SELECT is removed from the enum. Its comment in GameState.gd reserved it for this ticket, but it isn't needed under that decision. Adding it back later is a single enum case.
  • The new controller gates _unhandled_input on interaction_mode == NONE, the same way PlacementController and DemolishController gate on their modes. _unhandled_input is delivered bottom-up through game.tscn, so a controller outside its mode must return without marking the event handled (see #72's commit message).

Selection state:

  • GameState.selected: Entity, where null means nothing is selected. It is a setter-backed property with signal selection_changed(new: Entity, previous: Entity), the same pattern as interaction_mode.
  • It is one property rather than selected_pawn_id plus selected_building_id, because two ids can both be set and one reference cannot. This is the same argument #72 made for the mode enum.
  • It holds only a Pawn or a Building. Task also extends Entity; how a wrong type is rejected is decided in the outline.
  • Changing interaction_mode clears the selection (decided 2026-09-30). It happens in one place, the mode setter or a handler on interaction_mode_changed, and the outline picks which.
  • Removing the selected entity clears the selection, on pawn_removed or building_removed, so nothing holds a reference to a record that no longer exists.

Picking:

  • Buildings.pick(camera, screen_position) -> Building already exists. It has its own collision layer (layer 2, "buildings") and a meta id on the collider, and its doc comment names #78 as a caller. Use it as-is.
  • Add a matching Pawns.pick(camera, screen_position) -> Pawn:
    • Put the pawn Area3D (pawn.tscn) on its own layer 3, named "pawns" in [layer_names]. It is on default layer 1 today, with the terrain.
    • The ray query needs collide_with_areas = true. Ray queries ignore Area3Ds by default, so without it the pick silently never hits.
  • Pawn before building. A click that hits both selects the pawn. Pawns are small and would otherwise be nearly impossible to pick in front of a building. A click that hits neither clears the selection.
  • PlacementController keeps its own terrain raycast. Mode gating means only one controller raycasts per frame, so the "two raycasts disagreeing" concern in the old version of this ticket no longer applies. Pulling it out is optional tidy-up, not an AC.

Input:

  • New action select, bound to the left mouse button. It uses the same physical button as place_building; the mode decides which one acts, not the binding.
  • The left button is not used by CameraRig, so selecting does not need the right-click press/release/drag check.

Highlight:

  • The selected PawnView gets a visible highlight: a ring under the capsule, or a material tint.
  • The selected building's view gets one too. The two highlights never show at the same time, because entering DEMOLISH clears the selection. Even so, #123's demolish highlight and this one should share one mechanism (tint or outline) in clearly different colours: warm red or orange for "will be demolished", and a neutral or cool colour for "selected".

Acceptance criteria

  • In NONE, left-clicking a pawn sets GameState.selected to that pawn and shows its highlight.
  • In NONE, left-clicking a building sets GameState.selected to that building and shows its highlight.
  • A click that hits a pawn in front of a building selects the pawn.
  • In NONE, left-clicking empty ground clears the selection and the highlight.
  • In PLACE or DEMOLISH, a left-click never changes the selection. Placing or demolishing does not select what is under the cursor.
  • Any change of interaction_mode clears the selection.
  • Removing the selected pawn or building clears the selection.
  • selection_changed fires once per actual change, carrying the new and previous values, and not at all when the same entity is selected again.
  • InteractionMode.SELECT is removed and nothing references it.
  • Pawns are on collision layer 3 ("pawns"), and Pawns.pick() finds a pawn's Area3D.
  • Unit tests cover the GameState.selected rules and both pick() functions. A headed check, looked at by eye, gives a screenshot of a selected pawn and a selected building.

Decisions

  • 2026-09-30: Selection happens in NONE mode. SELECT is removed from the enum.
  • 2026-09-30: Changing the interaction mode clears the selection.
  • 2026-09-30: One selection at a time, either a pawn or a building.
  • 2026-09-30: Leaving PLACE or DEMOLISH happens by doing the action, right-clicking, pressing Esc, or opening the main menu. It never happens by clicking another UI element. The Esc and menu part is #139.

Out of scope

  • The pawn list (#140), building list (#141) and inspector panel (#142).
  • Esc and main-menu exits from PLACE or DEMOLISH (#139).
  • Multi-select and box-select.
  • Any command given to a selected pawn. The player never orders a pawn to a tile (milestone #4).
  • Pulling the duplicated right-click click-vs-drag check in PlacementController / DemolishController into shared code. DemolishController's comment points here, but selection uses the left button and does not need it.
Part of milestone #6 (Selection and inspection). Depends on #77 (closed) and #72 (closed). **Rewritten 2026-09-30** after #72 replaced the per-mode booleans with `GameState.interaction_mode`, and the scope widened from "select a pawn" to "select a pawn or a building". The earlier design (a `GameState.placing` flag, `building_placement_canceled`, a `command_move` action, `selected_pawn_id`) is superseded by what follows. ## Summary The player can left-click a pawn or a building on the map to select it. The selection is one shared piece of state on `GameState`. This ticket builds the selection model and the picking on the map. The rest of milestone #6 builds on it: the pawn list (#140) and building list (#141) select through the same state, and the inspector panel (#142) displays it. ## Design **Mode arbitration comes from `interaction_mode`, not a new flag.** - Selecting on the map is what a click means in **NONE** mode. This was decided 2026-09-30, and it matches how RTS games usually behave: with nothing armed, clicking selects. - `InteractionMode.SELECT` is removed from the enum. Its comment in `GameState.gd` reserved it for this ticket, but it isn't needed under that decision. Adding it back later is a single enum case. - The new controller gates `_unhandled_input` on `interaction_mode == NONE`, the same way `PlacementController` and `DemolishController` gate on their modes. `_unhandled_input` is delivered bottom-up through `game.tscn`, so a controller outside its mode must return **without** marking the event handled (see #72's commit message). **Selection state:** - `GameState.selected: Entity`, where `null` means nothing is selected. It is a setter-backed property with `signal selection_changed(new: Entity, previous: Entity)`, the same pattern as `interaction_mode`. - It is one property rather than `selected_pawn_id` plus `selected_building_id`, because two ids can both be set and one reference cannot. This is the same argument #72 made for the mode enum. - It holds only a `Pawn` or a `Building`. `Task` also extends `Entity`; how a wrong type is rejected is decided in the outline. - **Changing `interaction_mode` clears the selection** (decided 2026-09-30). It happens in one place, the mode setter or a handler on `interaction_mode_changed`, and the outline picks which. - **Removing the selected entity clears the selection**, on `pawn_removed` or `building_removed`, so nothing holds a reference to a record that no longer exists. **Picking:** - `Buildings.pick(camera, screen_position) -> Building` already exists. It has its own collision layer (layer 2, `"buildings"`) and a meta id on the collider, and its doc comment names #78 as a caller. Use it as-is. - Add a matching `Pawns.pick(camera, screen_position) -> Pawn`: - Put the pawn `Area3D` (`pawn.tscn`) on its own layer 3, named `"pawns"` in `[layer_names]`. It is on default layer 1 today, with the terrain. - The ray query needs **`collide_with_areas = true`**. Ray queries ignore `Area3D`s by default, so without it the pick silently never hits. - **Pawn before building.** A click that hits both selects the pawn. Pawns are small and would otherwise be nearly impossible to pick in front of a building. A click that hits neither clears the selection. - `PlacementController` keeps its own terrain raycast. Mode gating means only one controller raycasts per frame, so the "two raycasts disagreeing" concern in the old version of this ticket no longer applies. Pulling it out is optional tidy-up, not an AC. **Input:** - New action `select`, bound to the left mouse button. It uses the same physical button as `place_building`; the mode decides which one acts, not the binding. - The left button is not used by `CameraRig`, so selecting does not need the right-click press/release/drag check. **Highlight:** - The selected `PawnView` gets a visible highlight: a ring under the capsule, or a material tint. - The selected building's view gets one too. The two highlights never show at the same time, because entering DEMOLISH clears the selection. Even so, #123's demolish highlight and this one should share one mechanism (tint or outline) in clearly different colours: warm red or orange for "will be demolished", and a neutral or cool colour for "selected". ## Acceptance criteria - [ ] In NONE, left-clicking a pawn sets `GameState.selected` to that pawn and shows its highlight. - [ ] In NONE, left-clicking a building sets `GameState.selected` to that building and shows its highlight. - [ ] A click that hits a pawn in front of a building selects the pawn. - [ ] In NONE, left-clicking empty ground clears the selection and the highlight. - [ ] In PLACE or DEMOLISH, a left-click never changes the selection. Placing or demolishing does not select what is under the cursor. - [ ] Any change of `interaction_mode` clears the selection. - [ ] Removing the selected pawn or building clears the selection. - [ ] `selection_changed` fires once per actual change, carrying the new and previous values, and not at all when the same entity is selected again. - [ ] `InteractionMode.SELECT` is removed and nothing references it. - [ ] Pawns are on collision layer 3 (`"pawns"`), and `Pawns.pick()` finds a pawn's `Area3D`. - [ ] Unit tests cover the `GameState.selected` rules and both `pick()` functions. A headed check, looked at by eye, gives a screenshot of a selected pawn and a selected building. ## Decisions - 2026-09-30: Selection happens in NONE mode. `SELECT` is removed from the enum. - 2026-09-30: Changing the interaction mode clears the selection. - 2026-09-30: One selection at a time, either a pawn or a building. - 2026-09-30: Leaving PLACE or DEMOLISH happens by doing the action, right-clicking, pressing Esc, or opening the main menu. It never happens by clicking another UI element. The Esc and menu part is #139. ## Out of scope - The pawn list (#140), building list (#141) and inspector panel (#142). - Esc and main-menu exits from PLACE or DEMOLISH (#139). - Multi-select and box-select. - Any command given to a selected pawn. The player never orders a pawn to a tile (milestone #4). - Pulling the duplicated right-click click-vs-drag check in `PlacementController` / `DemolishController` into shared code. `DemolishController`'s comment points here, but selection uses the left button and does not need it.
liamjd changed title from Input actions and a shared picker for selecting a pawn to Select a pawn or building on the map 2026-09-30 21:51:18 +01:00
Author
Owner

Spec (drafted with Claude)

Tests are on branch 78-select-pawn-or-building: test_selection.gd, test_selection_highlight.gd and test_selection_picking.gd. They and this outline are one spec, so if one changes, the other changes with it.

Design calls made here, beyond the ticket's Decisions list:

  • The highlight uses material_overlay, not material_override. material_override is already taken twice. BuildingView.apply() rewrites it on every status change, and PawnView.flash() sets it and then nulls it when the flash ends. A selection tint in that slot would be wiped by the next refresh or flash, and would itself wipe the state tint. material_overlay is a separate slot that draws on top of whatever the mesh already wears, and nothing in the project uses it yet. #123 should use it too, with its own material.
  • The highlight is an outline shell: one shared, unshaded overlay material with grow enabled and front-face culling. Risk: grow pushes vertices out along their normals, so a flat-shaded box can split at the edges. If the headed check shows that, fall back to a translucent tint overlay (CULL_BACK, alpha about 0.35, no grow). The tests only check which material is in the overlay slot, so they pass either way.
  • The registries own their views, so each one gets a set_highlighted(id, on), and SelectionController never touches a view directly. Both are silent when there is no view. That is what makes un-highlighting a pawn that has just been removed safe: Pawns.remove() erases the view before pawn_removed fires.
  • Pawns pick before buildings, even a pawn behind a building. Each picker ignores the other's layer, so a pawn hidden behind a building is still picked through it. Pawns are 0.5 wide, so this is rare. It is a known limitation, not a bug to fix here.

2.1 Files

File New / changed Change
globals/GameState.gd changed Remove SELECT from InteractionMode. Add selected, selection_changed, and _clear_if_selected() connected in _ready(). The mode setter clears the selection.
project.godot changed [input]: new select action, left mouse button (copy place_building's event). [layer_names]: 3d_physics/layer_3="pawns".
entities/pawns/pawns.gd changed PICK_LAYER, PICK_META, pick(), set_highlighted(). _spawn_view() sets the Area3D's layer, mask and meta.
entities/pawns/pawn_view.gd changed set_highlighted(), is_highlighted()
entities/buildings/buildings.gd changed set_highlighted()
entities/selection/selection_highlight.gd new class_name SelectionHighlight: the shared overlay material and how to apply it
entities/selection/selection_controller.gd new class_name SelectionController: a click in NONE mode becomes a selection, and the selection becomes a highlight
common/game.tscn changed Add a SelectionController node (see 2.6)
entities/pawns/pawn.tscn unchanged The layer is set in code, next to the meta, the same way Buildings._add_pick_body() does it

Run --headless --editor --quit after adding the two class_names, or the tests fail to parse rather than fail to pass.

2.2 API: full signatures

# globals/GameState.gd - autoload, extends Node
enum InteractionMode {NONE, PLACE, DEMOLISH, TERRAIN}     # SELECT removed; TERRAIN's value moves 4 -> 3, nothing stores it
signal selection_changed(new: Entity, previous: Entity)   # either side may be null
var selected: Entity = null                               # setter; only a Pawn, a Building or null
func _clear_if_selected(entity: Entity) -> void           # private; connected in _ready()

# entities/pawns/pawns.gd - class_name Pawns extends Node3D
const PICK_LAYER: int = 4            # a mask value: bit 3, i.e. layer 3 "pawns". Same convention as Buildings.PICK_LAYER = 2 for layer 2
const PICK_META := &"pawn_id"
func pick(camera: Camera3D, screen_position: Vector2) -> Pawn   # null: no camera, no hit, or a hit with no live record
func set_highlighted(id: int, on: bool) -> void                 # silent when the pawn has no view

# entities/pawns/pawn_view.gd - class_name PawnView extends Node3D
func set_highlighted(on: bool) -> void
func is_highlighted() -> bool

# entities/buildings/buildings.gd - class_name Buildings extends Node3D
func set_highlighted(id: int, on: bool) -> void                 # silent when the building has no view

# entities/selection/selection_highlight.gd - class_name SelectionHighlight extends RefCounted
# All static; never instantiated.
const COLOUR: Color = Color(0.55, 0.85, 1.0)   # cool blue: apart from MARKED orange, BLOCKED red and the PLANNED pale blue at the outline's strength
const GROW: float = 0.04                       # world units the shell stands off the surface
static var _material: StandardMaterial3D = null
static func material() -> StandardMaterial3D                    # built on first call, then the same instance every time
static func apply(geometry: GeometryInstance3D, on: bool) -> void
static func is_applied(geometry: GeometryInstance3D) -> bool

# entities/selection/selection_controller.gd - class_name SelectionController extends Node
@onready var rig: CameraRig = $"../CameraRig"
@onready var pawns: Pawns = $"../Pawns"
@onready var buildings: Buildings = $"../Buildings"
func pick_at(screen_position: Vector2) -> Entity     # query only, changes nothing; null when nothing is there
func select_at(screen_position: Vector2) -> Entity   # pick_at(), then assign GameState.selected; returns what is now selected
func _unhandled_input(event: InputEvent) -> void
func _on_selection_changed(new: Entity, previous: Entity) -> void
func _set_highlighted(entity: Entity, on: bool) -> void

Error paths

Call Condition Result Reports
GameState.selected = x x is not null, a Pawn or a Building (e.g. a Task, which also extends Entity) unchanged, no signal push_error("Only a Pawn or a Building can be selected, not %s" % x). Tests match "Only a Pawn or a Building can be selected".
Pawns.pick() camera == null null silent
Pawns.pick() the ray hits nothing null silent
Pawns.pick() the hit has no PICK_META, or its id has no record (a removed pawn whose view is still queued for freeing) null silent. Use _pawns.get(id), not get_pawn(), which calls push_error
Pawns.set_highlighted() / Buildings.set_highlighted() no view for id nothing silent
SelectionHighlight.apply() / is_applied() geometry == null nothing / false silent

2.3 Each method's job, in one line

New

  • GameState.selected setter: return if the value is unchanged. push_error and return if it is not null, a Pawn or a Building. Otherwise keep the old value as previous, assign, then selection_changed.emit(value, previous).
  • GameState.interaction_mode setter, changed: after assigning the mode and before interaction_mode_changed.emit(...), set selected = null. A mode listener then never sees a selection left over from the old mode. Inside a setter, assigning a different property still goes through that property's setter, so the selection signal fires; only assigning the setter's own property skips it.
  • GameState._clear_if_selected(entity): if entity == selected, set selected = null. Connected to both pawn_removed and building_removed. Their parameters are Pawn and Building, and both extend Entity, so one handler serves both.
  • Pawns.pick(): the same ray as Buildings.pick(), plus query.collide_with_areas = true and query.collide_with_bodies = false. Ray queries skip Area3Ds by default, and the pawn's collider is an Area3D.
  • Pawns.set_highlighted(id, on): if _views.has(id): _views[id].set_highlighted(on).
  • PawnView.set_highlighted(on): SelectionHighlight.apply(mesh_instance_3d, on). is_highlighted(): SelectionHighlight.is_applied(mesh_instance_3d).
  • Buildings.set_highlighted(id, on): if _views.has(id): SelectionHighlight.apply(_views[id], on).
  • SelectionHighlight.material(): on the first call, build a StandardMaterial3D with shading_mode = SHADING_MODE_UNSHADED, albedo_color = COLOUR, cull_mode = CULL_FRONT, grow = true and grow_amount = GROW, and store it in _material. Return _material every time. It is shared deliberately. BuildingView's warning against shared materials is about per-building state, and this material carries none and is never modified after it is built.
  • SelectionHighlight.apply(geometry, on): geometry.material_overlay = material() if on else null. It never touches material_override.
  • SelectionHighlight.is_applied(geometry): geometry.material_overlay == material(). An identity check, so #123's overlay will not read as selected.
  • SelectionController._ready(): GameState.selection_changed.connect(_on_selection_changed).
  • SelectionController._unhandled_input(event): return if GameState.interaction_mode != NONE. On event.is_action_pressed("select"), call select_at((event as InputEventMouseButton).position) then get_viewport().set_input_as_handled(). The handled mark is safe: in NONE nothing else wants a left click, and CameraRig does not use the left button.
  • SelectionController.pick_at(pos): pawns.pick(rig.camera(), pos). If that returns null, return buildings.pick(rig.camera(), pos).
  • SelectionController.select_at(pos): GameState.selected = pick_at(pos), then return GameState.selected. Clicking empty ground assigns null, which is the deselect.
  • SelectionController._on_selection_changed(new, previous): _set_highlighted(previous, false), then _set_highlighted(new, true), in that order.
  • SelectionController._set_highlighted(entity, on): return on null. A Pawn calls pawns.set_highlighted(entity.id, on); a Building calls buildings.set_highlighted(entity.id, on). Use is and cast, as Buildings._task_blocked() does.

Existing code this relies on (read, not guessed)

  • Buildings.pick(camera, screen_position) -> Building: a ray masked to PICK_LAYER = 2, bodies only, with the id read from PICK_META on the StaticBody3D. Null for no camera, no hit, or no meta.
  • Buildings.remove(id) emits building_removed before _despawn_view(), so the view still exists when the selection clears. set_highlighted(id, false) then reaches a view that is about to be freed, which is harmless. _despawn_view() uses free(), so the view is gone straight after.
  • Pawns.remove(id) erases _views[id] and queue_free()s the view before it emits pawn_removed. So set_highlighted(id, false) finds no view and returns quietly, and pick() can still hit the dying Area3D for one frame, which is why pick() uses _pawns.get().
  • Pawns._spawn_view() instantiates pawn.tscn, names it PawnView_<id>, add_childs it, then positions it pawn_height / 2 above the spawn point. Set the Area3D's layer, mask and meta here, on view.get_node("Area3D"), before add_child().
  • BuildingView.apply(view, building) writes material_override and cast_shadow only, never material_overlay. That is why a highlight survives set_status(), set_build_progress() and the demolition marks.
  • PawnView.flash() sets material_override and _end_flash() nulls it. Neither touches material_overlay.
  • CameraRig.camera() -> Camera3D: the live camera, which DemolishController already uses for Buildings.pick().

2.4 Signals

Signal Declared on Custom / built-in Emitted by (method) Listened to by (method) Connected where
selection_changed(new: Entity, previous: Entity) GameState custom, new the GameState.selected setter: selection_changed.emit(value, previous) SelectionController._on_selection_changed(). Later also #140, #141, #142 SelectionController._ready()
pawn_removed(pawn: Pawn) GameState custom, existing Pawns.remove() GameState._clear_if_selected() (new listener) GameState._ready()
building_removed(building: Building) GameState custom, existing Buildings.remove() GameState._clear_if_selected() (new listener), plus the existing ones in game.gd GameState._ready()
interaction_mode_changed(new, previous) GameState custom, existing, unchanged the interaction_mode setter, now after selected = null BuildingPanel (unchanged) unchanged

2.5 Wiring pattern

click (InputEventMouseButton, "select") -> SelectionController._unhandled_input(event)
        return unless interaction_mode == NONE
        select_at(pos)
            pick_at(pos) -> Entity                    # query, changes nothing
            GameState.selected = <that>               # assignment, not emit
                setter: selection_changed.emit(value, previous)
                    -> SelectionController._on_selection_changed(new, previous)
                           _set_highlighted(previous, false) -> Pawns/Buildings.set_highlighted(id, false)
                           _set_highlighted(new, true)       -> Pawns/Buildings.set_highlighted(id, true)

GameState.interaction_mode = X (X != current) -> setter
        selected = null   -> selection_changed.emit(null, old)   # only if something was selected
        interaction_mode_changed.emit(X, previous)

Pawns.remove(id) / Buildings.remove(id) -> pawn_removed / building_removed.emit(record)
        -> GameState._clear_if_selected(record)   # selected = null only if it is that record
  • Queries are pure and never emit. pick(), pick_at(), SelectionHighlight.material() and is_applied() return values only, so tests call them with no wiring.
  • Only the selected setter emits selection_changed. Nothing calls selection_changed.emit() by hand. That rule is what keeps the value and the signal in step, as with interaction_mode.
  • Signals ignore return values. select_at() returns the selection for the caller's convenience, and that is a method return. _on_selection_changed() returns nothing, because nothing could receive it.

2.6 Scene structure

Game (Node3D, game.gd)
├── ...
├── PlacementController (Node3D, placement_controller.gd)
├── DemolishController (Node3D, demolish_controller.gd)
├── SelectionController (Node, selection_controller.gd)   # NEW. Directly after DemolishController
├── WorldEnvironment
└── ...
  • SelectionController is a sibling of CameraRig, Pawns and Buildings, because its @onready paths are $"../CameraRig", $"../Pawns" and $"../Buildings". It extends Node, not Node3D: the registries do the raycasting, and the controller has no position of its own.
  • Its position in the tree changes when it receives input, not whether it acts: _unhandled_input runs bottom-up, so it gets clicks before the other two controllers. The NONE-mode gate is what makes that safe, the same as in #72.
  • pawn.tscn is unchanged. Its nodes MeshInstance3D and Area3D / CollisionShape3D are reached by name: Pawns._spawn_view() uses "Area3D", and the tests read view.get_node("Area3D").
  • Node names the tests depend on in game.tscn: Map, Buildings, Pawns, CameraRig (existing) and SelectionController (new).
  • Set in code, not in the scene: the Area3D's collision_layer, collision_mask = 0, and meta.

2.7 Tests

test/unit/test_selection.gd, GameState alone. No scene; the registries are built with no map.

  • The property: starts null, holds a pawn, holds a building, and rejects a Task with the error text above, leaving the selection unchanged and emitting nothing.
  • The signal: fires once with (new, previous) on each change, including a change to null. It does not fire when the same entity is selected again.
  • Mode interplay:
    • Any real mode change clears the selection and emits selection_changed(null, old).
    • The selection is already null inside an interaction_mode_changed handler.
    • Setting the mode it already has (NONE -> NONE) keeps the selection.
    • A mode change with nothing selected emits no selection_changed.
  • Removal: removing the selected pawn or building clears the selection. Removing a different one does not.
  • SELECT is gone from InteractionMode.

test/unit/test_selection_highlight.gd, SelectionHighlight on a bare MeshInstance3D.

  • material() is one shared instance.
  • apply(true) / apply(false) set and clear material_overlay, and never touch material_override.
  • is_applied() is an identity check, so another overlay does not count.
  • A null geometry is silent.

test/unit/test_selection_picking.gd, the real game.tscn, as in test_building_picking.gd. The stub pawns are replaced with pawns at known cells, and the clock is paused through player_paused.

  • Pawns.pick(): hits a pawn aimed at its centre, misses open ground, returns null quietly with no camera, and ignores buildings.
  • Config: the pawn's Area3D is on Pawns.PICK_LAYER only, PICK_LAYER is layer 3, and layer 3 is named "pawns".
  • SelectionController:
    • select_at selects a pawn, a building, or nothing (deselecting).
    • A pawn standing inside a building's collider wins.
    • A synthesised select click selects in NONE, does nothing in PLACE or DEMOLISH, and is marked handled in NONE.
  • Highlights:
    • Selecting highlights the view. Changing the selection moves the highlight. A mode change clears it.
    • The building highlight survives a set_status() refresh, alongside the status tint.
    • The pawn highlight survives a flash.
    • Removing the selected pawn or building is silent.

Red state on the branch (56 new tests; main runs 36 scripts with 816 tests):

  • test_selection.gd runs, and all 21 of its tests fail on Invalid assignment of property or key 'selected'.
  • test_selection_highlight.gd (8 tests) fails to parse on Identifier "SelectionHighlight" not declared.
  • test_selection_picking.gd (27 tests) fails to parse on the missing SelectionHighlight / SelectionController.

The spec was checked against a throwaway implementation that followed this outline. All 872 tests passed, including Pawns.pick() hitting an Area3D under Jolt, which was the one part that hadn't been verified. Three deliberate breaks were each caught:

  • leaving collide_with_areas at its default fails 4 tests
  • clearing the selection after the mode signal instead of before fails 1
  • putting the highlight in material_override fails 4

The throwaway was then discarded.

AC coverage: every AC is covered by tests except "visible highlight", which is a headed check by eye. Take a screenshot of a selected pawn and a selected building, and confirm the outline is visible and doesn't split on the boxy building meshes. There is no tools/ harness for this yet; tools/screenshot_building_states.gd is the pattern to copy, with Node-typed references only (CLAUDE.md).

2.8 Checked

  • Every signature is above with full types.
  • selection_changed has an emitter, a listener and a connection site. pawn_removed and building_removed each gain one listener.
  • No query emits, and no flow relies on a signal's return value.
  • Every node the tests reach is in 2.6 with its exact name.
  • Every existing method the code leans on is in 2.3.
  • Every error path has its return value, and the one matched error text is given.
  • The new emit(...) call is written out: selection_changed.emit(value, previous).
  • The CLAUDE.md design constraints (threat cycles, upkeep, subsistence floor) don't apply here. The convention that does apply is static typing throughout.
## Spec (drafted with Claude) Tests are on branch `78-select-pawn-or-building`: `test_selection.gd`, `test_selection_highlight.gd` and `test_selection_picking.gd`. They and this outline are one spec, so if one changes, the other changes with it. Design calls made here, beyond the ticket's Decisions list: - **The highlight uses `material_overlay`, not `material_override`.** `material_override` is already taken twice. `BuildingView.apply()` rewrites it on every status change, and `PawnView.flash()` sets it and then nulls it when the flash ends. A selection tint in that slot would be wiped by the next refresh or flash, and would itself wipe the state tint. `material_overlay` is a separate slot that draws on top of whatever the mesh already wears, and nothing in the project uses it yet. #123 should use it too, with its own material. - **The highlight is an outline shell**: one shared, unshaded overlay material with `grow` enabled and front-face culling. Risk: `grow` pushes vertices out along their normals, so a flat-shaded box can split at the edges. If the headed check shows that, fall back to a translucent tint overlay (`CULL_BACK`, alpha about 0.35, no grow). The tests only check *which* material is in the overlay slot, so they pass either way. - **The registries own their views**, so each one gets a `set_highlighted(id, on)`, and `SelectionController` never touches a view directly. Both are silent when there is no view. That is what makes un-highlighting a pawn that has just been removed safe: `Pawns.remove()` erases the view *before* `pawn_removed` fires. - **Pawns pick before buildings, even a pawn behind a building.** Each picker ignores the other's layer, so a pawn hidden behind a building is still picked through it. Pawns are 0.5 wide, so this is rare. It is a known limitation, not a bug to fix here. ### 2.1 Files | File | New / changed | Change | |---|---|---| | `globals/GameState.gd` | changed | Remove `SELECT` from `InteractionMode`. Add `selected`, `selection_changed`, and `_clear_if_selected()` connected in `_ready()`. The mode setter clears the selection. | | `project.godot` | changed | `[input]`: new `select` action, left mouse button (copy `place_building`'s event). `[layer_names]`: `3d_physics/layer_3="pawns"`. | | `entities/pawns/pawns.gd` | changed | `PICK_LAYER`, `PICK_META`, `pick()`, `set_highlighted()`. `_spawn_view()` sets the Area3D's layer, mask and meta. | | `entities/pawns/pawn_view.gd` | changed | `set_highlighted()`, `is_highlighted()` | | `entities/buildings/buildings.gd` | changed | `set_highlighted()` | | `entities/selection/selection_highlight.gd` | **new** | `class_name SelectionHighlight`: the shared overlay material and how to apply it | | `entities/selection/selection_controller.gd` | **new** | `class_name SelectionController`: a click in NONE mode becomes a selection, and the selection becomes a highlight | | `common/game.tscn` | changed | Add a `SelectionController` node (see 2.6) | | `entities/pawns/pawn.tscn` | unchanged | The layer is set in code, next to the meta, the same way `Buildings._add_pick_body()` does it | Run `--headless --editor --quit` after adding the two `class_name`s, or the tests fail to parse rather than fail to pass. ### 2.2 API: full signatures ```gdscript # globals/GameState.gd - autoload, extends Node enum InteractionMode {NONE, PLACE, DEMOLISH, TERRAIN} # SELECT removed; TERRAIN's value moves 4 -> 3, nothing stores it signal selection_changed(new: Entity, previous: Entity) # either side may be null var selected: Entity = null # setter; only a Pawn, a Building or null func _clear_if_selected(entity: Entity) -> void # private; connected in _ready() # entities/pawns/pawns.gd - class_name Pawns extends Node3D const PICK_LAYER: int = 4 # a mask value: bit 3, i.e. layer 3 "pawns". Same convention as Buildings.PICK_LAYER = 2 for layer 2 const PICK_META := &"pawn_id" func pick(camera: Camera3D, screen_position: Vector2) -> Pawn # null: no camera, no hit, or a hit with no live record func set_highlighted(id: int, on: bool) -> void # silent when the pawn has no view # entities/pawns/pawn_view.gd - class_name PawnView extends Node3D func set_highlighted(on: bool) -> void func is_highlighted() -> bool # entities/buildings/buildings.gd - class_name Buildings extends Node3D func set_highlighted(id: int, on: bool) -> void # silent when the building has no view # entities/selection/selection_highlight.gd - class_name SelectionHighlight extends RefCounted # All static; never instantiated. const COLOUR: Color = Color(0.55, 0.85, 1.0) # cool blue: apart from MARKED orange, BLOCKED red and the PLANNED pale blue at the outline's strength const GROW: float = 0.04 # world units the shell stands off the surface static var _material: StandardMaterial3D = null static func material() -> StandardMaterial3D # built on first call, then the same instance every time static func apply(geometry: GeometryInstance3D, on: bool) -> void static func is_applied(geometry: GeometryInstance3D) -> bool # entities/selection/selection_controller.gd - class_name SelectionController extends Node @onready var rig: CameraRig = $"../CameraRig" @onready var pawns: Pawns = $"../Pawns" @onready var buildings: Buildings = $"../Buildings" func pick_at(screen_position: Vector2) -> Entity # query only, changes nothing; null when nothing is there func select_at(screen_position: Vector2) -> Entity # pick_at(), then assign GameState.selected; returns what is now selected func _unhandled_input(event: InputEvent) -> void func _on_selection_changed(new: Entity, previous: Entity) -> void func _set_highlighted(entity: Entity, on: bool) -> void ``` **Error paths** | Call | Condition | Result | Reports | |---|---|---|---| | `GameState.selected = x` | `x` is not null, a `Pawn` or a `Building` (e.g. a `Task`, which also extends `Entity`) | unchanged, no signal | `push_error("Only a Pawn or a Building can be selected, not %s" % x)`. **Tests match `"Only a Pawn or a Building can be selected"`.** | | `Pawns.pick()` | `camera == null` | `null` | silent | | `Pawns.pick()` | the ray hits nothing | `null` | silent | | `Pawns.pick()` | the hit has no `PICK_META`, or its id has no record (a removed pawn whose view is still queued for freeing) | `null` | silent. Use `_pawns.get(id)`, **not** `get_pawn()`, which calls `push_error` | | `Pawns.set_highlighted()` / `Buildings.set_highlighted()` | no view for `id` | nothing | silent | | `SelectionHighlight.apply()` / `is_applied()` | `geometry == null` | nothing / `false` | silent | ### 2.3 Each method's job, in one line **New** - `GameState.selected` setter: return if the value is unchanged. `push_error` and return if it is not null, a Pawn or a Building. Otherwise keep the old value as `previous`, assign, then `selection_changed.emit(value, previous)`. - `GameState.interaction_mode` setter, changed: after assigning the mode and **before** `interaction_mode_changed.emit(...)`, set `selected = null`. A mode listener then never sees a selection left over from the old mode. Inside a setter, assigning a *different* property still goes through that property's setter, so the selection signal fires; only assigning the setter's own property skips it. - `GameState._clear_if_selected(entity)`: if `entity == selected`, set `selected = null`. Connected to both `pawn_removed` and `building_removed`. Their parameters are `Pawn` and `Building`, and both extend `Entity`, so one handler serves both. - `Pawns.pick()`: the same ray as `Buildings.pick()`, plus **`query.collide_with_areas = true`** and `query.collide_with_bodies = false`. Ray queries skip `Area3D`s by default, and the pawn's collider is an `Area3D`. - `Pawns.set_highlighted(id, on)`: `if _views.has(id): _views[id].set_highlighted(on)`. - `PawnView.set_highlighted(on)`: `SelectionHighlight.apply(mesh_instance_3d, on)`. `is_highlighted()`: `SelectionHighlight.is_applied(mesh_instance_3d)`. - `Buildings.set_highlighted(id, on)`: `if _views.has(id): SelectionHighlight.apply(_views[id], on)`. - `SelectionHighlight.material()`: on the first call, build a `StandardMaterial3D` with `shading_mode = SHADING_MODE_UNSHADED`, `albedo_color = COLOUR`, `cull_mode = CULL_FRONT`, `grow = true` and `grow_amount = GROW`, and store it in `_material`. Return `_material` every time. It is shared deliberately. `BuildingView`'s warning against shared materials is about per-building state, and this material carries none and is never modified after it is built. - `SelectionHighlight.apply(geometry, on)`: `geometry.material_overlay = material() if on else null`. It **never** touches `material_override`. - `SelectionHighlight.is_applied(geometry)`: `geometry.material_overlay == material()`. An identity check, so #123's overlay will not read as selected. - `SelectionController._ready()`: `GameState.selection_changed.connect(_on_selection_changed)`. - `SelectionController._unhandled_input(event)`: return if `GameState.interaction_mode != NONE`. On `event.is_action_pressed("select")`, call `select_at((event as InputEventMouseButton).position)` then `get_viewport().set_input_as_handled()`. The handled mark is safe: in NONE nothing else wants a left click, and `CameraRig` does not use the left button. - `SelectionController.pick_at(pos)`: `pawns.pick(rig.camera(), pos)`. If that returns null, return `buildings.pick(rig.camera(), pos)`. - `SelectionController.select_at(pos)`: `GameState.selected = pick_at(pos)`, then `return GameState.selected`. Clicking empty ground assigns null, which is the deselect. - `SelectionController._on_selection_changed(new, previous)`: `_set_highlighted(previous, false)`, then `_set_highlighted(new, true)`, in that order. - `SelectionController._set_highlighted(entity, on)`: return on null. A `Pawn` calls `pawns.set_highlighted(entity.id, on)`; a `Building` calls `buildings.set_highlighted(entity.id, on)`. Use `is` and cast, as `Buildings._task_blocked()` does. **Existing code this relies on** (read, not guessed) - `Buildings.pick(camera, screen_position) -> Building`: a ray masked to `PICK_LAYER = 2`, bodies only, with the id read from `PICK_META` on the `StaticBody3D`. Null for no camera, no hit, or no meta. - `Buildings.remove(id)` emits `building_removed` **before** `_despawn_view()`, so the view still exists when the selection clears. `set_highlighted(id, false)` then reaches a view that is about to be freed, which is harmless. `_despawn_view()` uses `free()`, so the view is gone straight after. - `Pawns.remove(id)` erases `_views[id]` and `queue_free()`s the view **before** it emits `pawn_removed`. So `set_highlighted(id, false)` finds no view and returns quietly, and `pick()` can still hit the dying `Area3D` for one frame, which is why `pick()` uses `_pawns.get()`. - `Pawns._spawn_view()` instantiates `pawn.tscn`, names it `PawnView_<id>`, `add_child`s it, then positions it `pawn_height / 2` above the spawn point. Set the Area3D's layer, mask and meta here, on `view.get_node("Area3D")`, before `add_child()`. - `BuildingView.apply(view, building)` writes `material_override` and `cast_shadow` only, never `material_overlay`. That is why a highlight survives `set_status()`, `set_build_progress()` and the demolition marks. - `PawnView.flash()` sets `material_override` and `_end_flash()` nulls it. Neither touches `material_overlay`. - `CameraRig.camera() -> Camera3D`: the live camera, which `DemolishController` already uses for `Buildings.pick()`. ### 2.4 Signals | Signal | Declared on | Custom / built-in | Emitted by (method) | Listened to by (method) | Connected where | |---|---|---|---|---|---| | `selection_changed(new: Entity, previous: Entity)` | `GameState` | custom, **new** | the `GameState.selected` setter: `selection_changed.emit(value, previous)` | `SelectionController._on_selection_changed()`. Later also #140, #141, #142 | `SelectionController._ready()` | | `pawn_removed(pawn: Pawn)` | `GameState` | custom, existing | `Pawns.remove()` | `GameState._clear_if_selected()` (new listener) | `GameState._ready()` | | `building_removed(building: Building)` | `GameState` | custom, existing | `Buildings.remove()` | `GameState._clear_if_selected()` (new listener), plus the existing ones in `game.gd` | `GameState._ready()` | | `interaction_mode_changed(new, previous)` | `GameState` | custom, existing, **unchanged** | the `interaction_mode` setter, now after `selected = null` | `BuildingPanel` (unchanged) | unchanged | ### 2.5 Wiring pattern ``` click (InputEventMouseButton, "select") -> SelectionController._unhandled_input(event) return unless interaction_mode == NONE select_at(pos) pick_at(pos) -> Entity # query, changes nothing GameState.selected = <that> # assignment, not emit setter: selection_changed.emit(value, previous) -> SelectionController._on_selection_changed(new, previous) _set_highlighted(previous, false) -> Pawns/Buildings.set_highlighted(id, false) _set_highlighted(new, true) -> Pawns/Buildings.set_highlighted(id, true) GameState.interaction_mode = X (X != current) -> setter selected = null -> selection_changed.emit(null, old) # only if something was selected interaction_mode_changed.emit(X, previous) Pawns.remove(id) / Buildings.remove(id) -> pawn_removed / building_removed.emit(record) -> GameState._clear_if_selected(record) # selected = null only if it is that record ``` - **Queries are pure and never emit.** `pick()`, `pick_at()`, `SelectionHighlight.material()` and `is_applied()` return values only, so tests call them with no wiring. - **Only the `selected` setter emits `selection_changed`.** Nothing calls `selection_changed.emit()` by hand. That rule is what keeps the value and the signal in step, as with `interaction_mode`. - **Signals ignore return values.** `select_at()` returns the selection for the caller's convenience, and that is a method return. `_on_selection_changed()` returns nothing, because nothing could receive it. ### 2.6 Scene structure ``` Game (Node3D, game.gd) ├── ... ├── PlacementController (Node3D, placement_controller.gd) ├── DemolishController (Node3D, demolish_controller.gd) ├── SelectionController (Node, selection_controller.gd) # NEW. Directly after DemolishController ├── WorldEnvironment └── ... ``` - `SelectionController` is a sibling of `CameraRig`, `Pawns` and `Buildings`, because its `@onready` paths are `$"../CameraRig"`, `$"../Pawns"` and `$"../Buildings"`. It extends `Node`, not `Node3D`: the registries do the raycasting, and the controller has no position of its own. - Its position in the tree changes when it receives input, not whether it acts: `_unhandled_input` runs bottom-up, so it gets clicks before the other two controllers. The NONE-mode gate is what makes that safe, the same as in #72. - `pawn.tscn` is unchanged. Its nodes `MeshInstance3D` and `Area3D` / `CollisionShape3D` are reached by name: `Pawns._spawn_view()` uses `"Area3D"`, and the tests read `view.get_node("Area3D")`. - Node names the tests depend on in `game.tscn`: `Map`, `Buildings`, `Pawns`, `CameraRig` (existing) and **`SelectionController`** (new). - Set in code, not in the scene: the Area3D's `collision_layer`, `collision_mask = 0`, and meta. ### 2.7 Tests **`test/unit/test_selection.gd`**, `GameState` alone. No scene; the registries are built with no map. - The property: starts null, holds a pawn, holds a building, and rejects a `Task` with the error text above, leaving the selection unchanged and emitting nothing. - The signal: fires once with `(new, previous)` on each change, including a change to null. It does not fire when the same entity is selected again. - Mode interplay: - Any real mode change clears the selection and emits `selection_changed(null, old)`. - The selection is already null inside an `interaction_mode_changed` handler. - Setting the mode it already has (NONE -> NONE) keeps the selection. - A mode change with nothing selected emits no `selection_changed`. - Removal: removing the selected pawn or building clears the selection. Removing a different one does not. - `SELECT` is gone from `InteractionMode`. **`test/unit/test_selection_highlight.gd`**, `SelectionHighlight` on a bare `MeshInstance3D`. - `material()` is one shared instance. - `apply(true)` / `apply(false)` set and clear `material_overlay`, and never touch `material_override`. - `is_applied()` is an identity check, so another overlay does not count. - A null geometry is silent. **`test/unit/test_selection_picking.gd`**, the real `game.tscn`, as in `test_building_picking.gd`. The stub pawns are replaced with pawns at known cells, and the clock is paused through `player_paused`. - `Pawns.pick()`: hits a pawn aimed at its centre, misses open ground, returns null quietly with no camera, and ignores buildings. - Config: the pawn's Area3D is on `Pawns.PICK_LAYER` only, `PICK_LAYER` is layer 3, and layer 3 is named `"pawns"`. - `SelectionController`: - `select_at` selects a pawn, a building, or nothing (deselecting). - A pawn standing inside a building's collider wins. - A synthesised `select` click selects in NONE, does nothing in PLACE or DEMOLISH, and is marked handled in NONE. - Highlights: - Selecting highlights the view. Changing the selection moves the highlight. A mode change clears it. - The building highlight survives a `set_status()` refresh, alongside the status tint. - The pawn highlight survives a flash. - Removing the selected pawn or building is silent. **Red state on the branch** (56 new tests; `main` runs 36 scripts with 816 tests): - `test_selection.gd` runs, and all 21 of its tests fail on `Invalid assignment of property or key 'selected'`. - `test_selection_highlight.gd` (8 tests) fails to parse on `Identifier "SelectionHighlight" not declared`. - `test_selection_picking.gd` (27 tests) fails to parse on the missing `SelectionHighlight` / `SelectionController`. The spec was checked against a throwaway implementation that followed this outline. All 872 tests passed, including `Pawns.pick()` hitting an `Area3D` under Jolt, which was the one part that hadn't been verified. Three deliberate breaks were each caught: - leaving `collide_with_areas` at its default fails 4 tests - clearing the selection after the mode signal instead of before fails 1 - putting the highlight in `material_override` fails 4 The throwaway was then discarded. **AC coverage**: every AC is covered by tests except "visible highlight", which is a **headed check by eye**. Take a screenshot of a selected pawn and a selected building, and confirm the outline is visible and doesn't split on the boxy building meshes. There is no `tools/` harness for this yet; `tools/screenshot_building_states.gd` is the pattern to copy, with `Node`-typed references only (CLAUDE.md). ### 2.8 Checked - [x] Every signature is above with full types. - [x] `selection_changed` has an emitter, a listener and a connection site. `pawn_removed` and `building_removed` each gain one listener. - [x] No query emits, and no flow relies on a signal's return value. - [x] Every node the tests reach is in 2.6 with its exact name. - [x] Every existing method the code leans on is in 2.3. - [x] Every error path has its return value, and the one matched error text is given. - [x] The new `emit(...)` call is written out: `selection_changed.emit(value, previous)`. - [x] The CLAUDE.md design constraints (threat cycles, upkeep, subsistence floor) don't apply here. The convention that does apply is static typing throughout.
Author
Owner

Decision, 2026-10-09: tint, not outline

The spec's outline-shell highlight (front-culled grow overlay) failed the headed check:

  • On a built building, the rim was about 1px wide (GROW = 0.04).
  • On a translucent site, the shell's inner back wall showed through the building, so it read as a blue fill instead of an outline.
  • It would also have split at the corners of hard-edged low-poly models.
  • Its pale blue clashed with the PLANNED tint.

We switched to the fallback the spec had already named: a translucent tint in the same material_overlay slot (CULL_BACK, TRANSPARENCY_ALPHA, alpha 0.4, unshaded, no grow). No tests changed, because they pin the slot, the shared instance, UNSHADED and COLOUR, not how the material draws.

The pawn highlight is still subtle, because the tint colour is close to the pawn's own teal. That is accepted for now and tracked in #143.

## Decision, 2026-10-09: tint, not outline The spec's outline-shell highlight (front-culled `grow` overlay) failed the headed check: - On a **built** building, the rim was about 1px wide (`GROW = 0.04`). - On a **translucent** site, the shell's inner back wall showed through the building, so it read as a blue fill instead of an outline. - It would also have split at the corners of hard-edged low-poly models. - Its pale blue clashed with the PLANNED tint. We switched to the fallback the spec had already named: a translucent tint in the same `material_overlay` slot (`CULL_BACK`, `TRANSPARENCY_ALPHA`, alpha 0.4, unshaded, no grow). No tests changed, because they pin the slot, the shared instance, `UNSHADED` and `COLOUR`, not how the material draws. The pawn highlight is still subtle, because the tint colour is close to the pawn's own teal. That is accepted for now and tracked in #143.
Sign in to join this conversation.
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.

Reference
liamjd/UntitledColonyBuilder#78
No description provided.