55 Commits

Author SHA1 Message Date
590bca458c foo 2026-08-05 08:21:30 +02:00
59067a9c49 put BuildingTest's 33 hand-rolled fixtures onto PlacementFixture
Every one of the 33 standalone tests rebuilt the same seven-line preamble and
the same four-lambda BuildingSystem construction, while PlacementFixture sat
alongside doing exactly that. Each session of this refactor made those 33 blocks
a line longer, which is what made it worth fixing now.

They were not quite identical: 25 used the configured belt speed and 8 a fast
belt of one tile per tick, marked by comments rather than by anything the code
said. The fixture now takes an optional belt speed and kFastBeltSpeed_tps names
the concept, so the difference is visible at the call site instead of buried in
a static_cast.

The per-test comments that explained a choice — the fast belt, the RNG seed —
are kept; the fixture uses the same seed 0 those tests set by hand.

452 test cases and 3431 assertions before and after, so nothing was dropped in
the conversion.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-05 07:29:03 +02:00
72d85d681c depend on the registry instead of DebrisSystem in the AI path
getAllDebrisInfo and collectOne only ever touched EntityAdmin — DebrisSystem
holds nothing else — so they become free functions over the registry. That lets
AiSystem, SalvagerSystem and SalvageScrapEvaluator drop their DebrisSystem&
parameters entirely; SalvagerSystem already held the admin, and the other two
were handed it alongside.

No system in lib/ecs/system takes another system now. Every tick signature names
the data it works on: the registry, the factory state, or both.

DebrisSystem keeps spawn, tickDespawn and consume — the first two are genuine
tick behaviour rather than lookups.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-05 07:16:36 +02:00
a86ba3428a make deconstruction its own system
DeconstructionSystem takes over the demolition queue: it runs the front entry's
timer and, when it elapses, removes the building, releases its tiles and credits
the partial refund. Simulation::tick calls it directly, in the position
tickDeconstruction held.

It needs no BeltSystem, unlike its construction counterpart: a belt, splitter or
tunnel end is unregistered the moment it is queued, not when the timer completes.
It does need the refund sink, so it takes the same addBuildingBlocks callback
BuildingSystem holds.

startFrontDeconstruction becomes a shared free function rather than moving:
BuildingSystem::deconstruct starts the timer when it queues the first entry, and
the system restarts it after each completion.

Stubbing the refund sink out in the test helper made two tests fail on the
refund not arriving — correctly. runTicks now threads the caller's stock through
instead.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-05 07:01:22 +02:00
56b7248ac7 make construction its own system
ConstructionSystem takes over the construction queue: it runs the front site's
timer and, when it elapses, builds the Building itself — ports, buffers, belt
registration, and starting the next queued site. Simulation::tick calls it
directly, in the same position tickConstruction held.

No handoff. The earlier sketch had it return the completed site for BuildingSystem
to materialise, which put an intermediate value in Simulation and made the two
calls correct only when adjacent and ordered. Once the queries and the buffer
helpers became free functions there was nothing left on BuildingSystem that
materialisation needed, so the system does the whole job and the invariant
disappears rather than being documented.

Holds only the config; the world arrives per tick, like the systems in
lib/ecs/system.

Verified with a golden-checksum capture before and after — all four sample ticks
identical, which is the check that matters here since this moves a call in the
tick order.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-05 06:49:20 +02:00
009f8c6d14 free the buffer setup and belt registration from BuildingSystem
Prerequisite for ConstructionSystem completing a building itself rather than
handing a finished site back: materialisation needs the buffer initialisers and
the BeltSystem registration, and both were BuildingSystem members.

initBuffers turned out to need nothing at all — it works purely on the Building
and RecipeDef it is given. The other three need only the config.
reregisterBeltTile takes BeltSystem and the config; it stays shared rather than
moving, because cancelDeconstruction and rotateInPlace use it too and are staying
on BuildingSystem.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-05 06:38:31 +02:00
a90218f5c0 make BuildingSystem stateless: FactoryState becomes a parameter
The member reference is gone. All 23 methods that read or write the factory now
take FactoryState& (const for the two item walks and the checksum fold), so a
BuildingSystem is no longer bound to one state and its signatures say which data
each call touches. It holds only config, belts, rng and the callbacks — the same
shape as AiSystem and CombatSystem.

This completes what phase 2 set out to do; the ownership move landed earlier, but
the systems kept reaching the data through a member until the queries were off
them.

Seeding the asteroid bound moved with the state, and that broke four tests: the
fixtures build their own FactoryState, which defaulted the bound to 0 and refused
every placement on the asteroid. Rather than fix the four call sites, makeFactoryState()
now creates a run's state from the config, and Simulation, ArenaSimulation and the
test fixtures all use it — there is one place that knows what a fresh factory
looks like.

Verified with a golden-checksum capture before and after — all four sample ticks
identical — and by re-running the declaration/definition check over the header.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 23:00:09 +02:00
7ce0751c60 remove the stale findRotateInPlaceTarget declaration
Third declaration left behind by a move — the definition went to
PlacementRules.cpp but the declaration stayed. Checked the rest of the header
mechanically this time: every other declared method has a definition.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 22:43:00 +02:00
b3d6264ed3 move the placement rules and the config-dependent queries off BuildingSystem
isPlacementValid, findRotateInPlaceTarget and the bodyCellsWithinWorldBounds
helper become PlacementRules.h — where a building may go and what already sits
on those tiles, answered from the factory state and the config. getInputPorts
and getSiteSplitterInfo join FactoryQueries.h, whose header comment now says
plainly that the last two also take the config because answering them means
reading a building definition.

computeInputPorts goes to PortGeometry.h alongside outputBodyTile/inputBodyTile:
it needs only Port and QPoint, so it belongs in core rather than in sim.

BuildingSystem is left with no query that reads the factory — its remaining const
methods are the emerging/incoming item walks, the checksum fold, and the buffer
initialisers. It changes the factory now; it no longer describes it.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 22:31:16 +02:00
ade716edf2 delete the unused getAllBeltTiles and BeltTileInfo
Nothing in lib, ui, balancing or the tests calls it — the only references were
its own declaration and definition.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 22:23:34 +02:00
3272431353 extract the production rules as free functions over config and building
gatherCandidateRecipes, recipeInputsAvailable, computeShipyardRequiredMaterials,
hasInputsToStart and getProductionStatus read no factory state — they answer
"what can this building produce, and can it start" from the config and the
Building alone. They move to ProductionRules.h as free functions, and the
ProductionStatus enum goes with them since it is that group's return type.

Two of the five are pure in their arguments; the other three need GameConfig
through gatherCandidateRecipes and computeShipyardRequiredMaterials, so config is
a parameter rather than the group being split across two headers.

Only two callers outside BuildingSystem existed — the status light in
GameWorldView and one test lambda — so this is nearly all internal.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 22:17:01 +02:00
d1b688f45e remove two declarations left behind by the query migration
isTileOccupied and findNearestBuilding kept their declarations in
BuildingSystem.h after their definitions were deleted. Nothing calls them, so it
built and linked, but a caller would have hit an unresolved symbol rather than a
missing-member error.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 22:01:26 +02:00
df7f60c898 migrate every factory query off BuildingSystem onto the free functions
The eleven forwarding members added last commit are gone; callers now read the
data directly through FactoryQueries.h. Simulation and ArenaSimulation expose
getFactoryState() so the UI, the balancing view and the tests can reach it.

isQueuedForDeconstruction joined the free functions along the way — it only
reaches findBuilding, so it was state-pure too.

No facade was introduced. The chained form was the reason one looked attractive,
but rewriting sim.getBuildings().findBuilding(id) to findBuilding(sim.getFactoryState(), id)
turned out to be mechanical, and the result says which data is read rather than
which system happens to own it.

BuildingSystem.cpp is down to 1735 lines and no longer answers questions about
the factory — it only changes it. What remains on it are the mutators, the tick
phases, and the queries that also need GameConfig.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 21:52:49 +02:00
58e173ad5b move the asteroid width bound into FactoryState
It was the last piece of mutable world data BuildingSystem still owned, and the
placement queries need it: isPlacementValid reaches it through
bodyCellsWithinWorldBounds, so those queries cannot become free functions over
FactoryState while the bound lives on the system.

Left out of the checksum deliberately. It is derived from config and Simulation's
expansion count, which is folded already, so adding it would change every
checksum without adding information.

Still seeded from config by BuildingSystem's constructor, which keeps the
initialization at exactly the point it happened before; reset() clears the state
before initializeSubsystems() rebuilds the system, so the ordering holds.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 21:38:30 +02:00
bb50f527d6 drop CombatSystem's unused BuildingSystem parameter
The parameter was already commented out in the definition — combat resolution
never touched it. Removing it also removes the last reference to BuildingSystem
from CombatSystem, so the forward declaration goes too.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 21:34:20 +02:00
7540c21d5c depend on factory data instead of BuildingSystem in the AI path
Ten queries that read nothing but FactoryState become free functions in
FactoryQueries.h; the BuildingSystem methods stay as one-line forwards, so no
existing caller moves yet.

That lets the AI path drop its dependency on the system entirely. AiSystem,
SalvagerSystem, DeliverScrapEvaluator and DeliverScrapExecutor took a
BuildingSystem& purely to call findBuilding, findNearestBuilding and
deliverScrapToSalvageBay — all three are state-pure — so they now take
FactoryState& and say what they actually read. Four forward declarations of
BuildingSystem go with them.

No facade: the queries are plain free functions over the data. A facade was
considered to spare the ~180 UI call sites, but the AI needed only the data and
would have been given GameConfig it has no use for.

isProductionBuildingType moves to BuildingType.h beside isAutoRecipeBuildingType
and isBeltSubsystemType rather than being copied into the new file.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 21:26:49 +02:00
2522a8c974 move FactoryState ownership out of BuildingSystem to Simulation
Simulation (and ArenaSimulation in the balancing tool) now owns the factory's
world data; BuildingSystem holds a reference to it. This is what lets the systems
that operate on the data be handed the same state — phase 3's construction and
deconstruction systems, and later the ecs/system/ classes that today take a
BuildingSystem& only to query it.

reset() clears the state alongside m_admin and m_beltSystem, matching how the
subsystems were already rebuilt from scratch.

Falls short of the tick-argument form I sketched: BuildingSystem still reaches
the data through a member reference rather than a parameter. Making it truly
stateless means the const query surface has to find the data some other way, and
that surface is large — findBuilding alone has 64 call sites, with findSite,
getAllBuildings, getAllSites, isTileOccupied and the rest behind it. Doing that
needs a queries facade behind Simulation::getBuildings() so the callers do not
all move, which is its own decision rather than a side effect of this one.

The constructor gains a parameter, so the four owners and the three test fixtures
that build a BuildingSystem directly are updated; the 33 files that only use one
are untouched.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 20:55:32 +02:00
e39b81eb22 gather the factory's world data into FactoryState
BuildingSystem is the one system in the codebase that owns the world data it
operates on. The ecs/system/ classes already do the opposite — AiSystem and
SalvagerSystem hold config and their own scratch, and take EntityAdmin and the
other systems as tick arguments — so this is bringing the outlier in line, not
inventing a pattern.

Phase 1 of that: the buildings vector, both work queues and the tile grid move
into a FactoryState struct, still owned by BuildingSystem. Every method reaches
through m_state. The public API is untouched, so none of the 33 files that
reference BuildingSystem needed a change.

DeconstructionEntry moves out of BuildingSystem's private section into
FactoryState.h, since the queue that holds it lives there now.

m_asteroidWidth_tiles stays on the system: it is not checksummed and is a cached
placement bound derived from config and the expansion count, not factory data.

The intent is for Simulation to own FactoryState and pass it into the tick
methods, leaving the systems stateless over it. FactoryState.h notes explicitly
that this is a data/behaviour split and not a step toward putting buildings in
the entity model, which architecture.md rules out.

Verified with a golden-checksum capture before and after — all four sample
ticks identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 20:39:34 +02:00
71d0dad3f2 give tile occupancy its own class, BuildingGrid
Eleven methods maintained m_tileOccupancy by hand — place, deconstruct,
removeBuilding, placeImmediate, tickDeconstruction, findRotateInPlaceTarget and
tryDirectCoupleDeposit all indexed a raw std::map<std::pair<int,int>, BuildingId>
directly, so the invariant "occupancy stays in sync with placement" was
re-implemented at every call site. They now ask and tell a small owned index
instead: occupy / release / isOccupied / findOwner.

BuildingGrid is a member of BuildingSystem, not a peer system: it has no
per-tick behaviour and nothing outside BuildingSystem touches it.

The internal keying stays std::pair<int,int> rather than moving to QPoint. The
checksum folds the entries in map iteration order, so the comparator is part of
the determinism contract; changing it is a separate decision, not a side effect
of this move. Verified with a golden-checksum capture before and after — all
four sample ticks identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 17:22:40 +02:00
fda88fe75c share BuildingSystem's free functions instead of copying them
Four of the five file-local helpers in BuildingSystem.cpp had duplicates
elsewhere: isAutoRecipeBuildingType and isBeltSubsystemType were re-spelled as
isAutoRecipeBuilding and isBeltLike in SelectedBuildingPanel.cpp, and
outputBodyTile was copied verbatim as portBodyTile in GameWorldView.cpp. Same
predicates, different names, so a change to one would silently not reach the
others.

The two BuildingType predicates move to BuildingType.h, which already hosts the
free functions over that enum and is already included by both lib and ui. The
port geometry moves to a new PortGeometry.h; inputBodyTile has no duplicate but
is outputBodyTile's counterpart and belongs beside it — the sim moves items
across the port edge and the renderer draws the virtual belt there, so the two
must agree on which tile a port owns.

inputLaneEntryFree stays file-local: single use, and tied to BeltItemSlot rather
than to building types or port geometry.

Verified the six moved bodies are character-identical to their originals.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 16:09:01 +02:00
d713257fb5 move the field selection out of SelectedBuildingPanel
The two selection categories used to arbitrate ownership of the panel by
poking each other's widgets: buildFieldSelection() called clearContent()
and buildEmpty(), buildEmpty() hid the four entity widgets, and
hideAllWidgets() hid the scrap label. Splitting the halves apart without
naming an arbiter would only have spread that across a class boundary.

SelectedBuildingPanel is now the sole arbiter. It still receives all
three selection events, forwards the two field ones to the embedded
FieldSelectionPanel, and drops its own selection and content as soon as
the field panel reports a selection (yieldToFieldSelection), mirroring
what onSelectionChanged() already did in the other direction. The field
panel decides only what to render and whether it is visible at all.

Dropping the field branch of refreshSelectionDisplay() is behaviour
preserving: whenever the field category owns the panel, m_singleBuildingId
is null, so the building refresh returns immediately anyway.

clearContent() and buildEmpty() became identical once the cross-half
hiding was gone, so only buildEmpty() remains.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 15:47:22 +02:00
0029236135 add FieldSelectionPanel for the ships/stations/debris selection
SelectedBuildingPanel has grown to 1200 lines by carrying two unrelated
selection categories. Introduce the field half as its own widget first,
so the cut-over is a separate, reviewable step.

The panel owns only its own selection state and widgets: it renders the
single-object stats panel (ship, station, debris) or the multi-object
count summary, subscribes to the tick/commands-applied refresh signals
and the debug-draw toggle, and hides itself while it has no selection.
Which category owns the side panel is not its decision - the parent
feeds it through setSelectedEntities/setSelectedDebris/clearSelection.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 15:42:25 +02:00
622447c45b correct the belt subsystem interface description in architecture.md
The section documented a 5-method port interface and claimed "no other system
ever asks what is on tile X". The real surface is 15 methods and peekItem does
ask exactly that, so the doc was misleading about a subsystem it exists to
explain. It also showed a tryPutItem signature that no longer matches.

Describes what is there now, grouped by purpose, and separates the two claims
that had been conflated: item transport is still port-only, but tile topology
is genuinely coupled to BuildingSystem because belts are Buildings for cost and
construction. The v2 migration note is qualified accordingly.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 15:24:31 +02:00
a34d66f548 allow auto for named local lambdas and iterator types
The blanket ban could not be satisfied: a lambda's type is unnameable, and
std::function is the wrong substitute in per-tick hot paths, so around seven
named local lambdas in lib violated the rule with no way to comply. Spelled-out
iterator types were being followed inconsistently in the same file.

Carving out the two cases makes the guideline enforceable rather than silently
broken. No code is changed here; existing spelled-out iterator types stay valid.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 15:21:32 +02:00
cb5572ffdd cover unlock state in the determinism tests
The scripted session only placed buildings, so every unlock container stayed at
its initial value for the whole run and the checksum never saw them change. It
now destroys the enemy stations twice and takes the offered schematic choice,
so awarded groups, per-schematic levels and the derived recipe/item sets are
exercised too.

Adds a test that pins down that unlock state actually reaches the checksum: two
sessions in lockstep, one takes the choice, checksums must diverge. The
scripted-session tests cannot show this themselves — they compare runs against
each other, so they pass whether or not UnlockState is in the fold. Verified by
temporarily removing the fold: the new test fails, the old two do not.

The first choice is asserted rather than assumed, so a config change that stops
offering a group fails loudly instead of silently dropping the coverage.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 15:08:04 +02:00
10ba226af7 wire Simulation to forward schematic/unlock queries to UnlockState
Simulation now owns an UnlockState member (constructed before
initializeSubsystems(), since BuildingSystem's spawn-gating lambda
calls into it via isSchematicUnlocked instead of poking the old
m_schematicLevels map directly). The public isXUnlocked accessors
become one-line forwards, applySchematicChoice's group-awarding block
becomes a single awardUnlockGroup() call, and generateSchematicChoices
(which still owns m_rng and must not change call ordering) now reads
group state and builds options through UnlockState.

The checksum fold at computeStateChecksum's schematic/unlock section
had to move together with the containers it reads; UnlockState::
appendChecksum makes the identical seven appendSchematicMap/
appendStringSet calls in the identical order, so the fold is
unaffected. Verified via a temporary golden-checksum test case
(added, checked, then removed) that tick1/100/999/1999 checksums for
seed 12345 are byte-identical to pre-refactor.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 14:10:52 +02:00
0a3288d1d1 add UnlockState class for schematic/unlock bookkeeping
Simulation.h/.cpp had grown a large block of schematic/unlock state
(containers, the implicit-unlock traversal, checksum folding) that has
nothing to do with tick orchestration. Split it into its own class so
Simulation stays legible as "tick orchestration + subsystem handles".

This commit only adds the new UnlockState.h/.cpp (registered in
CMakeLists.txt) with the containers, types, and logic moved in
verbatim; nothing references it yet, so this is a no-op for behavior.
Simulation is wired to use it in the next commit.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 14:09:28 +02:00
2c9433cea6 move the shared TOML helpers into the utility namespace
The names are generic (makeError, requireInt, parseFile), and at global scope
with external linkage they would form an overload set with the same-named
anonymous-namespace helpers in VisualsLoader.cpp and BalancingConfig.cpp the
moment either file includes TomlHelpers.h — silently, since the signatures
differ. Namespacing keeps that door shut.

Call sites are qualified explicitly rather than pulled in with a using
directive, matching how utility::getRandomInt and friends are already called.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 09:27:43 +02:00
21eb6ad096 extract loadUnlocks into ConfigLoaderUnlocks.cpp
Finishes the ConfigLoader.cpp domain split. ConfigLoader.cpp now holds
only the cross-domain validateUnlocks pass and loadFromDirectory
orchestrator, as intended — everything else lives in its own
ConfigLoader<Domain>.cpp. Pure move; no logic change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:38:10 +02:00
bf579bb76e extract loadModules into ConfigLoaderModules.cpp
Continues the ConfigLoader.cpp domain split. StatEntry/kKnownStats are
only used by loadModules, so they move along as a domain-local
anonymous-namespace table rather than into TomlHelpers. Pure move; no
logic change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:36:08 +02:00
6bde69dc33 extract loadStations into ConfigLoaderStations.cpp
Continues the ConfigLoader.cpp domain split. Pure move of loadStations;
no domain-specific helpers to relocate here.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:33:40 +02:00
bf99cd0694 extract loadShips into ConfigLoaderShips.cpp
Continues the ConfigLoader.cpp domain split. parseRotationString and
parsePlacedModules are only used by loadShips, so they move along as
domain-local anonymous-namespace helpers rather than into TomlHelpers.
Pure move; no logic change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:31:39 +02:00
6f107e3479 extract loadRecipes into ConfigLoaderRecipes.cpp
Continues the ConfigLoader.cpp domain split. parseRecipeOutputs is only
used by loadRecipes, so it moves along as a domain-local anonymous-
namespace helper rather than into TomlHelpers. Pure move; no logic
change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:29:17 +02:00
8f42519911 extract loadBuildings into ConfigLoaderBuildings.cpp
Continues the ConfigLoader.cpp domain split. Pure move of loadBuildings;
no domain-specific helpers to relocate here.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:27:05 +02:00
bb7b90f9ea extract loadWorld into ConfigLoaderWorld.cpp
Continues the ConfigLoader.cpp domain split: world.toml parsing has no
domain-specific helpers of its own, so this is a straight move of
loadWorld with no logic change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:25:14 +02:00
e702c01005 extract shared TOML helpers into TomlHelpers.h/.cpp
ConfigLoader.cpp had grown to 877 lines by mixing generic TOML-parsing
helpers (used by every per-file loader) with per-domain parsing logic.
Splitting the file per config domain first requires pulling out the
helpers shared by two or more domains, so each domain .cpp can include
them without duplication. Pure move: no logic, message, or ordering
changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 22:22:48 +02:00
b1720dc2b3 drop the now-dead payloads from the sim-backed state-change events
TickAdvanced, BuildingBlocksChanged, ExpansionCostChanged, BossWaveUpdated and
ArtifactCountChanged all duplicated state the Simulation already owns. With
every subscriber re-reading from the sim, the fields had no readers left, so
the five events become payload-free refresh signals and the emitters keep the
values only as locals for change detection.

This makes the convention uniform: a state-change event backed by the
simulation carries nothing. Events whose state lives in the view (selection,
game speed, deconstruct and debug-draw modes) keep their payloads, since there
is no sim getter behind them.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 21:56:47 +02:00
099f0b55fc make HeaderBar read the tick, artifacts and boss wave from the simulation
The last three payloads HeaderBar still consumed as truth. All are backed by
Simulation getters (getCurrentTick, getArtifactCount, getBossWaveCounter,
getBossCountdownTicks) and the win count by world.artifacts.artifactWinCount,
so the handlers now re-read rather than trust the event.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 21:54:02 +02:00
b133a21914 make BlueprintPanel read the block stock from the simulation
BlueprintPanel was the second panel caching BuildingBlocksChangedEvent's
payload as truth; it already held a Simulation*, so refreshButtonStates()
now re-reads getBuildingBlocksStock() at the point of use, per the "events
are refresh signals" rule.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 21:53:19 +02:00
69848eb9f8 remove the last duplicate findModuleDef from ShipLayoutPreview
ShipLayoutPreview was the one widget the findFooDef sweep missed: it held a
bare const std::vector<ModuleDef>* rather than a config, so the shared
ModulesConfig::findModuleDef was not a drop-in and the file-local copy
survived. Hold the ModulesConfig instead and call the shared finder.

The sole caller already had the ModulesConfig one dereference away.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-03 21:28:53 +02:00
993325d97c remove duplicate findBuildingDef from BuildingSystem
BuildingSystem::findBuildingDef was a byte-equivalent re-implementation of
BuildingsConfig::findBuildingDef. Same cleanup as the GameWorldView copy;
this one sits in the sim layer, so it was missed by both earlier passes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:34:24 +02:00
75f306f650 route the win-path restart through ResetCommand
The Restart button on the Win dialog called Simulation::reset() directly,
while the escape-menu and Game Over restarts enqueue a ResetCommand. That
bypassed the command chokepoint every sim mutation is supposed to flow
through (docs/replay_design.md), so a post-win restart was never recorded
into the replay stream, and it had UI code calling a sim mutator directly.

Mirror the Game Over path instead. The manual resetForNewGame() call goes
away with it: GameWorldView::onFrame already resets the view when it drains
a Reset command.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:31:27 +02:00
0eb9c97e5d dedupe AttackExecutor and RepairExecutor via executeOrbitAndAssign
The two executors were line-for-line duplicates: orbit the behavior target,
then hand it to the owner's in-range modules. Both now call a templated
executeOrbitAndAssign<Behavior, ModuleComponent>; the view types, iteration
order, and sequence of component writes are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:16:15 +02:00
28d0416458 add ModalPauseScope for the pause-around-modal idiom
Four MainWindow sites hand-rolled snapshot speed / setGameSpeed(0) / modal /
restore + resetFrameTimer, with the restore duplicated on early-return paths.
ModalPauseScope does it via RAII, with restore()/release() for the two sites
that must restore early or not at all.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:12:53 +02:00
59fde8dbbc extract MainWindow::reloadConfig
The three restart paths each repeated the same config + visuals reload with
its own try/catch and error dialog. Only that shared part is extracted; each
site keeps its own follow-up (ResetCommand vs. direct Simulation::reset) and
its own error-path cleanup.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:06:41 +02:00
5355f9f77d drop EntityAdmin::add in favour of addComponent
The private add<T> template was identical to the public addComponent<T>;
the spawn factory methods now use the public one.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:04:03 +02:00
f678dab387 share a single ItemIconCache across the UI
Four separate caches rasterized the same item SVGs, one of them rebuilt on
every recipe-dialog open. MainWindow now owns one cache and hands a
non-owning pointer to HeaderBar, BuildButtonGrid, GameWorldView and
RecipeSelectionDialog.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 21:00:19 +02:00
ebee62166d lift the shared Centroid helper into ai/Centroid.h
AdvanceExecutor and StandbyExecutor each carried a verbatim copy of the
struct in an anonymous namespace; it now sits next to OrbitMath.h.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:55:29 +02:00
3c549a160c share one loadTestConfig() helper across the tests
19 test translation units each defined an identical local loadConfig().
They now include src/test/TestConfig.h, which lives off the lib/ui/app
include path like SimulationTestAccess.h.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:53:21 +02:00
dc58f6ea32 make HeaderBar read block stock and expansion cost from the simulation
HeaderBar was the only panel caching event payloads as truth. It now holds
a const Simulation* and re-reads getBuildingBlocksStock() /
getCurrentExpansionCost() in the handlers, per the "events are refresh
signals" rule.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:49:38 +02:00
5bd804601c dedupe tunnel lookup and key tunnel tiles by QPoint
The identical 7-line TunnelLookup lambda existed in updateTunnelGhost and
drawSelectedTunnelConnections; it is now GameWorldView::makeTunnelLookup.
The tile key moved from std::pair<int, int> to QPoint with the existing
QPointCompare comparator, dropping the manual packing at every site.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:47:28 +02:00
92a4f02cef extract Simulation::initializeSubsystems
The constructor and reset() held a character-for-character identical
26-line subsystem construction block, including three capturing lambdas.
Both run before the first tick, so the closures can be shared. Order is
unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:44:43 +02:00
84d32b6c16 add findShipDef/findModuleDef/findRecipeDef to config structs
Ships/Modules/RecipesConfig now carry lookup helpers mirroring
BuildingsConfig::findBuildingDef. The hand-rolled linear scans in
BuildingSystem, ShipSystem, ShipStatsCalculator, ThreatCostCalculator,
ShipLayoutDialog, SelectedBuildingPanel and SchematicChoiceDialog now
call them instead.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:42:24 +02:00
d31ff68ab7 remove duplicate findBuildingDef from GameWorldView
GameWorldView::findBuildingDef was a byte-equivalent re-implementation of
BuildingsConfig::findBuildingDef. All call sites now use the config helper,
matching SelectedBuildingPanel and BlueprintPanel.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:34:56 +02:00
b722955f7e fix splitter filters being lost when rotating in place
rotateInPlace re-implemented the belt-tile re-registration switch inline
instead of calling reregisterBeltTile, and its splitter branch omitted the
setSplitterFilters call the canonical version has. Since an operational
splitter keeps its filters only in BeltSystem, removeTile discarded them and
rotating a configured splitter silently reset it to "accept all".

