Builder, blueprint and deconstruct were three independent flags, and every entry
point cleared the other two by hand. The copies had drifted, and one had a real
bug: enterBuilderMode reset the blueprint directly instead of calling
exitBlueprintMode, so BlueprintModeExitedEvent never fired and BlueprintPanel
never ran clearActiveBlueprintButton. Activating a blueprint and then picking a
building left the blueprint button highlighted for a mode that had ended.
Exclusivity is now structural. One mode is active, and every transition runs
through enterMode(), which exits whatever was active first. Which mode the player
switches to can no longer change what the mode they left announces - a test
covers all four crossings, plus the blueprint regression above.
The controller also owns the state that belongs to a mode and had to be cleared
with it: ghost tile/rotation/validity, the resolved tunnel end and its completion
partner, the belt drag, and the deconstruct hover. Those were the things the
hand-written resets kept forgetting.
Everything needing the simulation stays in GameWorldView - placement validity,
tunnel matching, belt path building - and is handed back through setGhostValidity,
setTunnelGhost and setBeltDragPath. That is what keeps the controller a plain
value with tests.
Two smaller behaviour changes, both dropping redundant events. Restart now
announces only the mode that was actually active rather than all three exits
unconditionally; and entering builder or blueprint mode no longer publishes
DeconstructModeChangedEvent(false) when deconstruct mode was not on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
The selection rules were written out twice - once for point clicks and once for
box drags - and the two copies had already drifted. Unlike the earlier seams on
this branch this is a real de-duplication, not a relocation.
SelectionController owns all three categories and the rules for moving between
them: buildings win over field objects, so selecting a building clears actors and
debris and selecting either of those clears buildings, while actors and debris
coexist. It also publishes the change events, so callers never emit by hand.
A point click and a box drag now differ only in the SelectionMode they pass and
in how many hits they pass. That names the difference that was previously implicit
in two separate loops: Ctrl+click toggles, so dragging back over a selected
building would deselect it, whereas Ctrl+box adds and never deselects.
Hit-testing stays in GameWorldView, which is what keeps the controller free of any
simulation dependency and therefore testable - including the published events,
via a spy handler. That coverage is the point: these rules had none, and the two
copies were the kind of thing that drifts silently.
One behaviour difference, deliberate. Clearing the building selection was guarded
by "only if non-empty" in three of the four places and unguarded in the fourth
(the empty-box drag), which emitted a redundant SelectionChangedEvent with an
empty list. The unified rule is the guarded one. Subscribers re-read from
Simulation on every event, so dropping a redundant no-op refresh cannot change
what any of them display.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
Space, W, S, R, Q and F3 all acted on GameWorldView state, so each needed a
request event before it could move. One event per action rather than a shared
action enum, matching how the existing request events are named and keeping
subscribers from waking on actions they do not care about.
The split is deliberate about where knowledge lives. Each event says only what
the player asked for, never how to satisfy it:
- PauseToggleRequestedEvent carries no speed; which speed to restore is
remembered by the receiver.
- SpeedStepRequestedEvent is a relative notch, because the ladder of speeds
belongs to the receiver (unlike SpeedChangeRequestedEvent, which the speed
buttons send with an absolute multiplier).
- ModeCancelRequestedEvent names no mode; which of builder, blueprint or
deconstruct is active is state only the receiver has.
- DebugDrawToggleRequestedEvent is the request to flip the flag, where the
existing DebugDrawToggledEvent is the announcement that it was flipped, so
the flag keeps a single owner.
GameWorldView::keyPressEvent is now nothing but a forward to the mapper. Shift
release stays behind on purpose: it is the modifier of a mouse gesture
(REQ-BLD-COPY-CONFIG), not a keyboard action, and modelling it as one would
misrepresent it.
Bindings are still hard-coded, and behaviour is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
Holding A or D while a modal opened - the escape menu, a schematic choice, game
over - left the pan key held forever: the key-up went to the dialog and never
reached the view, so the world panned on its own once the dialog closed. Panning
runs on wall-clock time rather than ticks, so pausing did not mask it either.
Focus loss now drops every held action. This is a behaviour change, not a move,
so it is its own commit; it is only three lines because the input mapper already
owns the held state and can clear all of it at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
The build hotkeys, T, Escape and F4 already did nothing but publish an event, so
they move across unchanged - GameWorldView was only ever the object that happened
to have focus.
The build-hotkey digit tables become a lookup returning an optional BuildingType,
which flattens the nested switch-inside-if and puts the "which digits are unbound"
answer in one place. The nativeVirtualKey handling is kept verbatim, including why
it is used instead of key().
F3 stays behind for now: it toggles m_debugDraw before publishing, so it needs a
request event that does not exist yet. Same for Space, W, S, R and Q.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
First slice of pulling key handling out of GameWorldView. The widget no longer
holds A/D key state; the mapper owns it and publishes the resulting direction as
PanDirectionChangedEvent, which the view consumes like any other event.
The event is level-triggered on purpose — the payload is the complete current
direction, PanDirection::None included — so a receiver never reconstructs state
from edges and cannot be left panning by a missing key-up. It is also the one
place in the UI where caching an event payload is right rather than wrong: input
has no other authority to re-read from, so the mapper is the source of truth.
Both points are written down on the event, since they look like violations of
the surrounding conventions otherwise.
Holding the state in one object is what makes releaseAll() possible; restart
uses it, and it is what a focusOutEvent will call to fix the stuck-pan bug in a
follow-up.
Bindings stay hard-coded. Only the ownership moved, so behaviour is unchanged,
including both keys held cancelling out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
Second seam of the GameWorldView decomposition: the view no longer owns a scroll
position, only the pan intent and the bounds.
WorldCamera works purely in world units — tiles and tiles per second, never
pixels. That is what keeps it independent of WorldCoordinates: the two meet only
where GameWorldView feeds getViewCenterXTiles() into the transform, and neither
knows the other exists.
Two things are passed in rather than reached for, both so the camera stays a
plain value with no simulation dependency:
- ScrollBounds, because the pan limits move with asteroid expansion and with
pushes. The camera clamps on every advance(), not only when panning, so the
view follows the bounds inward when they shrink.
- PanDirection, because pan intent is not the camera's business. Today
GameWorldView collapses its two held-key flags into it; if controls become
rebindable the camera's interface does not change.
The config structs are referenced, not copied: they live inside the Simulation's
GameConfig, which is assigned in place on restart (REQ-CFG-RELOAD), so reloaded
scroll tuning takes effect without rebuilding the camera. A test pins that.
The pan-speed curve (REQ-UI-SCROLL-SPEED) had no coverage at all and is the
least obvious code in the file — two ramps combined by min, with a peak below
the fast speed where the bands overlap in a narrow contest zone, and a hard step
when the band width is zero. All of that is now tested.
Behaviour is unchanged, including the cases worth naming: holding both keys
still cancels out, and the moved/not-moved result that drives the box-select
refresh still counts movement caused purely by the bounds changing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
ArenaView carried its own copy of the same five transforms. It differs from the
game view in exactly two respects: the arena is a fixed world shown whole, so
the tile size is the tighter of the two axis fits rather than the height fit,
and there is no scrolling, so the left edge is always 0.
That is small enough to absorb into WorldCoordinates as a second named factory.
The raw constructor becomes private and both call sites go through
scrolling(...) or fitToWorld(...), which also reads better than three
positional ints at the call site.
ArenaView is threaded the same way GameWorldView was: paintGL builds one
snapshot, every draw takes a const WorldCoordinates&, and mousePressEvent takes
its own.
ArenaView::widgetToWorld guarded against a near-zero tile size; that guard now
lives in WorldCoordinates as a tilePx fallback to 1.0, alongside the existing
degenerate-world-size fallback, so both factories are total and no conversion
can divide by zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
The nine coordinate helpers on GameWorldView were the first seam of the
planned decomposition: stateless math that every draw method, the placement
validation, and the belt-drag code reached into private state to get at.
WorldCoordinates is an immutable value built from the viewport size, the world
height, and the scroll center. It lives in lib/core rather than ui, following
BeltDragPath and TunnelCompletion — UI-only helpers kept there so Catch2 can
reach them. That is what lets the transform math be unit-tested at all, since
the test target links lib only.
Rather than leave delegating one-liners behind, the snapshot is threaded
through the call graph: paintGL builds one per frame, every world-space draw
takes a const WorldCoordinates&, and the screen-space draws (vignettes, replay
overlay, debug text) take none. Those methods no longer touch m_scrollXTiles,
width(), or height(), which is most of the groundwork for extracting
WorldRenderer later. The mouse handlers each take their own snapshot.
The snapshot is deliberately not cached in a member: a resize or a scroll
would silently invalidate it, and a stale transform surfaces as misaligned
hit-testing rather than as a visible failure.
widgetToTile and widgetToWorld had the same arithmetic inlined separately;
the former now goes through the latter.
The architecture doc's coordinate section was already stale — it described a
painter.translate approach and a hardcoded tilePx = 20, neither of which was
true — so it is rewritten to match the code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K