Commit Graph

385 Commits

Author SHA1 Message Date
d54bf3587b move two placement queries out of the view into PlacementRules
Prerequisite for the WorldRenderer extraction: both the renderer and the click
path need these, so neither can own them.

GameWorldView::isValidPlacement was isPlacementValid plus the occupancy and
rotate-in-place rule - the gap PlacementRules.h already names in its own comment
("Tile occupancy is NOT checked here"). It becomes canPlaceBuilding there, named
for what it adds rather than colliding with isPlacementValid.

resolveBeltDragPath was a pure function of the path, the factory state and the
config, living in the view only because the view happened to draw the ghosts.
It moves next to the other placement rules, taking its BeltTileAction and
BeltDragResolved types with it out of the GameWorldView class body. It stays
shared for the reason it always was: the previewed ghosts and the placement on
release must not disagree about which tiles are affordable.

The view keeps two one-line wrappers that bind its own simulation, so call sites
still read canPlaceBuildingHere(type, anchor, rotation).

Behaviour is unchanged; both are now testable without a widget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-05 18:55:21 +02:00
286116d2b1 share the world shapes both views draw identically
Groundwork for the WorldRenderer extraction: lift the shapes GameWorldView and
ArenaView already draw the same way, before writing a renderer around them.

Four of them turned out to be identical down to the constants - the ship
triangle, the health bar, the debris marker and the sensor-range circle. The
health bar was written three times: once in GameWorldView::drawHpBar and inlined
twice in ArenaView, for stations and for ships.

The argument for sharing is not de-duplication - it is about fifty lines. It is
that the arena exists to eyeball combat, so it only does its job while a ship
there looks like a ship in the game. Retuning the ship shape and having the
balancing tool silently keep the old one is a quiet way to make the tool lie.

Deliberately narrow. The two views differ on selection highlights, beams, target
lines, and all of the factory rendering, and those stay where they are; the
arena has already diverged on some of that and nobody minded, which is a reason
to keep the shared set to shapes that are genuinely the same rather than to
aspire to more. Free functions over explicit values, no state and no simulation,
so each view keeps its own iteration and layer order.

The balancing target does not link the ui library, so it compiles
WorldPrimitives into itself - the mechanism already used for VisualsLoader and
ShipStatsPanel.

Two shared getters come with it: the ship's forward extent, which the selection
ring and health bar are positioned from, and the debris radius, which the debris
selection ring previously tracked via a comment saying "matching drawDebris".