Replace the duplicated switch with a call to reregisterBeltTile, capturing
the filters beforehand via getSplitterInfo — the same idiom deconstruct
already uses. This removes the second copy of the switch that allowed the
two to drift apart in the first place.

Add a regression test; the existing [rotate-in-place] cases covered belt
tiles only, which is why this went unnoticed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
2026-08-02 20:22:53 +02:00
51 changed files with 2646 additions and 5069 deletions

View File

@@ -5,10 +5,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Interaction
* ONLY modify code or other files if explicitly asked to do so
* keep the tone professional, brief and to the point — brevity applies to prose and
preamble, not to the substance of an objection or a design rationale
* be critical: where there is a concrete technical reason to disagree, name it once;
if the user reaffirms, proceed with their call without re-litigating
## Project Overview
@@ -35,15 +31,6 @@ keep the citation accurate.
## Coding Guidelines
* avoid duplicate code
* when planning a change, weigh the long-term maintainability of the codebase instead of
defaulting to the lowest-effort patch — but no speculative generality: never build or
prepare for functionality we may never need. If the maintainable solution is much larger
than the request, say so and let the user decide the scope.
* class layout: static members first, then non-static; within each, public, then protected,
then private (Qt `slots:`/`signals:` are ordinary non-static access groups). Inside an
access group the order is: nested types, static constants, aliases, methods, fields,
friends. Out-of-line method definitions follow the declaration order. Applies to new
classes and to files being edited anyway — don't reorder existing headers just to comply.
* do not use the "auto" keyword, with two exceptions:
* **named local lambdas** — a lambda's type is unnameable, and `std::function`
is not an acceptable substitute in per-tick code because it adds a heap
@@ -60,13 +47,6 @@ keep the citation accurate.
* don't use abbreviations, except very common ones ("s" for seconds, "min", "max", etc.)
* if a variable holds a value that has a unit or if a function returns a value that has a unit, append that unit to the name (e.g. "m_shipVelocity_mps", "getAcceleration_mpss()")
* always enclose scopes in braces
* keep source files ASCII-only. A non-ASCII character needed at runtime (a glyph in a
UI string, a symbol drawn on a widget) is written as its code point with a comment
naming it (`const QChar shiftGlyph(0x21E7); // U+21E7 UPWARDS WHITE ARROW`), never
as a literal character: MSVC 2017 does not read the sources as UTF-8 by default and
silently mangles them. Never round-trip a source file through
`Get-Content`/`Set-Content` either: Windows PowerShell reads it as ANSI and writes
it back double-encoded with a BOM. Use the Edit/Write tools.
## Build
@@ -103,15 +83,6 @@ output directories and copies the Qt DLLs.
Run the app: `build/DotaFactory/Debug/app/DotaFactory.exe`, optionally
`--replay <file>` for view-only playback of a recorded run.
**Visual verification is the user's job.** Screen-capturing the app window does not
work here: `CopyFromScreen` and `PrintWindow` both return a blank white client area
even while the app is running and rendering normally, because the capture cannot read
the composited surface of the `QOpenGLWidget`-backed window. A blank capture therefore
says nothing about whether the UI works, so do not read one as a regression and do not
try to work around it. To check a UI change: build, run the tests, launch the app, and
ask the user to look at it. Redirecting the process's stdout/stderr to a file does
work and is worth checking for Qt warnings.
## Tests
Catch2, single executable, links `lib` only — no QApplication, no display.

View File

@@ -95,7 +95,7 @@ Schematic drops: when an enemy station set is destroyed, the simulation generate
All UI interactions — building selection, builder/blueprint mode transitions, speed changes, deconstruct mode, escape menu, layout dialog requests — are communicated via EventManager events rather than Qt signals/slots. Each event is a small struct inheriting `Event` (e.g., `SelectionChangedEvent`, `BuildingTypeSelectedEvent`, `SpeedChangeRequestedEvent`). Widgets register as `CombinedEventHandler` for the events they care about and emit events via `EventManager::sendEventImmediately()`.
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonBar``GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView``BuildButtonBar`).
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonGrid``GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView``BuildButtonGrid`).
### Reading Simulation State
@@ -124,7 +124,7 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
Three product targets plus tests:
- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selected building panel, build button bar. Depends on `lib` and on Qt's OpenGL widgets module.
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selected building panel, build button grid. Depends on `lib` and on Qt's OpenGL widgets module.
- `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
- `tests/` — Catch2 tests. Links only against `lib`.
@@ -329,11 +329,7 @@ Buildings and the belt subsystem stay outside any entity model regardless of wha
## Rendering
The game world is drawn into a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization.
The drawing itself lives in `WorldRenderer`, not in the widget. `paintGL` is a call sequence: build the frame's `WorldCoordinates`, hand the renderer a `WorldRenderFrame`, then draw the screen-anchored chrome. The split is the world-space / screen-space line, and it is exact: the renderer draws everything positioned in tiles, while everything positioned in pixels — the pause and deconstruct vignettes, the replay overlay, the debug stats panel — stays with the widget. A useful consequence is that the renderer draws no translatable text at all (its text is config-driven glyphs, ASCII port arrows, and numbers), so it needs no `tr()` and no tie to the meta-object system.
`WorldRenderFrame` is what makes the renderer independent of the widget. The renderer reads the simulation directly, but everything else 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 into the frame each `paintGL` and passed by reference, so the renderer keeps no copy that a later click could invalidate. The renderer knows nothing about input: the widget resolves clicks and hit-tests, and the renderer only draws the result.
The game world is rendered by a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization.
### Render Loop
@@ -361,11 +357,9 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly
### Coordinates and Scrolling
- The horizontal view position lives in `WorldCamera` (`lib/core/`) as a continuous view-center X in tiles. A / D input pans it smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED). The camera works purely in world units — tiles and tiles/second, never pixels — which is what keeps it independent of `WorldCoordinates`; the two meet only where `GameWorldView` feeds `getViewCenterXTiles()` into the transform.
- The camera takes no simulation dependency. Its pan limits move with asteroid expansion and with pushes, so `GameWorldView` reads them from the sim each frame and passes them in as `ScrollBounds`; the camera clamps on every `advance()`, not only when panning, so the view follows the bounds inward when they shrink. Pan *intent* is likewise passed in as a `PanDirection` rather than read from key state, so the camera is unaffected if controls later become rebindable. Both properties are what make it a plain value with unit tests (`WorldCameraTest`) — notably over the two-ramp pan-speed curve, whose overlapping-band and zero-width-band cases are otherwise easy to break unnoticed.
- The world↔widget transform itself lives in `WorldCoordinates` (`lib/core/`), not in the view. It is an immutable value, built through one of two named factories that differ only in how `tilePx` and the left edge are derived; everything downstream is shared. `scrolling(...)` is the game world: `tilePx` makes the world height fill the viewport (REQ-GW-TILE-SIZE) and the view pans horizontally. `fitToWorld(...)` is the balancing tool's arena: a fixed world shown whole, so `tilePx` is the tighter of the two axis fits and there is no scroll. Being a plain value with no Qt Widgets dependency, it is unit-tested (`WorldCoordinatesTest`) even though the widgets around it are not.
- `GameWorldView::getCoordinates()` and `ArenaView::getCoordinates()` each build one per frame in `paintGL` and per event in the mouse handlers, and pass it down: every world-space `draw<X>` takes a `const WorldCoordinates&`, while the screen-space draws (vignette borders, replay overlay, debug text) take none. The snapshot is deliberately never cached in a member — a resize or a scroll would silently invalidate it.
- Conversions are per-call arithmetic rather than a `painter.translate`, because hit-testing needs the inverse (`widgetToWorld` / `widgetToTile`, flooring for a tile) as often as drawing needs the forward direction. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles, which is why the flooring must not be truncation.
- `GameWorldView` holds a continuous `scrollXTiles` (float). A / D input pans this smoothly (REQ-UI-SCROLL).
- At the start of `paintEvent`, a single `painter.translate(-scrollXTiles * tilePx, 0)` maps world tile units into widget pixels (`tilePx = 20`, per REQ-GW-TILE-SIZE).
- Mouse input converts the other way: `worldX = mouseX / tilePx + scrollXTiles`; apply `floor` for a tile. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles.
### Culling
@@ -375,8 +369,6 @@ The renderer iterates only entities and tiles whose world X lies within the visi
Shapes are hardcoded in the renderer — a building is a rectangle per footprint tile, a ship is an oriented arrow/triangle, a belt item is a 10×10 square, scrap is a small circle, a beam is a line. These structural choices live in the `draw<X>(painter, entity)` functions of the UI and are not expected to change frequently.
The few shapes the game view and the balancing tool's arena view draw *identically* — the ship body, the health bar, the debris marker, the sensor-range circle — live in `ui/WorldPrimitives` as free functions over explicit values. The arena exists to eyeball combat, so it only works while a ship there looks like a ship in the game; keeping these in one place means a retuned ship shape cannot silently stop applying to the tool that measures it. The balancing target does not link the `ui` library, so it compiles that file into itself, the same way it already does for `VisualsLoader` and `ShipStatsPanel` (see `balancing/CMakeLists.txt`). Everything the two views draw differently — selection highlights, beams, target lines, and all of the factory — stays with each view; the shared set is deliberately not grown beyond shapes that are genuinely the same.
Colors, outline widths, glyph text, and tile tints live in a separate config file, `visuals.toml`, loaded once by the UI at startup using the same pattern and lifetime as the sim config files (see Config Loading). The file is UI-scoped: the sim does not read it and does not depend on it.
Sketch of `visuals.toml`:

View File

@@ -135,7 +135,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- **Ghosts.** While dragging, a belt ghost (REQ-BLD-GHOST) is rendered on every path tile that would be acted on, instead of a single ghost under the cursor. Each ghost is oriented to point toward the next tile along the path toward the cursor, so the path forms one connected belt run that turns at the corner (curved belts along the path auto-derive per REQ-BLD-BELT); the final tile keeps the direction of its incoming step (unless the end tile is snapped to a building, in which case it points into the target — see **Snapping to a building**), and a single-tile path keeps the belt's current orientation. A tile occupied by only an existing belt or belt construction site is a valid target — its belt is re-oriented to follow the path — and shows a normal belt ghost. A tile occupied by a non-belt building or construction site, or otherwise an invalid belt position (REQ-BLD-PLACE-VALID), is drawn in the distinct invalid color, overriding the belt coloring. A tile whose new belt is unaffordable — the cumulative cost of the belts newly placed up to and including it exceeds the global stock — shows **no ghost at all**.
- **Placement on release.** No construction site is placed while dragging. On releasing the left mouse button, the path is applied in order (anchor to cursor): each cell occupied by only an existing belt or belt construction site has that belt re-oriented in place to its path direction, consuming no building blocks and preserving any construction progress (REQ-BLD-ROTATE-IN-PLACE); each empty, valid cell gets a new belt construction site, consuming building blocks from the global stock (REQ-BLD-COST). Cells occupied by a non-belt building or construction site, cells that are otherwise invalid (REQ-BLD-PLACE-VALID), and cells whose new belt can no longer be afforded once the running total has been spent are skipped. This supersedes the click-to-place of REQ-BLD-PLACE for belts, including both the single-tile case and multi-tile drags that pass over existing belts.
- **Right-click cancels the drag.** Right-clicking while a belt drag is in progress cancels it: the path is discarded, no construction site is placed, and belt builder mode stays active (the exception to REQ-BLD-BUILDER-MODE). Right-clicking when no drag is in progress exits builder mode as usual (REQ-BLD-BUILDER-MODE).
- REQ-BLD-TUNNEL-MODE: **Unified tunnel build mode.** The build button bar contains a single **Tunnel** button rather than separate Tunnel Entry and Tunnel Exit buttons (REQ-UI-BUILD-BAR), activated by that button or by hotkey 3 (REQ-UI-HOTKEYS). This one builder mode places either a Tunnel Entry or a Tunnel Exit construction site depending on the hovered position, so the player never manually chooses between the two ends. Both remain distinct building types (REQ-BLD-TUNNEL-ENTRY, REQ-BLD-TUNNEL-EXIT) with their own costs and construction; only their build-menu entry point is unified.
- REQ-BLD-TUNNEL-MODE: **Unified tunnel build mode.** The build button grid contains a single **Tunnel** button rather than separate Tunnel Entry and Tunnel Exit buttons (REQ-UI-BUILD-GRID), activated by that button or by hotkey 3 (REQ-UI-HOTKEYS). This one builder mode places either a Tunnel Entry or a Tunnel Exit construction site depending on the hovered position, so the player never manually chooses between the two ends. Both remain distinct building types (REQ-BLD-TUNNEL-ENTRY, REQ-BLD-TUNNEL-EXIT) with their own costs and construction; only their build-menu entry point is unified.
- **Default type.** The ghost (REQ-BLD-GHOST) is a **Tunnel Entry** by default; clicking places a Tunnel Entry construction site (REQ-BLD-PLACE). Rotation (REQ-BLD-ROTATE) sets the ghost's facing direction as for any building.
- **Exit-completion match.** While the ghost is at a valid position, the game tests whether placing a **Tunnel Exit** at the hovered tile with the current ghost rotation would pair — per the pairing rules of REQ-BLD-TUNNEL-PAIR (same facing direction, within `tunnel_max_distance`, first same-direction building along the search, nearest-claim semantics) — with an existing Tunnel Entry. If so, that Entry is the **exit-completion match** and the ghost turns into a **Tunnel Exit**; clicking then places a Tunnel Exit construction site.
- **Entry-completion match.** The game also tests whether placing a **Tunnel Entry** at the hovered tile with the current ghost rotation would pair — again per REQ-BLD-TUNNEL-PAIR — with an existing Tunnel Exit. If so, that Exit is the **entry-completion match** and the ghost stays a Tunnel Entry.
@@ -401,7 +401,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-LOCK-UI-SCHEMATIC: Locked ship schematics are not shown in the shipyard's schematic-selection dialog (REQ-UI-SELECT-BUTTON).
- REQ-LOCK-BUILDING: A building type granted by an unlock group (REQ-LOCK-EXPLICIT) is **locked** until that group is awarded. A locked building type has no button in the build button bar (REQ-UI-BUILD-BAR) and cannot be placed, selected as a build tool, or triggered by its build hotkey (REQ-UI-HOTKEYS); its button appears in the bar only once the building type is unlocked, at which point the bar re-centers. Building types not granted by any unlock group are available from game start. Lock state resets on Restart (REQ-CFG-RELOAD).
- REQ-LOCK-BUILDING: A building type granted by an unlock group (REQ-LOCK-EXPLICIT) is **locked** until that group is awarded. A locked building type has no button in the build button grid (REQ-UI-BUILD-GRID) and cannot be placed, selected as a build tool, or triggered by its build hotkey (REQ-UI-HOTKEYS); its button appears in the grid only once the building type is unlocked. Building types not granted by any unlock group are available from game start. Lock state resets on Restart (REQ-CFG-RELOAD).
- REQ-LOCK-UI-SPLITTER: Item types that are not implicitly unlocked are excluded from splitter filter dropdowns (REQ-BLD-SPLITTER).
@@ -433,7 +433,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
### Layout
The screen is divided into two columns: a main column (75% width) containing the header bar and game world, and a side panel column (25% width) containing the two UI panels stacked vertically. The build button bar (REQ-UI-BUILD-BAR) is not part of either column — it floats over the game world at its bottom center:
The screen is divided into two columns: a main column (75% width) containing the header bar and game world, and a side panel column (25% width) containing the three UI panels stacked vertically:
```
+--------------------------------------+--------------+
@@ -441,12 +441,14 @@ The screen is divided into two columns: a main column (75% width) containing the
+--------------------------------------+ Selected |
| | Building |
| | Panel |
| Game World | |
| +--------------+
| | |
| +------------------+ | Blueprint |
| | Build Button Bar | | Panel |
+--------+------------------+----------+--------------+
| Game World | Build |
| | Button |
| | Grid |
| +--------------+
| | Blueprint |
| | Panel |
+--------------------------------------+--------------+
(75% width) (25% width)
```
@@ -460,7 +462,7 @@ The screen is divided into two columns: a main column (75% width) containing the
- REQ-UI-DECONSTRUCT-BORDER: While deconstruct mode is active (REQ-UI-DECONSTRUCT-BUTTON, REQ-UI-HOTKEYS), a vignette border is drawn around the edges of the game world view to signal the mode, matching the geometry of the paused-state vignette (REQ-UI-PAUSE-BORDER): a 100-pixel thickness (capped at half the smaller viewport dimension on very small views) with the four sides meeting along mitred corner diagonals. It fades in the alpha channel from fully transparent at its inner (center-facing) edge to the deconstruct tint color at the viewport edge. The color — including its alpha, which sets the peak opacity at the viewport edge — is read from `visuals.toml [overlays].deconstruct_tint`, the same deconstruct-mode color used for the hover tint. The border is presentation-only and has no effect on the simulation. If the game is both paused and in deconstruct mode, both vignettes are drawn and compose over each other.
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x>` followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). When no icon file exists for `building_block`, the caption falls back to the `Expand: <x> Blocks` text. Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
- REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width).
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into two equal-height panels stacked top to bottom: selected building panel (top) and blueprint panel (bottom). The build buttons are not part of this column; they float over the game world (REQ-UI-BUILD-BAR).
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom).
- REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the side panel column — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
### Game World
@@ -527,7 +529,7 @@ The screen is divided into two columns: a main column (75% width) containing the
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs). For a selected construction site, the recipe/schematic selection (and, for a shipyard, the layout preview and "Configure" button) are shown but the buffer rows are omitted (REQ-BLD-SITE-CONFIG).
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown.
- REQ-UI-MULTI-SELECT: The player selects multiple objects by box-drag or by Ctrl+clicking individual objects to add or remove them from the selection. Multi-select operates within a single category (REQ-UI-SELECTION-CATEGORIES). A box-drag that covers at least one building selects buildings (any field objects within the box are ignored — buildings win); a box-drag that covers no building but does cover ships, defence stations, or debris selects all of those field objects together (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-MULTI-SELECT).
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. The panel additionally shows the **total building block cost** of the selection — the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button bar); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-BUTTON). Construction sites count at their building type's full placement cost regardless of construction progress.
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. The panel additionally shows the **total building block cost** of the selection — the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button grid); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-BUTTON). Construction sites count at their building type's full placement cost regardless of construction progress.
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel. Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selected building panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selected building panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
- REQ-UI-SELECT-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selected building panel when a selection is set, displays an info tooltip:
@@ -551,23 +553,20 @@ The screen is divided into two columns: a main column (75% width) containing the
- REQ-UI-DEBRIS-MULTI-SELECT: Multiple pieces of debris can be selected by box-drag or by Ctrl+clicking individual pieces to add or remove them, mirroring building multi-select (REQ-UI-MULTI-SELECT). Debris shares the field-object category with ships and defence stations (REQ-UI-SELECTION-CATEGORIES), so a field selection may hold debris and actors together. Ctrl+clicking a piece of debris while a field selection is active adds or removes that piece within the same selection; Ctrl+clicking a piece of debris while a building selection is active first clears the buildings and begins a field selection (buildings win). Conversely, selecting a building while a field selection is active clears it. Box-drag disambiguation follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering no building selects the ships, defence stations, and debris within it).
- REQ-UI-DEBRIS-PANEL: When exactly one piece of debris is selected (and no actors, REQ-UI-FIELD-MULTI-SELECTION), the selected building panel shows a **debris stats panel** structured like the ship and station stats panels (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL): a **"Debris"** heading followed by a single stat row, **"Scrap"**, showing that piece's current remaining scrap amount (REQ-RES-DEBRIS-DROP), rendered in the same label/value style as a ship hull stat row. When more than one field object is selected — multiple pieces of debris, or debris together with actors — the debris are instead summarized within the compact count summary (REQ-UI-FIELD-MULTI-SELECTION): a "Debris x <count>" line giving the number of selected debris pieces, followed by a "Scrap x <total>" line summing the remaining scrap across all selected debris. The displayed scrap value(s) update as selected debris are partially collected or despawn (REQ-UI-DEBRIS-CLICK-SELECT).
### Build Button Bar
### Build Button Grid
- REQ-UI-BUILD-BAR: All placeable building types are shown as a **single horizontal row** of buttons with no grouping and no wrapping, inside a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), horizontally centered and anchored at the bottom edge with a small margin. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each. The bar is sized to its buttons and re-centers whenever the set of shown buttons changes (REQ-LOCK-BUILDING) or the view is resized.
- **Overlay behavior.** The bar occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected (the world is not inset for the bar). The bar is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full 100-pixel bottom band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the bar.
- **Input.** Mouse events over the bar are consumed by the bar and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the bar does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG).
- REQ-UI-BUILD-COST: Each button is **icon-only with a cost**, its face composed of three elements: the button's **hotkey badge** in the top-left corner, the building's icon (REQ-UI-BUILD-ICON) centered below it, and the building block cost centered under the icon, shown with the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) to the right of the number in place of the trailing `Blocks` word, e.g. `2` then a small block icon. The building name is not shown on the button; it is shown in the button's hover tooltip instead (REQ-UI-BUILD-TOOLTIP). When no icon file exists for `building_block`, the cost is shown as the bare number. The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) has no cost and shows its name as a text caption in the cost's place.
- **Hotkey badge.** The badge names the build hotkey that activates the button (REQ-UI-HOTKEYS), so the player can learn the shortcuts from the bar itself. It is rendered dimmer than the cost so it reads as secondary, but at the same size and in bold, because a smaller badge is not legible. A plain-digit hotkey is shown as the bare digit (`1`, `2`, `3`); a Shift+digit hotkey is shown with an upwards arrow prefixed and no separator (`↑1``↑6`); the Deconstruct button shows `Q`. A button whose building type has no build hotkey shows no badge and keeps the same face size, so the row stays even.
- REQ-UI-BUILD-ICON: Each build button shows an icon. Icons are SVG files loaded at runtime from `data/icons/buildings/` (a sibling of the config directory, read the same way as `visuals.toml`), one file per button named after the building's id (e.g. `belt.svg`, `reprocessing_plant.svg`). The shared Tunnel button (REQ-UI-BUILD-BAR) uses `tunnel_entry.svg`; the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) uses `deconstruct.svg`. Each icon is a rounded colored "chip" bearing a white line glyph, the chip color following the building's fill color in `visuals.toml`. A missing icon file leaves the button showing its building name as a text caption in place of the icon, so the button stays identifiable in the icon-only bar (REQ-UI-BUILD-COST); it is not an error.
- REQ-UI-BUILD-TOOLTIP: Each building-type button shows a hover tooltip consisting of the building name followed by the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). Because the button caption is icon-only (REQ-UI-BUILD-COST), the name is always part of the tooltip; if a building type defines no tooltip text, the tooltip shows the name alone. This tooltip is distinct from the recipe/schematic selection tooltip (REQ-UI-SELECT-TOOLTIP). The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) is not a building type and so has no config-defined tooltip; it instead shows its own refund tooltip defined in REQ-UI-DECONSTRUCT-BUTTON.
- REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each.
- REQ-UI-BUILD-COST: Each button caption shows the building name and its building block cost with the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, e.g. `Belt: 2` then a small block icon. When no icon file exists for `building_block`, the caption falls back to the text form, e.g. "Belt: 2 Blocks".
- REQ-UI-BUILD-ICON: Each build button shows an icon alongside its caption. Icons are SVG files loaded at runtime from `data/icons/buildings/` (a sibling of the config directory, read the same way as `visuals.toml`), one file per button named after the building's id (e.g. `belt.svg`, `reprocessing_plant.svg`). The shared Tunnel button (REQ-UI-BUILD-GRID) uses `tunnel_entry.svg`; the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) uses `deconstruct.svg`. Each icon is a rounded colored "chip" bearing a white line glyph, the chip color following the building's fill color in `visuals.toml`. A missing icon file leaves the button with its caption and no icon; it is not an error.
- REQ-UI-BUILD-TOOLTIP: Each building-type button shows a hover tooltip with the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). This tooltip is distinct from the recipe/schematic selection tooltip (REQ-UI-SELECT-TOOLTIP). If a building type defines no tooltip text, its button shows no tooltip. The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) is not a building type and so has no config-defined tooltip; it instead shows its own refund tooltip defined in REQ-UI-DECONSTRUCT-BUTTON.
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained.
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button bar (REQ-UI-BUILD-BAR), as the last entry of the row and **visually separated** from the building-type buttons by a gap (not a divider line), because it toggles a mode rather than selecting a building type. Its face follows REQ-UI-BUILD-COST with two differences: its hotkey badge reads `Q`, and because it has no building block cost it shows its **Deconstruct** name as a text caption where the building-type buttons show their cost — so it is the one labelled button in the bar. It is therefore wider than the building-type buttons, which share a uniform width. Clicking it toggles deconstruct mode on and off, equivalent to the Q deconstruct toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage.
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button grid. Clicking it toggles deconstruct mode on and off, equivalent to the Q deconstruct toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage.
### Blueprint Panel
- REQ-UI-BLUEPRINT-PANEL: The blueprint panel is the lower of the two side panel column panels, below the selected building panel (REQ-UI-PANEL-COLUMN). It contains, from top to bottom: a "Create Blueprint" button, and a list of blueprint entries (one per saved blueprint, in creation order). The panel has no Save or Load buttons; blueprints are persisted automatically (REQ-UI-BLUEPRINT-SAVE) and restored at startup (REQ-UI-BLUEPRINT-LOAD).
- REQ-UI-BLUEPRINT-PANEL: The blueprint panel is shown to the right of the build button grid. It contains, from top to bottom: a "Create Blueprint" button, and a list of blueprint entries (one per saved blueprint, in creation order). The panel has no Save or Load buttons; blueprints are persisted automatically (REQ-UI-BLUEPRINT-SAVE) and restored at startup (REQ-UI-BLUEPRINT-LOAD).
- REQ-UI-BLUEPRINT-CREATE: The "Create Blueprint" button is enabled only when at least one player-placeable building (i.e. a building with a button in the build button bar) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition. A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). When clicked, a modal dialog appears prompting the player to enter a name. The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, and appends its button to the blueprint list.
- REQ-UI-BLUEPRINT-CREATE: The "Create Blueprint" button is enabled only when at least one player-placeable building (i.e. a building with a button in the build button grid) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition. A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). When clicked, a modal dialog appears prompting the player to enter a name. The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, and appends its button to the blueprint list.
- REQ-UI-BLUEPRINT-TEMP: Pressing the **T** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing T with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing. Entering this mode replaces any currently active build, blueprint placement, or deconstruct mode. The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint panel (REQ-UI-BLUEPRINT-PANEL), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode, at which point the temporary blueprint is discarded.

