Left-clicking a valid site places the building #54
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.
Dependencies
No dependencies set.
Reference
liamjd/UntitledColonyBuilder#54
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?
Left-clicking a valid site commits the building: a solid mesh is spawned on the terrain in the type's colour, the cells it occupies are recorded so nothing else can be built on them, and placement mode ends (ghost gone, highlight gone, panel card un-toggled).
This is the last open acceptance criterion of the #1 milestone's core flow — #33 → #39 → #37 → #44 all end at a ghost that never becomes a building. It is also the issue that introduces the first piece of persistent colony state, so the data structure it picks is load-bearing for construction, jobs, haulage and saves later on. That, not the click handling, is the interesting part.
Scope
In:
OCCUPIEDplacement rule.Out (each wants its own issue if it isn't already one):
PLANNED→UNDER_CONSTRUCTION→BUILTprogression. Buildings land asBUILT.BuildingType.unique— a second colony centre is currently placeable. The field exists and the milestone lists Uniqueness, but it is a separate rule with its own tests; file it as a follow-up.drawn_level_at()when #46 lands.Data model
Split the record from the view. A
Buildingis data; theMeshInstance3Dis one way of drawing it. Anything that later asks "what is on this tile", "which buildings need power", "what is in this warehouse" wants the record and not a node, and the node should be reconstructible from the record alone.RefCountedrather thanResource: it is not authored in the editor, not shared, and does not want auid://. It is also not aNode— nothing about a building record needs the tree, andRefCountedkeeps the registry tests free ofautofree()bookkeeping (CLAUDE.md:Nodes in tests needautofree(),RefCountedmust never be freed by hand).Store the inputs, not the outputs
anchor+rotation_steps+typefully determine the occupied cells, becauseBuildingFootprint.rotated()is pure. So derive the cells rather than storing them as the source of truth:The registry's occupancy dictionary is then a cache of that derivation, which is fine, rather than a second copy of the truth that can disagree with it. This is also what makes the record trivially serialisable later: three scalars and a
type.idstring, no cell arrays to keep in sync.The registry
A
Node3Dsibling ofMapingame.tscn, so it can parent the spawned meshes and inherit the map's transform, and soPlacementControllerreaches it the same way it already reaches the map:@onready var buildings: Buildings = $"../Buildings". Do not parent placed buildings toPlacementController— that node owns the transient ghost and gets cleared on cancel.remove()is in the API even with no caller, because it is the thing that proves the occupancy map is maintained in one place rather than smeared acrossplace(). A registry that can only add is one where the second developer to touch it invents their own removal path.Dictionary-as-set for
_occupancyfollows the existing house pattern (BuildingFootprint._has_disjoined_cells()); GDScript still has noSet.The OCCUPIED rule
PlacementRules.evaluate()gains a reason and a parameter:Pass the dictionary, not the registry.
PlacementRulesis a static, dependency-free pure function today andtest_placement_rules.gdleans on that hard; taking aBuildingsnode would drag aNode3Dinto every one of those tests for no gain. A default of{}also keeps every existing call site and test compiling unchanged.Append
OCCUPIEDto the end of the enum rather than inserting it in the middle — the values are ordinals and anything serialised or compared numerically shifts under you otherwise.Check order: after
OUT_OF_BOUNDS, beforeWATER. Bounds first because an out-of-range cell is not a meaningful lookup; occupancy before terrain because "there's already a mine there" is the more useful message than "that tile is steep", and a tile with a building on it is by definition one you already found flat enough.In
PlacementController, addPlacementRules.Reason.OCCUPIEDto theINVALID_TINTarm of the match.Committing the placement
Use an input action, not a raw button
#50 moved keybindings into the Input Map (debug panel excepted) and the right-click cancel in
PlacementController._unhandled_input()is a leftover rawMOUSE_BUTTON_RIGHTtest. Add aplace_buildingaction bound to the left mouse button, use it here, and add the row to the keybindings panel. Converting the existing right-click cancel to acancel_placementaction at the same time is welcome but optional — note that it deliberately implements click-with-no-drag (a 4px threshold so a camera drag doesn't cancel), which an action alone does not give you.Don't re-raycast on the click
_physics_process()already raycasts, computes the anchor, rotates the footprint and evaluates the rule, every frame. The click handler must use that answer, not compute a second one — two independent evaluations one frame apart is how you get a building placed on a site the player saw rejected.The existing
_anchor_cache/_reason_cachefields are not safe to reuse for this: they exist to skip_rebuild_highlight()and are only written when something changed, so after any of the three early returns (no type, GUI hovered, ray missed) they hold whatever was true before. Add explicit per-frame state that every path writes:Then the handler is just: if
_hover_valid, callbuildings.place(selected_type, _hover_anchor, _rotation_steps), thencancel_placement(), thenget_viewport().set_input_as_handled().Cancel through the public path
Call
cancel_placement(), not_cancel_placement(). The public one emitsbuilding_placement_canceled, which is what un-toggles the card inBuildingPanel._cancel_placement(). Using the private one leaves the panel button stuck pressed with no ghost — the same class of desync as bug #43.(Ending placement mode after one building is what this issue asks for, and it matches the milestone's "the cursor will be cleared". It does make placing a row of ten mines ten round trips to the panel; if that grates in play, file a follow-up for a sticky / shift-to-repeat mode rather than widening this one.)
Announce it
Add to
GameState, alongside the existing placement signals:Nothing consumes it yet. It is the hook for a building count in the debug overlay, and for resource deduction and job creation later, and adding it now costs a line.
Rendering the placed building
Reuse the ghost's geometry and positioning wholesale — extract the position calculation out of
_physics_process()into a shared helper and have the ghost call it too, withmesh_hover_heightas a parameter that placement passes0.0. If the two compute the site independently they will drift, and the bug shows up as a building that doesn't land where the ghost promised.The maths as it stands:
footprint.calculate_bounds(cells):(anchor + bounds.position + bounds.size * 0.5) * tile_size.max(surface_y(level_at(cell))), minusaabb.position.yto lift the mesh so its base sits on that surface rather than its centre.rotation.y = -deg_to_rad(90.0) * rotation_steps— Godot's +Y rotation is counter-clockwise viewed from above, hence the negation.Differences from the ghost, all of them deliberate:
material_override.BuildingMeshes.build()already bakesplaceholder_colorontoPrimitiveMesh.materialvia_placeholder_material(), so letting the mesh's own material through is how the building gets "its colour as defined in the building type". Setting an override is how you accidentally place a translucent ghost-tinted building.cast_shadowleft on. The ghost explicitly disables it; a real building should cast.BuildingMeshes.build(type, map.tile_size, map.level_height)with the same arguments the ghost uses, so #41's cache (keyed on exactly those three) can return one shared mesh for the ghost and every instance of that type.Things that will bite
TerrainGrid._build_mesh_library()gives the terrain box colliders; a bareMeshInstance3Dhas none. So the placement raycast passes straight through a placed building and still hits the ground underneath, and the ghost keeps drawing over it. Rejecting that site is entirely theOCCUPIEDrule's job — there is no physics fallback quietly covering for you. (When buildings later need to be clickable for selection, that is a collider added on purpose, not a fix for this.)TerrainGrid.world_to_cell()clamps to the grid, so dragging the cursor off the map still yields an in-bounds anchor and a plausible-looking placement near the edge. The footprint's ownin_bounds()check catches the actual overhang; don't add a second guard on the anchor and assume it did._rotation_steps = 0lives in_cancel_placement(). Another reason the click handler ends incancel_placement().level_at()is the sampled level, not the drawn one (#46). On a shoreline the placed mesh sinks exactly as the ghost does. Expected for now; switching both todrawn_level_at()is #46's job.footprint.rotated()returns a copy — cheap, but ifBuilding.cells()ends up called per frame by anything, cache at the call site rather than making the record stateful.TerrainGrid.reroll()/next_shape()would leave every placed building floating over a new map. Either clear on theregeneratedsignal or record it as a known debug-only wart — but decide, don't discover.Tests
test/unit/test_placement_rules.gd— extend, following the file's existing real-TerrainGrid-with-flat-amplitude pattern:OCCUPIED.OCCUPIED(partial overlap is the case naive implementations miss).VALID.occupiedargument entirely behaves as before.OUT_OF_BOUNDS, pinning the check order.test/unit/test_buildings.gd— new, one script per subject:place()returns aBuildingwith a fresh id and marks every rotated cell occupied.occupied_cells()for a rotated non-rectangular footprint (five_thick_corone_by_three_L) covers exactly the shape's cells and not its bounding box — the same bounds-vs-cells trapL_SHAPEpins in the rules tests.building_at()returns the same instance for every cell of a multi-tile building, andnullelsewhere.remove()frees the cells and a subsequentplace()on them succeeds.remove().Node3Dgoes in withadd_child_autofree();BuildingandBuildingTypeareRefCounted/Resourceand must not be freed.Rendering stays unverified by the suite (
gl_compatibilitydraws nothing headless) — the mesh colour, rotation and seating need a headed screenshot check, and that check is worth doing on thefive_thick_cwarehouse at all four rotations, since an asymmetric footprint is where the centring maths shows its errors.Acceptance criteria
mesh_hover_height), is inplaceholder_color, and is rotated to match the ghost at the moment of the click.selected_typeis null, rotation is reset, and the building panel's card is un-toggled.place_buildinginput action and listed in the keybindings panel.GameState.building_placedfires once per placement.test_buildings.gdand the extendedtest_placement_rules.gd.Placing a building on the terrace should place it thereto Left-clicking a valid site places the building