Select a pawn or building on the map #78
Labels
No labels
assets
bug
duplicate
enhancement
help wanted
invalid
question
testing
ui
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Blocks
#136 Worker slot control on a selected construction site
liamjd/UntitledColonyBuilder
#140 Pawn list in the HUD, selectable
liamjd/UntitledColonyBuilder
#141 Building list in the HUD, selectable
liamjd/UntitledColonyBuilder
#142 Inspector panel for the selected pawn or building
liamjd/UntitledColonyBuilder
Reference
liamjd/UntitledColonyBuilder#78
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 (aGameState.placingflag,building_placement_canceled, acommand_moveaction,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.InteractionMode.SELECTis removed from the enum. Its comment inGameState.gdreserved it for this ticket, but it isn't needed under that decision. Adding it back later is a single enum case._unhandled_inputoninteraction_mode == NONE, the same wayPlacementControllerandDemolishControllergate on their modes._unhandled_inputis delivered bottom-up throughgame.tscn, so a controller outside its mode must return without marking the event handled (see #72's commit message).Selection state:
GameState.selected: Entity, wherenullmeans nothing is selected. It is a setter-backed property withsignal selection_changed(new: Entity, previous: Entity), the same pattern asinteraction_mode.selected_pawn_idplusselected_building_id, because two ids can both be set and one reference cannot. This is the same argument #72 made for the mode enum.Pawnor aBuilding.Taskalso extendsEntity; how a wrong type is rejected is decided in the outline.interaction_modeclears the selection (decided 2026-09-30). It happens in one place, the mode setter or a handler oninteraction_mode_changed, and the outline picks which.pawn_removedorbuilding_removed, so nothing holds a reference to a record that no longer exists.Picking:
Buildings.pick(camera, screen_position) -> Buildingalready 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.Pawns.pick(camera, screen_position) -> Pawn:Area3D(pawn.tscn) on its own layer 3, named"pawns"in[layer_names]. It is on default layer 1 today, with the terrain.collide_with_areas = true. Ray queries ignoreArea3Ds by default, so without it the pick silently never hits.PlacementControllerkeeps 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:
select, bound to the left mouse button. It uses the same physical button asplace_building; the mode decides which one acts, not the binding.CameraRig, so selecting does not need the right-click press/release/drag check.Highlight:
PawnViewgets a visible highlight: a ring under the capsule, or a material tint.Acceptance criteria
GameState.selectedto that pawn and shows its highlight.GameState.selectedto that building and shows its highlight.interaction_modeclears the selection.selection_changedfires once per actual change, carrying the new and previous values, and not at all when the same entity is selected again.InteractionMode.SELECTis removed and nothing references it."pawns"), andPawns.pick()finds a pawn'sArea3D.GameState.selectedrules and bothpick()functions. A headed check, looked at by eye, gives a screenshot of a selected pawn and a selected building.Decisions
SELECTis removed from the enum.Out of scope
PlacementController/DemolishControllerinto shared code.DemolishController's comment points here, but selection uses the left button and does not need it.Input actions and a shared picker for selecting a pawnto Select a pawn or building on the mapSpec (drafted with Claude)
Tests are on branch
78-select-pawn-or-building:test_selection.gd,test_selection_highlight.gdandtest_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:
material_overlay, notmaterial_override.material_overrideis already taken twice.BuildingView.apply()rewrites it on every status change, andPawnView.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_overlayis 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.growenabled and front-face culling. Risk:growpushes 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.set_highlighted(id, on), andSelectionControllernever 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 beforepawn_removedfires.2.1 Files
globals/GameState.gdSELECTfromInteractionMode. Addselected,selection_changed, and_clear_if_selected()connected in_ready(). The mode setter clears the selection.project.godot[input]: newselectaction, left mouse button (copyplace_building's event).[layer_names]:3d_physics/layer_3="pawns".entities/pawns/pawns.gdPICK_LAYER,PICK_META,pick(),set_highlighted()._spawn_view()sets the Area3D's layer, mask and meta.entities/pawns/pawn_view.gdset_highlighted(),is_highlighted()entities/buildings/buildings.gdset_highlighted()entities/selection/selection_highlight.gdclass_name SelectionHighlight: the shared overlay material and how to apply itentities/selection/selection_controller.gdclass_name SelectionController: a click in NONE mode becomes a selection, and the selection becomes a highlightcommon/game.tscnSelectionControllernode (see 2.6)entities/pawns/pawn.tscnBuildings._add_pick_body()does itRun
--headless --editor --quitafter adding the twoclass_names, or the tests fail to parse rather than fail to pass.2.2 API: full signatures
Error paths
GameState.selected = xxis not null, aPawnor aBuilding(e.g. aTask, which also extendsEntity)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 == nullnullPawns.pick()nullPawns.pick()PICK_META, or its id has no record (a removed pawn whose view is still queued for freeing)null_pawns.get(id), notget_pawn(), which callspush_errorPawns.set_highlighted()/Buildings.set_highlighted()idSelectionHighlight.apply()/is_applied()geometry == nullfalse2.3 Each method's job, in one line
New
GameState.selectedsetter: return if the value is unchanged.push_errorand return if it is not null, a Pawn or a Building. Otherwise keep the old value asprevious, assign, thenselection_changed.emit(value, previous).GameState.interaction_modesetter, changed: after assigning the mode and beforeinteraction_mode_changed.emit(...), setselected = 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): ifentity == selected, setselected = null. Connected to bothpawn_removedandbuilding_removed. Their parameters arePawnandBuilding, and both extendEntity, so one handler serves both.Pawns.pick(): the same ray asBuildings.pick(), plusquery.collide_with_areas = trueandquery.collide_with_bodies = false. Ray queries skipArea3Ds by default, and the pawn's collider is anArea3D.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 aStandardMaterial3Dwithshading_mode = SHADING_MODE_UNSHADED,albedo_color = COLOUR,cull_mode = CULL_FRONT,grow = trueandgrow_amount = GROW, and store it in_material. Return_materialevery 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 touchesmaterial_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 ifGameState.interaction_mode != NONE. Onevent.is_action_pressed("select"), callselect_at((event as InputEventMouseButton).position)thenget_viewport().set_input_as_handled(). The handled mark is safe: in NONE nothing else wants a left click, andCameraRigdoes not use the left button.SelectionController.pick_at(pos):pawns.pick(rig.camera(), pos). If that returns null, returnbuildings.pick(rig.camera(), pos).SelectionController.select_at(pos):GameState.selected = pick_at(pos), thenreturn 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. APawncallspawns.set_highlighted(entity.id, on); aBuildingcallsbuildings.set_highlighted(entity.id, on). Useisand cast, asBuildings._task_blocked()does.Existing code this relies on (read, not guessed)
Buildings.pick(camera, screen_position) -> Building: a ray masked toPICK_LAYER = 2, bodies only, with the id read fromPICK_METAon theStaticBody3D. Null for no camera, no hit, or no meta.Buildings.remove(id)emitsbuilding_removedbefore_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()usesfree(), so the view is gone straight after.Pawns.remove(id)erases_views[id]andqueue_free()s the view before it emitspawn_removed. Soset_highlighted(id, false)finds no view and returns quietly, andpick()can still hit the dyingArea3Dfor one frame, which is whypick()uses_pawns.get().Pawns._spawn_view()instantiatespawn.tscn, names itPawnView_<id>,add_childs it, then positions itpawn_height / 2above the spawn point. Set the Area3D's layer, mask and meta here, onview.get_node("Area3D"), beforeadd_child().BuildingView.apply(view, building)writesmaterial_overrideandcast_shadowonly, nevermaterial_overlay. That is why a highlight survivesset_status(),set_build_progress()and the demolition marks.PawnView.flash()setsmaterial_overrideand_end_flash()nulls it. Neither touchesmaterial_overlay.CameraRig.camera() -> Camera3D: the live camera, whichDemolishControlleralready uses forBuildings.pick().2.4 Signals
selection_changed(new: Entity, previous: Entity)GameStateGameState.selectedsetter:selection_changed.emit(value, previous)SelectionController._on_selection_changed(). Later also #140, #141, #142SelectionController._ready()pawn_removed(pawn: Pawn)GameStatePawns.remove()GameState._clear_if_selected()(new listener)GameState._ready()building_removed(building: Building)GameStateBuildings.remove()GameState._clear_if_selected()(new listener), plus the existing ones ingame.gdGameState._ready()interaction_mode_changed(new, previous)GameStateinteraction_modesetter, now afterselected = nullBuildingPanel(unchanged)2.5 Wiring pattern
pick(),pick_at(),SelectionHighlight.material()andis_applied()return values only, so tests call them with no wiring.selectedsetter emitsselection_changed. Nothing callsselection_changed.emit()by hand. That rule is what keeps the value and the signal in step, as withinteraction_mode.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
SelectionControlleris a sibling ofCameraRig,PawnsandBuildings, because its@onreadypaths are$"../CameraRig",$"../Pawns"and$"../Buildings". It extendsNode, notNode3D: the registries do the raycasting, and the controller has no position of its own._unhandled_inputruns 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.tscnis unchanged. Its nodesMeshInstance3DandArea3D/CollisionShape3Dare reached by name:Pawns._spawn_view()uses"Area3D", and the tests readview.get_node("Area3D").game.tscn:Map,Buildings,Pawns,CameraRig(existing) andSelectionController(new).collision_layer,collision_mask = 0, and meta.2.7 Tests
test/unit/test_selection.gd,GameStatealone. No scene; the registries are built with no map.Taskwith the error text above, leaving the selection unchanged and emitting nothing.(new, previous)on each change, including a change to null. It does not fire when the same entity is selected again.selection_changed(null, old).interaction_mode_changedhandler.selection_changed.SELECTis gone fromInteractionMode.test/unit/test_selection_highlight.gd,SelectionHighlighton a bareMeshInstance3D.material()is one shared instance.apply(true)/apply(false)set and clearmaterial_overlay, and never touchmaterial_override.is_applied()is an identity check, so another overlay does not count.test/unit/test_selection_picking.gd, the realgame.tscn, as intest_building_picking.gd. The stub pawns are replaced with pawns at known cells, and the clock is paused throughplayer_paused.Pawns.pick(): hits a pawn aimed at its centre, misses open ground, returns null quietly with no camera, and ignores buildings.Pawns.PICK_LAYERonly,PICK_LAYERis layer 3, and layer 3 is named"pawns".SelectionController:select_atselects a pawn, a building, or nothing (deselecting).selectclick selects in NONE, does nothing in PLACE or DEMOLISH, and is marked handled in NONE.set_status()refresh, alongside the status tint.Red state on the branch (56 new tests;
mainruns 36 scripts with 816 tests):test_selection.gdruns, and all 21 of its tests fail onInvalid assignment of property or key 'selected'.test_selection_highlight.gd(8 tests) fails to parse onIdentifier "SelectionHighlight" not declared.test_selection_picking.gd(27 tests) fails to parse on the missingSelectionHighlight/SelectionController.The spec was checked against a throwaway implementation that followed this outline. All 872 tests passed, including
Pawns.pick()hitting anArea3Dunder Jolt, which was the one part that hadn't been verified. Three deliberate breaks were each caught:collide_with_areasat its default fails 4 testsmaterial_overridefails 4The 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.gdis the pattern to copy, withNode-typed references only (CLAUDE.md).2.8 Checked
selection_changedhas an emitter, a listener and a connection site.pawn_removedandbuilding_removedeach gain one listener.emit(...)call is written out:selection_changed.emit(value, previous).Decision, 2026-10-09: tint, not outline
The spec's outline-shell highlight (front-culled
growoverlay) failed the headed check:GROW = 0.04).We switched to the fallback the spec had already named: a translucent tint in the same
material_overlayslot (CULL_BACK,TRANSPARENCY_ALPHA, alpha 0.4, unshaded, no grow). No tests changed, because they pin the slot, the shared instance,UNSHADEDandCOLOUR, 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.