View File

@@ -1,3 +1,6 @@
set(TARGET_BASE_NAME "${PRODUCT_NAME}")
set(TARGET_APP_NAME "${TARGET_BASE_NAME}")

View File

@@ -30,7 +30,6 @@
#include "ShipIdentityComponent.h"
#include "StationBodyComponent.h"
#include "DebrisComponent.h"
#include "WorldPrimitives.h"
namespace
{
@@ -177,36 +176,55 @@ void ArenaView::paintGL()
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, false);
// One transform snapshot for the whole frame; every draw below reads the
// viewport through it.
const WorldCoordinates coordinates = getCoordinates();
drawTiles(painter, coordinates);
drawBuildings(painter, coordinates);
drawStations(painter, coordinates);
drawDebris(painter, coordinates);
drawTiles(painter);
drawBuildings(painter);
drawStations(painter);
drawDebris(painter);
if (m_debugDraw)
{
drawDebugSensorRanges(painter, coordinates);
drawDebugTargetLines(painter, coordinates);
drawDebugSensorRanges(painter);
drawDebugTargetLines(painter);
}
drawShips(painter, coordinates);
drawBeams(painter, coordinates);
drawShips(painter);
drawBeams(painter);
}
// ---------------------------------------------------------------------------
// Coordinate helpers
// ---------------------------------------------------------------------------
WorldCoordinates ArenaView::getCoordinates() const
float ArenaView::getTilePx() const
{
// The arena is a fixed, fully visible world — unlike the game view it has no
// scrolling, so the tile size comes from fitting the whole arena in the widget.
const ArenaConfig& ac = m_sim->getArenaConfig();
const int totalWidth = ac.playerBufferWidth_tiles
+ ac.contestZoneWidth_tiles
+ ac.enemyBufferWidth_tiles;
return WorldCoordinates::fitToWorld(size(), totalWidth, ac.heightTiles);
const int totalHeight = ac.heightTiles;
if (totalWidth <= 0 || totalHeight <= 0) { return 1.0f; }
const float pxPerTileH = static_cast<float>(height()) / static_cast<float>(totalHeight);
const float pxPerTileW = static_cast<float>(width()) / static_cast<float>(totalWidth);
return std::min(pxPerTileH, pxPerTileW);
}
QPointF ArenaView::worldToWidget(QVector2D worldPos) const
{
return QPointF(
static_cast<qreal>(worldPos.x() * getTilePx()),
static_cast<qreal>(worldPos.y() * getTilePx()));
}
QPointF ArenaView::tileToWidget(QPoint tile) const
{
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
static_cast<float>(tile.y())));
}
QRectF ArenaView::tileRect(QPoint tile) const
{
const QPointF tl = tileToWidget(tile);
return QRectF(tl.x(), tl.y(),
static_cast<qreal>(getTilePx()), static_cast<qreal>(getTilePx()));
}
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
@@ -218,11 +236,19 @@ std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
return m_sim->getAdmin().get<PositionComponent>(entity).value;
}
QVector2D ArenaView::widgetToWorld(QPoint widgetPt) const
{
const float px = getTilePx();
if (px < 0.001f) { return QVector2D(0.0f, 0.0f); }
return QVector2D(static_cast<float>(widgetPt.x()) / px,
static_cast<float>(widgetPt.y()) / px);
}
void ArenaView::mousePressEvent(QMouseEvent* event)
{
if (event->button() == Qt::LeftButton)
{
const QVector2D worldPos = getCoordinates().widgetToWorld(event->pos());
const QVector2D worldPos = widgetToWorld(event->pos());
entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
if (hit != entt::null)
@@ -261,7 +287,7 @@ void ArenaView::keyPressEvent(QKeyEvent* event)
// Rendering
// ---------------------------------------------------------------------------
void ArenaView::drawTiles(QPainter& painter, const WorldCoordinates& coordinates)
void ArenaView::drawTiles(QPainter& painter)
{
const ArenaConfig& ac = m_sim->getArenaConfig();
const int totalWidth = ac.playerBufferWidth_tiles
@@ -274,12 +300,12 @@ void ArenaView::drawTiles(QPainter& painter, const WorldCoordinates& coordinates
{
for (int y = 0; y < totalHeight; ++y)
{
painter.fillRect(coordinates.tileRect(QPoint(x, y)), m_visuals->space.fill);
painter.fillRect(tileRect(QPoint(x, y)), m_visuals->space.fill);
}
}
}
void ArenaView::drawBuildings(QPainter& painter, const WorldCoordinates& coordinates)
void ArenaView::drawBuildings(QPainter& painter)
{
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{
@@ -291,13 +317,13 @@ void ArenaView::drawBuildings(QPainter& painter, const WorldCoordinates& coordin
painter.setPen(Qt::NoPen);
for (const QPoint& cell : b.bodyCells)
{
painter.fillRect(coordinates.tileRect(cell), bv.fill);
painter.fillRect(tileRect(cell), bv.fill);
}
const QPointF tl = coordinates.tileToWidget(b.anchor);
const QPointF tl = tileToWidget(b.anchor);
const QRectF bboxRect(tl.x(), tl.y(),
b.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
b.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
b.footprint.width() * static_cast<qreal>(getTilePx()),
b.footprint.height() * static_cast<qreal>(getTilePx()));
painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush);
@@ -311,16 +337,20 @@ void ArenaView::drawBuildings(QPainter& painter, const WorldCoordinates& coordin
}
}
void ArenaView::drawDebris(QPainter& painter, const WorldCoordinates& coordinates)
void ArenaView::drawDebris(QPainter& painter)
{
const float r = getTilePx() * 0.2f;
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
{
drawDebrisMarker(painter, coordinates,
coordinates.worldToWidget(debris.position));
const QPointF center = worldToWidget(debris.position);
painter.setBrush(QColor(128, 110, 90));
painter.setPen(QPen(QColor(50, 40, 30), 1));
painter.drawEllipse(center,
static_cast<qreal>(r), static_cast<qreal>(r));
}
}
void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordinates)
void ArenaView::drawStations(QPainter& painter)
{
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h)
@@ -336,13 +366,13 @@ void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordina
painter.setPen(Qt::NoPen);
for (const QPoint& cell : sb.bodyCells)
{
painter.fillRect(coordinates.tileRect(cell), bv.fill);
painter.fillRect(tileRect(cell), bv.fill);
}
const QPointF tl = coordinates.tileToWidget(sb.anchor);
const QPointF tl = tileToWidget(sb.anchor);
const QRectF bboxRect(tl.x(), tl.y(),
sb.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
sb.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
sb.footprint.width() * static_cast<qreal>(getTilePx()),
sb.footprint.height() * static_cast<qreal>(getTilePx()));
painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush);
@@ -350,9 +380,14 @@ void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordina
if (h.maxHp > 0.0f)
{
drawHealthBar(painter, coordinates, bboxRect.left(),
bboxRect.bottom() + 1.0, bboxRect.width(),
h.hp / h.maxHp, f.isEnemy);
const float fraction = std::max(0.0f, h.hp / h.maxHp);
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
const qreal barY = bboxRect.bottom() + 1.0;
const qreal barW = bboxRect.width();
painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH),
QColor(60, 60, 60));
painter.fillRect(QRectF(bboxRect.left(), barY, barW * static_cast<qreal>(fraction), barH),
f.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
}
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
@@ -364,10 +399,8 @@ void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordina
});
}
void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates)
void ArenaView::drawShips(QPainter& painter)
{
const float forward = getShipForwardExtentPx(coordinates);
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent, HealthComponent>(
[&](entt::entity e, const ShipIdentityComponent& si,
@@ -378,22 +411,40 @@ void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates
m_visuals->ships.find(si.schematicId);
if (it == m_visuals->ships.end()) { return; }
const QPointF center = coordinates.worldToWidget(pos.value);
drawShipBody(painter, coordinates, center, facing.radians,
it->second.fill, it->second.outline);
const QPointF center = worldToWidget(pos.value);
const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians));
const QVector2D perp(-dir.y(), dir.x());
const float fwd = getTilePx() * 0.45f;
const float side = getTilePx() * 0.25f;
QPolygonF tri;
tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd),
center.y() + static_cast<qreal>(dir.y() * fwd))
<< QPointF(center.x() + static_cast<qreal>(perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(perp.y() * side - dir.y() * side))
<< QPointF(center.x() + static_cast<qreal>(-perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(-perp.y() * side - dir.y() * side));
painter.setPen(QPen(it->second.outline, 1));
painter.setBrush(it->second.fill);
painter.drawPolygon(tri);
if (h.maxHp > 0.0f)
{
const qreal barW = static_cast<qreal>(forward) * 2.0;
const qreal barX = center.x() - static_cast<qreal>(forward);
const qreal barY = center.y() + static_cast<qreal>(forward) + 1.0;
drawHealthBar(painter, coordinates, barX, barY, barW,
h.hp / h.maxHp, fac.isEnemy);
const float fraction = std::max(0.0f, h.hp / h.maxHp);
const qreal barW = static_cast<qreal>(fwd) * 2.0;
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
const qreal barX = center.x() - static_cast<qreal>(fwd);
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60));
painter.fillRect(QRectF(barX, barY, barW * static_cast<qreal>(fraction), barH),
fac.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
}
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
{
const qreal radius = static_cast<qreal>(coordinates.getTilePx()) * 0.55;
const qreal radius = static_cast<qreal>(getTilePx()) * 0.55;
painter.setPen(QPen(QColor(255, 255, 0), 2));
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(center, radius, radius);
@@ -401,9 +452,9 @@ void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates
});
}
void ArenaView::drawDebugSensorRanges(QPainter& painter,
const WorldCoordinates& coordinates)
void ArenaView::drawDebugSensorRanges(QPainter& painter)
{
painter.setBrush(Qt::NoBrush);
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const SensorRangeComponent& sensor)
@@ -412,14 +463,17 @@ void ArenaView::drawDebugSensorRanges(QPainter& painter,
m_visuals->ships.find(si.schematicId);
if (it == m_visuals->ships.end()) { return; }
drawSensorRange(painter, coordinates,
coordinates.worldToWidget(pos.value),
sensor.value_tiles, it->second.outline);
const QPointF center = worldToWidget(pos.value);
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
* static_cast<qreal>(getTilePx());
QColor circleColor = it->second.outline;
circleColor.setAlpha(77);
painter.setPen(QPen(circleColor, 1));
painter.drawEllipse(center, radiusPx, radiusPx);
});
}
void ArenaView::drawDebugTargetLines(QPainter& painter,
const WorldCoordinates& coordinates)
void ArenaView::drawDebugTargetLines(QPainter& painter)
{
// Draw a thin translucent line from a ship to a target, colored by the ship's
// team to match the per-side HQ/station colors used elsewhere in the arena
@@ -437,8 +491,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter,
QColor lineColor = it->second.fill;
lineColor.setAlpha(128);
painter.setPen(QPen(lineColor, 1));
painter.drawLine(coordinates.worldToWidget(from),
coordinates.worldToWidget(to));
painter.drawLine(worldToWidget(from), worldToWidget(to));
};
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
@@ -483,7 +536,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter,
});
}
void ArenaView::drawBeams(QPainter& painter, const WorldCoordinates& coordinates)
void ArenaView::drawBeams(QPainter& painter)
{
for (const ActiveBeam& beam : m_activeBeams)
{
@@ -499,7 +552,7 @@ void ArenaView::drawBeams(QPainter& painter, const WorldCoordinates& coordinates
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
}
painter.setPen(QPen(color, m_visuals->beams.widthPx));
painter.drawLine(coordinates.worldToWidget(*shooterPos),
coordinates.worldToWidget(*targetPos + beam.targetOffset));
painter.drawLine(worldToWidget(*shooterPos),
worldToWidget(*targetPos + beam.targetOffset));
}
}

View File

@@ -17,7 +17,6 @@
#include "Tick.h"
#include "TickDriver.h"
#include "VisualsConfig.h"
#include "WorldCoordinates.h"
class ArenaSimulation;
class QPainter;
@@ -48,22 +47,22 @@ private slots:
private:
void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
void drawTiles(QPainter& painter, const WorldCoordinates& coordinates);
void drawBuildings(QPainter& painter, const WorldCoordinates& coordinates);
void drawStations(QPainter& painter, const WorldCoordinates& coordinates);
void drawDebris(QPainter& painter, const WorldCoordinates& coordinates);
void drawShips(QPainter& painter, const WorldCoordinates& coordinates);
void drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates);
void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates);
void drawBeams(QPainter& painter, const WorldCoordinates& coordinates);
void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter);
void drawStations(QPainter& painter);
void drawDebris(QPainter& painter);
void drawShips(QPainter& painter);
void drawDebugSensorRanges(QPainter& painter);
void drawDebugTargetLines(QPainter& painter);
void drawBeams(QPainter& painter);
// The world <-> widget transform for the current viewport size. The arena
// shows the whole world at once and never scrolls, so this fits the arena's
// full extent into the widget; like GameWorldView's, it is a per-frame
// snapshot rather than cached state.
WorldCoordinates getCoordinates() const;
float getTilePx() const;
QPointF worldToWidget(QVector2D worldPos) const;
QPointF tileToWidget(QPoint tile) const;
QRectF tileRect(QPoint tile) const;
std::optional<QVector2D> entityPosition(entt::entity entity) const;
QVector2D widgetToWorld(QPoint widgetPt) const;
struct ActiveBeam
{

View File

@@ -9,10 +9,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h
# Shared world-space shapes so the arena keeps looking like the game
# (see WorldPrimitives.h). The balancing target does not link the ui library,
# so the few ui files it needs are compiled into it, as above.
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.h
PARENT_SCOPE
)
@@ -27,6 +23,5 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.cpp
PARENT_SCOPE
)

View File

@@ -1,261 +0,0 @@
#include "BuildModeController.h"
#include <memory>
#include <utility>
#include "BlueprintModeExitedEvent.h"
#include "BuilderModeExitedEvent.h"
#include "DeconstructModeChangedEvent.h"
#include "EventManager.h"
namespace
{
Rotation rotateClockwise(Rotation rotation)
{
switch (rotation)
{
case Rotation::North: return Rotation::East;
case Rotation::East: return Rotation::South;
case Rotation::South: return Rotation::West;
case Rotation::West: return Rotation::North;
}
return Rotation::East;
}
Rotation rotateCounterClockwise(Rotation rotation)
{
switch (rotation)
{
case Rotation::North: return Rotation::West;
case Rotation::East: return Rotation::North;
case Rotation::South: return Rotation::East;
case Rotation::West: return Rotation::South;
}
return Rotation::East;
}
} // namespace
BuildMode BuildModeController::getMode() const
{
return m_mode;
}
bool BuildModeController::isBuilderMode() const
{
return m_mode == BuildMode::Builder;
}
bool BuildModeController::isBlueprintMode() const
{
return m_mode == BuildMode::Blueprint;
}
bool BuildModeController::isDeconstructMode() const
{
return m_mode == BuildMode::Deconstruct;
}
void BuildModeController::enterMode(BuildMode mode)
{
if (m_mode == mode) { return; }
// Leave the current mode properly, so its widget hears about it however the
// player left. Each exit clears only its own state.
switch (m_mode)
{
case BuildMode::Builder:
m_draggingBelt = false;
m_beltDragPath.clear();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuilderModeExitedEvent>());
break;
case BuildMode::Blueprint:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintModeExitedEvent>());
break;
case BuildMode::Deconstruct:
m_deconstructHoverBuildingId.reset();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DeconstructModeChangedEvent>(false));
break;
case BuildMode::None:
break;
}
m_mode = mode;
if (mode == BuildMode::Deconstruct)
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DeconstructModeChangedEvent>(true));
}
}
void BuildModeController::enterBuilderMode(BuildingType type)
{
enterMode(BuildMode::Builder);
m_builderType = type;
m_ghostRotation = Rotation::East;
m_ghostValid = false;
m_tunnelGhostType = BuildingType::TunnelEntry;
m_tunnelPartnerTile.reset();
}
void BuildModeController::enterBlueprintMode(Blueprint blueprint)
{
enterMode(BuildMode::Blueprint);
// The layout starts where the builder ghost last was, so switching from a
// building to a blueprint does not jump the preview across the world.
m_blueprintGhostTile = m_ghostTile;
m_blueprint = std::move(blueprint);
}
void BuildModeController::toggleDeconstructMode()
{
enterMode(isDeconstructMode() ? BuildMode::None : BuildMode::Deconstruct);
}
void BuildModeController::exitBuilderMode()
{
if (!isBuilderMode()) { return; }
enterMode(BuildMode::None);
}
void BuildModeController::exitBlueprintMode()
{
if (!isBlueprintMode()) { return; }
enterMode(BuildMode::None);
}
void BuildModeController::exitCurrentMode()
{
enterMode(BuildMode::None);
}
BuildingType BuildModeController::getBuilderType() const
{
return m_builderType;
}
bool BuildModeController::isTunnelMode() const
{
return isBuilderMode() && m_builderType == BuildingType::TunnelEntry;
}
BuildingType BuildModeController::getEffectiveBuilderType() const
{
return isTunnelMode() ? m_tunnelGhostType : m_builderType;
}
QPoint BuildModeController::getGhostTile() const
{
return m_ghostTile;
}
Rotation BuildModeController::getGhostRotation() const
{
return m_ghostRotation;
}
bool BuildModeController::isGhostValid() const
{
return m_ghostValid;
}
void BuildModeController::setGhostTile(QPoint tile)
{
m_ghostTile = tile;
}
void BuildModeController::setGhostValidity(bool valid)
{
m_ghostValid = valid;
}
void BuildModeController::rotateGhost(bool clockwise)
{
m_ghostRotation = clockwise ? rotateClockwise(m_ghostRotation)
: rotateCounterClockwise(m_ghostRotation);
}
BuildingType BuildModeController::getTunnelGhostType() const
{
return m_tunnelGhostType;
}
const std::optional<QPoint>& BuildModeController::getTunnelPartnerTile() const
{
return m_tunnelPartnerTile;
}
void BuildModeController::setTunnelGhost(BuildingType resolvedType,
std::optional<QPoint> partnerTile)
{
m_tunnelGhostType = resolvedType;
m_tunnelPartnerTile = std::move(partnerTile);
}
bool BuildModeController::isDraggingBelt() const
{
return m_draggingBelt;
}
QPoint BuildModeController::getBeltDragAnchor() const
{
return m_beltDragAnchor;
}
const std::vector<BeltPathTile>& BuildModeController::getBeltDragPath() const
{
return m_beltDragPath;
}
void BuildModeController::beginBeltDrag(QPoint anchorTile)
{
m_draggingBelt = true;
m_beltDragAnchor = anchorTile;
}
void BuildModeController::setBeltDragPath(std::vector<BeltPathTile> path)
{
m_beltDragPath = std::move(path);
}
void BuildModeController::cancelBeltDrag()
{
m_draggingBelt = false;
m_beltDragPath.clear();
}
const Blueprint& BuildModeController::getBlueprint() const
{
return m_blueprint;
}
Blueprint& BuildModeController::getMutableBlueprint()
{
return m_blueprint;
}
QPoint BuildModeController::getBlueprintGhostTile() const
{
return m_blueprintGhostTile;
}
void BuildModeController::setBlueprintGhostTile(QPoint tile)
{
m_blueprintGhostTile = tile;
}
const std::optional<BuildingId>&
BuildModeController::getDeconstructHoverBuildingId() const
{
return m_deconstructHoverBuildingId;
}
void BuildModeController::setDeconstructHoverBuildingId(std::optional<BuildingId> id)
{
m_deconstructHoverBuildingId = std::move(id);
}

View File

@@ -1,123 +0,0 @@
#pragma once
#include <optional>
#include <vector>
#include <QPoint>
#include "BeltDragPath.h"
#include "Blueprint.h"
#include "BuildingId.h"
#include "BuildingType.h"
#include "Rotation.h"
// Which of the mutually exclusive world-interaction modes is active
// (REQ-UI-HOTKEYS, REQ-BLD-GHOST, REQ-UI-BLUEPRINT-PLACE, REQ-BLD-DECONSTRUCT).
enum class BuildMode
{
None, // plain selection
Builder, // placing one building type, ghost following the cursor
Blueprint, // placing a saved multi-building layout
Deconstruct // marking buildings for demolition
};
// The active build mode and the transient state that belongs to it.
//
// These modes were previously three independent flags, and every entry point
// cleared the other two by hand — inconsistently, which is how entering builder
// mode came to drop a blueprint without announcing it. Here exclusivity is
// structural: one mode is active, and every transition runs through enterMode(),
// which exits whatever was active first and publishes the same events regardless
// of which way the player got there.
//
// Everything needing the simulation — placement validity, tunnel matching, belt
// path building — stays with the caller, which computes and hands back the result
// (setGhostValidity, setTunnelGhost, setBeltDragPath). That keeps this a plain
// value that can be tested without a world.
class BuildModeController
{
public:
BuildMode getMode() const;
bool isBuilderMode() const;
bool isBlueprintMode() const;
bool isDeconstructMode() const;
// --- transitions ----------------------------------------------------------
// Each leaves the previously active mode with its proper exit event.
void enterBuilderMode(BuildingType type);
void enterBlueprintMode(Blueprint blueprint);
// Leaves deconstruct mode if it is active, enters it otherwise
// (REQ-BLD-DECONSTRUCT-CLICK).
void toggleDeconstructMode();
void exitBuilderMode();
void exitBlueprintMode();
// Backs out of whichever mode is active, if any (the Q key and right-click).
void exitCurrentMode();
// --- builder mode ---------------------------------------------------------
// Only meaningful while isBuilderMode().
BuildingType getBuilderType() const;
// True while the builder type is TunnelEntry, where the ghost resolves to an
// entry or an exit by hovered position (REQ-BLD-TUNNEL-MODE).
bool isTunnelMode() const;
// The type the ghost currently represents: the position-resolved tunnel type in
// tunnel mode, the plain builder type otherwise.
BuildingType getEffectiveBuilderType() const;
QPoint getGhostTile() const;
Rotation getGhostRotation() const;
bool isGhostValid() const;
void setGhostTile(QPoint tile);
void setGhostValidity(bool valid);
// Turns the ghost one quarter turn. Validity is not rechecked here; the caller
// does that and calls setGhostValidity, because only it can see the world.
void rotateGhost(bool clockwise);
BuildingType getTunnelGhostType() const;
const std::optional<QPoint>& getTunnelPartnerTile() const;
void setTunnelGhost(BuildingType resolvedType, std::optional<QPoint> partnerTile);
// --- belt drag placement (REQ-BLD-BELT-DRAG) ------------------------------
bool isDraggingBelt() const;
QPoint getBeltDragAnchor() const;
const std::vector<BeltPathTile>& getBeltDragPath() const;
void beginBeltDrag(QPoint anchorTile);
void setBeltDragPath(std::vector<BeltPathTile> path);
// Drops the drag without placing anything, staying in builder mode.
void cancelBeltDrag();
// --- blueprint mode -------------------------------------------------------
// Only meaningful while isBlueprintMode().
const Blueprint& getBlueprint() const;
// Mutable so the caller can rotate the layout in place; rotating a blueprint
// needs building footprints from the config, which does not belong here.
Blueprint& getMutableBlueprint();
QPoint getBlueprintGhostTile() const;
void setBlueprintGhostTile(QPoint tile);
// --- deconstruct mode -----------------------------------------------------
const std::optional<BuildingId>& getDeconstructHoverBuildingId() const;
void setDeconstructHoverBuildingId(std::optional<BuildingId> id);
private:
// The single transition point: leaves the active mode, then enters `mode`.
void enterMode(BuildMode mode);
BuildMode m_mode = BuildMode::None;
BuildingType m_builderType = BuildingType::Belt;
QPoint m_ghostTile;
Rotation m_ghostRotation = Rotation::East;
bool m_ghostValid = false;
BuildingType m_tunnelGhostType = BuildingType::TunnelEntry;
std::optional<QPoint> m_tunnelPartnerTile;
bool m_draggingBelt = false;
QPoint m_beltDragAnchor;
std::vector<BeltPathTile> m_beltDragPath;
Blueprint m_blueprint;
QPoint m_blueprintGhostTile;
std::optional<BuildingId> m_deconstructHoverBuildingId;
};

View File

@@ -14,10 +14,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h
PARENT_SCOPE
)
@@ -29,10 +25,6 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp
PARENT_SCOPE
)

View File

@@ -1,165 +0,0 @@
#include "SelectionController.h"
#include <algorithm>
#include <memory>
#include "DebrisSelectionChangedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventManager.h"
#include "SelectionChangedEvent.h"
namespace
{
template <typename T>
bool contains(const std::vector<T>& items, const T& item)
{
return std::find(items.begin(), items.end(), item) != items.end();
}
// Applies `hits` to `selection` per `mode`. Replace is handled by the caller so
// that an empty hit list can mean "clear this category" there but "leave this
// category alone" here.
template <typename T>
void combine(std::vector<T>& selection, const std::vector<T>& hits, SelectionMode mode)
{
for (const T& hit : hits)
{
const typename std::vector<T>::iterator it =
std::find(selection.begin(), selection.end(), hit);
if (it == selection.end())
{
selection.push_back(hit);
}
else if (mode == SelectionMode::Toggle)
{
// Only a toggle removes; an additive box drag never deselects.
selection.erase(it);
}
}
}
} // namespace
const std::vector<BuildingId>& SelectionController::getSelectedBuildings() const
{
return m_buildings;
}
const std::vector<entt::entity>& SelectionController::getSelectedActors() const
{
return m_actors;
}
const std::vector<entt::entity>& SelectionController::getSelectedDebris() const
{
return m_debris;
}
bool SelectionController::isActorSelected(entt::entity actor) const
{
return contains(m_actors, actor);
}
bool SelectionController::isDebrisSelected(entt::entity debris) const
{
return contains(m_debris, debris);
}
void SelectionController::selectBuildings(const std::vector<BuildingId>& ids,
SelectionMode mode)
{
// Buildings win over field objects (REQ-UI-SELECTION-CATEGORIES).
if (clearActorsQuietly()) { publishActors(); }
if (clearDebrisQuietly()) { publishDebris(); }
if (mode == SelectionMode::Replace)
{
m_buildings = ids;
}
else
{
combine(m_buildings, ids, mode);
}
publishBuildings();
}
void SelectionController::selectFieldObjects(const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris,
SelectionMode mode)
{
if (clearBuildingsQuietly()) { publishBuildings(); }
if (mode == SelectionMode::Replace)
{
m_actors = actors;
m_debris = debris;
}
else
{
combine(m_actors, actors, mode);
combine(m_debris, debris, mode);
}
publishActors();
publishDebris();
}
void SelectionController::clearAll()
{
if (clearBuildingsQuietly()) { publishBuildings(); }
if (clearActorsQuietly()) { publishActors(); }
if (clearDebrisQuietly()) { publishDebris(); }
}
void SelectionController::setSelectedActors(std::vector<entt::entity> actors)
{
if (actors == m_actors) { return; }
m_actors = std::move(actors);
publishActors();
}
void SelectionController::setSelectedDebris(std::vector<entt::entity> debris)
{
if (debris == m_debris) { return; }
m_debris = std::move(debris);
publishDebris();
}
bool SelectionController::clearBuildingsQuietly()
{
if (m_buildings.empty()) { return false; }
m_buildings.clear();
return true;
}
bool SelectionController::clearActorsQuietly()
{
if (m_actors.empty()) { return false; }
m_actors.clear();
return true;
}
bool SelectionController::clearDebrisQuietly()
{
if (m_debris.empty()) { return false; }
m_debris.clear();
return true;
}
void SelectionController::publishBuildings() const
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_buildings));
}
void SelectionController::publishActors() const
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectionChangedEvent>(m_actors));
}
void SelectionController::publishDebris() const
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebrisSelectionChangedEvent>(m_debris));
}

