Commit Graph

69 Commits

Author SHA1 Message Date
3eec462b4a move the debug stats panel out of the renderer
It was the one thing in WorldRenderer positioned in pixels rather than tiles,
kept there only to preserve its draw order. With the order agreed not to matter,
the world-space / screen-space split becomes exact.

The real payoff is translation. drawDebugOverlay was the only caller of tr() in
the renderer, so Q_DECLARE_TR_FUNCTIONS and the QCoreApplication include go with
it. Nothing left in the renderer draws translatable text - its text is
config-driven glyphs, ASCII port arrows and numbers - so it no longer needs a
tie to the meta-object system at all. That is the argument I should have weighed
originally instead of anchoring on draw order.

The panel now paints on top of ships and beams rather than under them, and the
widget gates it on its own m_debugDraw directly. The frame still carries
isDebugDrawEnabled, because the sensor ranges and target lines it also gates are
genuinely world-space.

Dropped the painter.resetTransform() the function opened with: it was vestigial
from the older design that drew the world through painter.translate, and has
been a no-op since every conversion became explicit arithmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-05 21:23:38 +02:00
afb4b8b744 fix Ctrl+click no longer deselecting
Regression from the SelectionController extraction. The original started a box
drag only in the empty-space branch, after the building, actor and debris hits
had each returned. Pulling the hit resolution out into selectAtPoint left the box
drag behind as unconditional, so every click started one.

That made a click on an object resolve twice: once on press, and again on release
as a 1x1 box over the same tile. For a plain click both resolutions are Replace
with the same object, so nothing looked wrong. For Ctrl+click the second
resolution toggled the object straight back on, so it never deselected - and the
selection rectangle drawn during the drag is the box that should not have been
started.

selectAtPoint now reports whether it hit anything, and only a click that hit
nothing starts a box drag.

The SelectionController itself was correct, and its tests still pass: the bug was
entirely in the widget wiring, which nothing on this branch can test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-05 20:08:30 +02:00
9aed6d844e extract WorldRenderer
The last seam of the decomposition, and the one the earlier ones were groundwork
for: every draw method already took a WorldCoordinates, read mode state through
BuildModeController and selection through SelectionController, and used the
shared shapes in WorldPrimitives, so the move needed almost no rewriting.

paintGL is now a call sequence. The split is the world-space / screen-space line
already drawn by the WorldCoordinates work: the renderer draws everything
positioned in tiles, while the pause and deconstruct vignettes and the replay
overlay - which never took a WorldCoordinates because they are anchored to the
viewport - stay with the widget.

WorldRenderFrame is what keeps the renderer independent of the widget. It reads
the simulation directly, but the rest of what it draws is interaction state the
widget owns: the selection, the active build mode, live beams, the copy-settings
feedback, the box-select rectangle. Those are gathered per frame and passed by
reference, so the renderer holds no copy a later click could invalidate, and it
knows nothing about input.

Three more queries had to stop belonging to the view first, because the renderer
and the click path both need them: buildingsInBox and collectTunnelTiles move to
FactoryQueries, and makeTunnelLookup with the TunnelTileMap and QPointCompare it
needs move to TunnelCompletion, which already owned that concept.

Two small things fell out of leaving QWidget. The port-item clip region used
QWidget::rect() and now takes the painter's own viewport. drawDebugOverlay puts
translated text on screen, so the renderer declares tr() via
Q_DECLARE_TR_FUNCTIONS rather than becoming a QObject. drawDebugOverlay stays in
the renderer despite being screen-anchored: it is drawn mid-sequence, so moving
it out would put it on top of the ships instead of under them.

The simulation reference is non-const only because EntityAdmin's component
accessors are; the renderer never writes it.

