Render PLANNED and UNDER_CONSTRUCTION buildings distinctly from BUILT #71
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#71
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 #5. Depends on #70. See also #119.
A placed-but-unbuilt building needs to look like a plan, not a finished structure, or the player cannot tell what the pawns have done.
AC:
Buildings._spawn_view()deliberately avoids duplicating the ghost, so give the view its own material rather than sharing one)Buildingsupdates the view when the record's status changes, without rebuilding the meshImplementation outline
Four appearances, but only three of them are a
Building.Status. BLOCKED is aTask.Status, not a building one — and it must not become one:ConstructionTask._can_start()gates onstatus in [PLANNED, UNDER_CONSTRUCTION], so abuilding parked in a hypothetical
Building.Status.BLOCKEDwould be permanentlyunstartable and could never unblock itself. The view therefore needs its own state enum,
derived from the record plus one transient flag.
New:
entities/buildings/building_view.gdclass_name BuildingView extends RefCounted, all static, no node state. Pure functions sothe decision table is testable headless — which matters more here than usual, because
gl_compatibilityrenders nothing headless and the suite can never see the result.state_for()mapsBuilding.Statusstraight through, except thatconstruction_blockedoverrides PLANNED/UNDER_CONSTRUCTION with BLOCKED. RUINED fallsthrough to BUILT for now — out of scope, flagged with a TODO.
material_for()returns a newStandardMaterial3Devery call. Never a shared staticone:
BuildingMeshes.build()already hands out one sharedMeshper[type.id, tile_size, level_height], so a shared override would repaint every buildingof that type at once. That is the trap
_spawn_view()'s comment already warns about,one level up.
base_color.lerp(STATE_TINT, 0.5)carrying the state's alpha, not the flat statecolour. A flat tint makes every planned building an identical blue blob; blending keeps
the type's
placeholder_colorlegible while still reading as a plan.cast_shadowoff for every state except BUILT. A plan should not throw a shadow.material_override = nulland shadows back on, so the baked placeholdermaterial shows through untouched — that is the "unchanged" AC, and it is why the
transition has to be reversible rather than one-way.
Buildinggains two fieldsBoth are written only through
Buildings, so there is one place that refreshes the view.construction_blockedis explicitly not part of the save format: it is a cache of atask's status, and a reload recomputes it the moment a pawn next asks for work.
Buildings— refresh, don't rebuild_spawn_view()ends withBuildingView.apply(view, building)rather than leaving themesh bare, so a building spawns already looking like a plan instead of flicking from
BUILT on the next status change. That also covers the load path: a PLANNED building
restored from a save draws correctly without a transition ever firing.
set_status()already exists, already no-ops on an unchanged status, and already emitsbuilding_status_changed. Add_refresh_view(id)to it. No new mesh —apply()only touches
material_overrideandcast_shadow.set_construction_blocked(id: int, blocked: bool) -> voidandset_build_progress(id: int, progress: float) -> void; both no-op on an unchangedvalue, both call
_refresh_view(id).view_for(id: int) -> MeshInstance3D, null when there is none. Public because thealternative is tests reaching into
_viewsor guessing child order, and a debug overlaywill want the same handle.
Where BLOCKED comes from
TaskManager.next_for()is the only place a task is marked BLOCKED, and it does itgenerically — it knows nothing about buildings. Two small additions:
TaskManagergainssignal task_blocked(task: Task)andsignal task_unblocked(task: Task), emitted fromnext_for()only when the statusactually crosses into or out of BLOCKED. Capture the old status before the
transition_to()call; a task re-evaluated as still blocked for the tenth time is notan event.
Buildings.tasksbecomes a property whose setter connects those two signals(disconnecting any previous manager first). The handler checks
task is ConstructionTaskand calls
set_construction_blocked(task.building_id, …). Wiring it in the setter ratherthan in
game.gdmeans the tests get it for free — they already assignregistry.tasks = manager— and there is no second place to forget it.Known limitation, worth stating rather than rediscovering:
next_for()only runs whena pawn asks for work, so a blocked building stays plain until some pawn next goes looking,
and if every pawn is busy that can lag arbitrarily. This is pre-existing —
Task.statushas exactly the same lag, and the debug panel already shows it — and is not made worse
here. Fixing it means a re-evaluation sweep on a timer, which is a separate issue if it
ever bites.
Where progress comes from
ConstructionTask._work()already decrementswork_remainingand already flips PLANNED toUNDER_CONSTRUCTION. After the decrement it computes
1.0 - work_remaining / float(total)from
building.type.build_work_ticks()and callsctx.buildings.set_build_progress().Guard
total <= 0:build_daysof 0.0 means the type is not pawn-raised at all, and thedivision would be by zero.
Touched files
entities/buildings/building_view.gdentities/buildings/building.gdconstruction_blocked,build_progressentities/buildings/buildings.gd_refresh_view(),view_for(), two setters,taskssetter,_spawn_view()tailentities/tasks/task_manager.gdtask_blocked/task_unblockedsignalsentities/tasks/construction_task.gd_work()test/unit/test_building_view.gdtest/unit/test_buildings.gdtest/unit/test_construction_task.gdtools/screenshot_building_states.gdAdding a
class_nameneeds--headless --editor --quitbefore the suite will parse.Decisions taken — worth overruling if you disagree
reads better and the data is nearly free, but it costs the
build_progressfield and awrite per worked tick. A flat colour drops both, and is a one-line change to
material_for()if the fade turns out to be invisible at these sizes.different from waiting-on-materials, and names this issue as the hook. This builds the
hook with a bool; #119 widens it to the reason enum and teaches
state_for()that awaiting-on-children task must not read as blocked. Bool first means #119 changes one
function rather than introducing the path.
BuildingViewclass rather than methods onBuildings. The suite cannotsee pixels, so the decision table is the only thing it can pin. Pure static functions
make that table testable without a scene, a map, or a mesh.
Out of scope
look-and-feel pass, not this.