View File

@@ -1,75 +0,0 @@
#pragma once
#include <vector>
#include "BuildingId.h"
#include "entt/entity/entity.hpp"
// How a new hit combines with what is already selected.
enum class SelectionMode
{
Replace, // plain click or drag: the hit becomes the whole selection
Toggle, // Ctrl + click: the hit joins the selection, or leaves it if present
Add // Ctrl + box drag: the hits join the selection, never leave it
};
// The player's current selection across the three categories, and the rules that
// govern moving between them (REQ-UI-SELECTION-CATEGORIES).
//
// The rules were previously written out once for point-clicks and once for box
// drags, which is why they live here now: buildings win over field objects, so
// selecting a building clears actors and debris, and selecting either of those
// clears buildings — but actors and debris coexist with each other
// (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT, REQ-UI-MULTI-SELECT,
// REQ-UI-DEBRIS-MULTI-SELECT). A point-click and a box drag then differ only in
// the SelectionMode they pass and in how many hits they pass.
//
// Every mutator publishes the change events, so callers never emit them by hand.
// Hit-testing is not done here: callers resolve what was hit and pass the result
// in, which keeps this free of any simulation dependency.
class SelectionController
{
public:
const std::vector<BuildingId>& getSelectedBuildings() const;
const std::vector<entt::entity>& getSelectedActors() const;
const std::vector<entt::entity>& getSelectedDebris() const;
bool isActorSelected(entt::entity actor) const;
bool isDebrisSelected(entt::entity debris) const;
// Selects buildings and/or construction sites, clearing any field selection.
// Always publishes SelectionChangedEvent, even when the result is unchanged,
// so a click on an already-selected building still refreshes its panel.
void selectBuildings(const std::vector<BuildingId>& ids, SelectionMode mode);
// Selects field objects, clearing any building selection. Actors and debris are
// set together so a Replace can express "these actors and no debris" — which is
// what a plain click on an actor means — while a Toggle or Add leaves the
// category whose vector is empty untouched. Always publishes both field events.
void selectFieldObjects(const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris,
SelectionMode mode);
// Empties all three categories, publishing only for those that were non-empty.
void clearAll();
// Replace one category outright, publishing only if it actually changed. For
// the per-frame prune of entities that despawned or died: the liveness query
// belongs to the caller, which is the one that can see the simulation.
void setSelectedActors(std::vector<entt::entity> actors);
void setSelectedDebris(std::vector<entt::entity> debris);
private:
// Each returns whether anything changed, without publishing.
bool clearBuildingsQuietly();
bool clearActorsQuietly();
bool clearDebrisQuietly();
void publishBuildings() const;
void publishActors() const;
void publishDebris() const;
std::vector<BuildingId> m_buildings;
std::vector<entt::entity> m_actors;
std::vector<entt::entity> m_debris;
};

View File

@@ -153,13 +153,3 @@ std::optional<QPoint> findTunnelPartner(const TunnelLookup& lookup, QPoint tile,
return std::nullopt;
}
TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels)
{
return [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
{
const TunnelTileMap::const_iterator it = tunnels.find(tile);
if (it == tunnels.end()) { return std::nullopt; }
return it->second;
};
}

View File

@@ -1,7 +1,6 @@
#pragma once
#include <functional>
#include <map>
#include <optional>
#include <QPoint>
@@ -10,17 +9,6 @@
#include "BuildingType.h"
#include "Rotation.h"
// QPoint has no operator<, so an explicit ordering is needed to key a map or set
// by tile.
struct QPointCompare
{
bool operator()(const QPoint& a, const QPoint& b) const
{
if (a.x() != b.x()) { return a.x() < b.x(); }
return a.y() < b.y();
}
};
// A tunnel building occupying a single tile: whether it is an entry or an exit and
// the direction it faces. Used by the tunnel pairing scan (REQ-BLD-TUNNEL-PAIR) and
// the unified tunnel build mode (REQ-BLD-TUNNEL-MODE).
@@ -34,13 +22,6 @@ struct TunnelTileInfo
// direction, or std::nullopt when the tile holds no tunnel building.
using TunnelLookup = std::function<std::optional<TunnelTileInfo>(QPoint)>;
// Tunnel entries/exits indexed by their single-cell tile (REQ-BLD-TUNNEL-MODE).
using TunnelTileMap = std::map<QPoint, TunnelTileInfo, QPointCompare>;
// Wraps a tunnel tile index in the lookup functor the helpers below take. The
// returned functor references `tunnels`, which must outlive it.
TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels);
// Steps from `start` in `stepDir` over the tiles at distance 1..maxDistance and
// returns the first tile whose tunnel faces `targetFacing`. Tunnel buildings facing
// any other direction are skipped, mirroring the "stop at the first same-direction

View File

@@ -1,72 +0,0 @@
#include "WorldCamera.h"
#include <algorithm>
namespace
{
// Linearly blend from valueAt0 (for x <= x0) to valueAt1 (for x >= x1), clamped
// outside [x0, x1]. A zero- or negative-width band collapses to a hard step at x1.
float lerpClamped(float valueAt0, float valueAt1, float x0, float x1, float x)
{
if (x1 <= x0) { return x < x1 ? valueAt0 : valueAt1; }
const float t = std::max(0.0f, std::min(1.0f, (x - x0) / (x1 - x0)));
return valueAt0 + (valueAt1 - valueAt0) * t;
}
}
WorldCamera::WorldCamera(const WorldScroll& scroll, const WorldRegions& regions)
: m_scroll(&scroll)
, m_regions(&regions)
, m_viewCenterXTiles(0.0f)
{
}
bool WorldCamera::advance(PanDirection direction, qint64 elapsedMs,
ScrollBounds bounds)
{
const float before = m_viewCenterXTiles;
if (direction != PanDirection::None)
{
const float distance =
getPanSpeedTilesPerSecondAt(m_viewCenterXTiles, bounds.rightTiles)
* static_cast<float>(elapsedMs) / 1000.0f;
m_viewCenterXTiles += (direction == PanDirection::Left) ? -distance : distance;
}
m_viewCenterXTiles = std::max(bounds.leftTiles,
std::min(m_viewCenterXTiles, bounds.rightTiles));
return m_viewCenterXTiles != before;
}
float WorldCamera::getViewCenterXTiles() const
{
return m_viewCenterXTiles;
}
void WorldCamera::reset()
{
m_viewCenterXTiles = 0.0f;
}
float WorldCamera::getPanSpeedTilesPerSecondAt(float viewCenterXTiles,
float contestZoneRightEdgeTiles) const
{
// Slow near the asteroid/player buffer, fast across the contest zone, with a
// linear ramp straddling each contest-zone boundary (REQ-UI-SCROLL-SPEED). The
// contest zone spans from the player buffer's right edge to the enemy stations,
// the latter tracked live so the ramp follows the front line as it is pushed.
const float slow = static_cast<float>(m_scroll->panSpeedSlow_tps);
const float fast = static_cast<float>(m_scroll->panSpeedFast_tps);
const float half = static_cast<float>(m_scroll->panRampBandWidth_tiles) / 2.0f;
const float leftEdge = static_cast<float>(m_regions->playerBufferWidth_tiles);
const float rightEdge = contestZoneRightEdgeTiles;
// Rising ramp at the left boundary (slow -> fast) and falling ramp at the right
// boundary (fast -> slow); their minimum yields flat-slow outside, flat-fast in
// the middle, and — if the bands overlap in a narrow contest zone — a single peak
// below the fast speed where the two ramps cross.
const float leftRamp = lerpClamped(slow, fast, leftEdge - half, leftEdge + half, viewCenterXTiles);
const float rightRamp = lerpClamped(fast, slow, rightEdge - half, rightEdge + half, viewCenterXTiles);
return std::min(leftRamp, rightRamp);
}

View File

@@ -1,72 +0,0 @@
#pragma once
#include <QtGlobal>
#include "WorldConfig.h"
// Which way the player is currently panning the view (REQ-UI-SCROLL). A plain
// direction rather than key state: the camera is deliberately agnostic about how
// the intent was expressed, so rebindable controls would change nothing here.
enum class PanDirection
{
None,
Left,
Right
};
// The horizontal limits of the view center, in world tiles (REQ-GW-SCROLL-LIMIT):
// the view can pan left until the asteroid's left edge is centered and right until
// the enemy stations are. Both move as the game progresses — the left edge with
// asteroid expansion (REQ-GW-ASTEROID-EXPAND), the right edge as stations are
// pushed back (REQ-GW-PUSH-EXPAND) — so they are supplied per frame by the caller
// that can see the simulation, rather than queried here. That keeps the camera a
// value with no simulation dependency.
struct ScrollBounds
{
float leftTiles;
float rightTiles;
};
// Horizontal view position for the game world (REQ-UI-SCROLL, REQ-UI-SCROLL-SPEED).
// Works purely in world units — tiles and tiles per second, never pixels. Turning
// the resulting position into a widget transform is WorldCoordinates' job; the two
// meet only where the view feeds getViewCenterXTiles() into that transform.
class WorldCamera
{
public:
// Both config structs are referenced rather than copied: they live inside the
// Simulation's GameConfig, which is assigned in place on restart
// (REQ-CFG-RELOAD), so a camera built once still picks up reloaded tuning.
WorldCamera(const WorldScroll& scroll, const WorldRegions& regions);
// Pans by `direction` for `elapsedMs` of wall-clock time, then clamps into
// `bounds`. Wall clock rather than ticks because panning is presentation only
// (REQ-UI-NO-ZOOM's sibling concern) and keeps working while the simulation is
// paused. Clamping happens on every call, not just when panning, so the view
// follows the bounds inward when they shrink.
//
// Returns true when the view center actually moved — including when it moved
// only because the bounds did. Callers use that to refresh anything anchored to
// the world under a stationary cursor, such as the box-select rectangle.
bool advance(PanDirection direction, qint64 elapsedMs, ScrollBounds bounds);
// World X (tiles) at the center of the viewport.
float getViewCenterXTiles() const;
// Returns the view to the start-of-run position. Deliberately does not clamp:
// a new run's bounds are not known here, and the next advance() clamps anyway.
void reset();
// Pan speed at a given view center, in tiles/s (REQ-UI-SCROLL-SPEED). Public
// because the ramp shape is the subtle part of this class and is worth testing
// directly; advance() uses it internally. `contestZoneRightEdgeTiles` is the
// live right-hand boundary — the same value as ScrollBounds::rightTiles — so
// the ramp follows the front line as it is pushed.
float getPanSpeedTilesPerSecondAt(float viewCenterXTiles,
float contestZoneRightEdgeTiles) const;
private:
const WorldScroll* m_scroll;
const WorldRegions* m_regions;
float m_viewCenterXTiles;
};

View File

@@ -1,118 +0,0 @@
#include "WorldCoordinates.h"
#include <algorithm>
#include <cmath>
namespace
{
// A zero-height widget, a not-yet-shown widget, or a degenerate world size
// would otherwise make every conversion divide by zero.
float sanitizeTilePx(float tilePx)
{
return tilePx > 0.0f ? tilePx : 1.0f;
}
}
WorldCoordinates WorldCoordinates::scrolling(QSize widgetSize_px,
int worldHeight_tiles,
float viewCenterX_tiles)
{
float tilePx = 1.0f;
if (worldHeight_tiles > 0)
{
tilePx = static_cast<float>(widgetSize_px.height())
/ static_cast<float>(worldHeight_tiles);
}
tilePx = sanitizeTilePx(tilePx);
const float viewportWidthTiles = static_cast<float>(widgetSize_px.width()) / tilePx;
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
viewCenterX_tiles - viewportWidthTiles / 2.0f,
worldHeight_tiles);
}
WorldCoordinates WorldCoordinates::fitToWorld(QSize widgetSize_px,
int worldWidth_tiles,
int worldHeight_tiles)
{
float tilePx = 1.0f;
if (worldWidth_tiles > 0 && worldHeight_tiles > 0)
{
// The tighter of the two fits, so the whole world stays on screen.
tilePx = std::min(
static_cast<float>(widgetSize_px.height()) / static_cast<float>(worldHeight_tiles),
static_cast<float>(widgetSize_px.width()) / static_cast<float>(worldWidth_tiles));
}
tilePx = sanitizeTilePx(tilePx);
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
0.0f, worldHeight_tiles);
}
WorldCoordinates::WorldCoordinates(float tilePx, float viewportWidth_px,
float viewLeft_tiles, int worldHeight_tiles)
: m_tilePx(tilePx)
, m_viewportWidthTiles(viewportWidth_px / tilePx)
, m_viewLeftTiles(viewLeft_tiles)
, m_worldHeightTiles(worldHeight_tiles)
{
}
float WorldCoordinates::getTilePx() const
{
return m_tilePx;
}
float WorldCoordinates::getViewportWidthTiles() const
{
return m_viewportWidthTiles;
}
float WorldCoordinates::getViewLeftTiles() const
{
return m_viewLeftTiles;
}
QPointF WorldCoordinates::worldToWidget(QVector2D worldPos) const
{
return QPointF(
static_cast<qreal>((worldPos.x() - m_viewLeftTiles) * m_tilePx),
static_cast<qreal>(worldPos.y() * m_tilePx));
}
QPointF WorldCoordinates::tileToWidget(QPoint tile) const
{
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
static_cast<float>(tile.y())));
}
QPoint WorldCoordinates::widgetToTile(QPoint widgetPoint) const
{
const QVector2D world = widgetToWorld(widgetPoint);
return QPoint(static_cast<int>(std::floor(world.x())),
static_cast<int>(std::floor(world.y())));
}
QVector2D WorldCoordinates::widgetToWorld(QPoint widgetPoint) const
{
return QVector2D(
static_cast<float>(widgetPoint.x()) / m_tilePx + m_viewLeftTiles,
static_cast<float>(widgetPoint.y()) / m_tilePx);
}
QRectF WorldCoordinates::tileRect(QPoint tile) const
{
const QPointF topLeft = tileToWidget(tile);
return QRectF(topLeft.x(), topLeft.y(),
static_cast<qreal>(m_tilePx), static_cast<qreal>(m_tilePx));
}
QRect WorldCoordinates::getViewportRect() const
{
const int left = static_cast<int>(std::floor(m_viewLeftTiles)) - 1;
const int top = 0;
const int right = static_cast<int>(
std::ceil(m_viewLeftTiles + m_viewportWidthTiles)) + 1;
const int bottom = m_worldHeightTiles;
return QRect(left, top, right - left, bottom - top);
}

View File

@@ -1,63 +0,0 @@
#pragma once
#include <QPoint>
#include <QPointF>
#include <QRect>
#include <QRectF>
#include <QSize>
#include <QVector2D>
// Immutable snapshot of the world <-> widget transform for one viewport state
// (REQ-GW-COORDS). Tiles are square; the two factories below differ only in how
// the tile size and the left edge are derived, and everything downstream of that
// is shared.
//
// The transform is a value: it is constructed from the viewport size and the view
// state, and never observes them again. A caller therefore builds one per frame
// (or per event) rather than holding one across a resize or a scroll, which would
// silently go stale.
class WorldCoordinates
{
public:
// The scrolling game world: the tile size is whatever makes the world height
// exactly fill the viewport height (REQ-GW-TILE-SIZE, no zoom per
// REQ-UI-NO-ZOOM), and the view pans horizontally. `viewCenterX_tiles` is the
// world X at the center of the viewport, matching how the scroll position is
// stored and clamped (REQ-GW-SCROLL-LIMIT).
static WorldCoordinates scrolling(QSize widgetSize_px, int worldHeight_tiles,
float viewCenterX_tiles);
// A whole world shown at once with no scrolling, as the balancing tool's arena
// does: the tile size is whichever axis is the tighter fit, so nothing is cut
// off, and the world origin sits at the widget's top-left. A viewport wider
// than the fitted world leaves empty space to the right rather than centering.
static WorldCoordinates fitToWorld(QSize widgetSize_px, int worldWidth_tiles,
int worldHeight_tiles);
// Side length of one tile in pixels. Always positive: a degenerate world or
// viewport size falls back to 1.0 so no conversion below divides by zero.
float getTilePx() const;
float getViewportWidthTiles() const;
// World X (tiles) at the left edge of the viewport.
float getViewLeftTiles() const;
QPointF worldToWidget(QVector2D worldPos) const;
QPointF tileToWidget(QPoint tile) const;
QPoint widgetToTile(QPoint widgetPoint) const;
QVector2D widgetToWorld(QPoint widgetPoint) const;
// Widget-space rect covering the whole of `tile`.
QRectF tileRect(QPoint tile) const;
// Tile-space rect of everything currently on screen, widened by one column on
// each side so items straddling an edge are still drawn.
QRect getViewportRect() const;
private:
WorldCoordinates(float tilePx, float viewportWidth_px, float viewLeft_tiles,
int worldHeight_tiles);
float m_tilePx;
float m_viewportWidthTiles;
float m_viewLeftTiles;
int m_worldHeightTiles;
};

View File

@@ -16,12 +16,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PanDirectionChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PauseToggleRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SpeedStepRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GhostRotationRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ModeCancelRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggleRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildHotkeyPressedEvent.h

View File

@@ -1,12 +0,0 @@
#pragma once
#include "Event.h"
// The player asked to toggle the debug overlays (REQ-UI-HOTKEYS). The request to
// flip the flag; DebugDrawToggledEvent is the announcement that it was flipped and
// what it now is. Splitting the two keeps the flag itself in one owner.
class DebugDrawToggleRequestedEvent : public Event
{
public:
DebugDrawToggleRequestedEvent() = default;
};

View File

@@ -1,13 +0,0 @@
#pragma once
#include "Event.h"
// The player asked to rotate the placement ghost (REQ-BLD-ROTATE, REQ-UI-HOTKEYS).
// Sent whether or not a builder or blueprint mode is actually active; deciding
// there is nothing to rotate is the receiver's job.
class GhostRotationRequestedEvent : public Event
{
public:
explicit GhostRotationRequestedEvent(bool clockwise) : clockwise(clockwise) {}
const bool clockwise;
};

View File

@@ -1,13 +0,0 @@
#pragma once
#include "Event.h"
// The player pressed the one "get me out of the current mode" key (REQ-UI-HOTKEYS).
// Intentionally says only that, not which mode to leave: which of builder,
// blueprint placement, or deconstruct is active — and that the key falls through to
// entering deconstruct mode when none of them is — is state only the receiver has.
class ModeCancelRequestedEvent : public Event
{
public:
ModeCancelRequestedEvent() = default;
};

View File

@@ -1,22 +0,0 @@
#pragma once
#include "Event.h"
#include "WorldCamera.h"
// The direction the player is currently panning the view has changed
// (REQ-UI-SCROLL). Deliberately level-triggered: the payload is the complete
// current direction, including PanDirection::None when panning stops, rather than
// separate started/stopped events. A receiver that only ever saw edges would have
// to reconstruct the state and would be left panning forever if one edge went
// missing — which is exactly what happens when the widget loses focus mid-pan.
//
// Unlike the state-change events elsewhere in the UI, the receiver does cache this
// payload instead of re-reading the value from somewhere authoritative. That is
// correct here: input has no other source of truth to re-read from, so the
// publisher (InputMapper) is the authority and the payload is the value.
class PanDirectionChangedEvent : public Event
{
public:
explicit PanDirectionChangedEvent(PanDirection direction) : direction(direction) {}
const PanDirection direction;
};

View File

@@ -1,12 +0,0 @@
#pragma once
#include "Event.h"
// The player asked to pause or unpause (REQ-UI-HOTKEYS). Carries no speed: which
// speed to restore on unpause is the receiver's business, since it is the one that
// remembers what was running before the pause.
class PauseToggleRequestedEvent : public Event
{
public:
PauseToggleRequestedEvent() = default;
};

View File

@@ -1,14 +0,0 @@
#pragma once
#include "Event.h"
// The player asked to step the game speed one notch (REQ-UI-HOTKEYS): +1 faster,
// -1 slower. A relative step rather than a target speed, because the ladder of
// available speeds belongs to the receiver — contrast SpeedChangeRequestedEvent,
// which names an absolute multiplier and is what the speed buttons send.
class SpeedStepRequestedEvent : public Event
{
public:
explicit SpeedStepRequestedEvent(int delta) : delta(delta) {}
const int delta;
};

View File

@@ -5,7 +5,7 @@
// Emitted when the set of unlocked building types changes (REQ-LOCK-BUILDING),
// i.e. after an unlock group granting a building is awarded (REQ-DEF-SCHEMATIC-DROP)
// or on Restart. The build button bar re-evaluates which buttons are shown.
// or on Restart. The build button grid re-evaluates which buttons are shown.
class UnlockedBuildingsChangedEvent : public Event
{
};

View File

@@ -1,6 +1,5 @@
#include "FactoryQueries.h"
#include <algorithm>
#include <limits>
#include "PortGeometry.h"
@@ -180,61 +179,3 @@ getSiteSplitterInfo(const FactoryState& state, const GameConfig& config, Buildin
return std::nullopt;
}
std::vector<BuildingId> buildingsInBox(const FactoryState& state,
QPoint cornerA, QPoint cornerB)
{
const int x0 = std::min(cornerA.x(), cornerB.x());
const int y0 = std::min(cornerA.y(), cornerB.y());
const int x1 = std::max(cornerA.x(), cornerB.x());
const int y1 = std::max(cornerA.y(), cornerB.y());
const auto covers = [&](const std::vector<QPoint>& bodyCells)
{
for (const QPoint& cell : bodyCells)
{
if (cell.x() >= x0 && cell.x() <= x1
&& cell.y() >= y0 && cell.y() <= y1)
{
return true;
}
}
return false;
};
std::vector<BuildingId> ids;
for (const Building& building : getAllBuildings(state))
{
if (covers(building.bodyCells)) { ids.push_back(building.id); }
}
for (const ConstructionSite& site : getAllSites(state))
{
if (covers(site.bodyCells)) { ids.push_back(site.id); }
}
return ids;
}
TunnelTileMap collectTunnelTiles(const FactoryState& state)
{
// Index every tunnel entry/exit — built or still a construction site — by its
// single-cell tile, so a just-placed tunnel (not yet constructed) is matchable
// (REQ-BLD-TUNNEL-MODE, REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
TunnelTileMap tunnels;
for (const Building& building : getAllBuildings(state))
{
if (building.type == BuildingType::TunnelEntry
|| building.type == BuildingType::TunnelExit)
{
tunnels[building.anchor] = TunnelTileInfo{building.type, building.rotation};
}
}
for (const ConstructionSite& site : getAllSites(state))
{
if (site.type == BuildingType::TunnelEntry
|| site.type == BuildingType::TunnelExit)
{
tunnels[site.anchor] = TunnelTileInfo{site.type, site.rotation};
}
}
return tunnels;
}

View File

@@ -12,7 +12,6 @@
#include "FactoryState.h"
#include "GameConfig.h"
#include "Port.h"
#include "TunnelCompletion.h"
// Queries and operations over the factory's world data that need nothing but that
// data — no config, no belts, no RNG. Free functions rather than BuildingSystem
@@ -70,13 +69,3 @@ std::vector<Port> getInputPorts(const FactoryState& state, const GameConfig& con
std::optional<BeltSystem::SplitterInfo> getSiteSplitterInfo(const FactoryState& state,
const GameConfig& config,
BuildingId id);
// Ids of all buildings and construction sites whose footprint intersects the tile
// box spanned by the two (unordered) corner tiles (REQ-UI-MULTI-SELECT,
// REQ-BLD-DECONSTRUCT-BOX).
std::vector<BuildingId> buildingsInBox(const FactoryState& state,
QPoint cornerA, QPoint cornerB);
// Every tunnel entry and exit, built or still a construction site, indexed by its
// single-cell tile. Shared by the placement preview and the selection highlight.
TunnelTileMap collectTunnelTiles(const FactoryState& state);

View File

@@ -119,82 +119,3 @@ std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state, con
return std::nullopt;
}
bool canPlaceBuilding(const FactoryState& state, const GameConfig& config,
BuildingType type, QPoint anchor, Rotation rotation)
{
// Terrain and world-bounds validity first (REQ-BLD-PLACE-VALID); occupancy is
// the extra rule this adds.
if (!isPlacementValid(state, config, type, anchor, rotation))
{
return false;
}
const BuildingDef* def = config.buildings.findBuildingDef(type);
if (!def) { return false; }
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation);
bool anyOccupied = false;
for (const QPoint& relativeCell : parsed.bodyCells)
{
if (isTileOccupied(state, anchor + relativeCell))
{
anyOccupied = true;
break;
}
}
if (anyOccupied)
{
// Occupied is still placeable when what is there is the same building being
// re-oriented (REQ-BLD-ROTATE-IN-PLACE).
return findRotateInPlaceTarget(state, config, type, anchor, rotation).has_value();
}
return true;
}
std::vector<BeltDragResolved> resolveBeltDragPath(const std::vector<BeltPathTile>& path,
const FactoryState& state,
const GameConfig& config,
int buildingBlocksStock)
{
std::vector<BeltDragResolved> resolved;
resolved.reserve(path.size());
const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::Belt);
const int beltCost = (def != nullptr) ? def->cost : 0;
int spent = 0;
for (const BeltPathTile& entry : path)
{
BeltDragResolved item;
const std::optional<BuildingId> rotateTarget =
findRotateInPlaceTarget(state, config, BuildingType::Belt,
entry.tile, entry.rotation);
if (rotateTarget.has_value())
{
// A tile holding only a belt (or belt site) is re-oriented, no cost.
item.action = BeltTileAction::RotateInPlace;
item.affordable = true;
item.rotateId = rotateTarget;
}
else if (canPlaceBuilding(state, config, BuildingType::Belt,
entry.tile, entry.rotation))
{
// Empty, valid cell: a new belt, subject to cumulative affordability.
item.action = BeltTileAction::PlaceNew;
item.affordable = (spent + beltCost <= buildingBlocksStock);
item.rotateId = std::nullopt;
if (item.affordable) { spent += beltCost; }
}
else
{
// Occupied by a non-belt building/site, or otherwise invalid terrain.
item.action = BeltTileAction::Invalid;
item.affordable = false;
item.rotateId = std::nullopt;
}
resolved.push_back(item);
}
return resolved;
}