Draw order is unchanged, and so is behaviour. GameWorldView.cpp is 1431 lines,
from 3265 when this branch started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-05 19:09:40 +02:00
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
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
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
e02e323cb2 share a single ItemIconCache across the UI 2026-08-03 21:08:19 +02:00
932b57720c dedupe tunnel lookup and key tunnel tiles by QPoint 2026-08-03 21:02:06 +02:00
af6828c348 remove duplicate findBuildingDef from GameWorldView 2026-08-03 20:50:03 +02:00
f766ae4a86 Allow to draw produced-item icons in recipe dialog and game world 2026-07-23 20:54:04 +02:00
8b71fe1a03 Rename ship/station scrap drop entities to "debris" 2026-07-23 20:51:05 +02:00
60d6767d93 draw building icons in the game world 2026-07-23 20:45:50 +02:00
e20a0bba67 Rename Demolish to Deconstruct 2026-07-22 21:40:42 +02:00
a9082c57f3 Implement config-driven unlock groups so that multiple things can be unlocked at once (including buildings) 2026-07-22 21:34:59 +02:00
a8a6a04f1e Highlight tunnel connections in green when selected 2026-07-21 21:16:21 +02:00
9b63af6ccb Unify tunnel build mode into a single Tunnel button 2026-07-21 21:12:06 +02:00
4ca5b332cd Snap belt-drag end tile to a building's input edge 2026-07-21 21:09:07 +02:00
b2c1ea34fd Implement deferred L-shaped belt drag placement 2026-07-21 21:05:31 +02:00
be475e2836 allow multi-select for ships/stations, mixable with scrap 2026-07-20 21:03:11 +02:00
9622fa4345 Use std::optional instead of sentinel values for absent data 2026-07-20 20:27:20 +02:00
1cdafb7bcd draw glyph for output port at the target tile during build mode 2026-07-19 22:30:03 +02:00
b708e1b29d Add demolish-mode vignette 2026-07-19 21:51:12 +02:00
b2e7e4897a Add paused-state vignette border to game world view 2026-07-19 21:34:56 +02:00
d412c69f82 Prefix all getters with "get" 2026-07-19 21:17:38 +02:00
c9f14970a1 Animate items entering building input ports 2026-07-14 20:21:25 +02:00
6a8c456aa1 Animate items emerging from building output ports 2026-07-14 20:18:47 +02:00
80a2622267 Redefine camera scroll as view center 2026-07-13 21:31:52 +02:00
535d4f8f24 allow to (multi) select scrap 2026-07-13 21:09:01 +02:00
5f6ecbf6c8 implement visual feedback for copy building settings with shift + click gesture 2026-07-09 21:33:24 +02:00
4986c1bac8 implement copy building settings with shift + click gesture 2026-07-09 20:29:20 +02:00
de1ebb8a5f increase scroll speed across contest zone 2026-07-08 22:25:12 +02:00
24c18f8ac6 render HP bar below the HQ 2026-07-08 21:39:11 +02:00
2cedd5d433 fix issue where building selection outline is hidden by other buildings drawn later 2026-07-08 21:29:44 +02:00
f11db0c072 Add demolish box-drag interaction 2026-07-08 21:04:59 +02:00
e8786c3922 implement cost formula for asteroid expansion 2026-07-08 20:33:19 +02:00
0a7e9a34ef Add artifact win condition (#3)
Reviewed-on: #3
Co-authored-by: Malte Langkabel <malte.langkabel@gmail.com>
Co-committed-by: Malte Langkabel <malte.langkabel@gmail.com>
2026-07-01 20:27:29 +00:00
d74ba5bfad Replay: deterministic record & playback (#4)
Add deterministic record/playback for a run.

Recording captures `(seed, config hash, ordered tick-tagged commands)` and re-simulates on playback — no state snapshots. `DotaFactory.exe --replay <file>` re-plays a recorded run view-only with manual speed/pause.

Reviewed-on: #4
Co-authored-by: Malte Langkabel <malte.langkabel@gmail.com>
Co-committed-by: Malte Langkabel <malte.langkabel@gmail.com>
2026-07-01 19:20:08 +00:00
31a8915b0f update keyboard shortcuts 2026-06-23 22:09:26 +02:00
f818c90af0 draw ghost in semi-transparent building color 2026-06-23 21:29:38 +02:00
c43225b6fa fix issue where beams were also disappearing while the game was paused 2026-06-21 22:09:25 +02:00
9573b9789a change repair_tool application and add beams for salvager and repair_tool 2026-06-19 21:15:47 +02:00
bd2391876c draw debug lines to target 2026-06-16 21:38:58 +02:00
10c5ad678f derive threat cost dynamically 2026-06-13 22:47:46 +02:00
5317f35198 switch to using own event system 2026-06-13 17:52:22 +02:00