Behaviour is unchanged. WorldRenderer is next and will be built on these.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-05 17:56:50 +02:00
119d13ba1f extract BuildModeController, fixing a silent blueprint exit
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
2026-08-05 17:20:22 +02:00
1eca61e934 extract SelectionController
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
2026-08-05 14:24:52 +02:00
71c07531f0 move the remaining hotkeys into the InputMapper
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
2026-08-05 14:02:35 +02:00
ed84e44428 stop panning when the view loses focus
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
2026-08-05 13:58:27 +02:00
2bff80f405 move the already-event-driven hotkeys into the InputMapper
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
2026-08-05 13:38:48 +02:00
1748be57fb move pan input into an InputMapper
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
2026-08-05 13:35:23 +02:00
b0fffdb00f extract the scroll position into WorldCamera
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
2026-08-05 13:15:49 +02:00
8ec413e1b9 use WorldCoordinates in ArenaView too
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
2026-08-05 10:18:52 +02:00
4f6de79352 extract the world<->widget transform into WorldCoordinates
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
2026-08-05 10:04:14 +02:00
949937d2c2 re-use PlacementFixture in BuildingTests 2026-08-05 07:53:08 +02:00
4c166bf47f depend on the registry instead of DebrisSystem in the AI path 2026-08-05 07:25:59 +02:00
60260540cd make deconstruction its own system 2026-08-05 07:10:46 +02:00
1f4503176b make construction its own system (extracted from BuildingSystem) 2026-08-05 06:57:12 +02:00
fd85e8e10a free the buffer setup and belt registration from BuildingSystem 2026-08-05 06:55:13 +02:00
114a43b205 make BuildingSystem stateless: FactoryState becomes a parameter 2026-08-05 06:50:11 +02:00
d87d063b10 move the placement rules and the config-dependent queries off BuildingSystem 2026-08-05 06:49:49 +02:00
537597c854 delete the unused getAllBeltTiles and BeltTileInfo 2026-08-05 06:49:28 +02:00
9c3be0fbd0 extract the production rules as free functions over config and building 2026-08-05 06:49:15 +02:00
58b94223f7 migrate every factory query off BuildingSystem onto the free functions 2026-08-05 06:46:13 +02:00
1fb63cce4e move the asteroid width bound into FactoryState 2026-08-05 06:45:33 +02:00
0b7e94b4e4 drop CombatSystem's unused BuildingSystem parameter 2026-08-05 06:45:16 +02:00
0408336cf9 depend on factory data instead of BuildingSystem in the AI path 2026-08-05 06:44:49 +02:00
46932e4abf move FactoryState ownership out of BuildingSystem to Simulation 2026-08-05 06:43:52 +02:00
0edea5d961 gather the factory's world data into FactoryState 2026-08-05 06:43:30 +02:00
60cc187d92 add BuildingGrid to manage tile occupancy 2026-08-04 18:26:01 +02:00
3990351a16 share BuildingSystem's free functions instead of copying them 2026-08-04 18:24:38 +02:00
bd344e4fbe add FieldSelectionPanel for extracting the ships/stations/debris selection 2026-08-04 18:23:28 +02:00
64c344c3a3 correct the belt subsystem interface description in architecture.md 2026-08-04 18:12:02 +02:00
02c7fed9b4 allow "auto" for named local lambdas and iterator types via claude.md 2026-08-04 18:11:37 +02:00
5d4a975384 cover unlock state in the determinism tests 2026-08-04 18:11:03 +02:00
475df0e5fd extract unlock state from Simulation to UnlockState class 2026-08-04 18:10:43 +02:00
61634f6fd2 move the shared TOML helpers into the utility namespace to avoid name collisions 2026-08-04 18:05:52 +02:00
c8ff7da345 extract load methods into their own files 2026-08-04 18:05:27 +02:00
d664ab54cc extract shared TOML helpers into TomlHelpers.h/.cpp 2026-08-04 18:02:23 +02:00
906000b0e9 drop the dead payloads from the state-change events 2026-08-03 22:02:47 +02:00
d9ef6aa728 make HeaderBar read the tick, artifacts and boss wave from the simulation instead of event 2026-08-03 22:02:19 +02:00
b44a85685e make BlueprintPanel read the block stock from the simulation instead of event 2026-08-03 22:02:05 +02:00
edd1c31785 remove duplicate findModuleDef from ShipLayoutPreview 2026-08-03 22:00:25 +02:00
785ce3ebfe remove duplicate findBuildingDef from BuildingSystem 2026-08-03 21:14:13 +02:00
7017f8b4dc route the win-path restart through ResetCommand 2026-08-03 21:13:43 +02:00
77a842f884 dedupe AttackExecutor and RepairExecutor via executeOrbitAndAssign 2026-08-03 21:13:24 +02:00
d5ba72d554 add ModalPauseScope for the pause-around-modal idiom 2026-08-03 21:11:52 +02:00
83177729e9 extract MainWindow::reloadConfig 2026-08-03 21:11:02 +02:00
a8e933b7f2 drop EntityAdmin::add in favour of addComponent 2026-08-03 21:09:18 +02:00
e02e323cb2 share a single ItemIconCache across the UI 2026-08-03 21:08:19 +02:00
b4e622daa5 extract the shared Centroid helper into ai/Centroid.h 2026-08-03 21:06:06 +02:00
1150985c1f share one loadTestConfig() helper across the tests 2026-08-03 21:05:28 +02:00
594c3b93c5 make HeaderBar read block stock and expansion cost from the simulation 2026-08-03 21:04:43 +02:00