View File

@@ -5,7 +5,6 @@
#include <QPoint>
#include "BeltDragPath.h"
#include "BuildingId.h"
#include "BuildingType.h"
#include "FactoryState.h"
@@ -37,35 +36,3 @@ std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state,
const GameConfig& config,
BuildingType type, QPoint anchor,
Rotation rot);
// True if placing here would actually do something: the terrain and bounds rules of
// isPlacementValid hold, and the body cells are either all free or occupied only by
// a building this placement would rotate in place. This is the question the ghost
// asks to colour itself and the click path asks before enqueuing a command
// (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID, REQ-BLD-ROTATE-IN-PLACE).
bool canPlaceBuilding(const FactoryState& state, const GameConfig& config,
BuildingType type, QPoint anchor, Rotation rotation);
// What a belt drag would do to one tile of its path (REQ-BLD-BELT-DRAG).
enum class BeltTileAction
{
PlaceNew, // empty, valid cell: a new belt, subject to affordability
RotateInPlace, // already a belt (or belt site): re-oriented, free
Invalid // occupied by something else, or invalid terrain
};
struct BeltDragResolved
{
BeltTileAction action;
bool affordable; // meaningful only for PlaceNew
std::optional<BuildingId> rotateId; // set only for RotateInPlace
};
// Classifies every tile of a belt drag path against the current factory state,
// spending `buildingBlocksStock` cumulatively across the PlaceNew tiles so a path
// longer than the player can afford is only partly buildable (REQ-BLD-BELT-DRAG).
// Shared so the previewed ghosts and the placement on release cannot disagree.
std::vector<BeltDragResolved> resolveBeltDragPath(const std::vector<BeltPathTile>& path,
const FactoryState& state,
const GameConfig& config,
int buildingBlocksStock);

View File

@@ -1,301 +0,0 @@
#include "catch.hpp"
#include <memory>
#include "BlueprintModeExitedEvent.h"
#include "BuildModeController.h"
#include "BuilderModeExitedEvent.h"
#include "DeconstructModeChangedEvent.h"
#include "EventHandler.h"
#include "EventManager.h"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Records the mode events, which are the half of this class's contract that the
// panels depend on: a mode that ends without announcing it leaves its button
// stuck highlighted.
class ModeEventSpy : public CombinedEventHandler<BuilderModeExitedEvent,
BlueprintModeExitedEvent,
DeconstructModeChangedEvent>
{
public:
ModeEventSpy() { registerForEvents(); }
~ModeEventSpy() { unregisterForEvents(); }
int builderExits = 0;
int blueprintExits = 0;
int deconstructChanges = 0;
bool lastDeconstructActive = false;
private:
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/) override
{
++builderExits;
}
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/) override
{
++blueprintExits;
}
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override
{
++deconstructChanges;
lastDeconstructActive = event->active;
}
};
static Blueprint makeBlueprint()
{
Blueprint blueprint;
BlueprintBuilding building;
building.type = BuildingType::Belt;
building.offset = QPoint(0, 0);
building.rotation = Rotation::East;
blueprint.buildings.push_back(building);
return blueprint;
}
// ---------------------------------------------------------------------------
// Exclusivity
// ---------------------------------------------------------------------------
TEST_CASE("Only one mode is active at a time", "[buildmode]")
{
BuildModeController controller;
REQUIRE(controller.getMode() == BuildMode::None);
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE(controller.isBuilderMode());
REQUIRE_FALSE(controller.isBlueprintMode());
REQUIRE_FALSE(controller.isDeconstructMode());
controller.enterBlueprintMode(makeBlueprint());
REQUIRE(controller.isBlueprintMode());
REQUIRE_FALSE(controller.isBuilderMode());
controller.toggleDeconstructMode();
REQUIRE(controller.isDeconstructMode());
REQUIRE_FALSE(controller.isBlueprintMode());
}
TEST_CASE("Entering builder mode announces that blueprint mode ended", "[buildmode]")
{
// Regression: entering builder mode used to drop the blueprint silently, so the
// blueprint panel kept its button highlighted for a mode that was over.
BuildModeController controller;
controller.enterBlueprintMode(makeBlueprint());
ModeEventSpy spy;
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE(spy.blueprintExits == 1);
}
TEST_CASE("Every mode announces its exit however it is left", "[buildmode]")
{
// The point of routing all transitions through one place: which mode the player
// switches to must not change what the mode they left announces.
SECTION("builder, left for a blueprint")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
ModeEventSpy spy;
controller.enterBlueprintMode(makeBlueprint());
REQUIRE(spy.builderExits == 1);
}
SECTION("builder, left for deconstruct")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
ModeEventSpy spy;
controller.toggleDeconstructMode();
REQUIRE(spy.builderExits == 1);
}
SECTION("blueprint, left for deconstruct")
{
BuildModeController controller;
controller.enterBlueprintMode(makeBlueprint());
ModeEventSpy spy;
controller.toggleDeconstructMode();
REQUIRE(spy.blueprintExits == 1);
}
SECTION("deconstruct, left for builder")
{
BuildModeController controller;
controller.toggleDeconstructMode();
ModeEventSpy spy;
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE(spy.deconstructChanges == 1);
REQUIRE_FALSE(spy.lastDeconstructActive);
}
}
TEST_CASE("Switching between builder types stays in builder mode", "[buildmode]")
{
// Picking a different building is not leaving the mode, so nothing is announced.
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
ModeEventSpy spy;
controller.enterBuilderMode(BuildingType::Splitter);
REQUIRE(controller.getBuilderType() == BuildingType::Splitter);
REQUIRE(spy.builderExits == 0);
}
TEST_CASE("Exiting a mode that is not active does nothing", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
ModeEventSpy spy;
controller.exitBlueprintMode();
REQUIRE(controller.isBuilderMode());
REQUIRE(spy.blueprintExits == 0);
REQUIRE(spy.builderExits == 0);
}
TEST_CASE("Deconstruct mode toggles off and announces both edges", "[buildmode]")
{
BuildModeController controller;
ModeEventSpy spy;
controller.toggleDeconstructMode();
REQUIRE(controller.isDeconstructMode());
REQUIRE(spy.lastDeconstructActive);
controller.toggleDeconstructMode();
REQUIRE(controller.getMode() == BuildMode::None);
REQUIRE_FALSE(spy.lastDeconstructActive);
REQUIRE(spy.deconstructChanges == 2);
}
TEST_CASE("Leaving the current mode works from any of them", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.exitCurrentMode();
REQUIRE(controller.getMode() == BuildMode::None);
controller.enterBlueprintMode(makeBlueprint());
controller.exitCurrentMode();
REQUIRE(controller.getMode() == BuildMode::None);
controller.toggleDeconstructMode();
controller.exitCurrentMode();
REQUIRE(controller.getMode() == BuildMode::None);
}
// ---------------------------------------------------------------------------
// State cleared on transition
// ---------------------------------------------------------------------------
TEST_CASE("Leaving builder mode drops an in-progress belt drag", "[buildmode]")
{
// A drag surviving the mode change would place belts on the next release.
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.beginBeltDrag(QPoint(3, 4));
controller.setBeltDragPath({BeltPathTile{QPoint(3, 4), Rotation::East}});
REQUIRE(controller.isDraggingBelt());
controller.toggleDeconstructMode();
REQUIRE_FALSE(controller.isDraggingBelt());
REQUIRE(controller.getBeltDragPath().empty());
}
TEST_CASE("Leaving deconstruct mode drops the hovered building", "[buildmode]")
{
BuildModeController controller;
controller.toggleDeconstructMode();
controller.setDeconstructHoverBuildingId(BuildingId(4));
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE_FALSE(controller.getDeconstructHoverBuildingId().has_value());
}
TEST_CASE("Entering builder mode resets the ghost", "[buildmode]")
{
// A fresh builder starts facing East and invalid until the first hover, rather
// than inheriting the previous building's facing (REQ-BLD-GHOST).
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.rotateGhost(true);
controller.setGhostValidity(true);
controller.enterBuilderMode(BuildingType::Splitter);
REQUIRE(controller.getGhostRotation() == Rotation::East);
REQUIRE_FALSE(controller.isGhostValid());
}
TEST_CASE("Cancelling a belt drag stays in builder mode", "[buildmode]")
{
// Right-click during a drag abandons the path but keeps the belt selected
// (REQ-BLD-BELT-DRAG).
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.beginBeltDrag(QPoint(1, 1));
ModeEventSpy spy;
controller.cancelBeltDrag();
REQUIRE(controller.isBuilderMode());
REQUIRE_FALSE(controller.isDraggingBelt());
REQUIRE(spy.builderExits == 0);
}
// ---------------------------------------------------------------------------
// Ghost and tunnel state
// ---------------------------------------------------------------------------
TEST_CASE("Rotating the ghost cycles through the four facings", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.rotateGhost(true);
REQUIRE(controller.getGhostRotation() == Rotation::South);
controller.rotateGhost(true);
REQUIRE(controller.getGhostRotation() == Rotation::West);
controller.rotateGhost(false);
REQUIRE(controller.getGhostRotation() == Rotation::South);
}
TEST_CASE("Tunnel mode is the tunnel entry builder type", "[buildmode]")
{
// REQ-BLD-TUNNEL-MODE: one builder type covers both tunnel ends.
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE_FALSE(controller.isTunnelMode());
controller.enterBuilderMode(BuildingType::TunnelEntry);
REQUIRE(controller.isTunnelMode());
}
TEST_CASE("The effective builder type follows the resolved tunnel end", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::TunnelEntry);
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelEntry);
controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 5));
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelExit);
REQUIRE(controller.getTunnelPartnerTile() == QPoint(5, 5));
}
TEST_CASE("A non-tunnel builder ignores any resolved tunnel end", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::TunnelEntry);
controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 5));
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::Belt);
REQUIRE_FALSE(controller.getTunnelPartnerTile().has_value());
}

View File

@@ -12,10 +12,6 @@ add_files(
SurfaceMaskTest.cpp
BeltDragPathTest.cpp
TunnelCompletionTest.cpp
WorldCoordinatesTest.cpp
WorldCameraTest.cpp
SelectionControllerTest.cpp
BuildModeControllerTest.cpp
BuildingTest.cpp
BuildingConfigTest.cpp
ShipTest.cpp

View File

@@ -1,282 +0,0 @@
#include "catch.hpp"
#include <memory>
#include <vector>
#include "DebrisSelectionChangedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventHandler.h"
#include "EventManager.h"
#include "SelectionChangedEvent.h"
#include "SelectionController.h"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Counts the change events the controller publishes, so the tests can assert not
// just the resulting selection but that the widgets were told about it — and, for
// the categories a change did not touch, that they were not told needlessly.
class SelectionEventSpy : public CombinedEventHandler<SelectionChangedEvent,
EntitySelectionChangedEvent,
DebrisSelectionChangedEvent>
{
public:
SelectionEventSpy() { registerForEvents(); }
~SelectionEventSpy() { unregisterForEvents(); }
int buildingEvents = 0;
int actorEvents = 0;
int debrisEvents = 0;
void reset() { buildingEvents = 0; actorEvents = 0; debrisEvents = 0; }
private:
void handleEvent(std::shared_ptr<const SelectionChangedEvent> /*event*/) override
{
++buildingEvents;
}
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> /*event*/) override
{
++actorEvents;
}
void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> /*event*/) override
{
++debrisEvents;
}
};
static entt::entity makeEntity(int index)
{
return static_cast<entt::entity>(index);
}
// ---------------------------------------------------------------------------
// Category precedence (REQ-UI-SELECTION-CATEGORIES)
// ---------------------------------------------------------------------------
TEST_CASE("Selecting a building clears actors and debris", "[selection]")
{
SelectionController controller;
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
SelectionMode::Replace);
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
REQUIRE(controller.getSelectedBuildings() == std::vector<BuildingId>{BuildingId(7)});
REQUIRE(controller.getSelectedActors().empty());
REQUIRE(controller.getSelectedDebris().empty());
}
TEST_CASE("Selecting a field object clears buildings", "[selection]")
{
SelectionController controller;
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
controller.selectFieldObjects({makeEntity(1)}, {}, SelectionMode::Replace);
REQUIRE(controller.getSelectedBuildings().empty());
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
}
TEST_CASE("Actors and debris coexist", "[selection]")
{
// The one pair of categories that does not evict each other
// (REQ-UI-MULTI-SELECT, REQ-UI-DEBRIS-MULTI-SELECT).
SelectionController controller;
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
SelectionMode::Replace);
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
REQUIRE(controller.getSelectedDebris() == std::vector<entt::entity>{makeEntity(2)});
}
TEST_CASE("A plain click on an actor drops any selected debris", "[selection]")
{
// A point click passes only the category it hit, so Replace with an empty
// debris list is what "this actor and nothing else" means
// (REQ-UI-ENTITY-CLICK-SELECT).
SelectionController controller;
controller.selectFieldObjects({}, {makeEntity(2)}, SelectionMode::Replace);
controller.selectFieldObjects({makeEntity(1)}, {}, SelectionMode::Replace);
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
REQUIRE(controller.getSelectedDebris().empty());
}
// ---------------------------------------------------------------------------
// Replace / Toggle / Add
// ---------------------------------------------------------------------------
TEST_CASE("Replace makes the hit the whole selection", "[selection]")
{
SelectionController controller;
controller.selectBuildings({BuildingId(1), BuildingId(2)}, SelectionMode::Replace);
controller.selectBuildings({BuildingId(3)}, SelectionMode::Replace);
REQUIRE(controller.getSelectedBuildings() == std::vector<BuildingId>{BuildingId(3)});
}
TEST_CASE("Toggle adds a building that was not selected", "[selection]")
{
SelectionController controller;
controller.selectBuildings({BuildingId(1)}, SelectionMode::Replace);
controller.selectBuildings({BuildingId(2)}, SelectionMode::Toggle);
REQUIRE(controller.getSelectedBuildings()
== std::vector<BuildingId>{BuildingId(1), BuildingId(2)});
}
TEST_CASE("Toggle removes a building that was already selected", "[selection]")
{
// Ctrl+clicking a selected building deselects it, leaving the rest alone.
SelectionController controller;
controller.selectBuildings({BuildingId(1), BuildingId(2), BuildingId(3)},
SelectionMode::Replace);
controller.selectBuildings({BuildingId(2)}, SelectionMode::Toggle);
REQUIRE(controller.getSelectedBuildings()
== std::vector<BuildingId>{BuildingId(1), BuildingId(3)});
}
TEST_CASE("Add never deselects", "[selection]")
{
// This is where a Ctrl box drag differs from a Ctrl click: dragging over
// already-selected buildings must not toggle them off (REQ-UI-MULTI-SELECT).
SelectionController controller;
controller.selectBuildings({BuildingId(1), BuildingId(2)}, SelectionMode::Replace);
controller.selectBuildings({BuildingId(2), BuildingId(3)}, SelectionMode::Add);
REQUIRE(controller.getSelectedBuildings()
== std::vector<BuildingId>{BuildingId(1), BuildingId(2), BuildingId(3)});
}
TEST_CASE("An additive field selection leaves the untouched category alone",
"[selection]")
{
// Ctrl+clicking an actor passes an empty debris list, which must mean "do not
// touch debris" rather than "clear debris" (REQ-UI-DEBRIS-MULTI-SELECT).
SelectionController controller;
controller.selectFieldObjects({}, {makeEntity(5)}, SelectionMode::Replace);
controller.selectFieldObjects({makeEntity(1)}, {}, SelectionMode::Toggle);
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
REQUIRE(controller.getSelectedDebris() == std::vector<entt::entity>{makeEntity(5)});
}
// ---------------------------------------------------------------------------
// Membership queries used by the renderer
// ---------------------------------------------------------------------------
TEST_CASE("Membership queries answer per category", "[selection]")
{
SelectionController controller;
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
SelectionMode::Replace);
REQUIRE(controller.isActorSelected(makeEntity(1)));
REQUIRE_FALSE(controller.isActorSelected(makeEntity(2)));
REQUIRE(controller.isDebrisSelected(makeEntity(2)));
REQUIRE_FALSE(controller.isDebrisSelected(makeEntity(1)));
}
// ---------------------------------------------------------------------------
// Published events
// ---------------------------------------------------------------------------
TEST_CASE("Selecting a building announces the categories it cleared", "[selection]")
{
SelectionController controller;
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
SelectionMode::Replace);
SelectionEventSpy spy;
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
REQUIRE(spy.buildingEvents == 1);
REQUIRE(spy.actorEvents == 1);
REQUIRE(spy.debrisEvents == 1);
}
TEST_CASE("Clearing an already-empty category announces nothing", "[selection]")
{
// The panels re-read on every event, so a redundant one is only noise — but the
// point-click and box paths used to disagree about this, so it is pinned.
SelectionController controller;
SelectionEventSpy spy;
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
REQUIRE(spy.buildingEvents == 1);
REQUIRE(spy.actorEvents == 0);
REQUIRE(spy.debrisEvents == 0);
}
TEST_CASE("Re-selecting the same building still announces it", "[selection]")
{
// Clicking an already-selected building refreshes its panel, so the event
// fires even though the selection did not change.
SelectionController controller;
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
SelectionEventSpy spy;
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
REQUIRE(spy.buildingEvents == 1);
}
TEST_CASE("Clearing an empty selection announces nothing at all", "[selection]")
{
SelectionController controller;
SelectionEventSpy spy;
controller.clearAll();
REQUIRE(spy.buildingEvents == 0);
REQUIRE(spy.actorEvents == 0);
REQUIRE(spy.debrisEvents == 0);
}
TEST_CASE("Clearing a populated selection announces every populated category",
"[selection]")
{
SelectionController controller;
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
SelectionMode::Replace);
SelectionEventSpy spy;
controller.clearAll();
REQUIRE(spy.buildingEvents == 0); // buildings were already empty
REQUIRE(spy.actorEvents == 1);
REQUIRE(spy.debrisEvents == 1);
REQUIRE(controller.getSelectedActors().empty());
REQUIRE(controller.getSelectedDebris().empty());
}
// ---------------------------------------------------------------------------
// Pruning despawned entities
// ---------------------------------------------------------------------------
TEST_CASE("Pruning announces only when something actually went", "[selection]")
{
// Runs every frame, so re-announcing an unchanged selection would spam the
// panels 60 times a second.
SelectionController controller;
controller.selectFieldObjects({makeEntity(1), makeEntity(2)}, {},
SelectionMode::Replace);
SelectionEventSpy spy;
controller.setSelectedActors({makeEntity(1), makeEntity(2)});
REQUIRE(spy.actorEvents == 0);
controller.setSelectedActors({makeEntity(1)});
REQUIRE(spy.actorEvents == 1);
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
}

View File

@@ -1,243 +0,0 @@
#include "catch.hpp"
#include "WorldCamera.h"
#include "WorldConfig.h"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Slow 10 tiles/s, fast 50 tiles/s, and a 4-tile ramp band — so each ramp spans
// 2 tiles either side of a contest-zone boundary and the arithmetic stays exact.
static WorldScroll makeScroll(int rampBandWidth_tiles = 4)
{
WorldScroll scroll;
scroll.panSpeedSlow_tps = 10.0;
scroll.panSpeedFast_tps = 50.0;
scroll.panRampBandWidth_tiles = rampBandWidth_tiles;
return scroll;
}
// The player buffer's right edge — the contest zone's left boundary — sits at 20.
static WorldRegions makeRegions()
{
WorldRegions regions;
regions.asteroidWidth_tiles = 10;
regions.playerBufferWidth_tiles = 20;
regions.contestZoneWidth_tiles = 60;
regions.enemyBufferWidth_tiles = 10;
return regions;
}
static ScrollBounds makeBounds(float leftTiles = -100.0f, float rightTiles = 100.0f)
{
return ScrollBounds{leftTiles, rightTiles};
}
// ---------------------------------------------------------------------------
// Pan speed ramp (REQ-UI-SCROLL-SPEED)
// ---------------------------------------------------------------------------
TEST_CASE("Pan speed is slow over the asteroid and player buffer", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// Left of the rising ramp band (which starts at 20 - 2 = 18).
REQUIRE(camera.getPanSpeedTilesPerSecondAt(-50.0f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(18.0f, 80.0f) == Approx(10.0f));
}
TEST_CASE("Pan speed is fast across the middle of the contest zone", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// Between the two ramp bands: past 20 + 2 and before 80 - 2.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(22.0f, 80.0f) == Approx(50.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(50.0f, 80.0f) == Approx(50.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(78.0f, 80.0f) == Approx(50.0f));
}
TEST_CASE("Pan speed ramps linearly across each contest-zone boundary", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// Rising band spans [18, 22]; the boundary itself is the midpoint.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(20.0f, 80.0f) == Approx(30.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(19.0f, 80.0f) == Approx(20.0f));
// Falling band spans [78, 82], mirrored.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(80.0f, 80.0f) == Approx(30.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(81.0f, 80.0f) == Approx(20.0f));
}
TEST_CASE("Pan speed returns to slow past the enemy stations", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
REQUIRE(camera.getPanSpeedTilesPerSecondAt(82.0f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(200.0f, 80.0f) == Approx(10.0f));
}
TEST_CASE("The ramp follows the front line as it is pushed", "[camera]")
{
// The right boundary is passed in per call, so a push that moves the enemy
// stations moves the falling ramp with it (REQ-GW-PUSH-EXPAND).
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// X = 90 is past the old boundary (slow) but well inside the pushed-back one.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(90.0f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(90.0f, 140.0f) == Approx(50.0f));
}
TEST_CASE("Overlapping ramp bands peak below the fast speed", "[camera]")
{
// A contest zone narrower than the ramp band never reaches full speed: the two
// ramps cross before either tops out, leaving a single peak where they meet.
const WorldScroll scroll = makeScroll(/*rampBandWidth_tiles*/ 40);
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// Bands are [0, 40] rising and [10, 50] falling; they cross at x = 25.
const float peak = camera.getPanSpeedTilesPerSecondAt(25.0f, 30.0f);
REQUIRE(peak > 10.0f);
REQUIRE(peak < 50.0f);
// And it really is the maximum — the neighbours on both sides are lower.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(20.0f, 30.0f) < peak);
REQUIRE(camera.getPanSpeedTilesPerSecondAt(30.0f, 30.0f) < peak);
}
TEST_CASE("A zero-width ramp band steps straight from slow to fast", "[camera]")
{
const WorldScroll scroll = makeScroll(/*rampBandWidth_tiles*/ 0);
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
REQUIRE(camera.getPanSpeedTilesPerSecondAt(19.99f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(50.0f, 80.0f) == Approx(50.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(80.01f, 80.0f) == Approx(10.0f));
}
// ---------------------------------------------------------------------------
// Panning
// ---------------------------------------------------------------------------
TEST_CASE("The camera starts centered on the world origin", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
}
TEST_CASE("Panning moves the view center at the local pan speed", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
// Starting at 0, the local speed is the slow one: 10 tiles/s for 500 ms.
REQUIRE(camera.advance(PanDirection::Right, 500, makeBounds()));
REQUIRE(camera.getViewCenterXTiles() == Approx(5.0f));
REQUIRE(camera.advance(PanDirection::Left, 500, makeBounds()));
REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
}
TEST_CASE("Not panning leaves the view center alone", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
camera.advance(PanDirection::Right, 500, makeBounds());
const float before = camera.getViewCenterXTiles();
REQUIRE_FALSE(camera.advance(PanDirection::None, 500, makeBounds()));
REQUIRE(camera.getViewCenterXTiles() == Approx(before));
}
// ---------------------------------------------------------------------------
// Clamping (REQ-GW-SCROLL-LIMIT)
// ---------------------------------------------------------------------------
TEST_CASE("Panning stops at the scroll bounds", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
// Far more time than it takes to cross the bound.
camera.advance(PanDirection::Right, 100000, makeBounds(-5.0f, 12.0f));
REQUIRE(camera.getViewCenterXTiles() == Approx(12.0f));
camera.advance(PanDirection::Left, 100000, makeBounds(-5.0f, 12.0f));
REQUIRE(camera.getViewCenterXTiles() == Approx(-5.0f));
}
TEST_CASE("Shrinking bounds pull the view in even without panning", "[camera]")
{
// Bounds move as the game progresses, so clamping cannot wait for the player
// to press a key — a view left outside them would show unreachable world.
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
camera.advance(PanDirection::Right, 100000, makeBounds(-50.0f, 40.0f));
REQUIRE(camera.getViewCenterXTiles() == Approx(40.0f));
// The right bound comes in; the camera must follow it despite no pan input,
// and must report that it moved.
REQUIRE(camera.advance(PanDirection::None, 16, makeBounds(-50.0f, 25.0f)));
REQUIRE(camera.getViewCenterXTiles() == Approx(25.0f));
}
TEST_CASE("Panning into a bound the view already sits on reports no movement",
"[camera]")
{
// The caller refreshes the box-select rectangle on a true return, so a camera
// pinned against its limit must not keep claiming to have moved.
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
camera.advance(PanDirection::Right, 100000, makeBounds(-5.0f, 12.0f));
REQUIRE_FALSE(camera.advance(PanDirection::Right, 16, makeBounds(-5.0f, 12.0f)));
}
TEST_CASE("Reset returns the view to the origin", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
camera.advance(PanDirection::Right, 2000, makeBounds());
REQUIRE(camera.getViewCenterXTiles() != Approx(0.0f));
camera.reset();
REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
}
TEST_CASE("The camera tracks config edited after construction", "[camera]")
{
// The camera references the config rather than copying it, so a restart that
// reloads world.toml in place (REQ-CFG-RELOAD) changes the pan speed without
// the camera being rebuilt.
WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(10.0f));
scroll.panSpeedSlow_tps = 3.0;
REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(3.0f));
}

View File

@@ -1,203 +0,0 @@
#include "catch.hpp"
#include <QPoint>
#include <QSize>
#include <QVector2D>
#include "WorldCoordinates.h"
// A 800x400 viewport over a 20-tile-high world gives exactly 20 px per tile and a
// 40-tile-wide view, so every expectation below is a whole number.
static WorldCoordinates makeCoordinates(float viewCenterX_tiles)
{
return WorldCoordinates::scrolling(QSize(800, 400), 20, viewCenterX_tiles);
}
// ---------------------------------------------------------------------------
// Tile size and viewport extent
// ---------------------------------------------------------------------------
TEST_CASE("Tile size makes the world height fill the viewport height", "[coords]")
{
// REQ-GW-TILE-SIZE: tiles are square and sized so the world height exactly
// fills the view's height.
REQUIRE(makeCoordinates(0.0f).getTilePx() == Approx(20.0f));
REQUIRE(WorldCoordinates::scrolling(QSize(800, 600), 20, 0.0f).getTilePx()
== Approx(30.0f));
}
TEST_CASE("A degenerate world height falls back to a unit tile", "[coords]")
{
// Guards the division in every conversion; a zero or negative height would
// otherwise produce infinities.
REQUIRE(WorldCoordinates::scrolling(QSize(800, 400), 0, 0.0f).getTilePx()
== Approx(1.0f));
REQUIRE(WorldCoordinates::scrolling(QSize(800, 400), -5, 0.0f).getTilePx()
== Approx(1.0f));
}
TEST_CASE("A zero-size viewport falls back to a unit tile", "[coords]")
{
// A widget that has not been shown yet still has to answer conversions —
// the arena hit-tests through the same transform.
REQUIRE(WorldCoordinates::scrolling(QSize(0, 0), 20, 0.0f).getTilePx()
== Approx(1.0f));
REQUIRE(WorldCoordinates::fitToWorld(QSize(0, 0), 40, 20).getTilePx()
== Approx(1.0f));
}
TEST_CASE("Viewport width in tiles follows the widget width", "[coords]")
{
REQUIRE(makeCoordinates(0.0f).getViewportWidthTiles() == Approx(40.0f));
REQUIRE(WorldCoordinates::scrolling(QSize(400, 400), 20, 0.0f).getViewportWidthTiles()
== Approx(20.0f));
}
TEST_CASE("The view left edge is half a viewport left of the center", "[coords]")
{
REQUIRE(makeCoordinates(0.0f).getViewLeftTiles() == Approx(-20.0f));
REQUIRE(makeCoordinates(100.0f).getViewLeftTiles() == Approx(80.0f));
}
// ---------------------------------------------------------------------------
// World <-> widget conversion
// ---------------------------------------------------------------------------
TEST_CASE("World positions map to widget pixels relative to the view left edge",
"[coords]")
{
const WorldCoordinates coordinates = makeCoordinates(0.0f); // left edge at -20
REQUIRE(coordinates.worldToWidget(QVector2D(-20.0f, 0.0f)).x() == Approx(0.0));
REQUIRE(coordinates.worldToWidget(QVector2D(0.0f, 0.0f)).x() == Approx(400.0));
// Y is not scrolled: world Y maps straight through the tile size.
REQUIRE(coordinates.worldToWidget(QVector2D(0.0f, 3.5f)).y() == Approx(70.0));
}
TEST_CASE("Scrolling right shifts the world left on screen", "[coords]")
{
const QVector2D worldPos(10.0f, 5.0f);
const qreal atOrigin = makeCoordinates(0.0f).worldToWidget(worldPos).x();
const qreal scrolled = makeCoordinates(4.0f).worldToWidget(worldPos).x();
// Panning the view 4 tiles right moves the same world point 4 tiles (80 px) left.
REQUIRE(scrolled == Approx(atOrigin - 80.0));
}
TEST_CASE("A tile's widget position is its top-left corner", "[coords]")
{
const WorldCoordinates coordinates = makeCoordinates(0.0f);
REQUIRE(coordinates.tileToWidget(QPoint(-20, 0)) == QPointF(0.0, 0.0));
REQUIRE(coordinates.tileToWidget(QPoint(0, 2)) == QPointF(400.0, 40.0));
}
TEST_CASE("A tile rect covers exactly one tile", "[coords]")
{
const QRectF rect = makeCoordinates(0.0f).tileRect(QPoint(-19, 1));
REQUIRE(rect.left() == Approx(20.0));
REQUIRE(rect.top() == Approx(20.0));
REQUIRE(rect.width() == Approx(20.0));
REQUIRE(rect.height() == Approx(20.0));
}
// ---------------------------------------------------------------------------
// Widget -> world conversion
// ---------------------------------------------------------------------------
TEST_CASE("Widget points map back to the world position they came from", "[coords]")
{
const WorldCoordinates coordinates = makeCoordinates(7.0f);
const QVector2D world = coordinates.widgetToWorld(QPoint(250, 130));
const QPointF back = coordinates.worldToWidget(world);
REQUIRE(back.x() == Approx(250.0));
REQUIRE(back.y() == Approx(130.0));
}
TEST_CASE("Widget points resolve to the tile that contains them", "[coords]")
{
const WorldCoordinates coordinates = makeCoordinates(0.0f); // left edge at -20
// Anywhere inside a tile's 20px cell resolves to that tile.
REQUIRE(coordinates.widgetToTile(QPoint(0, 0)) == QPoint(-20, 0));
REQUIRE(coordinates.widgetToTile(QPoint(19, 19)) == QPoint(-20, 0));
REQUIRE(coordinates.widgetToTile(QPoint(20, 20)) == QPoint(-19, 1));
REQUIRE(coordinates.widgetToTile(QPoint(405, 45)) == QPoint(0, 2));
}
TEST_CASE("Tile resolution floors, so negative world positions round down", "[coords]")
{
// Truncation toward zero would map the whole strip from -1 to 1 onto tile 0,
// making the tile under the cursor wrong on the asteroid side (REQ-GW-COORDS:
// all asteroid tiles have x < 0).
const WorldCoordinates coordinates = makeCoordinates(0.0f); // left edge at -20
REQUIRE(coordinates.widgetToTile(QPoint(399, 0)) == QPoint(-1, 0));
REQUIRE(coordinates.widgetToTile(QPoint(400, 0)) == QPoint(0, 0));
}
// ---------------------------------------------------------------------------
// Viewport rect
// ---------------------------------------------------------------------------
TEST_CASE("The viewport rect spans the visible tiles with a one-column margin",
"[coords]")
{
// The margin keeps items that straddle an edge from popping in and out.
const QRect rect = makeCoordinates(0.0f).getViewportRect(); // left edge at -20
REQUIRE(rect.left() == -21);
REQUIRE(rect.right() == 20);
REQUIRE(rect.top() == 0);
REQUIRE(rect.height() == 20); // the full world height
}
TEST_CASE("A fractional scroll position widens the viewport rect outward", "[coords]")
{
// Left edge at -19.5: the rect must still cover the partially visible columns
// on both sides, so it floors on the left and ceils on the right.
const QRect rect = makeCoordinates(0.5f).getViewportRect();
REQUIRE(rect.left() == -21);
REQUIRE(rect.right() == 21);
}
// ---------------------------------------------------------------------------
// Fitted (non-scrolling) worlds
// ---------------------------------------------------------------------------
TEST_CASE("A fitted world takes the tighter of the two axis fits", "[coords]")
{
// Height-limited: 400/20 = 20 px per tile beats 800/30 = 26.67.
REQUIRE(WorldCoordinates::fitToWorld(QSize(800, 400), 30, 20).getTilePx()
== Approx(20.0f));
// Width-limited: 800/80 = 10 px per tile beats 400/20 = 20.
REQUIRE(WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20).getTilePx()
== Approx(10.0f));
}
TEST_CASE("A fitted world keeps its whole width on screen", "[coords]")
{
// The point of taking the tighter fit: the far edge must land inside the
// viewport, never past it.
const WorldCoordinates coordinates =
WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20);
REQUIRE(coordinates.worldToWidget(QVector2D(80.0f, 0.0f)).x() <= 800.0);
REQUIRE(coordinates.worldToWidget(QVector2D(0.0f, 20.0f)).y() <= 400.0);
}
TEST_CASE("A fitted world puts the origin at the widget's top-left", "[coords]")
{
// No scrolling, so there is no view center to subtract.
const WorldCoordinates coordinates =
WorldCoordinates::fitToWorld(QSize(800, 400), 40, 20);
REQUIRE(coordinates.getViewLeftTiles() == Approx(0.0f));
REQUIRE(coordinates.tileToWidget(QPoint(0, 0)) == QPointF(0.0, 0.0));
REQUIRE(coordinates.tileToWidget(QPoint(3, 2)) == QPointF(60.0, 40.0));
}
TEST_CASE("A fitted world round-trips widget points back to world positions",
"[coords]")
{
const WorldCoordinates coordinates =
WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20);
const QVector2D world = coordinates.widgetToWorld(QPoint(120, 55));
const QPointF back = coordinates.worldToWidget(world);
REQUIRE(back.x() == Approx(120.0));
REQUIRE(back.y() == Approx(55.0));
}

View File

@@ -1,459 +0,0 @@
#include "BuildButtonBar.h"
#include <string>
#include <QByteArray>
#include <QColor>
#include <QFile>
#include <QFont>
#include <QFontMetrics>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QIcon>
#include <QPainter>
#include <QPalette>
#include <QPixmap>
#include <QPushButton>
#include <QRect>
#include <QRegularExpression>
#include <QSignalMapper>
#include <QSize>
#include <QString>
#include <QSvgRenderer>
#include "BuildingType.h"
#include "BuildingTypeSelectedEvent.h"
#include "DeconstructModeToggleRequestedEvent.h"
#include "DisplayName.h"
#include "EventManager.h"
#include "ExitBuilderModeRequestedEvent.h"
#include "IconCaption.h"
#include "InputMapper.h"
#include "ItemIconCache.h"
#include "Simulation.h"
namespace
{
// Size the SVG chips are drawn at on a button face, in device-independent pixels.
const QSize kIconSize(32, 32);
// Minimum width of a button face. The building-type buttons all come out at this
// width, so the row is uniform; a face whose caption is wider than this — the
// Deconstruct button's name — grows to fit rather than clipping
// (REQ-UI-DECONSTRUCT-BUTTON).
const int kFaceMinWidthPx = 48;
// Gap between the last building button and the Deconstruct button, which toggles
// a mode rather than selecting a building type (REQ-UI-DECONSTRUCT-BUTTON).
const int kDeconstructGapPx = 16;
// Distance from the bar to the bottom edge of the game world view
// (REQ-UI-BUILD-BAR).
const int kBottomMarginPx = 8;
// Gap between the chip icon and the cost line on a button face.
const int kFaceGapPx = 2;
// Rasterizes a chip SVG straight at its on-screen size times the device pixel
// ratio, so it stays crisp without a downscale step.
QPixmap renderChip(const QByteArray& svg)
{
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
QSvgRenderer renderer(svg);
QPixmap pixmap(static_cast<int>(kIconSize.width() * dpr),
static_cast<int>(kIconSize.height() * dpr));
pixmap.setDevicePixelRatio(dpr);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
renderer.render(&painter);
return pixmap;
}
// Normal and grey-background chip pixmaps for a "<id>.svg" file. Empty pixmaps if
// the file cannot be read; the caller then falls back to a name caption
// (REQ-UI-BUILD-ICON).
struct ChipPixmaps { QPixmap normal; QPixmap grey; };
ChipPixmaps loadChipPixmaps(const QString& path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) { return {}; }
const QByteArray svg = file.readAll();
ChipPixmaps result;
result.normal = renderChip(svg);
// Recolor only the chip background: the first "#rrggbb" fill in the file is the
// rounded background rect; the white glyph uses fill="none" and is left alone.
QString greyed = QString::fromUtf8(svg);
static const QRegularExpression fillPattern(QStringLiteral("fill=\"#[0-9a-fA-F]{6}\""));
const QRegularExpressionMatch match = fillPattern.match(greyed);
if (match.hasMatch())
{
greyed.replace(match.capturedStart(), match.capturedLength(),
QStringLiteral("fill=\"#5f636e\""));
}
result.grey = renderChip(greyed.toUtf8());
return result;
}
// A pixmap's size in device-independent pixels. The pixmaps composed here are
// rasterized at the device pixel ratio, so their raw size is not their layout size.
QSize getLogicalSize(const QPixmap& pixmap)
{
if (pixmap.isNull()) { return QSize(0, 0); }
const qreal dpr = pixmap.devicePixelRatio();
return QSize(static_cast<int>(pixmap.width() / dpr),
static_cast<int>(pixmap.height() / dpr));
}
// One button face: the hotkey badge in the top-left corner, the chip icon centered
// below it, and the caption — the cost, or the Deconstruct name — centered at the
// bottom (REQ-UI-BUILD-COST). The three are composed into a single pixmap because
// a QPushButton holds only one icon.
QPixmap composeButtonFace(const QString& hotkeyLabel, const QPixmap& chip,
const QPixmap& caption, const QFont& badgeFont,
const QColor& badgeColor)
{
const QSize chipSize = getLogicalSize(chip);
const QSize captionSize = getLogicalSize(caption);
// The badge row is kept even for a building type without a hotkey, so buttons
// stay the same height and the row reads as one strip (REQ-UI-BUILD-COST).
const int badgeHeight = QFontMetrics(badgeFont).height();
const int width = qMax(kFaceMinWidthPx, qMax(chipSize.width(), captionSize.width()));
const int height = badgeHeight + chipSize.height() + kFaceGapPx + captionSize.height();
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
QPixmap face(static_cast<int>(width * dpr), static_cast<int>(height * dpr));
face.setDevicePixelRatio(dpr);
face.fill(Qt::transparent);
QPainter painter(&face);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
if (!hotkeyLabel.isEmpty())
{
painter.setFont(badgeFont);
painter.setPen(badgeColor);
painter.drawText(QRect(0, 0, width, badgeHeight),
Qt::AlignLeft | Qt::AlignVCenter, hotkeyLabel);
}
painter.drawPixmap((width - chipSize.width()) / 2, badgeHeight, chip);
painter.drawPixmap((width - captionSize.width()) / 2,
badgeHeight + chipSize.height() + kFaceGapPx, caption);
return face;
}
// A composed button face and the size to show it at; the button needs both, and
// only the composer knows the size it arrived at.
struct ButtonFace { QIcon icon; QSize size; };
// The two-mode face of one build button. The modes differ only in the chip variant
// and the text color, so a disabled (unaffordable) button greys itself when Qt
// swaps the pixmap, with no extra work in updateAffordability()
// (REQ-UI-BUILD-DISABLED).
ButtonFace buildButtonFace(const ChipPixmaps& chip, const QString& hotkeyLabel,
const QString& name, const QString& captionText,
const QPixmap& blockIcon, const QFont& font,
const QPalette& palette)
{
// Full button size and bold: at a smaller size the badge was hard to read and
// its arrow glyph illegible. It stays dimmed in both modes instead, so it
// reads as a reminder without competing with the cost.
QFont badgeFont = font;
badgeFont.setBold(true);
const QColor badgeColor = palette.color(QPalette::Disabled, QPalette::ButtonText);
ButtonFace result;
for (QIcon::Mode mode : { QIcon::Normal, QIcon::Disabled })
{
const bool enabled = (mode == QIcon::Normal);
const QColor textColor = palette.color(
enabled ? QPalette::Active : QPalette::Disabled, QPalette::ButtonText);
const QPixmap& chipPixmap = enabled ? chip.normal : chip.grey;
// A missing "<id>.svg" puts the building name where the chip would be, so
// the button stays identifiable in an icon-only bar (REQ-UI-BUILD-ICON).
const QPixmap middle = chipPixmap.isNull()
? renderCaptionWithIcon(name, QPixmap(), font, textColor)
: chipPixmap;
const QPixmap caption =
renderCaptionWithIcon(captionText, blockIcon, font, textColor);
const QPixmap face =
composeButtonFace(hotkeyLabel, middle, caption, badgeFont, badgeColor);
result.icon.addPixmap(face, mode);
// Both modes compose to the same size; keeping the larger is only a guard
// against a fallback name caption widening one of them.
result.size = result.size.expandedTo(getLogicalSize(face));
}
return result;
}
}
BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
const std::string& iconDir,
ItemIconCache* itemIcons, QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_iconDir(iconDir)
, m_itemIcons(itemIcons)
{
// The bar floats over the rendered world rather than sitting in a panel, so it
// brings its own opaque background to stay legible over any world content
// (REQ-UI-BUILD-BAR). Palette colors keep it consistent with the buttons it holds
// and with the side panels; this is widget chrome, not world rendering, so it is
// deliberately not a visuals.toml color.
setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral(
"BuildButtonBar { background-color: palette(window);"
" border: 1px solid palette(mid); border-radius: 4px; }"));
QHBoxLayout* layout = new QHBoxLayout(this);
layout->setSpacing(4);
layout->setContentsMargins(6, 4, 6, 4);
QSignalMapper* mapper = new QSignalMapper(this);
// Block icon shown to the right of each button's cost (REQ-UI-BUILD-COST); null
// when no building_block icon exists, in which case the cost is the bare number.
const QPixmap blockIcon = m_itemIcons->hasIcon(kBlockItemId)
? m_itemIcons->getPixmap(kBlockItemId, QFontMetrics(font()).height())
: QPixmap();
for (const BuildingDef& def : config->buildings.buildings)
{
if (!def.playerPlaceable)
{
continue;
}
// Tunnel Entry and Tunnel Exit share a single "Tunnel" button; the exit is
// reached through the unified tunnel build mode, not its own button
// (REQ-BLD-TUNNEL-MODE, REQ-UI-BUILD-BAR). Both stay player-placeable so
// blueprints and cost totals still account for exits.
if (def.type == BuildingType::TunnelExit)
{
continue;
}
m_types.push_back(def.type);
m_costs[def.type] = def.cost;
const QString name = (def.type == BuildingType::TunnelEntry)
? tr("Tunnel")
: QString::fromStdString(toDisplayName(def.id));
// Icon file name matches the building id (REQ-UI-BUILD-ICON); Tunnel Entry's
// "tunnel_entry.svg" serves the shared Tunnel button.
const QString iconPath = QString::fromStdString(m_iconDir) + "/"
+ QString::fromStdString(def.id) + ".svg";
const ButtonFace face = buildButtonFace(
loadChipPixmaps(iconPath), InputMapper::getBuildHotkeyLabel(def.type), name,
QString::number(def.cost), blockIcon, font(), palette());
QPushButton* btn = new QPushButton(this);
btn->setIcon(face.icon);
btn->setIconSize(face.size);
btn->setCheckable(true);
// The button face carries no name (REQ-UI-BUILD-COST), so the tooltip always
// leads with it and adds the config description when there is one
// (REQ-UI-BUILD-TOOLTIP).
btn->setToolTip(def.tooltip
? QStringLiteral("%1\n%2").arg(name, QString::fromStdString(*def.tooltip))
: name);
layout->addWidget(btn);
const int idx = static_cast<int>(m_buttons.size());
m_buttons.push_back(btn);
mapper->setMapping(btn, idx);
connect(btn, &QPushButton::clicked, mapper, qOverload<>(&QSignalMapper::map));
}
connect(mapper, qOverload<int>(&QSignalMapper::mapped), this, &BuildButtonBar::onBuildButton);
// Set apart from the building-type buttons by a gap, because it toggles a mode
// rather than selecting a building type (REQ-UI-DECONSTRUCT-BUTTON). A fixed
// spacer rather than a stretch: the bar is sized to its contents, so there is no
// right edge for a stretch to push against.
layout->addSpacing(kDeconstructGapPx);
// Having no cost, it shows its name where the building buttons show theirs
// (REQ-UI-DECONSTRUCT-BUTTON), and its Q toggle as the badge (REQ-UI-HOTKEYS).
const ButtonFace deconstructFace = buildButtonFace(
loadChipPixmaps(QString::fromStdString(m_iconDir) + "/deconstruct.svg"),
QStringLiteral("Q"), tr("Deconstruct"), tr("Deconstruct"), QPixmap(),
font(), palette());
m_deconstructButton = new QPushButton(this);
m_deconstructButton->setCheckable(true);
m_deconstructButton->setIcon(deconstructFace.icon);
m_deconstructButton->setIconSize(deconstructFace.size);
// Refund tooltip composed from world.refund_percentage (REQ-UI-DECONSTRUCT-BUTTON,
// REQ-BLD-DECONSTRUCT). A finished building refunds the configured percentage; a
// construction site removed before it is built is refunded in full. When the
// percentage is 100% both cases coincide, so the tooltip is simplified to one case.
const int refundPercentage = m_config->world.refundPercentage;
const QString deconstructTooltip = (refundPercentage >= 100)
? tr("Deconstruct buildings. Refunds %1% of the building block cost.")
.arg(refundPercentage)
: tr("Deconstruct buildings. A finished building refunds %1% of its building "
"block cost once removed; a construction site removed before it is built "
"is refunded in full.")
.arg(refundPercentage);
m_deconstructButton->setToolTip(deconstructTooltip);
layout->addWidget(m_deconstructButton);
connect(m_deconstructButton, &QPushButton::clicked, this, [this]() {
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DeconstructModeToggleRequestedEvent>());
});
updateVisibility();
registerForEvents();
}
BuildButtonBar::~BuildButtonBar()
{
unregisterForEvents();
}
void BuildButtonBar::anchorTo(const QRect& worldViewRect)
{
m_viewRect = worldViewRect;
recenter();
}
void BuildButtonBar::clearActiveButton()
{
if (m_activeIndex)
{
m_buttons[*m_activeIndex]->setChecked(false);
}
m_activeIndex.reset();
}
void BuildButtonBar::updateAffordability()
{
const int buildingBlocks = m_sim->getBuildingBlocksStock();
// If the currently selected tool can no longer be afforded, exit builder mode
// before recomputing button states so it does not stay selected. Clearing the
// active index first lets the loop below disable the now-unaffordable button.
if (m_activeIndex)
{
const BuildingType activeType = m_types[*m_activeIndex];
const std::map<BuildingType, int>::const_iterator it = m_costs.find(activeType);
const int cost = (it != m_costs.end()) ? it->second : 0;
if (buildingBlocks < cost)
{
clearActiveButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBuilderModeRequestedEvent>());
}
}
for (std::size_t i = 0; i < m_buttons.size(); ++i)
{
const BuildingType type = m_types[i];
const std::map<BuildingType, int>::const_iterator it = m_costs.find(type);
const int cost = (it != m_costs.end()) ? it->second : 0;
m_buttons[i]->setEnabled(buildingBlocks >= cost || m_activeIndex == i);
}
}
void BuildButtonBar::updateVisibility()
{
// A locked building type's button is hidden until its unlock group is awarded
// (REQ-LOCK-BUILDING). Buttons keep their index so hotkeys/affordability stay
// stable; they simply appear once the type is unlocked.
for (std::size_t i = 0; i < m_buttons.size(); ++i)
{
m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i]));
}
// A hidden button leaves the row, so the bar has to take up its new width and
// re-center on it (REQ-UI-BUILD-BAR).
recenter();
}
void BuildButtonBar::recenter()
{
if (m_viewRect.isNull())
{
return;
}
// The layout drops hidden buttons from its size hint, but only once it has been
// re-run: updateVisibility() calls this straight after setVisible(), before Qt
// would get around to it on its own.
layout()->activate();
const QSize barSize = sizeHint();
// Centered, except that a bar wider than the view stays flush with its left edge
// rather than hanging off both sides.
const int x = qMax(m_viewRect.left(),
m_viewRect.left() + (m_viewRect.width() - barSize.width()) / 2);
const int y = m_viewRect.bottom() - kBottomMarginPx - barSize.height() + 1;
setGeometry(QRect(QPoint(x, y), barSize));
}
void BuildButtonBar::onBuildButton(int index)
{
if (index < 0 || index >= static_cast<int>(m_buttons.size()))
{
return;
}
const std::size_t idx = static_cast<std::size_t>(index);
if (m_activeIndex == idx)
{
clearActiveButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBuilderModeRequestedEvent>());
return;
}
if (m_activeIndex)
{
m_buttons[*m_activeIndex]->setChecked(false);
}
m_activeIndex = idx;
m_buttons[idx]->setChecked(true);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildingTypeSelectedEvent>(m_types[idx]));
}
void BuildButtonBar::handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/)
{
clearActiveButton();
}
void BuildButtonBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
{
updateAffordability();
}
void BuildButtonBar::handleEvent(std::shared_ptr<const UnlockedBuildingsChangedEvent> /*event*/)
{
updateVisibility();
updateAffordability();
}
void BuildButtonBar::handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event)
{
m_deconstructButton->setChecked(event->active);
}
void BuildButtonBar::handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event)
{
for (std::size_t i = 0; i < m_types.size(); ++i)
{
if (m_types[i] == event->type)
{
// Equivalent to clicking the build button: a disabled (unaffordable) or
// hidden (locked, REQ-LOCK-BUILDING) button cannot be clicked, so the
// hotkey is likewise inert.
if (m_buttons[i]->isEnabled() && m_buttons[i]->isVisible())
{
onBuildButton(static_cast<int>(i));
}
return;
}
}
}

419
src/ui/BuildButtonGrid.cpp Normal file
View File

@@ -0,0 +1,419 @@
#include "BuildButtonGrid.h"
#include <string>
#include <QByteArray>
#include <QColor>
#include <QFile>
#include <QFontMetrics>
#include <QGridLayout>
#include <QIcon>
#include <QPainter>
#include <QPaintEvent>
#include <QPalette>
#include <QPixmap>
#include <QPushButton>
#include <QRect>
#include <QRegularExpression>
#include <QSignalMapper>
#include <QSize>
#include <QString>
#include <QSvgRenderer>
#include "BuildingType.h"
#include "BuildingTypeSelectedEvent.h"
#include "DeconstructModeToggleRequestedEvent.h"
#include "DisplayName.h"
#include "EventManager.h"
#include "ExitBuilderModeRequestedEvent.h"
#include "ItemIconCache.h"
#include "Simulation.h"
namespace
{
// Pixel size the SVG chips are rasterized at; downscaled to the button icon size.
const int kIconRenderSize = 64;
const QSize kIconSize(28, 28);
QPixmap renderChip(const QByteArray& svg)
{
QSvgRenderer renderer(svg);
QPixmap pixmap(kIconRenderSize, kIconRenderSize);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
renderer.render(&painter);
return pixmap;
}
// Builds a build-button icon from a "<id>.svg" chip file. The returned QIcon also
// carries a Disabled-mode pixmap whose chip background is recolored grey, so an
// unaffordable (disabled) button shows the grey variant automatically
// (REQ-UI-BUILD-DISABLED) without any extra work in updateAffordability().
QIcon loadBuildingIcon(const QString& path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
{
return QIcon();
}
const QByteArray svg = file.readAll();
QIcon icon;
icon.addPixmap(renderChip(svg), QIcon::Normal);
// Recolor only the chip background: the first "#rrggbb" fill in the file is the
// rounded background rect; the white glyph uses fill="none" and is left alone.
QString greyed = QString::fromUtf8(svg);
static const QRegularExpression fillPattern(QStringLiteral("fill=\"#[0-9a-fA-F]{6}\""));
const QRegularExpressionMatch match = fillPattern.match(greyed);
if (match.hasMatch())
{
greyed.replace(match.capturedStart(), match.capturedLength(),
QStringLiteral("fill=\"#5f636e\""));
}
icon.addPixmap(renderChip(greyed.toUtf8()), QIcon::Disabled);
return icon;
}
// Normal and grey-background chip pixmaps for a "<id>.svg" file, using the same
// recolor rule as loadBuildingIcon. Empty pixmaps if the file cannot be read.
struct ChipPixmaps { QPixmap normal; QPixmap grey; };
ChipPixmaps loadChipPixmaps(const QString& path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) { return {}; }
const QByteArray svg = file.readAll();
ChipPixmaps result;
result.normal = renderChip(svg);
QString greyed = QString::fromUtf8(svg);
static const QRegularExpression fillPattern(QStringLiteral("fill=\"#[0-9a-fA-F]{6}\""));
const QRegularExpressionMatch match = fillPattern.match(greyed);
if (match.hasMatch())
{
greyed.replace(match.capturedStart(), match.capturedLength(),
QStringLiteral("fill=\"#5f636e\""));
}
result.grey = renderChip(greyed.toUtf8());
return result;
}
// A build button that paints its own face — chip icon at the left, the building
// name above the cost, and the building_block item icon after the cost number in
// place of the "Blocks" word (REQ-UI-BUILD-COST, REQ-UI-BUILD-ICON). Custom paint
// (rather than the native icon+text) is needed because a QPushButton holds only
// one icon; this stays adaptive to the button width and greys itself when the
// button is disabled/unaffordable (REQ-UI-BUILD-DISABLED).
class BuildButton : public QPushButton
{
public:
BuildButton(const ChipPixmaps& chip, const QString& name,
const QString& costText, const QPixmap& blockIcon, QWidget* parent)
: QPushButton(parent)
, m_chip(chip)
, m_name(name)
, m_costText(costText)
, m_blockIcon(blockIcon)
{
}
protected:
void paintEvent(QPaintEvent* event) override
{
QPushButton::paintEvent(event); // frame, checked/hover state
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
const bool on = isEnabled();
const QRect area = rect().adjusted(6, 4, -6, -4);
const int chipSize = kIconSize.width();
const QPixmap& chip = on ? m_chip.normal : m_chip.grey;
if (!chip.isNull())
{
painter.drawPixmap(
QRect(area.x(), area.y() + (area.height() - chipSize) / 2,
chipSize, chipSize), chip);
}
const QRect textArea(area.x() + chipSize + 6, area.y(),
area.width() - chipSize - 6, area.height());
const QFontMetrics metrics(font());
const int lineHeight = metrics.height();
painter.setFont(font());
painter.setPen(palette().color(on ? QPalette::Active : QPalette::Disabled,
QPalette::ButtonText));
// Name fills everything above the bottom cost line (word-wrapped).
painter.drawText(
QRect(textArea.x(), textArea.y(),
textArea.width(), textArea.height() - lineHeight),
Qt::AlignLeft | Qt::AlignVCenter | Qt::TextWordWrap, m_name);
// Cost line: "<n>" then the block icon (or "<n> Blocks" when no icon).
const int costY = textArea.bottom() - lineHeight + 1;
if (m_blockIcon.isNull())
{
painter.drawText(
QRect(textArea.x(), costY, textArea.width(), lineHeight),
Qt::AlignLeft | Qt::AlignVCenter,
QObject::tr("%1 Blocks").arg(m_costText));
return;
}
const int costWidth = metrics.horizontalAdvance(m_costText);
painter.drawText(QRect(textArea.x(), costY, costWidth, lineHeight),
Qt::AlignLeft | Qt::AlignVCenter, m_costText);
const qreal iconDpr = m_blockIcon.devicePixelRatio();
const int iconW = static_cast<int>(m_blockIcon.width() / iconDpr);
const int iconH = static_cast<int>(m_blockIcon.height() / iconDpr);
if (!on) { painter.setOpacity(0.45); }
painter.drawPixmap(
QRect(textArea.x() + costWidth + 4, costY + (lineHeight - iconH) / 2,
iconW, iconH), m_blockIcon);
}
private:
ChipPixmaps m_chip;
QString m_name;
QString m_costText;
QPixmap m_blockIcon;
};
}
BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config,
const std::string& iconDir,
ItemIconCache* itemIcons, QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_iconDir(iconDir)
, m_itemIcons(itemIcons)
{
QGridLayout* layout = new QGridLayout(this);
layout->setSpacing(4);
layout->setContentsMargins(4, 4, 4, 4);
QSignalMapper* mapper = new QSignalMapper(this);
int col = 0;
int row = 0;
const int kCols = 3;
// Block icon shown in each button's cost line (REQ-UI-BUILD-COST); null when no
// building_block icon exists, in which case buttons fall back to text costs.
const bool hasBlockIcon = m_itemIcons->hasIcon("building_block");
const QPixmap blockIcon = hasBlockIcon
? m_itemIcons->getPixmap("building_block", QFontMetrics(font()).height())
: QPixmap();
for (const BuildingDef& def : config->buildings.buildings)
{
if (!def.playerPlaceable)
{
continue;
}
// Tunnel Entry and Tunnel Exit share a single "Tunnel" button; the exit is
// reached through the unified tunnel build mode, not its own button
// (REQ-BLD-TUNNEL-MODE, REQ-UI-BUILD-GRID). Both stay player-placeable so
// blueprints and cost totals still account for exits.
if (def.type == BuildingType::TunnelExit)
{
continue;
}
m_types.push_back(def.type);
m_costs[def.type] = def.cost;
const QString name = (def.type == BuildingType::TunnelEntry)
? tr("Tunnel")
: QString::fromStdString(toDisplayName(def.id));
// Icon file name matches the building id (REQ-UI-BUILD-GRID); Tunnel Entry's
// "tunnel_entry.svg" serves the shared Tunnel button.
const QString iconPath = QString::fromStdString(m_iconDir) + "/"
+ QString::fromStdString(def.id) + ".svg";
QPushButton* btn = nullptr;
if (hasBlockIcon)
{
// Custom-painted button showing the cost with the block icon in place of
// the "Blocks" word (REQ-UI-BUILD-COST).
btn = new BuildButton(loadChipPixmaps(iconPath), name,
QString::number(def.cost), blockIcon, this);
}
else
{
// Fallback: native chip icon + text cost when no block icon exists.
btn = new QPushButton(name + "\n" + tr("%1 Blocks").arg(def.cost), this);
btn->setIcon(loadBuildingIcon(iconPath));
btn->setIconSize(kIconSize);
}
btn->setCheckable(true);
btn->setFixedHeight(48);
if (def.tooltip)
{
btn->setToolTip(QString::fromStdString(*def.tooltip));
}
layout->addWidget(btn, row, col);
const int idx = static_cast<int>(m_buttons.size());
m_buttons.push_back(btn);
mapper->setMapping(btn, idx);
connect(btn, &QPushButton::clicked, mapper, qOverload<>(&QSignalMapper::map));
++col;
if (col >= kCols)
{
col = 0;
++row;
}
}
connect(mapper, qOverload<int>(&QSignalMapper::mapped), this, &BuildButtonGrid::onBuildButton);
m_deconstructButton = new QPushButton(tr("Deconstruct"), this);
m_deconstructButton->setCheckable(true);
m_deconstructButton->setFixedHeight(48);
m_deconstructButton->setIcon(loadBuildingIcon(
QString::fromStdString(m_iconDir) + "/deconstruct.svg"));
m_deconstructButton->setIconSize(kIconSize);
// Refund tooltip composed from world.refund_percentage (REQ-UI-DECONSTRUCT-BUTTON,
// REQ-BLD-DECONSTRUCT). A finished building refunds the configured percentage; a
// construction site removed before it is built is refunded in full. When the
// percentage is 100% both cases coincide, so the tooltip is simplified to one case.
const int refundPercentage = m_config->world.refundPercentage;
const QString deconstructTooltip = (refundPercentage >= 100)
? tr("Deconstruct buildings. Refunds %1% of the building block cost.")
.arg(refundPercentage)
: tr("Deconstruct buildings. A finished building refunds %1% of its building "
"block cost once removed; a construction site removed before it is built "
"is refunded in full.")
.arg(refundPercentage);
m_deconstructButton->setToolTip(deconstructTooltip);
layout->addWidget(m_deconstructButton, row, col);
connect(m_deconstructButton, &QPushButton::clicked, this, [this]() {
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DeconstructModeToggleRequestedEvent>());
});
updateVisibility();
registerForEvents();
}
BuildButtonGrid::~BuildButtonGrid()
{
unregisterForEvents();
}
void BuildButtonGrid::updateAffordability()
{
const int buildingBlocks = m_sim->getBuildingBlocksStock();
// If the currently selected tool can no longer be afforded, exit builder mode
// before recomputing button states so it does not stay selected. Clearing the
// active index first lets the loop below disable the now-unaffordable button.
if (m_activeIndex)
{
const BuildingType activeType = m_types[*m_activeIndex];
const std::map<BuildingType, int>::const_iterator it = m_costs.find(activeType);
const int cost = (it != m_costs.end()) ? it->second : 0;
if (buildingBlocks < cost)
{
clearActiveButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBuilderModeRequestedEvent>());
}
}
for (std::size_t i = 0; i < m_buttons.size(); ++i)
{
const BuildingType type = m_types[i];
const std::map<BuildingType, int>::const_iterator it = m_costs.find(type);
const int cost = (it != m_costs.end()) ? it->second : 0;
m_buttons[i]->setEnabled(buildingBlocks >= cost || m_activeIndex == i);
}
}
void BuildButtonGrid::updateVisibility()
{
// A locked building type's button is hidden until its unlock group is awarded
// (REQ-LOCK-BUILDING). Buttons keep their index so hotkeys/affordability stay
// stable; they simply appear once the type is unlocked.
for (std::size_t i = 0; i < m_buttons.size(); ++i)
{
m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i]));
}
}
void BuildButtonGrid::clearActiveButton()
{
if (m_activeIndex)
{
m_buttons[*m_activeIndex]->setChecked(false);
}
m_activeIndex.reset();
}
void BuildButtonGrid::onBuildButton(int index)
{
if (index < 0 || index >= static_cast<int>(m_buttons.size()))
{
return;
}
const std::size_t idx = static_cast<std::size_t>(index);
if (m_activeIndex == idx)
{
clearActiveButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBuilderModeRequestedEvent>());
return;
}
if (m_activeIndex)
{
m_buttons[*m_activeIndex]->setChecked(false);
}
m_activeIndex = idx;
m_buttons[idx]->setChecked(true);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildingTypeSelectedEvent>(m_types[idx]));
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/)
{
clearActiveButton();
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
{
updateAffordability();
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const UnlockedBuildingsChangedEvent> /*event*/)
{
updateVisibility();
updateAffordability();
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event)
{
m_deconstructButton->setChecked(event->active);
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event)
{
for (std::size_t i = 0; i < m_types.size(); ++i)
{
if (m_types[i] == event->type)
{
// Equivalent to clicking the build button: a disabled (unaffordable) or
// hidden (locked, REQ-LOCK-BUILDING) button cannot be clicked, so the
// hotkey is likewise inert.
if (m_buttons[i]->isEnabled() && m_buttons[i]->isVisible())
{
onBuildButton(static_cast<int>(i));
}
return;
}
}
}

View File

@@ -6,7 +6,6 @@
#include <string>
#include <vector>
#include <QRect>
#include <QWidget>
#include "BuilderModeExitedEvent.h"
@@ -22,34 +21,25 @@ class QPushButton;
class Simulation;
class ItemIconCache;
// The build menu: one horizontal row of build buttons floating over the game world
// view (REQ-UI-BUILD-BAR). The bar is sized to its buttons; its owner hands it the
// world view's rect through anchorTo() and it centers itself along that rect's
// bottom edge.
class BuildButtonBar : public QWidget,
public CombinedEventHandler<BuilderModeExitedEvent,
DeconstructModeChangedEvent,
BuildHotkeyPressedEvent,
BuildingBlocksChangedEvent,
UnlockedBuildingsChangedEvent>
class BuildButtonGrid : public QWidget,
public CombinedEventHandler<BuilderModeExitedEvent,
DeconstructModeChangedEvent,
BuildHotkeyPressedEvent,
BuildingBlocksChangedEvent,
UnlockedBuildingsChangedEvent>
{
Q_OBJECT
public:
// iconDir is the directory holding the per-building "<id>.svg" chip icons
// (REQ-UI-BUILD-ICON), read from disk at runtime like the config files.
// (REQ-UI-BUILD-GRID), read from disk at runtime like the config files.
// itemIcons is the window-wide per-item icon cache and supplies the
// building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Not
// owned; must outlive this widget.
BuildButtonBar(Simulation* sim, const GameConfig* config,
const std::string& iconDir, ItemIconCache* itemIcons,
QWidget* parent = nullptr);
~BuildButtonBar() override;
// Centers the bar along the bottom edge of the game world view's rect, given in
// the bar's parent coordinates (REQ-UI-BUILD-BAR). The rect is remembered, so a
// re-center later driven by an unlock needs no second call from the owner.
void anchorTo(const QRect& worldViewRect);
BuildButtonGrid(Simulation* sim, const GameConfig* config,
const std::string& iconDir, ItemIconCache* itemIcons,
QWidget* parent = nullptr);
~BuildButtonGrid() override;
void clearActiveButton();
@@ -63,11 +53,6 @@ private:
// unlock state (REQ-LOCK-BUILDING); a locked building type's button is hidden.
void updateVisibility();
// Shrinks the bar to its currently shown buttons and re-centers it in the
// anchored rect (REQ-UI-BUILD-BAR). Does nothing until anchorTo() supplied that
// rect, so the construction-time call is harmless.
void recenter();
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override;
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override;
void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override;
@@ -87,5 +72,4 @@ private:
std::map<BuildingType, int> m_costs;
std::optional<std::size_t> m_activeIndex;
QPushButton* m_deconstructButton;
QRect m_viewRect;
};

View File

@@ -6,11 +6,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
@@ -31,11 +28,8 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,6 @@
#include <QVector2D>
#include "Blueprint.h"
#include "BuildModeController.h"
#include "BuildingConfig.h"
#include "BlueprintModeExitedEvent.h"
#include "BlueprintPlacementRequestedEvent.h"
@@ -32,13 +31,6 @@
#include "EventHandler.h"
#include "ExitBlueprintModeRequestedEvent.h"
#include "ExitBuilderModeRequestedEvent.h"
#include "DebugDrawToggleRequestedEvent.h"
#include "GhostRotationRequestedEvent.h"
#include "InputMapper.h"
#include "ModeCancelRequestedEvent.h"
#include "PanDirectionChangedEvent.h"
#include "PauseToggleRequestedEvent.h"
#include "SpeedStepRequestedEvent.h"
#include "DebugDrawToggledEvent.h"
#include "ArtifactCountChangedEvent.h"
#include "BeamFiredEvent.h"
@@ -52,16 +44,11 @@
#include "CommandManager.h"
#include "EntitySelectionChangedEvent.h"
#include "GameConfig.h"
#include "PlacementRules.h"
#include "Rotation.h"
#include "SelectionController.h"
#include "Tick.h"
#include "TickDriver.h"
#include "TunnelCompletion.h"
#include "VisualsConfig.h"
#include "WorldCamera.h"
#include "WorldCoordinates.h"
#include "WorldRenderer.h"
struct Command;
struct ParsedReplay;
@@ -69,6 +56,19 @@ class ItemIconCache;
class ReplayPlayer;
class Simulation;
class QPainter;
class QSvgRenderer;
struct QPointCompare
{
bool operator()(const QPoint& a, const QPoint& b) const
{
if (a.x() != b.x()) { return a.x() < b.x(); }
return a.y() < b.y();
}
};
// Tunnel entries/exits indexed by their single-cell tile (REQ-BLD-TUNNEL-MODE).
using TunnelTileMap = std::map<QPoint, TunnelTileInfo, QPointCompare>;
class GameWorldView : public QOpenGLWidget,
public CombinedEventHandler<BeamFiredEvent,
@@ -78,12 +78,6 @@ class GameWorldView : public QOpenGLWidget,
BlueprintPlacementRequestedEvent,
ExitBlueprintModeRequestedEvent,
SpeedChangeRequestedEvent,
PanDirectionChangedEvent,
PauseToggleRequestedEvent,
SpeedStepRequestedEvent,
GhostRotationRequestedEvent,
ModeCancelRequestedEvent,
DebugDrawToggleRequestedEvent,
CommandRequestedEvent>
{
Q_OBJECT
@@ -106,14 +100,8 @@ public:
protected:
void initializeGL() override;
void paintGL() override;
// Only forwards to the input mapper; every key this widget acts on reaches it
// as a published action instead (REQ-UI-HOTKEYS).
void keyPressEvent(QKeyEvent* event) override;
void keyReleaseEvent(QKeyEvent* event) override;
// Key-up never arrives for a key that was still held when focus moved away —
// to a modal dialog, another widget, or another window. Any held action would
// otherwise stay held forever, so focus loss drops all of them.
void focusOutEvent(QFocusEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
@@ -129,12 +117,6 @@ private:
void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const PanDirectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const PauseToggleRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const SpeedStepRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const GhostRotationRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const ModeCancelRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const CommandRequestedEvent> event) override;
// Enqueue a sim command onto the CommandManager (the single mutation path).
@@ -147,13 +129,29 @@ private:
// Used to pre-validate placements whose UI follow-up depends on success.
bool canAfford(BuildingType type) const;
// Screen-anchored chrome, drawn after the world (see WorldRenderer): these
// need no world transform, which is exactly why they stayed here.
void drawScreenSpace(QPainter& painter);
// Threat and production readout shown while debug draw is on (F3). Positioned
// in pixels at the top-left corner, so it belongs with the chrome rather than
// with the world.
void drawTiles(QPainter& painter);
void drawPortItems(QPainter& painter);
void drawBuildings(QPainter& painter);
void drawSelectionHighlights(QPainter& painter);
void drawCopyConfigFeedback(QPainter& painter);
void drawStations(QPainter& painter);
void drawBeltItems(QPainter& painter);
// Draws a single item centered at widget-space `center`, spanning `halfPx` in
// each direction (a half-tile). Uses the item's icon when one exists
// (REQ-UI-ITEM-ICON), otherwise falls back to the colored square from
// visuals.toml. Shared by drawBeltItems and drawPortItems.
void drawWorldItem(QPainter& painter, const std::string& itemId,
QPointF center, float halfPx);
void drawDebris(QPainter& painter);
void drawShips(QPainter& painter);
void drawHpBar(QPainter& painter, qreal left, qreal top, qreal width,
float fraction, bool isEnemy);
void drawDebugSensorRanges(QPainter& painter);
void drawDebugTargetLines(QPainter& painter);
void drawDebugOverlay(QPainter& painter);
void drawBeams(QPainter& painter);
void drawOverlays(QPainter& painter);
void drawScreenSpace(QPainter& painter);
// Vignette-style border shown while the game is paused (speed 0x, REQ-UI-PAUSE-BORDER)
// to make the paused state hard to miss: a black frame whose alpha fades from 50% at
// the viewport edges to 0% toward the center.
@@ -168,51 +166,110 @@ private:
void drawVignetteBorder(QPainter& painter, const QColor& edgeColor);
void drawReplayOverlay(QPainter& painter);
// Gathers the interaction state the renderer needs for this frame.
WorldRenderFrame makeRenderFrame() const;
// The world <-> widget transform for the current viewport size and scroll
// position. Cheap to build and deliberately not cached: it is a snapshot that
// a resize or a scroll invalidates, so every user takes a fresh one.
WorldCoordinates getCoordinates() const;
float getTilePx() const;
float getViewportWidthTiles() const;
// World-X (tiles) at the left edge of the viewport. m_scrollXTiles stores the
// view center; this derives the left edge the world<->widget conversions need.
float getViewLeftTiles() const;
QPointF worldToWidget(QVector2D worldPos) const;
QPointF tileToWidget(QPoint tile) const;
QPoint widgetToTile(QPoint widgetPt) const;
QRectF tileRect(QPoint tile) const;
QRect getViewportRect() const;
// Widget-space rectangle covering a building or construction site's footprint,
// or nullopt if the id resolves to neither. Shared by the selection highlight
// and the copy-settings feedback (REQ-BLD-COPY-CONFIG-FEEDBACK).
std::optional<QRectF> footprintWidgetRect(BuildingId id) const;
float getAsteroidLeftEdge() const;
float getEnemyStationRightEdge() const;
// The camera's current pan limits, read fresh from the simulation each frame:
// both edges move with asteroid expansion and with pushes (REQ-GW-SCROLL-LIMIT).
ScrollBounds getScrollBounds() const;
// Horizontal pan speed at a given view-center X, in tiles/s (REQ-UI-SCROLL-SPEED).
float panSpeedTilesPerSecondAt(float viewCenterXTiles) const;
void clampScroll();
// canPlaceBuilding (PlacementRules) against this view's simulation.
bool canPlaceBuildingHere(BuildingType type, QPoint anchor, Rotation rot) const;
bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const;
std::optional<BuildingId> buildingAtTile(QPoint tile) const;
std::optional<BuildingId> siteAtTile(QPoint tile) const;
// Ids of all buildings and construction sites whose footprint intersects
// the tile box spanned by the two (unordered) corner tiles.
std::vector<BuildingId> buildingsInBox(QPoint cornerA, QPoint cornerB) const;
QVector2D widgetToWorld(QPoint widgetPt) const;
void drawPortGlyph(QPainter& painter, QPoint tile,
Rotation direction, const QColor& color,
bool centered);
void drawBuildingGhost(QPainter& painter, BuildingType type,
QPoint anchorTile, Rotation rotation, bool valid,
bool showPortTargetGlyphs);
// Loads the per-building world icons (REQ-UI-WORLD-ICON) from
// <configDir>/../icons/buildings once at construction. Only the building
// types with a world icon are loaded (production buildings, HQ, stations);
// belts, splitters, and tunnels are deliberately excluded so their
// orientation stays readable. The SVG's chip background is stripped; the
// glyph is pre-rendered in both white and dark ink for auto-contrast.
void loadBuildingIcons(const std::string& configDir);
// Draws a building's world icon glyph centered in box, choosing the white or
// dark pre-rendered variant by fill luminance so it stays legible. Returns
// false if the type has no world icon (caller falls back to the text glyph).
bool drawBuildingIcon(QPainter& painter, BuildingType type,
const QRectF& box, const QColor& fill) const;
void placeBlueprintAtTile(QPoint center);
// Drops despawned or fully-collected debris from the selection
// (REQ-UI-DEBRIS-CLICK-SELECT). Called each frame from onFrame().
std::optional<QVector2D> entityPosition(entt::entity entity) const;
// Clears the debris selection, emitting an empty DebrisSelectionChangedEvent when
// it was non-empty (REQ-UI-DEBRIS-CLICK-SELECT). Used when another selection
// category takes over.
void clearDebrisSelection();
// Drops despawned or fully-collected debris from the selection and re-emits
// when it changed (REQ-UI-DEBRIS-CLICK-SELECT). Called each frame from onFrame().
void pruneDespawnedDebris();
// Drops despawned or dead actors from the selection (REQ-UI-ENTITY-CLICK-SELECT).
// Called each frame from onFrame().
// Clears the actor selection, emitting an empty EntitySelectionChangedEvent when it was
// non-empty (REQ-UI-ENTITY-CLICK-SELECT). Used when buildings take over.
void clearEntitySelection();
// Drops despawned or dead actors from the selection and re-emits when it changed
// (REQ-UI-ENTITY-CLICK-SELECT). Called each frame from onFrame().
void pruneDespawnedActors();
// Resolves a click into the selection it should produce, applying the category
// precedence of REQ-UI-SELECTION-CATEGORIES; the controller owns what that then
// does to the existing selection. Returns false when the click hit nothing,
// which is the only case that goes on to start a box drag.
bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive);
void selectInBox(bool additive);
// True if the given actor is part of the current actor selection.
bool isEntitySelected(entt::entity entity) const;
void stepSpeed(int delta);
void placeAtTile(QPoint tile);
// Re-resolves the tunnel ghost type and completion partner from the current
// ghost tile, rotation, and sub-tile cursor position, storing both on the build
// mode controller. Only meaningful in tunnel mode (REQ-BLD-TUNNEL-MODE).
// Unified tunnel build mode (REQ-BLD-TUNNEL-MODE). Active while the builder type
// is TunnelEntry; the ghost then resolves to an entry or exit by hovered position.
bool inTunnelMode() const;
// The building type the ghost currently represents: the position-resolved tunnel
// type in tunnel mode, otherwise the plain builder type.
BuildingType effectiveBuilderType() const;
// Recomputes m_tunnelGhostType and m_tunnelPartnerTile from the current ghost
// tile, rotation, and sub-tile cursor position. Only meaningful in tunnel mode.
void updateTunnelGhost();
// Indexes every tunnel entry/exit — built or still a construction site — by its
// single-cell tile. Shared by the placement preview and the selection highlight.
TunnelTileMap collectTunnelTiles() const;
// Wraps a tunnel tile index in the lookup functor the TunnelCompletion helpers
// take. The returned functor references `tunnels`, which must outlive it.
static TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels);
// Draws the green connection highlight for every selected tunnel end that has a
// matching end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
void drawSelectedTunnelConnections(QPainter& painter);
// Belt drag placement (REQ-BLD-BELT-DRAG).
// Recomputes the drag path from its anchor to cursorTile using the current ghost
// orientation, and stores it on the build mode controller.
// Per-path-tile decision, shared by ghost drawing and release-time placement.
enum class BeltTileAction { PlaceNew, RotateInPlace, Invalid };
struct BeltDragResolved
{
BeltTileAction action;
bool affordable; // meaningful only for PlaceNew
std::optional<BuildingId> rotateId; // set only for RotateInPlace
};
// Recomputes m_beltDragPath from m_beltDragAnchor to cursorTile using the
// current ghost orientation.
void recomputeBeltDragPath(QPoint cursorTile);
// resolveBeltDragPath (PlacementRules) against this view's simulation.
// Classifies each path tile against the current sim state, applying cumulative
// affordability to the PlaceNew tiles.
std::vector<BeltDragResolved> resolveBeltDragPath() const;
// Enqueues placements and rotate-in-place commands for the resolved path.
void applyBeltDragPath();
@@ -223,11 +280,19 @@ private:
void copyConfigFrom(BuildingId id);
void pasteConfigTo(BuildingId id);
// Turns the ghost and refreshes everything that depends on its facing: placement
// validity, the tunnel completion match, and an in-progress belt drag's path.
// The mode transitions themselves live on m_buildMode.
void enterBuilderMode(BuildingType type);
void exitBuilderMode();
void enterBlueprintMode(Blueprint blueprint);
void exitBlueprintMode();
void toggleDeconstructMode();
void rotateGhost(bool clockwise);
struct ActiveBeam
{
BeamFiredEvent event;
QVector2D targetOffset;
};
// Beam lifetime in game ticks so beams freeze with the simulation when
// paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM).
static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3);
@@ -236,6 +301,22 @@ private:
const GameConfig* m_config;
const VisualsConfig* m_visuals;
// World icon glyph renderers per building type (REQ-UI-WORLD-ICON), in a
// white and a dark variant so drawBuildingIcon can auto-contrast against the
// building's fill. Rendered as vector at the view scale each draw so they
// stay crisp. Populated once by loadBuildingIcons().
struct BuildingIconRenderers
{
std::unique_ptr<QSvgRenderer> white;
std::unique_ptr<QSvgRenderer> dark;
};
std::map<BuildingType, BuildingIconRenderers> m_buildingIcons;
// Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by
// MainWindow. Shared draw path for belt and port items; pixmaps are cached
// per target size.
ItemIconCache* m_itemIcons;
// Funnels all player input into the single Simulation::apply chokepoint.
CommandManager m_commandManager;
// A Reset command was enqueued; reset the view after the next drain applies it.
@@ -244,32 +325,40 @@ private:
// live input is ignored (the CommandManager is in replay mode).
std::unique_ptr<ReplayPlayer> m_replayPlayer;
// Draws the world; this widget supplies the interaction state each frame and
// keeps only the screen-anchored chrome for itself.
std::unique_ptr<WorldRenderer> m_renderer;
TickDriver m_tickDriver;
QElapsedTimer m_frameTimer;
std::mt19937 m_rng;
double m_gameSpeedMultiplier;
double m_prevNonZeroSpeed;
// Horizontal view position (REQ-UI-SCROLL). Owns the scroll position itself;
// this widget only supplies the pan intent and the simulation-derived bounds.
WorldCamera m_camera;
// World-X (tiles) at the center of the viewport (see getViewLeftTiles()).
float m_scrollXTiles;
QTimer* m_renderTimer;
std::vector<ActiveBeam> m_activeBeams;
// The active build mode (builder / blueprint / deconstruct, or none) and the
// ghost, tunnel and belt-drag state belonging to it. Owns the transitions
// between them; this widget supplies the parts that need the simulation.
BuildModeController m_buildMode;
std::optional<BuildingType> m_builderType;
Rotation m_ghostRotation;
QPoint m_ghostTile;
bool m_ghostValid;
// Unified tunnel build mode (REQ-BLD-TUNNEL-MODE): while m_builderType is
// TunnelEntry the ghost resolves to an entry or an exit based on the hovered
// position; m_tunnelPartnerTile is the existing tunnel it would complete (drawn
// as the green connection preview), or unset when there is no completion match.
BuildingType m_tunnelGhostType = BuildingType::TunnelEntry;
std::optional<QPoint> m_tunnelPartnerTile;
// Deferred belt drag placement (REQ-BLD-BELT-DRAG): while dragging, the
// rectilinear anchor->cursor path is recomputed on each move and only applied
// on release. Empty unless a belt drag is in progress.
std::vector<BeltPathTile> m_beltDragPath;
QPoint m_beltDragAnchor;
// Last known cursor position in world (tile) units; used to pick the belt-drag
// end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG)
// and to resolve the tunnel ghost sub-tile (REQ-BLD-TUNNEL-MODE).
// end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG).
QVector2D m_cursorWorldPos;
bool m_dragging;
std::optional<Blueprint> m_blueprintMode;
QPoint m_blueprintGhostTile;
// Temporary cache for the copy-settings gesture (REQ-BLD-COPY-CONFIG); held
// only while Shift is down and cleared on Shift release.
@@ -279,29 +368,27 @@ private:
// pasted onto it (REQ-BLD-COPY-CONFIG-FEEDBACK). remainingMs counts down in
// wall-clock time so the flash plays at a fixed length regardless of game speed
// (and while paused).
struct CopyConfigFlash
{
BuildingId id;
qint64 remainingMs;
};
std::vector<CopyConfigFlash> m_copyConfigFlashes;
static constexpr qint64 kCopyFlashDurationMs = 300;
bool m_deconstructMode;
std::optional<BuildingId> m_deconstructHoverBuildingId;
bool m_debugDraw;
// Owns the selection across all three categories and the rules for moving
// between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change
// events. This widget only resolves what was hit.
SelectionController m_selection;
std::vector<BuildingId> m_selectedBuildingIds;
std::vector<entt::entity> m_selectedEntities;
std::vector<entt::entity> m_selectedDebris;
bool m_boxSelecting;
QPoint m_boxStartTile;
QPoint m_boxCurrentTile;
// Interprets this widget's key events into semantic actions and publishes them
// (REQ-UI-HOTKEYS). Owned here for now because this is the widget that holds
// focus; everything it produces travels by event, so it can move to a
// window-wide owner later without touching its consumers.
InputMapper m_inputMapper;
// Latest pan direction published by the input mapper (REQ-UI-SCROLL), fed to
// the camera each frame. Cached from the event payload rather than re-read:
// input has no other source of truth (see PanDirectionChangedEvent).
PanDirection m_panDirection = PanDirection::None;
bool m_scrollLeft;
bool m_scrollRight;
bool m_gameOverShown;
bool m_winShown;
bool m_schematicChoiceShown;

View File

@@ -21,6 +21,13 @@
#include "SpeedChangeRequestedEvent.h"
#include "Tick.h"
namespace
{
// Item id of the building blocks resource, whose icon stands in for the "Blocks"
// word in the header stock and expand button (REQ-UI-BLOCKS-ICON).
const char* const kBlockItemId = "building_block";
}
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
const int HeaderBar::kSpeedCount = 5;

View File

@@ -14,9 +14,3 @@
// theme-correct; the result is devicePixelRatio-aware so text and icon stay crisp.
QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
const QFont& font, const QColor& textColor);
// Item id of the building blocks resource, whose icon stands in for the "Blocks"
// word wherever a cost or stock is captioned (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST,
// REQ-UI-EXPAND-BUTTON). It lives next to the caption helper because every caller of
// one is a caller of the other.
const char* const kBlockItemId = "building_block";

View File

@@ -1,195 +0,0 @@
#include "InputMapper.h"
#include <memory>
#include <optional>
#include <QKeyEvent>
#include "BuildHotkeyPressedEvent.h"
#include "BuildingType.h"
#include "DebugDrawToggleRequestedEvent.h"
#include "EscapeMenuRequestedEvent.h"
#include "EventManager.h"
#include "GhostRotationRequestedEvent.h"
#include "ModeCancelRequestedEvent.h"
#include "PanDirectionChangedEvent.h"
#include "PauseToggleRequestedEvent.h"
#include "SpeedStepRequestedEvent.h"
#include "TemporaryBlueprintRequestedEvent.h"
#include "TracePrintRequestedEvent.h"
namespace
{
// The building type a number-key build hotkey selects, or nullopt for the digits
// that are unbound (REQ-UI-HOTKEYS). Plain digits pick the transport buildings,
// Shift+digit the production ones.
std::optional<BuildingType> buildHotkeyType(int digit, bool shiftHeld)
{
if (!shiftHeld)
{
switch (digit)
{
case 1: return BuildingType::Belt;
case 2: return BuildingType::Splitter;
case 3: return BuildingType::TunnelEntry; // unified tunnel mode (REQ-BLD-TUNNEL-MODE); 4 unused
}
return std::nullopt;
}
switch (digit)
{
case 1: return BuildingType::Miner;
case 2: return BuildingType::Smelter;
case 3: return BuildingType::Assembler;
case 4: return BuildingType::Shipyard;
case 5: return BuildingType::SalvageBay;
case 6: return BuildingType::ReprocessingPlant;
}
return std::nullopt;
}
} // namespace
QString InputMapper::getBuildHotkeyLabel(BuildingType type)
{
// Searched out of the binding table above rather than spelled out a second time,
// so a badge can never claim a key the handler does not act on. The shift glyph
// is written as a code point because the sources are not guaranteed to be read
// as UTF-8 by every compiler this builds with. A plain stroke arrow rather than
// U+21E7 UPWARDS WHITE ARROW: the standard UI fonts do not all carry the outlined
// shift glyph, and the substitute they fall back to is unreadable at badge size.
const QChar shiftGlyph(0x2191); // U+2191 UPWARDS ARROW
for (int digit = 1; digit <= 9; ++digit)
{
if (buildHotkeyType(digit, false) == type)
{
return QString::number(digit);
}
if (buildHotkeyType(digit, true) == type)
{
return shiftGlyph + QString::number(digit);
}
}
return QString();
}
bool InputMapper::handleKeyPress(QKeyEvent* event)
{
// Auto-repeat says nothing new about which keys are down, and a held action is
// already held.
if (event->isAutoRepeat()) { return false; }
// Number-key build-mode hotkeys (REQ-UI-HOTKEYS). nativeVirtualKey gives the
// physical digit independent of keyboard layout and Shift (with Shift held, key()
// for the number row can arrive as Key_Exclam etc.). VK_1..VK_9 = 0x31..0x39.
const quint32 virtualKey = event->nativeVirtualKey();
if (virtualKey >= 0x31 && virtualKey <= 0x39)
{
const std::optional<BuildingType> type = buildHotkeyType(
static_cast<int>(virtualKey - 0x30),
(event->modifiers() & Qt::ShiftModifier) != 0);
if (type.has_value())
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildHotkeyPressedEvent>(*type));
return true;
}
}
switch (event->key())
{
case Qt::Key_A:
m_panLeftHeld = true;
updatePanDirection();
return true;
case Qt::Key_D:
m_panRightHeld = true;
updatePanDirection();
return true;
case Qt::Key_Space:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<PauseToggleRequestedEvent>());
return true;
case Qt::Key_W:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedStepRequestedEvent>(+1));
return true;
case Qt::Key_S:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedStepRequestedEvent>(-1));
return true;
case Qt::Key_R:
// Shift reverses the rotation direction (REQ-BLD-ROTATE).
EventManager::getInstance()->sendEventImmediately(
std::make_shared<GhostRotationRequestedEvent>(
(event->modifiers() & Qt::ShiftModifier) != 0));
return true;
case Qt::Key_Q:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ModeCancelRequestedEvent>());
return true;
case Qt::Key_T:
// Request a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP).
// The BlueprintPanel owns the selection and blueprint-capture logic; it decides
// whether anything placeable is selected and drives placement mode from there.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintRequestedEvent>());
return true;
case Qt::Key_F3:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebugDrawToggleRequestedEvent>());
return true;
case Qt::Key_Escape:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EscapeMenuRequestedEvent>());
return true;
case Qt::Key_F4:
EventManager::getInstance()->addEvent(
std::make_shared<TracePrintRequestedEvent>());
return true;
default:
return false;
}
}
bool InputMapper::handleKeyRelease(QKeyEvent* event)
{
if (event->isAutoRepeat()) { return false; }
switch (event->key())
{
case Qt::Key_A:
m_panLeftHeld = false;
updatePanDirection();
return true;
case Qt::Key_D:
m_panRightHeld = false;
updatePanDirection();
return true;
default:
return false;
}
}
void InputMapper::releaseAll()
{
m_panLeftHeld = false;
m_panRightHeld = false;
updatePanDirection();
}
void InputMapper::updatePanDirection()
{
// Holding both keys cancels out rather than favouring one.
PanDirection direction = PanDirection::None;
if (m_panLeftHeld != m_panRightHeld)
{
direction = m_panLeftHeld ? PanDirection::Left : PanDirection::Right;
}
if (direction == m_panDirection) { return; }
m_panDirection = direction;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<PanDirectionChangedEvent>(direction));
}

View File

@@ -1,53 +0,0 @@
#pragma once
#include <QString>
#include "BuildingType.h"
#include "WorldCamera.h"
class QKeyEvent;
// Turns raw key events into the game's semantic actions and publishes them
// (REQ-UI-HOTKEYS). Widgets react to the action, never to the key, so the two can
// be rebound independently later; the bindings themselves are still hard-coded
// here for now.
//
// Two output shapes, chosen by the nature of the action rather than by taste:
//
// * Discrete actions — one press, one thing happens — fire an event as they
// always have.
// * Continuous actions, currently just panning, are held state. They also travel
// as events, but level-triggered ones carrying the complete current value (see
// PanDirectionChangedEvent), so a receiver never has to reconstruct state from
// edges.
//
// Holding the state here rather than in the widgets is what makes releaseAll()
// possible: one call clears every held action at once, which is how a lost key-up
// (focus moving to a modal mid-pan) stops being a stuck input.
class InputMapper
{
public:
// The build hotkey that selects the given building type, spelled for display on
// its build button's badge (REQ-UI-BUILD-COST): "1" for a plain digit, "⇧1" for
// a Shift+digit, and an empty string for a building type with no build hotkey.
// Lives here so the badge and the key handling read the same binding table.
static QString getBuildHotkeyLabel(BuildingType type);
// Both return true when the key was consumed; the caller passes anything else
// on to its base class so unrelated shortcuts keep working.
bool handleKeyPress(QKeyEvent* event);
bool handleKeyRelease(QKeyEvent* event);
// Drops all held-key state, publishing the resulting change. Call when the
// receiving widget can no longer expect key-up events.
void releaseAll();
private:
// Recomputes the pan direction from the held keys and publishes it if it
// changed. Holding both keys cancels out.
void updatePanDirection();
bool m_panLeftHeld = false;
bool m_panRightHeld = false;
PanDirection m_panDirection = PanDirection::None;
};

View File

@@ -15,7 +15,7 @@
#include <QVBoxLayout>
#include "BlueprintPanel.h"
#include "BuildButtonBar.h"
#include "BuildButtonGrid.h"
#include "BuildingSystem.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
@@ -58,43 +58,36 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
m_itemIcons.get(), m_replay.get(), this);
// Building icons live alongside the config (a sibling of the config dir), read
// from disk at runtime the same way visuals.toml is.
const std::string iconDir = QDir::cleanPath(
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
// Floats over the game world rather than living in the side panel column
// (REQ-UI-BUILD-BAR). Creation order is the stacking order for siblings, so
// building it after the world view puts it above the world and its vignettes,
// and before the dim overlay keeps modals dimming it too (REQ-UI-MODAL-DIM).
// Its geometry comes from layoutPanels().
m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(), iconDir,
m_itemIcons.get(), this);
m_sidePanel = new QWidget(this);
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
sideLayout->setContentsMargins(1, 1, 1, 1);
sideLayout->setSpacing(1);
// Building icons live alongside the config (a sibling of the config dir), read
// from disk at runtime the same way visuals.toml is.
const std::string iconDir = QDir::cleanPath(
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), iconDir, m_itemIcons.get(), m_sidePanel);
m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
// Equal stretch gives the two panels half the column height each
// (REQ-UI-PANEL-COLUMN).
sideLayout->addWidget(m_selectedBuildingPanel, 1);
sideLayout->addWidget(m_buildButtonGrid, 1);
sideLayout->addWidget(m_blueprintPanel, 1);
// Draw a thin border around each of the two side-panel sections. The class
// Draw a thin border around each of the three side-panel sections. The class
// scoped selectors keep the border on the panels themselves rather than
// cascading onto their child widgets; WA_StyledBackground lets the plain
// QWidget subclasses honor the stylesheet box (border/background).
for (QWidget* panel : { static_cast<QWidget*>(m_selectedBuildingPanel),
static_cast<QWidget*>(m_buildButtonGrid),
static_cast<QWidget*>(m_blueprintPanel) })
{
panel->setAttribute(Qt::WA_StyledBackground, true);
}
m_sidePanel->setStyleSheet(QStringLiteral(
"SelectedBuildingPanel, BlueprintPanel {"
"SelectedBuildingPanel, BuildButtonGrid, BlueprintPanel {"
" border: 1px solid palette(mid); }"));
// Created last so it stacks above the other children; covers the whole window and
@@ -171,9 +164,6 @@ void MainWindow::layoutPanels()
m_headerBar->setGeometry(0, 0, mainW, headerH);
m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH);
m_sidePanel->setGeometry(mainW, 0, sideW, totalH);
// Sizes itself to its buttons and centers along the bottom of the world view
// (REQ-UI-BUILD-BAR).
m_buildButtonBar->anchorTo(QRect(0, headerH, mainW, totalH - headerH));
m_dimOverlay->setGeometry(0, 0, totalW, totalH);
}

View File

@@ -27,7 +27,7 @@ class Simulation;
class GameWorldView;
class HeaderBar;
class SelectedBuildingPanel;
class BuildButtonBar;
class BuildButtonGrid;
class BlueprintPanel;
class ItemIconCache;
class QCloseEvent;
@@ -78,12 +78,12 @@ private:
VisualsConfig m_visuals;
Simulation* m_sim;
// One per-item icon cache for the whole window (REQ-UI-ITEM-ICON): the header,
// build bar, world view, and recipe dialog all rasterize the same SVGs.
// build grid, world view, and recipe dialog all rasterize the same SVGs.
std::unique_ptr<ItemIconCache> m_itemIcons;
GameWorldView* m_gameWorldView;
HeaderBar* m_headerBar;
SelectedBuildingPanel* m_selectedBuildingPanel;
BuildButtonBar* m_buildButtonBar;
BuildButtonGrid* m_buildButtonGrid;
BlueprintPanel* m_blueprintPanel;
QWidget* m_sidePanel;
ModalDimOverlay* m_dimOverlay = nullptr;

View File

@@ -1,101 +0,0 @@
#include "WorldPrimitives.h"
#include <algorithm>
#include <cmath>
#include <QPainter>
#include <QPen>
#include <QPolygonF>
#include <QRectF>
#include <QVector2D>
namespace
{
// Ship triangle proportions, as fractions of a tile: the nose reaches further
// than the tail corners spread, so the facing direction reads at a glance.
const float kShipForwardTileFactor = 0.45f;
const float kShipSideTileFactor = 0.25f;
const float kDebrisRadiusTileFactor = 0.2f;
// Health bar height as a fraction of a tile.
const qreal kHealthBarHeightTileFactor = 0.12;
const QColor kHealthBarTrack (60, 60, 60);
const QColor kHealthBarEnemy (200, 60, 60);
const QColor kHealthBarPlayer (60, 200, 60);
const QColor kDebrisFill (128, 110, 90);
const QColor kDebrisOutline(50, 40, 30);
// Sensor circles are drawn faint so a crowded field stays readable.
const int kSensorRangeAlpha = 77;
}
float getShipForwardExtentPx(const WorldCoordinates& coordinates)
{
return coordinates.getTilePx() * kShipForwardTileFactor;
}
float getDebrisRadiusPx(const WorldCoordinates& coordinates)
{
return coordinates.getTilePx() * kDebrisRadiusTileFactor;
}
void drawShipBody(QPainter& painter, const WorldCoordinates& coordinates,
QPointF center, float facing_radians,
const QColor& fill, const QColor& outline)
{
const QVector2D direction(std::cos(facing_radians), std::sin(facing_radians));
const QVector2D perpendicular(-direction.y(), direction.x());
const float forward = getShipForwardExtentPx(coordinates);
const float side = coordinates.getTilePx() * kShipSideTileFactor;
QPolygonF triangle;
triangle
<< QPointF(center.x() + static_cast<qreal>(direction.x() * forward),
center.y() + static_cast<qreal>(direction.y() * forward))
<< QPointF(center.x() + static_cast<qreal>(perpendicular.x() * side - direction.x() * side),
center.y() + static_cast<qreal>(perpendicular.y() * side - direction.y() * side))
<< QPointF(center.x() + static_cast<qreal>(-perpendicular.x() * side - direction.x() * side),
center.y() + static_cast<qreal>(-perpendicular.y() * side - direction.y() * side));
painter.setPen(QPen(outline, 1));
painter.setBrush(fill);
painter.drawPolygon(triangle);
}
void drawHealthBar(QPainter& painter, const WorldCoordinates& coordinates,
qreal left, qreal top, qreal width, float fraction, bool isEnemy)
{
const qreal height = static_cast<qreal>(coordinates.getTilePx())
* kHealthBarHeightTileFactor;
const float clamped = std::max(0.0f, fraction);
painter.fillRect(QRectF(left, top, width, height), kHealthBarTrack);
painter.fillRect(QRectF(left, top, width * static_cast<qreal>(clamped), height),
isEnemy ? kHealthBarEnemy : kHealthBarPlayer);
}
void drawDebrisMarker(QPainter& painter, const WorldCoordinates& coordinates,
QPointF center)
{
const qreal radius = static_cast<qreal>(getDebrisRadiusPx(coordinates));
painter.setBrush(kDebrisFill);
painter.setPen(QPen(kDebrisOutline, 1));
painter.drawEllipse(center, radius, radius);
}
void drawSensorRange(QPainter& painter, const WorldCoordinates& coordinates,
QPointF center, float range_tiles, const QColor& shipOutline)
{
const qreal radius = static_cast<qreal>(range_tiles)
* static_cast<qreal>(coordinates.getTilePx());
QColor circleColor = shipOutline;
circleColor.setAlpha(kSensorRangeAlpha);
painter.setPen(QPen(circleColor, 1));
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(center, radius, radius);
}

View File

@@ -1,50 +0,0 @@
#pragma once
#include <QColor>
#include <QPointF>
#include "WorldCoordinates.h"
class QPainter;
// The handful of world-space shapes the game view and the balancing tool's arena
// view draw identically: a ship, its health bar, a piece of debris, a sensor range.
//
// Shared because the arena exists to eyeball combat, which only works while a ship
// there looks like a ship in the game — if these drift, the balancing tool quietly
// stops representing what it is measuring. Deliberately kept to shapes that are
// genuinely the same in both: the two views differ on selection highlights, beams
// and target lines, and those stay with each view.
//
// Free functions taking explicit values, with no state and no simulation: each
// view keeps its own iteration and layer order. Sizes derive from the tile size so
// everything scales with the view (REQ-GW-TILE-SIZE).
// Distance from a ship's centre to its nose, in pixels. The selection ring and the
// health bar are positioned from this, so callers need it whether or not they are
// the ones drawing the body.
float getShipForwardExtentPx(const WorldCoordinates& coordinates);
// Radius of a piece of debris, in pixels. Shared so the selection ring around
// debris cannot drift from the debris it is meant to circle.
float getDebrisRadiusPx(const WorldCoordinates& coordinates);
// Draws a ship as a triangle at `center` (widget space), pointing along
// `facing_radians`.
void drawShipBody(QPainter& painter, const WorldCoordinates& coordinates,
QPointF center, float facing_radians,
const QColor& fill, const QColor& outline);
// Draws a health bar: a dark track with `fraction` of it filled, red for an enemy
// and green for the player. A negative fraction is clamped to empty.
void drawHealthBar(QPainter& painter, const WorldCoordinates& coordinates,
qreal left, qreal top, qreal width, float fraction, bool isEnemy);
// Draws a piece of debris as a small brown circle at `center` (widget space).
void drawDebrisMarker(QPainter& painter, const WorldCoordinates& coordinates,
QPointF center);
// Draws a ship's sensor range as a faint circle in the ship's own outline colour,
// so overlapping ranges stay distinguishable.
void drawSensorRange(QPainter& painter, const WorldCoordinates& coordinates,
QPointF center, float range_tiles, const QColor& shipOutline);

File diff suppressed because it is too large Load Diff

View File

@@ -1,175 +0,0 @@
#pragma once
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include <QColor>
#include <QPoint>
#include <QPointF>
#include <QRectF>
#include <QVector2D>
#include "BeamFiredEvent.h"
#include "BuildModeController.h"
#include "BuildingConfig.h"
#include "BuildingId.h"
#include "BuildingType.h"
#include "Rotation.h"
#include "SelectionController.h"
#include "VisualsConfig.h"
#include "WorldCoordinates.h"
class ItemIconCache;
class Simulation;
class QPainter;
class QSvgRenderer;
// A beam still being drawn. Lifetime is counted in game ticks so beams freeze with
// the simulation when it is paused or slowed (REQ-SHP-FIRING-BEAM); the view owns
// the ageing, the renderer only draws what it is given.
struct ActiveBeam
{
BeamFiredEvent event;
QVector2D targetOffset;
};
// Brief outline flash shown on a building when settings are copied from it or
// pasted onto it (REQ-BLD-COPY-CONFIG-FEEDBACK). remainingMs counts down in
// wall-clock time so the flash plays at a fixed length regardless of game speed
// (and while paused).
struct CopyConfigFlash
{
BuildingId id;
qint64 remainingMs;
};
// Everything the renderer draws that the simulation does not know about: what the
// player has selected, which build mode is active, and the other transient bits of
// interaction state the view owns. Assembled fresh each frame and passed by
// reference, so the renderer holds no copy that could go stale.
struct WorldRenderFrame
{
const SelectionController& selection;
const BuildModeController& buildMode;
const std::vector<ActiveBeam>& beams;
const std::optional<BuildingConfig>& copiedConfig;
const std::vector<CopyConfigFlash>& copyConfigFlashes;
bool isBoxSelecting;
QPoint boxStartTile;
QPoint boxCurrentTile;
bool isDebugDrawEnabled;
};
// Draws the game world: terrain, buildings, items, ships, effects and the build
// overlays, in back-to-front order (see the Layer Order section of
// docs/architecture.md).
//
// Reads the simulation and never writes it, and knows nothing about input — the
// view resolves clicks and owns the interaction state, and hands the parts the
// renderer needs over in a WorldRenderFrame.
//
// Everything here is positioned in tiles. Screen-anchored chrome — the pause and
// deconstruct vignettes, the replay overlay, the debug stats panel — stays with
// the view, which is why nothing in this class draws translatable text.
class WorldRenderer
{
public:
// `itemIcons` is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); not
// owned, must outlive this renderer. `configDir` is used once, to load the
// per-building world icons.
WorldRenderer(Simulation& sim, const VisualsConfig& visuals,
ItemIconCache* itemIcons, const std::string& configDir);
~WorldRenderer();
void render(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
private:
void drawTiles(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawBuildings(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawSelectionHighlights(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawCopyConfigFeedback(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawPortItems(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawStations(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawBeltItems(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawDebris(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawShips(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawBeams(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawSelectedTunnelConnections(QPainter& painter,
const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawOverlays(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
// Draws a single item centered at widget-space `center`, spanning `halfPx` in
// each direction (a half-tile). Uses the item's icon when one exists
// (REQ-UI-ITEM-ICON), otherwise falls back to the colored square from
// visuals.toml. Shared by drawBeltItems and drawPortItems.
void drawWorldItem(QPainter& painter, const std::string& itemId,
QPointF center, float halfPx);
void drawPortGlyph(QPainter& painter, const WorldCoordinates& coordinates,
QPoint tile, Rotation direction, const QColor& color,
bool centered);
void drawBuildingGhost(QPainter& painter, const WorldCoordinates& coordinates,
BuildingType type, QPoint anchorTile, Rotation rotation,
bool valid, bool showPortTargetGlyphs);
// Loads the per-building world icons (REQ-UI-WORLD-ICON) from
// <configDir>/../icons/buildings once at construction. Only the building
// types with a world icon are loaded (production buildings, HQ, stations);
// belts, splitters, and tunnels are deliberately excluded so their
// orientation stays readable. The SVG's chip background is stripped; the
// glyph is pre-rendered in both white and dark ink for auto-contrast.
void loadBuildingIcons(const std::string& configDir);
// Draws a building's world icon glyph centered in box, choosing the white or
// dark pre-rendered variant by fill luminance so it stays legible. Returns
// false if the type has no world icon (caller falls back to the text glyph).
bool drawBuildingIcon(QPainter& painter, const WorldCoordinates& coordinates,
BuildingType type, const QRectF& box,
const QColor& fill) const;
// Widget-space rectangle covering a building or construction site's footprint,
// or nullopt if the id resolves to neither. Shared by the selection highlight
// and the copy-settings feedback (REQ-BLD-COPY-CONFIG-FEEDBACK).
std::optional<QRectF> footprintWidgetRect(const WorldCoordinates& coordinates,
BuildingId id) const;
std::optional<QVector2D> entityPosition(entt::entity entity) const;
// Non-const only because EntityAdmin's component accessors are; the renderer
// reads the simulation and never writes it.
Simulation& m_sim;
const VisualsConfig& m_visuals;
// Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by
// MainWindow. Shared draw path for belt and port items; pixmaps are cached
// per target size.
ItemIconCache* m_itemIcons;
// World icon glyph renderers per building type (REQ-UI-WORLD-ICON), in a
// white and a dark variant so drawBuildingIcon can auto-contrast against the
// building's fill. Rendered as vector at the view scale each draw so they
// stay crisp. Populated once by loadBuildingIcons().
struct BuildingIconRenderers
{
std::unique_ptr<QSvgRenderer> white;
std::unique_ptr<QSvgRenderer> dark;
};
std::map<BuildingType, BuildingIconRenderers> m_buildingIcons;
};