25 Commits

Author SHA1 Message Date
099d614395 show a tooltip on click, and keep it up until the pointer leaves
One tooltip mechanism for the whole UI: a Tooltip popup plus a
TooltipTrigger event filter, replacing every setToolTip call. Qt's own
tooltip cannot do what REQ-UI-TOOLTIP-TRIGGER and REQ-UI-TOOLTIP-DISMISS
ask for -- it times out, hides on the first mouse move, cannot be hovered
and cannot be brought up by a click.

A tooltip now appears the moment an element with no click action of its
own is clicked, and it is placed with its top-left corner on the pointer,
so the pointer can move onto the tooltip and hold it open. The pointer is
polled while a tooltip is up rather than tracked through enter and leave
events, whose order depends on which widget the pointer crosses first.

The item chip's children become transparent to the mouse. They were
taking the chip's enter and leave events, so its tooltip only ever
appeared when the pointer rested on the chip's padding.

The modal header's close button loses its "Close" tooltip: a close button
in a header says that by being one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 14:52:02 +02:00
1554d99739 show an icon's tooltip the moment it is clicked
An icon that displays a value and does nothing when clicked is free to use
the click for "tell me what this is", which beats waiting out the hover
delay and is the obvious thing to try. Buttons whose click already acts
keep hover only, so one gesture never both acts and explains.

Tooltips also stop timing out and become hoverable themselves, so a
tooltip listing several recipe lines can be read at any pace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 14:05:23 +02:00
0e64f45a5e forward-declare GameConfig as the struct it is
Command.h announced it as a class while GameConfig.h defines a struct, which
MSVC reports as C4099 in every translation unit that sees both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 12:31:10 +02:00
fb65cbabf4 close a dialog when the player clicks beside it
REQ-UI-DIALOG-DISMISS asks that a dialog be put away by clicking away from
it, the gesture that already puts the selection panel away. The layer is
the widget those clicks land on, so this is where it belongs -- and the
three dialogs that take the gesture already say so through isDismissible(),
which Q has been using since the base class arrived.

A left press and a left release must both land beyond the open modal's
rectangle. Where the click landed is what decides, never that the layer
received it: a click on an inert part of a dialog -- a label, the gap
between two controls -- propagates up from the widget that ignored it and
arrives here with a position inside the modal, which is not a dismissal. A
drag begun inside the dialog never reaches the layer at all, the widget it
began on keeping the release, so no gesture ends by discarding the dialog it
was made in.

Every click on the layer is consumed either way, dismissing or not: the
window behind a modal takes no input, and closing the modal does not turn
that click into one for whatever lies under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 12:16:57 +02:00
9b88deff69 give the escape menu and the name prompts the game's own frame
The last dialogs wearing system chrome were the three QMessageBoxes -- the
escape menu, the game-over and win screens -- and the two QInputDialogs that
name a blueprint. They are the ones the player meets at the sharpest moments
of a run, and they looked like alerts from the operating system.

MessageDialog and NameInputDialog replace them on the layer. The message
dialog names its buttons by the index addButton hands back and reports which
was clicked, with Escape standing for a button the caller nominates rather
than for a dismissal of its own -- Continue in the escape menu, as it was
explicitly set to before, and Quit on the two state screens, which is where
the reject role sent it. Neither dialog takes Q or a click outside: every
button is a decision, and a half-typed name is work in progress
(REQ-UI-DIALOG-DISMISS).

Two things the layer needed for the nested case: it now tracks the scroll
area each modal is shown in, so a modal opened with no anchor centers on the
one it was opened from rather than on the window -- which is where the
Create Blueprint prompt belongs (REQ-UI-PANEL-MODAL) -- and findFor() walks
a widget's parents to the layer, so the blueprint panel buried in the layout
dialog can open a modal without every widget in between carrying a pointer.

The three error boxes stay system dialogs on purpose: config load, config
reload, and blueprint file load all report a failure that may leave nothing
to draw on (REQ-UI-MODAL-CHROME).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 12:14:31 +02:00
f03003194f host the modals in the window instead of in windows of their own
Every dialog was an OS window with a dim widget behind it in the game
window. That is why a click beside one could not reach it: Qt drops mouse
events for a window a modal blocks, so the dim -- a child of the blocked
window -- never saw the press.

ModalLayer is that dim grown up: still a child of the main window covering
its rect, but it now hosts the open modal, places it, and runs its event
loop, so it is the widget the clicks beside a modal land on. It keeps a
stack, paints one dim however many modals are open, and a hold keeps it up
while one modal hands over to the next. ModalDimOverlay and ModalDimScope
are gone; ModalLayerHold replaces the scope at the sites that still open a
system message box.

ModalDialog is what a dialog inherits in place of the window it lost: the
panel background, the drawn header with its optional close button, and the
dismissal gestures. isDismissible() governs Q and, next, the click outside
-- one predicate because both gestures reach the same dialogs. Its default
refuses, so a dialog opts in. requestDismiss() is what a gesture asks for,
and ShipLayoutDialog overrides it with the one-step ladder its Q handler
used to spell out, which is now reached by both. DialogDismiss.h folded into
the base.

The four dialogs the player meets keep their contents unchanged and lose
their title bars: the recipe/schematic selection, blueprint selection, ship
layout, and schematic choice dialogs. Placement moved with them --
placeOnSelectionPanel became getSelectionPanelAnchor, and the layer centers
on that rectangle in window coordinates, with no global mapping and no
frame height to guess at.

Two things followed from dropping OS modality: the focus guard that hands
focus back to the game world now also asks the layer whether a modal is
open, and closeEvent refuses to close the window while one is, since its
nested loop runs over widgets the window owns.

The escape menu, the game-over and win screens, and the two name dialogs are
still system dialogs; they are dimmed by a layer hold until they are
converted next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 12:07:25 +02:00
7327343b2a draw every modal the player meets while playing
The dialogs were OS windows with a dim widget behind them, which is why a
click beside one could not reach them: Qt drops mouse events for a window a
modal blocks, so the dim -- a child of the game window -- never sees the
press that REQ-UI-DIALOG-DISMISS now asks it to act on.

REQ-UI-MODAL-CHROME states the rule the fix follows: a modal is drawn by the
game on the dim, with no title bar, no window border, no window-manager
close, and no way for the player to move it, resize it, or drag it off the
window. Each draws its own header, as the blueprint selection dialog already
does; content too large for the window scrolls inside the modal instead of
hanging past the edge. The exception is failure reporting -- a config or
blueprint file that will not load is still a system message box, since it
must reach the player when there is nothing left to draw on.

The requirements that described the old chrome follow: the blueprint dialog
loses its window-manager close, the escape menu, game-over and win screens
and both name dialogs say they are drawn, and REQ-UI-MODAL-DIM says the dim
is the surface a modal sits on rather than a layer kept in step with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 11:54:54 +02:00
da3f417464 let a click outside a dialog dismiss it
A dialog was left by Q, Escape, or its own Cancel; a click beside it did
nothing, though that is exactly the gesture that already puts the selection
panel away. REQ-UI-DIALOG-DISMISS now covers both ways out, for the same
three dialogs and to the same effect, and the layout configuration dialog
backs out one step at a time under the click as it does under Q -- so a
mis-aimed click during module placement costs a re-click, not the session.

Three things the bare "clicks outside" left open are settled: the press and
the release must both land outside, so a drag begun in the dialog cannot end
by discarding it; the click is spent on the dismissal and does not reach what
it landed on; and the name-entry dialogs refuse it as they refuse Q, a
half-typed name being work in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 09:29:02 +02:00
01aa1a08b0 stop the layout dialog opening for a shipyard with no schematic
Clearing a shipyard with "(None)" opened the layout configuration dialog on a
grid of no cells. The auto-open guard asked only whether the chosen id differs
from the current one, and the "(None)" option carries the empty id, which
differs from every schematic; ShipLayoutDialog then found no ship def and
derived a 0x0 grid. Predates this branch -- 698dd4d, 2026-07-13.

The question all three sites were answering by hand is now one:
findLayoutShipDef() returns the ship to configure a layout against, or nullptr
when no schematic is set, the id names no ship, or the ship defines no grid.
The auto-open path and the LayoutDialogRequestedEvent handler now ask it
before opening, and ShipyardContent asks it instead of spelling the same test
out for the preview and the Configure button.

The event handler was reachable only through a button ShipyardContent already
disables, so guarding it changes nothing today; it is guarded because the
dialog's precondition belongs to the dialog's entry, not to the widget that
happens to be the only caller.

REQ-MOD-UI-AUTO-DIALOG said "differs" and left clearing implicit, which is the
reading the code took. It now says clearing opens nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 22:50:14 +02:00
5968e5f40a dismiss a dialog with Q, and refuse to dismiss the drop dialog
Three dialogs take Q as a second way out beside Escape: the recipe/schematic
selection dialog and the blueprint selection dialog close outright, and the
layout configuration dialog steps out one level per press -- the module being
placed, then remove mode, then the session. Both of those mode exits now go
through the handler the Remove button uses, extracted from a lambda into
onRemoveButtonClicked(), so the key and the button cannot leave different
state behind.

The schematic choice dialog goes the other way and declines reject(). It had
no close button but Escape still closed it, and the caller then applied
choiceIndex 0 -- awarding whichever option happened to be first. Refusing
reject() covers Escape, Alt+F4, and the window manager together, since all
three funnel through it. It is also the only dialog whose dismissal would
strand state: the poll that opened it does not reopen it while the choices
stay pending, so a drop dismissed is a drop lost.

The key itself is spelled once in DialogDismiss.h rather than in three key
handlers. It stays out of the ControlAction table on purpose: that table
answers what an input does in the player's current situation, and a dialog
has none -- it holds focus and takes the key whatever the world is doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 22:31:59 +02:00
1e24b87640 let Q dismiss a dialog, and stop the drop dialog being dismissed at all
Q backs the player out in the game world, so it backs them out of a dialog
too: the recipe/schematic selection dialog, the blueprint selection dialog,
and the layout configuration dialog, where it is Cancel. In that last one it
steps out one level at a time as it does in the world -- a selected module,
then remove mode, then the session -- so one press never both leaves a mode
and discards the changes.

The schematic choice dialog goes the other way: it is now stated to be
undismissable, which it was already built to be (no close button) but was not
written down. Escape currently closes it and the handler then applies the
default choiceIndex of 0, awarding the first option the player never picked;
saying no way out exists but choosing is what closes that.

Q stays an ordinary character in the two dialogs that take a typed name. The
Escape bullet named only the blueprint dialog and was narrower than what
Escape has always done, so it now covers dismissible dialogs generally.

Requirements only; no code yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 22:22:05 +02:00
b0fa0da813 clear the selection with Q, and drop it on entering a build mode
Implements the requirements committed in 731b887.

Q becomes a three-way branch in the action table, which is the layer that owns
what an input does: ExitMode while a mode is active, the new ClearSelection
while something is selected, EnterDeconstruct otherwise. The three partition
the situations between them, so resolution stays first-match-wins over
available actions and the handler never re-derives the precedence -- which is
why ClearSelection gets its own event rather than joining ModeCancel on Q.

GameWorldView clears the selection at each of the three events that enter a
mode; those are the only ways in, whichever button or key the player used. No
clear is needed where ModeCancel falls through to deconstruct mode: Q resolves
to ClearSelection while anything is selected, so there is nothing left by then.

The Selection context's Q row reads "Clear selection", sits last as the row
that hands the context back does everywhere, and carries the exit badge
styling -- one key that backs out should look the same wherever it appears.
Requirements follow that last point in REQ-UI-CONTROLS-CARD and
REQ-UI-CONTROLS-CONTENT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 21:49:33 +02:00
731b8874c9 let Q clear the selection, and keep selection and build mode apart
Q backed out of a build mode or toggled deconstruct mode; it now clears the
selection as its middle case. That needed a rule for a selection held while a
build mode is active, a state the code allows today: clearAll() is called only
by a click or drag that hit nothing, so a selection survives into builder,
blueprint, and deconstruct mode.

Make the two mutually exclusive instead, matching what the controls panel
already shows: entering any build mode clears the selection. The blueprint
gestures read the selection before the mode entry clears it, so C and Ctrl+C
lose nothing.

Drops the accuracy carve-out that had C / Ctrl+C merely omitted from the build
contexts on the strength of a surviving selection -- with no selection there,
they are unavailable rather than omitted, and the list is the three cases its
intro claims.

Requirements only; no code yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 21:31:35 +02:00
392a2b8d00 keep the selection panel half a tile off what it describes
The panel had one distance for the view edges, the widgets it steps around,
and the selection alike, so it stood eight pixels from a building and touched
it outright along the top. The gap from the selection is now its own value,
half a tile, and it is horizontal only -- the top edges stay level.

It is sampled where the tile size is known, in the same moment as the anchor
rectangle, and travels with it: a rectangle frozen in one moment has no
meaningful distance to a tile size measured in another. chooseSide now takes
the gap in place of the margin, the band having already taken the margin off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 16:24:14 +02:00
abaccc45b5 give the selection panel its own gap from what it describes
The panel kept one distance for everything: the view edges, the widgets it
steps around, and the selection itself. Beside a building that read as
touching it -- eight pixels to the side and nothing at all above, the top
edges flush. Split the two apart: an edge margin as before, and a selection
gap of half a tile, horizontal only, frozen with the anchor rectangle it is
measured from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 15:53:30 +02:00
b2148f00ac stop hovering when the cursor points at no tile
A cursor resting on a panel or outside the window kept whatever it last
pointed at: the ghost, the tunnel preview, the deconstruct tint all stayed
put, because "not hovering" was not a state the build mode could hold. Make
both ghost tiles optional, clear the hover with them, and re-derive it when
the cursor comes back or a mode is entered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 15:19:22 +02:00
9490a96e12 let the hover follow a view that scrolls under a still cursor
Only the selection box was refreshed while the camera panned, so the ghost,
its validity, the resolved tunnel end and the deconstruct hover all kept the
tile of the last mouse move. Give the whole hover update one entry point and
run it from the pan step as well, for a cursor that is over the world.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 14:54:34 +02:00
fd0c246bc0 show the tunnel end the click would actually place
The controls panel header read the type builder mode was entered with, so
tunnel mode always said Tunnel Entry even where the ghost had resolved to an
exit. Feed the header the same effective type the ghost and placement already
use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 13:26:50 +02:00
9c275e283c give every recipe one shape: a list of output groups
Implements REQ-MAT-OUTPUT-GROUP. A recipe had two shapes -- outputs produced
together, or outputs of which exactly one happened -- and every rule over them
was written twice, selected by `building == ReprocessingPlant`: sizing a
buffer, deciding whether a cycle fits, resolving what a cycle makes, costing an
item. RecipeDef now holds output groups, each a weight and a list of items, and
a cycle yields exactly one group. One group is the ordinary recipe, so the old
two cases are the same shape with one and with several, and all four rules
collapse to one expression apiece with no building-type test left.

rollReprocessingOutput becomes rollOutputGroup, where a single group returns
without drawing or testing eligibility. That early-out is load-bearing twice
over. Drawing there would consume entropy for every ordinary recipe and shift
every later random outcome; and eligibility must not apply either, since
implicit unlocking is demand-derived, so an ordinary recipe's output can be
producible while nothing yet calls for it -- testing it would stop the building
producing rather than gate a drop. Past the early-out a group is eligible only
when all of its items are unlocked, being produced whole.

Threat follows the recipe's shape rather than the building, and the per-unit
value now divides by the group's amount as well as its odds. That moves no
number today: every item resolved through this path has amount 1, which is why
the threat expectations are untouched.

Config keeps `outputs = [...]` as the single-group form, so only the two
reprocessing recipes change shape. The recipe summary gains "/" between groups
and keeps "+" within one, which also fixes the plant reading as though a cycle
produced all of its items at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 12:47:09 +02:00
41c45d73ce cut the reprocessing plant entry back to what is still its own
Everything it used to specify moved out as the output-group merge generalised
it: the pick is REQ-MAT-OUTPUT-GROUP, the all-outcomes gate REQ-MAT-CYCLE, the
buffers REQ-MAT-OUTPUT-BUFFER, the eligible set REQ-LOCK-OUTPUT-POOL, the
recipe control REQ-BLD-AUTO-RECIPE. Restating them here only invited the two
to drift apart.

What is left is what config cannot say: why the building exists -- the
value-preserving counterpart to smelting scrap down, and the only path to
voidsteel -- plus the one rule that really is specific to it, that reprocessing
recipes take no part in the implicit unlock traversal, which until now was only
implied by REQ-LOCK-IMPLICIT naming the other three building types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 10:51:54 +02:00
f39e4f3506 name the output pool restriction for what it restricts
It is no longer about reprocessing: any recipe with several output groups is
subject to it, and the plant is only the building that happens to have one
(REQ-MAT-OUTPUT-GROUP). REQ-LOCK-REPROCESSING-POOL becomes REQ-LOCK-OUTPUT-POOL.
Four citations, all in docs -- no code cites it.

Also corrects a line REQ-LOCK-IMPLICIT still carried from before smelters had a
recipe control: their recipes are not "never shown in any UI dropdown" any more,
they are simply never gated, so the dialog offers all of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 10:32:30 +02:00
3c63205c6c make every recipe one shape: a list of output groups
Deterministic recipes could have several outputs produced together; a
probabilistic one could have several outputs of which exactly one happened.
Two shapes meant two rules everywhere -- sizing a buffer, deciding whether a
cycle fits, resolving what a cycle makes -- each written as a branch on
whether the building was a reprocessing plant.

New REQ-MAT-OUTPUT-GROUP merges them. A recipe has one or more output groups,
each with a weight and a list of items; a cycle produces exactly one group,
and the items within it together. One group is the ordinary recipe and is
always chosen, so the old deterministic and probabilistic cases are the same
shape with one group and with several -- and every rule downstream is written
over groups, needing no branch at all. Config keeps outputs = [...] as the
single-group form, so only the two reprocessing recipes change shape.

It also lets an outcome yield several items, which was unrepresentable, and
fixes a display bug on the way: the recipe summary drew all outputs as one
combined yield, so a plant read as if a cycle made all four items. Groups are
now separated by "/" and the items within one by "+".

REQ-LOCK-REPROCESSING-POOL now restricts the choice between groups rather
than the pool of output items, and says why that distinction is load-bearing:
implicit unlocking is demand-derived, so an ordinary recipe's output can be
producible while nothing yet calls for it. Testing eligibility there would not
gate a drop, it would stop the building producing at all -- so a recipe with
one group, having no choice to restrict, is never tested. A group is eligible
only if all of its items are unlocked, since they are produced together.

Requirements only; the implementation follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 09:46:31 +02:00
a1c567715e make selection box sub-tile aware 2026-08-14 22:51:05 +02:00
1cf7c264c1 draw selection rect only if mouse has moved 2026-08-14 22:20:35 +02:00
d36e59fd26 make selection box use the selection or deconstruct color to indicate the mode 2026-08-14 21:49:55 +02:00
92 changed files with 2826 additions and 989 deletions

View File

@@ -99,25 +99,21 @@ building = "reprocessing_plant"
inputs = [{item = "scrap", amount = 4}] inputs = [{item = "scrap", amount = 4}]
duration_seconds = 4.0 duration_seconds = 4.0
[[recipe.outputs]] [[recipe.output_group]]
item = "iron_ingot"
amount = 1
probability = 0.3 probability = 0.3
items = [{item = "iron_ingot", amount = 1}]
[[recipe.outputs]] [[recipe.output_group]]
item = "copper_ingot"
amount = 1
probability = 0.3 probability = 0.3
items = [{item = "copper_ingot", amount = 1}]
[[recipe.outputs]] [[recipe.output_group]]
item = "silicon"
amount = 1
probability = 0.2 probability = 0.2
items = [{item = "silicon", amount = 1}]
[[recipe.outputs]] [[recipe.output_group]]
item = "voidsteel"
amount = 1
probability = 0.2 probability = 0.2
items = [{item = "voidsteel", amount = 1}]
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Tier 2 — early intermediates (clean ratios, ~2:3) # Tier 2 — early intermediates (clean ratios, ~2:3)

View File

@@ -344,10 +344,12 @@ width_px = 2
[overlays] [overlays]
ghost_valid = "#ffffff44" # builder-mode ghost, placement allowed (REQ-BLD-GHOST) ghost_valid = "#ffffff44" # builder-mode ghost, placement allowed (REQ-BLD-GHOST)
ghost_invalid = "#ff000044" # builder-mode ghost, placement invalid (REQ-BLD-PLACE-VALID) ghost_invalid = "#ff000044" # builder-mode ghost, placement invalid (REQ-BLD-PLACE-VALID)
deconstruct_tint = "#ff000033" # deconstruct-mode hover tint deconstruct_tint = "#ff000033" # deconstruct-mode hover tint; its RGB also draws the
selection_rect = "#00ff00" # box-drag selection rectangle (REQ-UI-MULTI-SELECT) # box-drag rectangle in deconstruct mode, opaque
# (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-BOX)
tile_highlight = "#ffffff22" # tile under cursor tile_highlight = "#ffffff22" # tile under cursor
selected_outline = "#ffff00" # outline drawn around currently-selected building(s) selected_outline = "#ffff00" # outline around currently-selected building(s), and the
# box-drag selection rectangle (REQ-UI-MULTI-SELECT)
config_transfer = "#33ccff66" # blueprint ghost over a configuration-transfer target (REQ-UI-BLUEPRINT-TRANSFER) config_transfer = "#33ccff66" # blueprint ghost over a configuration-transfer target (REQ-UI-BLUEPRINT-TRANSFER)
locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion) locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion)
modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM) modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM)

View File

@@ -75,20 +75,17 @@ building = "reprocessing_plant"
inputs = [{item = "scrap", amount = 5}] inputs = [{item = "scrap", amount = 5}]
duration_seconds = 3.0 duration_seconds = 3.0
[[recipe.outputs]] [[recipe.output_group]]
item = "iron_ingot"
amount = 2
probability = 0.6 probability = 0.6
items = [{item = "iron_ingot", amount = 2}]
[[recipe.outputs]] [[recipe.output_group]]
item = "circuit_board"
amount = 1
probability = 0.3 probability = 0.3
items = [{item = "circuit_board", amount = 1}]
[[recipe.outputs]] [[recipe.output_group]]
item = "advanced_alloy"
amount = 1
probability = 0.1 probability = 0.1
items = [{item = "advanced_alloy", amount = 1}]
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# Extra recipes for ThreatCostCalculator unit tests (fixes 6-9) # Extra recipes for ThreatCostCalculator unit tests (fixes 6-9)

View File

@@ -427,7 +427,7 @@ width_px = 2
ghost_valid = "#ffffff44" ghost_valid = "#ffffff44"
ghost_invalid = "#ff000044" ghost_invalid = "#ff000044"
deconstruct_tint = "#ff000033" deconstruct_tint = "#ff000033"
selection_rect = "#00ff00" selected_outline = "#ffff00"
[toast] [toast]
bg = "#000000cc" bg = "#000000cc"

View File

@@ -230,8 +230,8 @@ supporting different fleet doctrines feel structurally different to build.
**smelting** (same basic materials as ore — the safe, boring option) and **smelting** (same basic materials as ore — the safe, boring option) and
**reprocessing** (probabilistic higher intermediates, including the **reprocessing** (probabilistic higher intermediates, including the
late-game input — the gamble that eventually becomes mandatory). late-game input — the gamble that eventually becomes mandatory).
- The reprocessing output pool renormalizes over implicitly unlocked items - The reprocessing output pool renormalizes over the output groups whose items
(REQ-LOCK-REPROCESSING-POOL), so its output quality improves are implicitly unlocked (REQ-LOCK-OUTPUT-POOL), so its output quality improves
automatically as the run progresses. **Rule:** weights are authored for automatically as the run progresses. **Rule:** weights are authored for
the *fully unlocked* pool state; early-game behavior falls out of the *fully unlocked* pool state; early-game behavior falls out of
renormalization for free and needs no separate staging. renormalization for free and needs no separate staging.

View File

@@ -4,9 +4,9 @@
Config files use the TOML format. The following config files drive game parameters: Config files use the TOML format. The following config files drive game parameters:
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, building deconstruction time, wave timing, boss wave timing, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, scrap-per-threat conversion, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, artifact win count, view pan speeds (slow and fast horizontal pan speed and pan ramp band width), an optional building blocks tooltip string (shown as the header bar's building blocks stock hover tooltip, REQ-UI-BLOCKS-TOOLTIP; omitted when unset), and an optional artifact tooltip string (shown as the header bar's artifact count hover tooltip, REQ-UI-ARTIFACTS-TOOLTIP; omitted when unset). - **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, building deconstruction time, wave timing, boss wave timing, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, scrap-per-threat conversion, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, artifact win count, view pan speeds (slow and fast horizontal pan speed and pan ramp band width), an optional building blocks tooltip string (shown as the header bar's building blocks stock tooltip, REQ-UI-BLOCKS-TOOLTIP; omitted when unset), and an optional artifact tooltip string (shown as the header bar's artifact count tooltip, REQ-UI-ARTIFACTS-TOOLTIP; omitted when unset).
- **buildings.toml** — building block cost and construction time per building type, plus an optional tooltip description string per building type (shown as the build button's hover tooltip, REQ-UI-BUILD-TOOLTIP; omitted when unset). Whether a building type is available from game start or must be unlocked during play is not defined here but in **unlocks.toml** (REQ-LOCK-EXPLICIT): a building type granted by an unlock group starts locked and is hidden from the build menu until its group is awarded (REQ-LOCK-BUILDING). - **buildings.toml** — building block cost and construction time per building type, plus an optional tooltip description string per building type (shown as the build button's hover tooltip, REQ-UI-BUILD-TOOLTIP; omitted when unset). Whether a building type is available from game start or must be unlocked during play is not defined here but in **unlocks.toml** (REQ-LOCK-EXPLICIT): a building type granted by an unlock group starts locked and is hidden from the build menu until its group is awarded (REQ-LOCK-BUILDING).
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlocked_at_start` (boolean, default false): when true the recipe is available from game start regardless of the implicit item graph — used for base recipes that no schematic's materials reach (such as building blocks; see REQ-LOCK-IMPLICIT). Which assembler recipes must instead be awarded during play (explicitly gated) is defined in **unlocks.toml**, not here (REQ-LOCK-EXPLICIT); every remaining assembler recipe is implicitly unlocked through the item graph (REQ-LOCK-IMPLICIT).- **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as plain values, required build materials, a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES). Whether a ship schematic is available from game start or must be unlocked during play is defined in **unlocks.toml** (REQ-LOCK-EXPLICIT), not here. - **recipes.toml** — crafting recipes: inputs, output groups with their quantities and probability weights, and durations (REQ-MAT-OUTPUT-GROUP). Assembler recipe entries may optionally define `unlocked_at_start` (boolean, default false): when true the recipe is available from game start regardless of the implicit item graph — used for base recipes that no schematic's materials reach (such as building blocks; see REQ-LOCK-IMPLICIT). Which assembler recipes must instead be awarded during play (explicitly gated) is defined in **unlocks.toml**, not here (REQ-LOCK-EXPLICIT); every remaining assembler recipe is implicitly unlocked through the item graph (REQ-LOCK-IMPLICIT).- **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as plain values, required build materials, a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES). Whether a ship schematic is available from game start or must be unlocked during play is defined in **unlocks.toml** (REQ-LOCK-EXPLICIT), not here.
- **modules.toml** — per module type: id, surface mask, materials list, production time, fill color, glyph, an optional tooltip description string (shown as the module selection button's hover tooltip, REQ-MOD-UI-MODULE-TOOLTIP; omitted when unset), and an optional capability section and/or stat modifier formulas. Whether a module schematic is available from game start or must be unlocked during play is defined in **unlocks.toml** (REQ-LOCK-EXPLICIT), not here. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC). - **modules.toml** — per module type: id, surface mask, materials list, production time, fill color, glyph, an optional tooltip description string (shown as the module selection button's hover tooltip, REQ-MOD-UI-MODULE-TOOLTIP; omitted when unset), and an optional capability section and/or stat modifier formulas. Whether a module schematic is available from game start or must be unlocked during play is defined in **unlocks.toml** (REQ-LOCK-EXPLICIT), not here. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC).
- **unlocks.toml** — unlock groups: each `[[unlock]]` entry names a group of ship schematics, module schematics, building types, and/or assembler recipes that are awarded together from a single defence station drop (see Unlock Group Format, REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP). Anything not granted by any unlock group is available from game start. - **unlocks.toml** — unlock groups: each `[[unlock]]` entry names a group of ship schematics, module schematics, building types, and/or assembler recipes that are awarded together from a single defence station drop (see Unlock Group Format, REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP). Anything not granted by any unlock group is available from game start.
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level. - **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
@@ -109,7 +109,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-HQ-BELT-INPUT: The HQ has a belt input port. Building blocks delivered to it are added to the global building blocks stock. - REQ-HQ-BELT-INPUT: The HQ has a belt input port. Building blocks delivered to it are added to the global building blocks stock.
- REQ-HQ-STATS: HQ stats (HP) are read from `stations.toml [hq]`. - REQ-HQ-STATS: HQ stats (HP) are read from `stations.toml [hq]`.
- REQ-HQ-STARTING-BLOCKS: At game start, the global building blocks stock is initialized to `world.toml [world].starting_building_blocks` (default 100). - REQ-HQ-STARTING-BLOCKS: At game start, the global building blocks stock is initialized to `world.toml [world].starting_building_blocks` (default 100).
- REQ-HQ-GAME-OVER: If the HQ is destroyed, the game ends. A game-over screen shows the final survival time and offers "Restart" and "Quit" buttons. - REQ-HQ-GAME-OVER: If the HQ is destroyed, the game ends. A game-over screen, drawn by the game as every modal is (REQ-UI-MODAL-CHROME), shows the final survival time and offers "Restart" and "Quit" buttons.
- REQ-HQ-INVULNERABLE: Factory buildings (other than the HQ) are never targeted or destroyed by enemies. - REQ-HQ-INVULNERABLE: Factory buildings (other than the HQ) are never targeted or destroyed by enemies.
## Win Condition ## Win Condition
@@ -122,8 +122,8 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-BLD-COST: The player places buildings from a build menu. Placement costs building blocks from the global stock. The cost per building type is read from `buildings.toml [[building]].cost`. - REQ-BLD-COST: The player places buildings from a build menu. Placement costs building blocks from the global stock. The cost per building type is read from `buildings.toml [[building]].cost`.
- REQ-BLD-QUEUE: Placed buildings enter a construction queue and are built one at a time. Each building takes a duration defined in `buildings.toml [[building]].construction_time_seconds` to construct. - REQ-BLD-QUEUE: Placed buildings enter a construction queue and are built one at a time. Each building takes a duration defined in `buildings.toml [[building]].construction_time_seconds` to construct.
- REQ-BLD-ASTEROID-ONLY: Buildings can only be placed on asteroid tiles (per surface_mask; tiles marked `S` may extend into space). - REQ-BLD-ASTEROID-ONLY: Buildings can only be placed on asteroid tiles (per surface_mask; tiles marked `S` may extend into space).
- REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type. Builder mode is exited by right-clicking in the game world or clicking the same build button again. (Exception: while a belt drag placement is in progress, right-clicking cancels that drag instead of exiting, and builder mode stays active — REQ-BLD-BELT-DRAG.) - REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type, clearing the selection as entering any build mode does (REQ-UI-SELECTION-EXCLUSIVE). Builder mode is exited by right-clicking in the game world or clicking the same build button again. (Exception: while a belt drag placement is in progress, right-clicking cancels that drag instead of exiting, and builder mode stays active — REQ-BLD-BELT-DRAG.)
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. The ghost is drawn semi-transparently in the building type's own visuals — its `fill` and `outline` colors and `glyph` from `visuals.toml` — so that different building types are visually distinguishable in builder mode rather than all looking alike. When the current cursor position is invalid, the ghost instead uses the distinct "invalid" color (REQ-BLD-PLACE-VALID), which overrides the per-building coloring. - REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. The ghost follows the tile the cursor points at, which includes the view scrolling (REQ-UI-SCROLL) under a cursor that has not moved: the ghost then moves across the world with the scroll rather than sticking to the tile it was last placed on by a mouse move, exactly as a running selection box does (REQ-UI-MULTI-SELECT). The same holds for everything else the hovered position determines — placement validity (REQ-BLD-PLACE-VALID), the tunnel end being placed (REQ-BLD-TUNNEL-MODE), a belt drag's path (REQ-BLD-BELT-DRAG), the blueprint ghost (REQ-UI-BLUEPRINT-MODE), and the deconstruct hover (REQ-UI-DECONSTRUCT-BUTTON). A cursor that is not over the game world — resting on one of the floating panels (REQ-UI-CONTROLS-PANEL, REQ-UI-BUILD-BAR, REQ-UI-SELECTION-PANEL), or outside the window — points at no tile and therefore hovers nothing: no builder ghost and no blueprint ghost is drawn, no tunnel connection is previewed, and no building is tinted as the deconstruct hover, until the cursor returns to the world. The mode itself is unaffected — the player is still building, just not over anything — and the position the ghost had is not remembered: it is re-derived from wherever the cursor comes back. This applies whenever the cursor points elsewhere, including at the moment a mode is entered, so a mode entered from a build button (REQ-UI-BUILD-BAR) shows its ghost only once the cursor is over the world, while one entered by hotkey (REQ-UI-HOTKEYS) under a cursor already there shows it at once. The one exception is a gesture that holds the mouse button — a belt drag (REQ-BLD-BELT-DRAG) or a selection or deconstruct box (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-BOX) — which goes on following the cursor across the panels and beyond the window until the button is released. The ghost is drawn semi-transparently in the building type's own visuals — its `fill` and `outline` colors and `glyph` from `visuals.toml` — so that different building types are visually distinguishable in builder mode rather than all looking alike. When the current cursor position is invalid, the ghost instead uses the distinct "invalid" color (REQ-BLD-PLACE-VALID), which overrides the per-building coloring.
- REQ-BLD-ROTATE: While in builder mode, pressing Shift+R rotates the ghost 90° clockwise and R rotates it 90° counter-clockwise. Rotation affects the direction of the output port. - REQ-BLD-ROTATE: While in builder mode, pressing Shift+R rotates the ghost 90° clockwise and R rotates it 90° counter-clockwise. Rotation affects the direction of the output port.
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. (For belts, placement is instead deferred to a drag gesture and happens on mouse release — REQ-BLD-BELT-DRAG.) - REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. (For belts, placement is instead deferred to a drag gesture and happens on mouse release — REQ-BLD-BELT-DRAG.)
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE (builder mode) or REQ-UI-BLUEPRINT-OVERLAP and REQ-UI-BLUEPRINT-TRANSFER (blueprint placement mode), and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions. - REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE (builder mode) or REQ-UI-BLUEPRINT-OVERLAP and REQ-UI-BLUEPRINT-TRANSFER (blueprint placement mode), and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions.
@@ -144,15 +144,15 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-BLD-DECONSTRUCT: The player can deconstruct a placed factory building. Deconstructing a **fully-built** factory building does not remove it instantly: it is added to the deconstruction queue (REQ-BLD-DECON-QUEUE) and, once its deconstruction completes, `world.toml [world].refund_percentage` percent of the original building block cost (default 75%) is returned to the global stock. Exception: if the building is still in the construction queue (not yet fully built, including the one currently being constructed), it is **not** queued for deconstruction but removed instantly from the construction queue, and the **full** building block cost is refunded immediately. The HQ and player defence stations cannot be deconstructed. - REQ-BLD-DECONSTRUCT: The player can deconstruct a placed factory building. Deconstructing a **fully-built** factory building does not remove it instantly: it is added to the deconstruction queue (REQ-BLD-DECON-QUEUE) and, once its deconstruction completes, `world.toml [world].refund_percentage` percent of the original building block cost (default 75%) is returned to the global stock. Exception: if the building is still in the construction queue (not yet fully built, including the one currently being constructed), it is **not** queued for deconstruction but removed instantly from the construction queue, and the **full** building block cost is refunded immediately. The HQ and player defence stations cannot be deconstructed.
- REQ-BLD-DECON-QUEUE: Fully-built factory buildings marked for demolition (REQ-BLD-DECONSTRUCT) enter a **deconstruction queue** that is processed one building at a time and runs in parallel with the construction queue (REQ-BLD-QUEUE) — the two queues advance independently and simultaneously. Each building takes `world.toml [world].deconstruction_time_seconds` (default 0.1) to deconstruct, the same duration for every building type. When a building's deconstruction completes it is removed from the world and its refund is credited (REQ-BLD-DECONSTRUCT). A building **stops operating the moment it enters the queue**: it runs no production and transports no items, and no longer participates as a live building (its tunnel pairing is re-evaluated as if it were gone, REQ-BLD-TUNNEL-PAIR), but it still physically occupies its tiles until removed, so those tiles stay blocked for placement. A queued building can be taken back out of the deconstruction queue before it is removed (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX) — including the one currently being deconstructed; doing so discards any deconstruction progress, credits no refund, and the building resumes operating (and re-pairs, REQ-BLD-TUNNEL-PAIR). Construction sites never enter the deconstruction queue (REQ-BLD-DECONSTRUCT). Every building in the deconstruction queue is rendered with the deconstruct tint — the `visuals.toml [overlays].deconstruct_tint` color, the same tint applied to a building hovered in deconstruct mode (REQ-UI-DECONSTRUCT-BORDER) — so queued buildings are visually distinct. - REQ-BLD-DECON-QUEUE: Fully-built factory buildings marked for demolition (REQ-BLD-DECONSTRUCT) enter a **deconstruction queue** that is processed one building at a time and runs in parallel with the construction queue (REQ-BLD-QUEUE) — the two queues advance independently and simultaneously. Each building takes `world.toml [world].deconstruction_time_seconds` (default 0.1) to deconstruct, the same duration for every building type. When a building's deconstruction completes it is removed from the world and its refund is credited (REQ-BLD-DECONSTRUCT). A building **stops operating the moment it enters the queue**: it runs no production and transports no items, and no longer participates as a live building (its tunnel pairing is re-evaluated as if it were gone, REQ-BLD-TUNNEL-PAIR), but it still physically occupies its tiles until removed, so those tiles stay blocked for placement. A queued building can be taken back out of the deconstruction queue before it is removed (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX) — including the one currently being deconstructed; doing so discards any deconstruction progress, credits no refund, and the building resumes operating (and re-pairs, REQ-BLD-TUNNEL-PAIR). Construction sites never enter the deconstruction queue (REQ-BLD-DECONSTRUCT). Every building in the deconstruction queue is rendered with the deconstruct tint — the `visuals.toml [overlays].deconstruct_tint` color, the same tint applied to a building hovered in deconstruct mode (REQ-UI-DECONSTRUCT-BORDER) — so queued buildings are visually distinct.
- REQ-BLD-DECONSTRUCT-CLICK: While in deconstruct mode (REQ-UI-HOTKEYS, REQ-UI-DECONSTRUCT-BUTTON), left-clicking a placed factory building or construction site in the game world marks it for demolition, following the rules of REQ-BLD-DECONSTRUCT: a fully-built building is added to the deconstruction queue (REQ-BLD-DECON-QUEUE), and a construction site is removed instantly with the full refund. Left-clicking a fully-built building that is **already in the deconstruction queue** instead removes it from the queue (un-queues it, REQ-BLD-DECON-QUEUE), with no refund; repeated clicks on the same building therefore alternate between queueing and un-queueing it. Clicking a building that cannot be deconstructed (the HQ or a player defence station, per REQ-BLD-DECONSTRUCT), or clicking empty world space, has no effect. Deconstruct mode stays active after each action so the player can continue without re-entering the mode; it is exited via the Q toggle (REQ-UI-HOTKEYS) or the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON). - REQ-BLD-DECONSTRUCT-CLICK: While in deconstruct mode (REQ-UI-HOTKEYS, REQ-UI-DECONSTRUCT-BUTTON), left-clicking a placed factory building or construction site in the game world marks it for demolition, following the rules of REQ-BLD-DECONSTRUCT: a fully-built building is added to the deconstruction queue (REQ-BLD-DECON-QUEUE), and a construction site is removed instantly with the full refund. Left-clicking a fully-built building that is **already in the deconstruction queue** instead removes it from the queue (un-queues it, REQ-BLD-DECON-QUEUE), with no refund; repeated clicks on the same building therefore alternate between queueing and un-queueing it. Clicking a building that cannot be deconstructed (the HQ or a player defence station, per REQ-BLD-DECONSTRUCT), or clicking empty world space, has no effect. Deconstruct mode stays active after each action so the player can continue without re-entering the mode; it is exited via the Q toggle (REQ-UI-HOTKEYS) or the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON).
- REQ-BLD-DECONSTRUCT-BOX: While in deconstruct mode (REQ-UI-HOTKEYS, REQ-UI-DECONSTRUCT-BUTTON), the player can click and drag a selection box in the game world. A selection rectangle is drawn while dragging, using the same box-drag gesture and coverage semantics as the multi-select box (REQ-UI-MULTI-SELECT). On mouse up, following the rules of REQ-BLD-DECONSTRUCT: every construction site covered by the box is removed instantly with the full refund; and among the fully-built deconstructible buildings covered by the box, if **all** of them are already in the deconstruction queue they are all removed from it (un-queued, REQ-BLD-DECON-QUEUE), otherwise every covered building not yet in the queue is added to the deconstruction queue (already-queued ones stay). Buildings that cannot be deconstructed (the HQ and player defence stations, per REQ-BLD-DECONSTRUCT) are excluded from the box demolition; ships and defence stations are never affected. - REQ-BLD-DECONSTRUCT-BOX: While in deconstruct mode (REQ-UI-HOTKEYS, REQ-UI-DECONSTRUCT-BUTTON), the player can click and drag a selection box in the game world. A selection rectangle is drawn while dragging, using the same box-drag gesture and coverage semantics as the multi-select box (REQ-UI-MULTI-SELECT), but drawn in the deconstruct color rather than the ordinary selection color (REQ-UI-MULTI-SELECT, Rectangle color). On mouse up, following the rules of REQ-BLD-DECONSTRUCT: every construction site covered by the box is removed instantly with the full refund; and among the fully-built deconstructible buildings covered by the box, if **all** of them are already in the deconstruction queue they are all removed from it (un-queued, REQ-BLD-DECON-QUEUE), otherwise every covered building not yet in the queue is added to the deconstruction queue (already-queued ones stay). Buildings that cannot be deconstructed (the HQ and player defence stations, per REQ-BLD-DECONSTRUCT) are excluded from the box demolition; ships and defence stations are never affected.
- REQ-BLD-SITE-CONFIG: A construction site — a building that has been placed but is still queued or under construction (REQ-BLD-QUEUE) — can be selected and configured exactly like the equivalent operational building, before it finishes building. Whatever configuration the building type supports is available on the site: the recipe for a Miner, Assembler, Smelter or Reprocessing Plant (REQ-UI-SELECT-BUTTON, REQ-BLD-AUTO-RECIPE), the produced-ship schematic and its module layout for a Shipyard (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-PREVIEW, REQ-MOD-UI-DIALOG), and the output filters for a Splitter (REQ-BLD-SPLITTER) — all set through the same selection panel controls (REQ-UI-CONFIG-INLINE). Only currently unlocked recipes and schematics are offered, exactly as for operational buildings (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC, REQ-LOCK-UI-SPLITTER). The configuration is stored on the construction site and carries over unchanged when construction completes, so the building becomes operational already configured. A construction site has no input/output buffers and runs no production cycle, so the buffer and production-progress portions of the panel (REQ-UI-SINGLE-SELECTION, REQ-UI-PRODUCTION-PROGRESS) are not shown for it; only its construction progress (REQ-UI-CONSTRUCTION-PROGRESS) and its configuration controls appear. (Blueprint placement already applies a stored recipe or schematic to a construction site on placement per REQ-UI-BLUEPRINT-PLACE; this requirement additionally lets the player set or change that configuration directly on an existing site.) - REQ-BLD-SITE-CONFIG: A construction site — a building that has been placed but is still queued or under construction (REQ-BLD-QUEUE) — can be selected and configured exactly like the equivalent operational building, before it finishes building. Whatever configuration the building type supports is available on the site: the recipe for a Miner, Assembler, Smelter or Reprocessing Plant (REQ-UI-SELECT-BUTTON, REQ-BLD-AUTO-RECIPE), the produced-ship schematic and its module layout for a Shipyard (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-PREVIEW, REQ-MOD-UI-DIALOG), and the output filters for a Splitter (REQ-BLD-SPLITTER) — all set through the same selection panel controls (REQ-UI-CONFIG-INLINE). Only currently unlocked recipes and schematics are offered, exactly as for operational buildings (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC, REQ-LOCK-UI-SPLITTER). The configuration is stored on the construction site and carries over unchanged when construction completes, so the building becomes operational already configured. A construction site has no input/output buffers and runs no production cycle, so the buffer and production-progress portions of the panel (REQ-UI-SINGLE-SELECTION, REQ-UI-PRODUCTION-PROGRESS) are not shown for it; only its construction progress (REQ-UI-CONSTRUCTION-PROGRESS) and its configuration controls appear. (Blueprint placement already applies a stored recipe or schematic to a construction site on placement per REQ-UI-BLUEPRINT-PLACE; this requirement additionally lets the player set or change that configuration directly on an existing site.)
## Building Types ## Building Types
- REQ-BLD-MINER: **Miner** (2×2): The player selects which ore type it extracts. Each ore type corresponds to a `recipes.toml [[recipe]]` entry with `building = "miner"`, defining the output item and `duration_seconds`. Every asteroid tile is equivalent for mining — any miner can produce any ore type based solely on its selected recipe. Ore never depletes. Only implicitly unlocked ore-type recipes are available for selection (REQ-LOCK-UI-RECIPE). - REQ-BLD-MINER: **Miner** (2×2): The player selects which ore type it extracts. Each ore type corresponds to a `recipes.toml [[recipe]]` entry with `building = "miner"`, defining the output item and `duration_seconds`. Every asteroid tile is equivalent for mining — any miner can produce any ore type based solely on its selected recipe. Ore never depletes. Only implicitly unlocked ore-type recipes are available for selection (REQ-LOCK-UI-RECIPE).
- REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. Its recipe is selected as any other building's is, except that it also picks one for itself from the first material it is offered (REQ-BLD-AUTO-RECIPE). Inputs, outputs, and rates are defined in `recipes.toml [[recipe]]` entries with `building = "smelter"`. - REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. Its recipe is selected as any other building's is, except that it also picks one for itself from the first material it is offered (REQ-BLD-AUTO-RECIPE). Inputs, outputs, and rates are defined in `recipes.toml [[recipe]]` entries with `building = "smelter"`.
- REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces the selected output item at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. Only implicitly unlocked recipes are available for selection (REQ-LOCK-UI-RECIPE). - REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces what that recipe produces (REQ-MAT-OUTPUT-GROUP) at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. Only implicitly unlocked recipes are available for selection (REQ-LOCK-UI-RECIPE).
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one **kind** of higher-level intermediate product per cycle via weighted random pick, in that output's configured amount (which may be more than one item). The input quantity, possible output items, per-output weights, and amounts are defined in `recipes.toml [[recipe]]` entries with `building = "reprocessing_plant"` (`inputs`, `outputs[].item`, `outputs[].amount`, `outputs[].weight`). Weights are normalized at load time; their sum does not need to equal 1. The output is rolled at cycle start (see REQ-MAT-CYCLE); the pool of eligible outputs is restricted to implicitly unlocked item types (REQ-LOCK-REPROCESSING-POOL). Its output side follows the general rules with no exception: one buffer per possible output item (REQ-MAT-OUTPUT-BUFFER), and a cycle starts only when every possible roll would fit (REQ-MAT-CYCLE) — which is also what denies the player a reroll by stalling the output belt. Like the Smelter it picks its recipe from the first material it is offered while none is set (REQ-BLD-AUTO-RECIPE). - REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap and returns a higher-tier material chosen by chance, as the value-preserving counterpart to smelting scrap down (REQ-BLD-SMELTER) and the only source of voidsteel. Its inputs, output groups and weights are ordinary recipe config (REQ-MAT-OUTPUT-GROUP) with `building = "reprocessing_plant"`; nothing about its behaviour is specific to the building. Reprocessing recipes take no part in the implicit unlock traversal (REQ-LOCK-IMPLICIT), so what it can yield is governed by REQ-LOCK-OUTPUT-POOL alone.
- REQ-BLD-AUTO-RECIPE: **Automatic recipe selection.** The Smelter and the Reprocessing Plant (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are *auto-recipe buildings*. They carry a selected recipe and are configured exactly as a Miner or Assembler is — the same selection button and dialog (REQ-UI-SELECT-BUTTON), buffers sized for that one recipe alone (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER), and the same pre-configuration on a construction site (REQ-BLD-SITE-CONFIG). They differ in one respect only: - REQ-BLD-AUTO-RECIPE: **Automatic recipe selection.** The Smelter and the Reprocessing Plant (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are *auto-recipe buildings*. They carry a selected recipe and are configured exactly as a Miner or Assembler is — the same selection button and dialog (REQ-UI-SELECT-BUTTON), buffers sized for that one recipe alone (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER), and the same pre-configuration on a construction site (REQ-BLD-SITE-CONFIG). They differ in one respect only:
- **Selection while none is set.** When such a building has no recipe, the first material offered at any of its input ports that some recipe of its type consumes selects that recipe; the material is then accepted as normal. This holds for every intake path — a belt, splitter or tunnel exit at an input port, and a directly coupled producer (REQ-MAT-DIRECT-COUPLE) — because a building that accepts nothing would otherwise leave a coupled producer stuck at its port forever. The choice is deterministic: input ports are examined in order, and where several recipes of the type consume the offered material the first in config order wins. - **Selection while none is set.** When such a building has no recipe, the first material offered at any of its input ports that some recipe of its type consumes selects that recipe; the material is then accepted as normal. This holds for every intake path — a belt, splitter or tunnel exit at an input port, and a directly coupled producer (REQ-MAT-DIRECT-COUPLE) — because a building that accepts nothing would otherwise leave a coupled producer stuck at its port forever. The choice is deterministic: input ports are examined in order, and where several recipes of the type consume the offered material the first in config order wins.
- **No further switching.** Once a recipe is set the building keeps it. It does not switch when its buffers run empty, nor when a material belonging to another of its recipes arrives — that material is simply not an accepted input, exactly as for any other building. - **No further switching.** Once a recipe is set the building keeps it. It does not switch when its buffers run empty, nor when a material belonging to another of its recipes arrives — that material is simply not an accepted input, exactly as for any other building.
@@ -202,8 +202,13 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-MAT-ACCEPT-DIR: A transport tile (belt, splitter, tunnel entry, or tunnel exit) accepts an incoming item only through a non-output edge; an item that would enter through one of the tile's output edges is refused. For a belt or a tunnel entry/exit the sole output edge is the one in its facing direction; for a splitter either of its two output directions is an output edge. This applies both to items pushed from an adjacent transport tile and to items deposited by a building's output port (REQ-MAT-OUTPUT-PORT). - REQ-MAT-ACCEPT-DIR: A transport tile (belt, splitter, tunnel entry, or tunnel exit) accepts an incoming item only through a non-output edge; an item that would enter through one of the tile's output edges is refused. For a belt or a tunnel entry/exit the sole output edge is the one in its facing direction; for a splitter either of its two output directions is an output edge. This applies both to items pushed from an adjacent transport tile and to items deposited by a building's output port (REQ-MAT-OUTPUT-PORT).
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared. - REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared.
- **Setting a configuration to the value it already holds is a no-op.** Selecting the recipe or schematic already set, or applying a ship layout identical to the one already configured, changes nothing: buffers are not cleared, an in-progress production cycle is not cancelled (REQ-BLD-SHIPYARD), and a construction site's progress and stored settings are untouched. This holds however the setting is applied — through the selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), a blueprint placement (REQ-UI-BLUEPRINT-PLACE), or a blueprint configuration transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a setting that genuinely differs has effects. - **Setting a configuration to the value it already holds is a no-op.** Selecting the recipe or schematic already set, or applying a ship layout identical to the one already configured, changes nothing: buffers are not cleared, an in-progress production cycle is not cancelled (REQ-BLD-SHIPYARD), and a construction site's progress and stored settings are untouched. This holds however the setting is applied — through the selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), a blueprint placement (REQ-UI-BLUEPRINT-PLACE), or a blueprint configuration transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a setting that genuinely differs has effects.
- REQ-MAT-OUTPUT-BUFFER: Each building has **one output buffer per item its recipe can produce** — each output of a deterministic recipe, and every possible roll of a probabilistic one (REQ-BLD-REPROCESSING). Each per-material buffer holds up to twice that item's per-cycle amount, mirroring the input side (REQ-MAT-INPUT-BUFFER); a buffer's contents never occupy another item's capacity. An emerging item still occupies its buffer for the whole animation (REQ-MAT-OUTPUT-EMERGE). Whether a buffer without room stops production is decided by REQ-MAT-CYCLE. When the player selects a new recipe or schematic, all items in all output buffers are cleared (relevant when the adjacent belt is jammed and items have accumulated). Re-applying a setting the building already has clears nothing, per REQ-MAT-INPUT-BUFFER. Two buildings stand outside this rule: the Shipyard has no output buffer at all, since it spawns a ship rather than producing items (REQ-BLD-SHIPYARD), and the Salvage Bay has no recipe, so its single scrap buffer is sized by config instead (REQ-BLD-SALVAGE-BAY). - REQ-MAT-OUTPUT-GROUP: **What a recipe produces.** A recipe's output is defined as one or more **output groups**, each carrying a probability weight and a list of item/amount pairs. One cycle produces the items of exactly **one** group, decided at cycle start (REQ-MAT-CYCLE); the items within that group are all produced together.
- REQ-MAT-CYCLE: Production cycle lifecycle. When a building is idle, it attempts to start a new cycle: (a) all required inputs must be present in the per-material input buffers, and (b) **every** output the cycle could produce must fit in that item's own output buffer (REQ-MAT-OUTPUT-BUFFER). For a deterministic recipe (b) is simply its own outputs. The Reprocessing Plant rolls its output at cycle start (weighted pick, REQ-BLD-REPROCESSING), so every possible roll must fit: the roll is committed the moment the cycle begins, and a cycle whose result could not be stored must not be started at all. Testing every possibility rather than the rolled one is what keeps a stalled output belt from biasing the distribution — with one item type's buffer full the plant stops entirely instead of going on producing only the others. On cycle start, inputs are consumed immediately and the production timer begins. On cycle completion, the (already-decided) output is deposited into its output buffer and the building returns to idle. - A recipe with a **single group** always produces it. There is nothing to choose, so no weight is consulted, no randomness is involved, and no eligibility is tested (REQ-LOCK-OUTPUT-POOL). This is the ordinary recipe, and the shape of all but the reprocessing ones.
- A recipe with **several groups** picks one by weight at cycle start. Weights are normalized at load time; their sum does not need to equal 1.
- There is no separate deterministic and probabilistic kind of recipe: the two are one shape with one group and with several. Every rule about outputs is written over groups, so none of them needs to distinguish the two cases — or which building runs the recipe.
- **Config shape.** A group is a `[[recipe.output_group]]` entry with `probability` and `items = [{item, amount}, ...]`. The single-group case may instead be written as `outputs = [{item, amount}, ...]`, which means exactly one group holding those items. A recipe uses one form or the other; both present fails config load.
- REQ-MAT-OUTPUT-BUFFER: Each building has **one output buffer per item its recipe can produce** — every item of every one of its output groups (REQ-MAT-OUTPUT-GROUP). Each per-material buffer holds up to twice that item's per-cycle amount, meaning the largest total any single group produces of it, mirroring the input side (REQ-MAT-INPUT-BUFFER); a buffer's contents never occupy another item's capacity. An emerging item still occupies its buffer for the whole animation (REQ-MAT-OUTPUT-EMERGE). Whether a buffer without room stops production is decided by REQ-MAT-CYCLE. When the player selects a new recipe or schematic, all items in all output buffers are cleared (relevant when the adjacent belt is jammed and items have accumulated). Re-applying a setting the building already has clears nothing, per REQ-MAT-INPUT-BUFFER. Two buildings stand outside this rule: the Shipyard has no output buffer at all, since it spawns a ship rather than producing items (REQ-BLD-SHIPYARD), and the Salvage Bay has no recipe, so its single scrap buffer is sized by config instead (REQ-BLD-SALVAGE-BAY).
- REQ-MAT-CYCLE: Production cycle lifecycle. When a building is idle, it attempts to start a new cycle: (a) all required inputs must be present in the per-material input buffers, and (b) **every one** of the recipe's output groups must fit — for each group, the items it would produce must fit in their own output buffers (REQ-MAT-OUTPUT-GROUP, REQ-MAT-OUTPUT-BUFFER). With a single group (b) is simply that group. With several it means every possible outcome, tested **before** one is picked: the pick is committed the moment the cycle begins, and a cycle whose result could not be stored must not be started at all. Testing every outcome rather than the picked one is what keeps a stalled output belt from biasing the distribution — with one item type's buffer full the building stops entirely instead of going on producing only the others. On cycle start the group is picked (by weight where there is more than one, REQ-MAT-OUTPUT-GROUP), inputs are consumed immediately, and the production timer begins. On cycle completion the (already-decided) items are deposited into their output buffers and the building returns to idle.
- REQ-MAT-GLOBAL-STOCK: The building blocks stock is the only global inventory. All other materials exist only in building buffers or on belt tiles. - REQ-MAT-GLOBAL-STOCK: The building blocks stock is the only global inventory. All other materials exist only in building buffers or on belt tiles.
## Resources ## Resources
@@ -268,9 +273,9 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
### Module Placement ### Module Placement
- REQ-MOD-PLACEMENT: In the layout configuration dialog (REQ-MOD-UI-DIALOG), the player places modules onto the ship's layout grid. Clicking a module button in the module selection grid enters module placement mode for that module type. While in placement mode, a ghost of the module's surface mask is rendered at the cell under the cursor. Clicking a valid position places one instance of the module. A position is valid if every `O` cell in the module's (rotated) surface mask coincides with an unoccupied buildable cell of the ship's layout. The player may place unlimited instances of the same module type. - REQ-MOD-PLACEMENT: In the layout configuration dialog (REQ-MOD-UI-DIALOG), the player places modules onto the ship's layout grid. Clicking a module button in the module selection grid enters module placement mode for that module type. While in placement mode, a ghost of the module's surface mask is rendered at the cell under the cursor. Clicking a valid position places one instance of the module. A position is valid if every `O` cell in the module's (rotated) surface mask coincides with an unoccupied buildable cell of the ship's layout. The player may place unlimited instances of the same module type. Placement mode is left by pressing Q or clicking outside the dialog, either of which clears the selected module before it reaches the dialog itself (REQ-UI-DIALOG-DISMISS), and by the existing gestures: clicking the same module button again, picking another module, or entering remove mode (REQ-MOD-REMOVE).
- REQ-MOD-ROTATION: While in module placement mode, pressing R rotates the module ghost 90° counter-clockwise and Shift+R rotates it 90° clockwise. Rotation transforms the surface mask grid identically to building rotation (REQ-BLD-ROTATE). - REQ-MOD-ROTATION: While in module placement mode, pressing R rotates the module ghost 90° counter-clockwise and Shift+R rotates it 90° clockwise. Rotation transforms the surface mask grid identically to building rotation (REQ-BLD-ROTATE).
- REQ-MOD-REMOVE: The module selection grid includes a "Remove" button. Clicking it enters remove mode. In remove mode, clicking on a cell occupied by a placed module removes that entire module instance from the layout. Remove mode is exited by clicking the Remove button again or by selecting a module for placement. - REQ-MOD-REMOVE: The module selection grid includes a "Remove" button. Clicking it enters remove mode. In remove mode, clicking on a cell occupied by a placed module removes that entire module instance from the layout. Remove mode is exited by clicking the Remove button again, by selecting a module for placement, or by pressing Q or clicking outside the dialog, either of which leaves the mode before it reaches the dialog itself (REQ-UI-DIALOG-DISMISS).
### Module Effects ### Module Effects
@@ -285,7 +290,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- **Miner recipe**: `duration_seconds / output_amount`, where `output_amount` is the number of units produced per cycle. - **Miner recipe**: `duration_seconds / output_amount`, where `output_amount` is the number of units produced per cycle.
- **Smelter recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs. - **Smelter recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
- **Assembler recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs. - **Assembler recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
- **Reprocessing-only item** (an item type that has no miner, smelter, or assembler recipe producing it, and is only obtainable via reprocessing): `(scrap_threat × scrap_per_cycle + duration_seconds) / probability`, where `scrap_threat` is the threat value of scrap (see REQ-THREAT-SCRAP), `scrap_per_cycle` is the number of scrap consumed per reprocessing cycle, `duration_seconds` is the reprocessing cycle time, and `probability` is the normalized weight of that item in the reprocessing output pool. (Reprocessing output amounts are 1 in practice, so per-unit division is already implicit in the formula.) - **Item from a recipe that picks between output groups** (an item type produced by no single-group recipe, and so obtainable only where a cycle picks one outcome of several — REQ-MAT-OUTPUT-GROUP): `(scrap_threat × scrap_per_cycle + duration_seconds) / probability / output_amount`, where `scrap_threat` is the threat value of scrap (see REQ-THREAT-SCRAP), `scrap_per_cycle` is the scrap consumed per cycle, `duration_seconds` is the cycle time, `probability` is the group's normalized weight, and `output_amount` is how many units of the item that group yields. The cycle's cost is divided by the odds of getting the group at all, and then by how many units it yields, so the value is per unit as everywhere else in this requirement.
- **Multiple recipes**: if an item type can be produced by more than one non-reprocessing recipe (miner, smelter, or assembler), its threat value is the **maximum** across **all** such eligible recipes, and the threat is committed only once every eligible recipe is computable (so a shallow shortcut recipe that resolves earlier than a deeper base recipe cannot lower the item's threat). The reprocessing path is only used when no other recipe exists. If recipe cycles prevent full resolution, the max over the currently computable subset is used as a fallback. - **Multiple recipes**: if an item type can be produced by more than one non-reprocessing recipe (miner, smelter, or assembler), its threat value is the **maximum** across **all** such eligible recipes, and the threat is committed only once every eligible recipe is computable (so a shallow shortcut recipe that resolves earlier than a deeper base recipe cannot lower the item's threat). The reprocessing path is only used when no other recipe exists. If recipe cycles prevent full resolution, the max over the currently computable subset is used as a fallback.
- **Scrap-consuming recipe fallback**: a non-reprocessing recipe that takes `scrap` as an input participates in an item's threat computation only if no scrap-free recipe (miner, smelter, or assembler) produces that item. This mirrors the reprocessing fallback rule and prevents the scrap-to-ingot smelter recipe from inflating basic material threats via the max rule. - **Scrap-consuming recipe fallback**: a non-reprocessing recipe that takes `scrap` as an input participates in an item's threat computation only if no scrap-free recipe (miner, smelter, or assembler) produces that item. This mirrors the reprocessing fallback rule and prevents the scrap-to-ingot smelter recipe from inflating basic material threats via the max rule.
@@ -316,13 +321,13 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- **Left** (below the grid): The ship stats panel (see REQ-MOD-UI-STATS-PANEL), and beneath it the layout's **build cost**, so the price of a configuration is visible while it is being assembled rather than only once it has been confirmed: the total materials required for the ship — the union of the schematic's base materials and those of every placed module, summed per item type (REQ-MOD-MATERIALS) — drawn as item icons on their colored squares with their amounts (REQ-UI-ITEM-ICON), and beside them the total production time (REQ-MOD-PRODUCTION-TIME). Both update in real time as modules are placed and removed. The cost sits beside the stats panel rather than within it because a build cost belongs to a ship being configured: the same panel serves an existing ship (REQ-UI-SHIP-STATS-PANEL) and the balancing tool, neither of which costs anything. - **Left** (below the grid): The ship stats panel (see REQ-MOD-UI-STATS-PANEL), and beneath it the layout's **build cost**, so the price of a configuration is visible while it is being assembled rather than only once it has been confirmed: the total materials required for the ship — the union of the schematic's base materials and those of every placed module, summed per item type (REQ-MOD-MATERIALS) — drawn as item icons on their colored squares with their amounts (REQ-UI-ITEM-ICON), and beside them the total production time (REQ-MOD-PRODUCTION-TIME). Both update in real time as modules are placed and removed. The cost sits beside the stats panel rather than within it because a build cost belongs to a ship being configured: the same panel serves an existing ship (REQ-UI-SHIP-STATS-PANEL) and the balancing tool, neither of which costs anything.
- **Center** (below the grid): A grid of module selection buttons (one per **unlocked** module type; see REQ-DEF-SCHEMATIC-DROP) plus a "Remove" button. A module button shows the module's name — its `id` under the usual display convention — on its first line, and beneath it what the module costs: the icons of its `materials` items on their colored squares with their amounts (REQ-UI-ITEM-ICON) and the production time it adds, as `+<n> s` (REQ-MOD-MATERIALS, REQ-MOD-PRODUCTION-TIME). This is the same form a recipe option button uses to state what it makes (REQ-UI-SELECT-OPTIONS), and it puts the price of a module in front of the player before it is placed. The "Remove" button costs nothing and shows its caption alone. - **Center** (below the grid): A grid of module selection buttons (one per **unlocked** module type; see REQ-DEF-SCHEMATIC-DROP) plus a "Remove" button. A module button shows the module's name — its `id` under the usual display convention — on its first line, and beneath it what the module costs: the icons of its `materials` items on their colored squares with their amounts (REQ-UI-ITEM-ICON) and the production time it adds, as `+<n> s` (REQ-MOD-MATERIALS, REQ-MOD-PRODUCTION-TIME). This is the same form a recipe option button uses to state what it makes (REQ-UI-SELECT-OPTIONS), and it puts the price of a module in front of the player before it is placed. The "Remove" button costs nothing and shows its caption alone.
- **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). - **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD).
- **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selection panel are recalculated, and the ship layout preview is refreshed. - **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog; Q and a click outside the dialog are two further ways to reach it, each once nothing is left to back out of within the dialog (REQ-UI-DIALOG-DISMISS). Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selection panel are recalculated, and the ship layout preview is refreshed.
- REQ-MOD-UI-EMPTY-PULSE: While a module is selected for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG), the empty buildable cells of the layout grid pulse smoothly around their normal fill shade, oscillating between a slightly darker and a slightly brighter shade at approximately 1 Hz (one full cycle per second), to draw the player's attention to where the module can be placed. All empty buildable cells pulse in phase. When no module is selected for placement (including remove mode), empty buildable cells render at their normal static shade. Non-buildable cells and cells occupied by a placed module do not pulse. - REQ-MOD-UI-EMPTY-PULSE: While a module is selected for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG), the empty buildable cells of the layout grid pulse smoothly around their normal fill shade, oscillating between a slightly darker and a slightly brighter shade at approximately 1 Hz (one full cycle per second), to draw the player's attention to where the module can be placed. All empty buildable cells pulse in phase. When no module is selected for placement (including remove mode), empty buildable cells render at their normal static shade. Non-buildable cells and cells occupied by a placed module do not pulse.
- REQ-MOD-UI-AUTO-DIALOG: When the player selects a schematic for a shipyard (operational building or construction site) through the schematic selection dialog (REQ-UI-SELECT-BUTTON), and the chosen schematic **differs** from the shipyard's current schematic, the layout configuration dialog (REQ-MOD-UI-DIALOG) opens automatically and immediately once the selection dialog closes — exactly as if the player had then clicked "Configure". Re-selecting the schematic already set does not reopen the dialog. This auto-open applies only to the manual schematic selection dialog; a schematic applied by blueprint placement (REQ-UI-BLUEPRINT-PLACE) does **not** auto-open the dialog. The player may still cancel the auto-opened dialog (REQ-MOD-UI-DIALOG), which leaves the newly selected schematic in place with its default empty layout; the "Configure" button (REQ-MOD-UI-PREVIEW) remains available to open the dialog again later. - REQ-MOD-UI-AUTO-DIALOG: When the player selects a schematic for a shipyard (operational building or construction site) through the schematic selection dialog (REQ-UI-SELECT-BUTTON), and the chosen schematic **differs** from the shipyard's current schematic, the layout configuration dialog (REQ-MOD-UI-DIALOG) opens automatically and immediately once the selection dialog closes — exactly as if the player had then clicked "Configure". Re-selecting the schematic already set does not reopen the dialog. Neither does **clearing** the shipyard: the `(None)` option (REQ-UI-SELECT-OPTIONS) differs from whatever was set, but it names no schematic and leaves nothing to configure, so no dialog opens — the same condition under which the "Configure" button is disabled (REQ-MOD-UI-PREVIEW). This auto-open applies only to the manual schematic selection dialog; a schematic applied by blueprint placement (REQ-UI-BLUEPRINT-PLACE) does **not** auto-open the dialog. The player may still cancel the auto-opened dialog (REQ-MOD-UI-DIALOG), which leaves the newly selected schematic in place with its default empty layout; the "Configure" button (REQ-MOD-UI-PREVIEW) remains available to open the dialog again later.
- REQ-MOD-UI-MODULE-TOOLTIP: Each module selection button in the layout configuration dialog (REQ-MOD-UI-DIALOG) shows a hover tooltip with the descriptive text defined for that module type in `modules.toml` (the optional per-module tooltip field). If a module type defines no tooltip text, its button shows no tooltip. The "Remove" button is not a module type and has no config-defined tooltip. - REQ-MOD-UI-MODULE-TOOLTIP: Each module selection button in the layout configuration dialog (REQ-MOD-UI-DIALOG) shows a hover tooltip — hover only, its click selecting the module instead (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS) — with the descriptive text defined for that module type in `modules.toml` (the optional per-module tooltip field). If a module type defines no tooltip text, its button shows no tooltip. The "Remove" button is not a module type and has no config-defined tooltip.
- REQ-MOD-UI-STATS-PANEL: The **ship stats panel** in the layout configuration dialog shows the stats of the currently configured ship layout as they would be computed, incorporating all passive module modifiers per REQ-MOD-STAT-CALC. The panel updates in real time whenever modules are placed or removed in the layout grid. - REQ-MOD-UI-STATS-PANEL: The **ship stats panel** in the layout configuration dialog shows the stats of the currently configured ship layout as they would be computed, incorporating all passive module modifiers per REQ-MOD-STAT-CALC. The panel updates in real time whenever modules are placed or removed in the layout grid.
@@ -351,7 +356,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-MOD-UI-BLUEPRINT-PANEL: The right column of the layout configuration dialog is the layout blueprint panel. It shows only blueprints whose `ship_type` matches the schematic of the shipyard for which the dialog was opened. The panel contains, from top to bottom: a "Create Blueprint" button, followed by a scrollable list of blueprint entries (one per matching blueprint, in creation order). - REQ-MOD-UI-BLUEPRINT-PANEL: The right column of the layout configuration dialog is the layout blueprint panel. It shows only blueprints whose `ship_type` matches the schematic of the shipyard for which the dialog was opened. The panel contains, from top to bottom: a "Create Blueprint" button, followed by a scrollable list of blueprint entries (one per matching blueprint, in creation order).
- REQ-MOD-UI-BLUEPRINT-CREATE: Clicking "Create Blueprint" opens a modal dialog prompting for 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 module layout currently shown in the left-side layout grid (the in-progress state of the dialog, not the previously confirmed shipyard layout) and appends it to the blueprint list. - REQ-MOD-UI-BLUEPRINT-CREATE: Clicking "Create Blueprint" opens a modal dialog prompting for a name, drawn by the game like every other modal (REQ-UI-MODAL-CHROME) and, like the other name dialog, dismissed by neither Q nor a click outside it (REQ-UI-DIALOG-DISMISS). 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 module layout currently shown in the left-side layout grid (the in-progress state of the dialog, not the previously confirmed shipyard layout) and appends it to the blueprint list.
- REQ-MOD-UI-BLUEPRINT-ENTRY: Each blueprint entry shows the blueprint name and a delete icon ("×") to the right of the name. Clicking the entry (name area) loads that blueprint's module list into the left-side layout grid, replacing all currently placed modules. Module instances that are invalid for the current ship layout (unknown module type, locked module type, position outside the grid, position on a non-buildable cell, or overlapping another module in the same blueprint) are silently skipped; the remaining valid instances are placed. Clicking the delete icon ("×") removes that blueprint entry from the list immediately. - REQ-MOD-UI-BLUEPRINT-ENTRY: Each blueprint entry shows the blueprint name and a delete icon ("×") to the right of the name. Clicking the entry (name area) loads that blueprint's module list into the left-side layout grid, replacing all currently placed modules. Module instances that are invalid for the current ship layout (unknown module type, locked module type, position outside the grid, position on a non-buildable cell, or overlapping another module in the same blueprint) are silently skipped; the remaining valid instances are placed. Clicking the delete icon ("×") removes that blueprint entry from the list immediately.
@@ -378,6 +383,8 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
Each recipe is shown as a **recipe line** of the same two-row card the item production tooltip uses (REQ-UI-ITEM-TOOLTIP): the icon of the building that runs it (REQ-UI-BUILD-ICON) and the recipe's name — by its `id`, using the same display convention as the assembler recipe-selection dialog — on the first row, and the recipe drawn as the recipe summary draws it (REQ-UI-RECIPE-SUMMARY) on the second. The lines are sorted alphabetically by recipe name. The line says everything there is to say about the recipe, so nothing in this list carries a tooltip, as in the selection dialog (REQ-UI-SELECT-OPTIONS). If no recipes would be newly unlocked, the list shows "None". Each recipe is shown as a **recipe line** of the same two-row card the item production tooltip uses (REQ-UI-ITEM-TOOLTIP): the icon of the building that runs it (REQ-UI-BUILD-ICON) and the recipe's name — by its `id`, using the same display convention as the assembler recipe-selection dialog — on the first row, and the recipe drawn as the recipe summary draws it (REQ-UI-RECIPE-SUMMARY) on the second. The lines are sorted alphabetically by recipe name. The line says everything there is to say about the recipe, so nothing in this list carries a tooltip, as in the selection dialog (REQ-UI-SELECT-OPTIONS). If no recipes would be newly unlocked, the list shows "None".
**The dialog cannot be dismissed.** Clicking an option is the only thing that closes it: it has no close button, no Cancel, neither Escape nor Q dismisses it, and a click outside it does nothing (REQ-UI-DIALOG-DISMISS). The drop is a reward the player has earned by destroying the station set, and every way out of the dialog would have to either forfeit it or pick an option the player did not — so there is no way out but choosing. The dialog is modal and the game is paused meanwhile, so nothing is waiting on the decision.
The player selects one option by clicking it. If the player selects the artifact option, the player's artifact count is incremented by 1 (REQ-WIN-ARTIFACT-COUNT) and the dialog closes; no unlock is applied. Otherwise the selected unlock group is awarded and the dialog closes: every ship, module, building, and assembler recipe the group grants becomes unlocked at once — ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG); building types become available in the build menu (REQ-LOCK-BUILDING); assembler recipes become available in the assembler recipe-selection dialog (subject to REQ-LOCK-UI-RECIPE). The unlock group is removed from the pool permanently (REQ-LOCK-EXPLICIT), and the implicit unlock set is recomputed (REQ-LOCK-IMPLICIT). The player selects one option by clicking it. If the player selects the artifact option, the player's artifact count is incremented by 1 (REQ-WIN-ARTIFACT-COUNT) and the dialog closes; no unlock is applied. Otherwise the selected unlock group is awarded and the dialog closes: every ship, module, building, and assembler recipe the group grants becomes unlocked at once — ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG); building types become available in the build menu (REQ-LOCK-BUILDING); assembler recipes become available in the assembler recipe-selection dialog (subject to REQ-LOCK-UI-RECIPE). The unlock group is removed from the pool permanently (REQ-LOCK-EXPLICIT), and the implicit unlock set is recomputed (REQ-LOCK-IMPLICIT).
## Progression & Locking ## Progression & Locking
@@ -387,12 +394,14 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-LOCK-PREREQ: An unlock group may optionally define `requires` — a list of prerequisite **unlock-group ids** that must already have been awarded before this group may enter the drop pool. A prerequisite is **satisfied** only when the unlock group it names has been awarded (REQ-LOCK-EXPLICIT). This check is applied in addition to the conditions in REQ-DEF-SCHEMATIC-DROP: a group enters the eligible pool only when its `station_level` condition is met, it has not yet been awarded, and every id in its `requires` is satisfied. `requires` defaults to empty (no prerequisites). The check is re-evaluated against the current set of awarded unlock groups every time a drop pool is built (after each REQ-DEF-SCHEMATIC-DROP and on Restart per REQ-CFG-RELOAD), so a gated group becomes eligible in the first drop after its last prerequisite is awarded. Every id listed in any `requires` must resolve to an unlock group defined in `unlocks.toml`; an id that names no such group is a configuration error that fails config load with a descriptive message (config is loaded at startup and reloaded on Restart, REQ-CFG-RELOAD). An unlock group that lists itself, or a cycle of mutually dependent prerequisites, is not a load error but can never become eligible, since no group in the cycle can be the first to be awarded. - REQ-LOCK-PREREQ: An unlock group may optionally define `requires` — a list of prerequisite **unlock-group ids** that must already have been awarded before this group may enter the drop pool. A prerequisite is **satisfied** only when the unlock group it names has been awarded (REQ-LOCK-EXPLICIT). This check is applied in addition to the conditions in REQ-DEF-SCHEMATIC-DROP: a group enters the eligible pool only when its `station_level` condition is met, it has not yet been awarded, and every id in its `requires` is satisfied. `requires` defaults to empty (no prerequisites). The check is re-evaluated against the current set of awarded unlock groups every time a drop pool is built (after each REQ-DEF-SCHEMATIC-DROP and on Restart per REQ-CFG-RELOAD), so a gated group becomes eligible in the first drop after its last prerequisite is awarded. Every id listed in any `requires` must resolve to an unlock group defined in `unlocks.toml`; an id that names no such group is a configuration error that fails config load with a descriptive message (config is loaded at startup and reloaded on Restart, REQ-CFG-RELOAD). An unlock group that lists itself, or a cycle of mutually dependent prerequisites, is not a load error but can never become eligible, since no group in the cycle can be the first to be awarded.
- REQ-LOCK-IMPLICIT: Item types and miner/assembler recipes are **implicitly** unlocked or locked based on the current set of unlocked ship, module, and assembler recipe schematics. The implicit unlock set is recomputed whenever any schematic changes lock state (on Restart or after REQ-DEF-SCHEMATIC-DROP). Computation: - REQ-LOCK-IMPLICIT: Item types and miner/assembler recipes are **implicitly** unlocked or locked based on the current set of unlocked ship, module, and assembler recipe schematics. The implicit unlock set is recomputed whenever any schematic changes lock state (on Restart or after REQ-DEF-SCHEMATIC-DROP). Computation:
1. Start with the union of: (a) all item types listed in `materials` across all currently unlocked ship schematics and all currently unlocked module schematics, and (b) the output item type of every assembler recipe that is currently **explicitly available** — that is, either flagged `unlocked_at_start` in `recipes.toml`, or granted by an unlock group that has been awarded (REQ-LOCK-EXPLICIT). 1. Start with the union of: (a) all item types listed in `materials` across all currently unlocked ship schematics and all currently unlocked module schematics, and (b) the output item types of every assembler recipe that is currently **explicitly available** — that is, either flagged `unlocked_at_start` in `recipes.toml`, or granted by an unlock group that has been awarded (REQ-LOCK-EXPLICIT).
2. For each item type in the current set: for every recipe (miner, smelter, or assembler) that produces it — skipping any assembler recipe that is granted by an unlock group whose group has not yet been awarded — add each of that recipe's input item types to the set. If the recipe is a miner recipe, or an assembler recipe that is not granted by any unlock group, mark it as implicitly unlocked. Assembler recipes that are explicitly available (flagged `unlocked_at_start`, or granted by an awarded unlock group) are available in the assembler recipe-selection dialog by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step. 2. For each item type in the current set: for every recipe (miner, smelter, or assembler) that produces it — skipping any assembler recipe that is granted by an unlock group whose group has not yet been awarded — add each of that recipe's input item types to the set. If the recipe is a miner recipe, or an assembler recipe that is not granted by any unlock group, mark it as implicitly unlocked. Assembler recipes that are explicitly available (flagged `unlocked_at_start`, or granted by an awarded unlock group) are available in the assembler recipe-selection dialog by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step.
3. Repeat step 2 until no new item types are added. 3. Repeat step 2 until no new item types are added.
Item types and miner/assembler recipes not reached by this process (and not explicitly unlocked) are locked. Smelter recipes participate in the traversal to propagate unlocking to their inputs but are never themselves shown in any UI dropdown. Item types and miner/assembler recipes not reached by this process (and not explicitly unlocked) are locked. Smelter recipes participate in the traversal to propagate unlocking to their inputs but are never themselves gated by it: they are not granted by unlock groups either (REQ-LOCK-EXPLICIT), so a Smelter's dialog offers all of them once the building is unlocked (REQ-LOCK-UI-RECIPE, REQ-BLD-AUTO-RECIPE).
- REQ-LOCK-REPROCESSING-POOL: The pool of possible outputs for a Reprocessing Plant cycle (REQ-BLD-REPROCESSING) is restricted to item types that are currently implicitly unlocked (REQ-LOCK-IMPLICIT). Weights are renormalized over the eligible outputs. If no eligible outputs remain, the Reprocessing Plant cannot start a production cycle. - REQ-LOCK-OUTPUT-POOL: **The choice between output groups is restricted to what the player can use.** Where a recipe has several output groups (REQ-MAT-OUTPUT-GROUP), only groups whose items are **all** currently implicitly unlocked (REQ-LOCK-IMPLICIT) are eligible; a group holding any locked item is dropped whole, since its items are produced together and taking it would hand the player a locked one. Weights are renormalized over the eligible groups, and if none remains the cycle cannot start.
The restriction is on the **choice**, not on production: a recipe with a single group has no choice to restrict and is never tested for eligibility. This distinction is load-bearing rather than an optimization. Implicit unlocking is derived from demand — an item becomes unlocked because something the player can build needs it (REQ-LOCK-IMPLICIT) — so an ordinary recipe's output can be perfectly producible while nothing yet calls for it. Testing eligibility there would not gate a drop, it would stop the building producing at all. In practice this governs the Reprocessing Plant, the only building whose recipes have several groups (REQ-BLD-REPROCESSING).
- REQ-LOCK-UI-RECIPE: Locked miner ore-type recipes and assembler recipes are not shown in their respective recipe-selection dialogs (REQ-UI-SELECT-BUTTON). Smelter and Reprocessing Plant recipes are never granted by an unlock group (REQ-LOCK-EXPLICIT restricts `recipes` grants to assembler recipes), so they are available from the start and their dialogs list them all; what gates them in practice is whether the building itself is unlocked (REQ-LOCK-BUILDING). - REQ-LOCK-UI-RECIPE: Locked miner ore-type recipes and assembler recipes are not shown in their respective recipe-selection dialogs (REQ-UI-SELECT-BUTTON). Smelter and Reprocessing Plant recipes are never granted by an unlock group (REQ-LOCK-EXPLICIT restricts `recipes` grants to assembler recipes), so they are available from the start and their dialogs list them all; what gates them in practice is whether the building itself is unlocked (REQ-LOCK-BUILDING).
@@ -428,6 +437,13 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
## UI ## UI
### Tooltips
Several UI elements carry a tooltip: the header bar's building blocks stock display (REQ-UI-BLOCKS-TOOLTIP) and artifact count (REQ-UI-ARTIFACTS-TOOLTIP), the selection panel's item chips (REQ-UI-ITEM-TOOLTIP), the build button bar's building-type buttons (REQ-UI-BUILD-TOOLTIP) and Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON), the layout configuration dialog's module selection buttons (REQ-MOD-UI-MODULE-TOOLTIP), and the blueprint card's delete icon (REQ-UI-BLUEPRINT-DELETE). Each of those requirements defines what its tooltip says; the two requirements here define how every one of them is shown and hidden. An element not named there carries no tooltip at all: the modal header's close button (REQ-UI-MODAL-CHROME) says what it does by being a close button, and the selection dialog's option buttons describe themselves on their own faces (REQ-UI-SELECT-OPTIONS).
- REQ-UI-TOOLTIP-TRIGGER: A tooltip is shown by **hovering** the element it belongs to, after the usual short hover delay. On an element that has **no click action of its own** it is in addition shown **immediately on left click**, without waiting the delay out. That covers the header bar's building blocks stock display and artifact count and the selection panel's item chips: these display a value and do nothing when clicked, so a click on them is free to mean "tell me what this is", which is quicker than waiting on a hover and is the obvious thing to try. It does not cover elements whose click already does something — the building-type buttons, the Deconstruct button, and the module selection buttons — whose tooltips are reached by hovering alone, so that one gesture never both acts and explains. Only the left mouse button shows a tooltip; the right and middle buttons never do. Clicking an element whose tooltip is already up leaves it up.
- REQ-UI-TOOLTIP-DISMISS: A tooltip stays visible until the pointer leaves **both the tooltip and the element it belongs to**, however it was triggered. It does **not time out**: there is no display duration after which it vanishes on its own, so a tooltip listing several recipe lines (REQ-UI-ITEM-TOOLTIP) can be read at whatever pace the player needs. The tooltip is itself part of the area that keeps it open — moving the pointer off the element and onto the tooltip does not hide it — which is what fixes where it is placed: its **top-left corner sits at the pointer**, at the position the pointer had the moment the tooltip was triggered. The pointer therefore starts on the tooltip's own corner and reaches the rest of it without crossing a gap. A tooltip that would run off the screen is pushed back onto it, which only moves it further over the pointer. Once the pointer is outside both, the tooltip hides. Nothing else dismisses it, and nothing else needs to: a click elsewhere is a click the pointer has already travelled to, so the tooltip is gone before it lands.
### Layout ### Layout
The screen is a single column: a header bar across the top and the game world view filling the whole area below it. There is no side panel. All three permanent UI widgets float over the game world — the build button bar (REQ-UI-BUILD-BAR) at its bottom center, the selection panel (REQ-UI-SELECTION-PANEL) beside whatever is currently selected — or wherever the player has dragged it by its header (REQ-UI-SELECTION-PANEL-DRAG) — shown only while something is selected and holding its place on the screen until the next selection, and the controls panel (REQ-UI-CONTROLS-PANEL) in its bottom-left corner, beside the build button bar and rising above it only when the two would overlap. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG): The screen is a single column: a header bar across the top and the game world view filling the whole area below it. There is no side panel. All three permanent UI widgets float over the game world — the build button bar (REQ-UI-BUILD-BAR) at its bottom center, the selection panel (REQ-UI-SELECTION-PANEL) beside whatever is currently selected — or wherever the player has dragged it by its header (REQ-UI-SELECTION-PANEL-DRAG) — shown only while something is selected and holding its place on the screen until the next selection, and the controls panel (REQ-UI-CONTROLS-PANEL) in its bottom-left corner, beside the build button bar and rising above it only when the two would overlap. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG):
@@ -450,37 +466,55 @@ The screen is a single column: a header bar across the top and the game world vi
- REQ-UI-HEADER: The header bar spans the full width of the game window and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) and an asteroid expansion button (REQ-UI-EXPAND-BUTTON) to the left of the speed buttons, and game speed controls on the right. - REQ-UI-HEADER: The header bar spans the full width of the game window and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) and an asteroid expansion button (REQ-UI-EXPAND-BUTTON) to the left of the speed buttons, and game speed controls on the right.
- REQ-UI-BLOCKS-ICON: In the header bar (REQ-UI-HEADER), the global building blocks stock is displayed as `Stock: <n>` followed by the `building_block` item icon (REQ-UI-ITEM-ICON) — e.g. `Stock: 200` then a small block icon — replacing the `Building Blocks: <n>` text label. The icon is sized to the header text height. When no icon file exists for `building_block` (a missing icon is not an error, REQ-UI-ITEM-ICON), the display falls back to the `Stock: <n> Blocks` text. The hover tooltip (REQ-UI-BLOCKS-TOOLTIP) applies in either form. - REQ-UI-BLOCKS-ICON: In the header bar (REQ-UI-HEADER), the global building blocks stock is displayed as `Stock: <n>` followed by the `building_block` item icon (REQ-UI-ITEM-ICON) — e.g. `Stock: 200` then a small block icon — replacing the `Building Blocks: <n>` text label. The icon is sized to the header text height. When no icon file exists for `building_block` (a missing icon is not an error, REQ-UI-ITEM-ICON), the display falls back to the `Stock: <n> Blocks` text. The hover tooltip (REQ-UI-BLOCKS-TOOLTIP) applies in either form.
- REQ-UI-BLOCKS-TOOLTIP: The header bar's building blocks stock display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].building_blocks_tooltip` — intended to tell the player what building blocks are used for and how to obtain them. If the field is unset, the stock display shows no tooltip. This tooltip is distinct from the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP). - REQ-UI-BLOCKS-TOOLTIP: The header bar's building blocks stock display (REQ-UI-HEADER) shows a tooltip — on hover and, the display having no click action of its own, on click as well (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS) — with the descriptive text defined in `world.toml [world].building_blocks_tooltip` — intended to tell the player what building blocks are used for and how to obtain them. If the field is unset, the stock display shows no tooltip. This tooltip is distinct from the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
- REQ-UI-ARTIFACTS-TOOLTIP: The header bar's artifact count display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].artifact_tooltip` — intended to tell the player what artifacts are, how they are obtained (REQ-DEF-SCHEMATIC-DROP), and that collecting `world.toml [world].artifact_win_count` of them wins the game (REQ-WIN-ARTIFACT-COUNT). If the field is unset, the artifact count display shows no tooltip. This tooltip is distinct from the building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP) and the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP). - REQ-UI-ARTIFACTS-TOOLTIP: The header bar's artifact count display (REQ-UI-HEADER) shows a tooltip — on hover and, the display having no click action of its own, on click as well (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS) — with the descriptive text defined in `world.toml [world].artifact_tooltip` — intended to tell the player what artifacts are, how they are obtained (REQ-DEF-SCHEMATIC-DROP), and that collecting `world.toml [world].artifact_win_count` of them wins the game (REQ-WIN-ARTIFACT-COUNT). If the field is unset, the artifact count display shows no tooltip. This tooltip is distinct from the building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP) and the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
- REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs. - REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs.
- REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 10× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game. - REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 10× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game.
- REQ-UI-PAUSE-BORDER: While the game is paused (speed 0×, whether set via the speed controls (REQ-UI-SPEED), the Space toggle (REQ-UI-HOTKEYS), or an auto-pausing modal), a vignette border is drawn around the edges of the game world view to make the paused state hard to miss. The border is black and fades in the alpha channel from fully transparent at its inner (center-facing) edge to 50% opacity at the viewport edge, over a thickness of 100 pixels (capped at half the smaller viewport dimension on very small views). - REQ-UI-PAUSE-BORDER: While the game is paused (speed 0×, whether set via the speed controls (REQ-UI-SPEED), the Space toggle (REQ-UI-HOTKEYS), or an auto-pausing modal), a vignette border is drawn around the edges of the game world view to make the paused state hard to miss. The border is black and fades in the alpha channel from fully transparent at its inner (center-facing) edge to 50% opacity at the viewport edge, over a thickness of 100 pixels (capped at half the smaller viewport dimension on very small views).
- 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-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-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 width of the game window and the full height below the header bar. No widget insets it: the build button bar (REQ-UI-BUILD-BAR), the selection panel (REQ-UI-SELECTION-PANEL), and the controls panel (REQ-UI-CONTROLS-PANEL) float over it. - REQ-UI-WORLD-SIZE: The game world view occupies the full width of the game window and the full height below the header bar. No widget insets it: the build button bar (REQ-UI-BUILD-BAR), the selection panel (REQ-UI-SELECTION-PANEL), and the controls panel (REQ-UI-CONTROLS-PANEL) float over it.
- REQ-UI-SELECTION-PANEL: The **selection panel** (the panel described under Selection Panel, REQ-UI-SINGLE-SELECTION and following) is a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), placed **beside the objects it describes** rather than at a fixed corner of the view, so it appears where the player is already looking. It is **sized to its content in both width and height**, so it grows and shrinks as the content changes. It keeps the same small margin from the view's edges that it uses as its gap from the selection. - REQ-UI-SELECTION-PANEL: The **selection panel** (the panel described under Selection Panel, REQ-UI-SINGLE-SELECTION and following) is a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), placed **beside the objects it describes** rather than at a fixed corner of the view, so it appears where the player is already looking. It is **sized to its content in both width and height**, so it grows and shrinks as the content changes. Two distances shape its placement, and they are deliberately different: a small **edge margin** it keeps from the view's edges and from the widgets it steps around, and a larger **selection gap** of half a tile (REQ-GW-TILE-SIZE) it keeps from the selection itself, so the panel stands clear of the objects it describes instead of touching them. The selection gap applies **horizontally only**, on the side facing the selection; vertically the panel stays level with the selection (see **Vertical placement**).
- **Anchor rectangle.** The panel is placed against the screen rectangle of the selection **at the moment that selection started**: the footprint of the single object selected (a building or construction site, an actor, or a piece of debris), or, when the selection started as a multi-selection (REQ-UI-MULTI-SELECT), the bounding box of all the objects it started with. - **Anchor rectangle.** The panel is placed against the screen rectangle of the selection **at the moment that selection started**: the footprint of the single object selected (a building or construction site, an actor, or a piece of debris), or, when the selection started as a multi-selection (REQ-UI-MULTI-SELECT), the bounding box of all the objects it started with.
- **Side.** The panel goes to the **right** of the anchor rectangle, separated from it by the panel's margin, whenever it fits within the view there. Otherwise it goes to the **left** of the anchor rectangle by that same margin. When it fits on neither side — a bounding box spanning most of the view, or an object too close to an edge — it is placed on whichever side leaves more room and then pushed inside the view. That is the one case in which the panel covers part of the selection. - **Side.** The panel goes to the **right** of the anchor rectangle, separated from it by the selection gap, whenever it fits within the view there. Otherwise it goes to the **left** of the anchor rectangle by that same gap. The room a side offers is measured accordingly: from the anchor rectangle's edge to the view's edge, less the selection gap and less the edge margin. When it fits on neither side — a bounding box spanning most of the view, or an object too close to an edge — it is placed on whichever side leaves more room and then pushed inside the view. That is the one case in which the panel covers part of the selection.
- **Vertical placement.** The panel's **top edge is aligned with the anchor rectangle's top edge** and it extends downward. Its bottom is limited by the lowest of: the view's bottom edge less the panel's margin; and the top edge, less that margin, of the build button bar (REQ-UI-BUILD-BAR) or the controls panel (REQ-UI-CONTROLS-PANEL) — but each of those two only where the panel's own horizontal extent actually overlaps that widget's current rectangle, so a panel whose column misses them is not shortened by them. Should the panel not fit above that limit, it is shifted up, as far as the view's top margin and no further; if it still does not fit, its height is capped at the space available there and the content scrolls vertically within it. - **Vertical placement.** The panel's **top edge is aligned with the anchor rectangle's top edge** and it extends downward. The selection gap plays no part here: the panel's top sits level with the top of the topmost object it describes, the gap separating the two horizontally alone. Its bottom is limited by the lowest of: the view's bottom edge less the edge margin; and the top edge, less that same margin, of the build button bar (REQ-UI-BUILD-BAR) or the controls panel (REQ-UI-CONTROLS-PANEL) — but each of those two only where the panel's own horizontal extent actually overlaps that widget's current rectangle, so a panel whose column misses them is not shortened by them. Should the panel not fit above that limit, it is shifted up, as far as the edge margin at the view's top and no further; if it still does not fit, its height is capped at the space available there and the content scrolls vertically within it.
- **Fixed for the life of the selection.** The anchor rectangle and the side are determined once, when the selection starts, and are not revisited while that selection lasts; the panel's own size is the only thing that may still move it (see **Resizing in place** below). The player may override the resulting position by dragging the panel's header (REQ-UI-SELECTION-PANEL-DRAG); the dragged position then takes the anchor rectangle's and the side's place for the rest of that selection. The panel **keeps its place on the screen** when the player scrolls the view (REQ-UI-SCROLL) and when a selected object moves under it (a selected ship flying away), rather than following the object — which may leave it beside nothing, or beside an object that has left the view entirely. It likewise does not move when the selection is **expanded** by adding objects or reduced by removing them (REQ-UI-MULTI-SELECT), nor when a selected object is destroyed or deconstructed. Starting a **new** selection — clicking a different object, or a box drag that replaces the selection — places the panel anew against the new anchor rectangle. - **Fixed for the life of the selection.** The anchor rectangle, the selection gap, and the side are determined once, when the selection starts, and are not revisited while that selection lasts; the panel's own size is the only thing that may still move it (see **Resizing in place** below). The gap is therefore half a tile as the tile stood in that moment, and a view resize that changes the tile size (REQ-GW-TILE-SIZE) does not change it: the anchor rectangle it is measured from is a screen rectangle frozen in the same moment, and re-measuring one against a later tile size than the other has no meaning. The player may override the resulting position by dragging the panel's header (REQ-UI-SELECTION-PANEL-DRAG); the dragged position then takes the anchor rectangle's and the side's place for the rest of that selection. The panel **keeps its place on the screen** when the player scrolls the view (REQ-UI-SCROLL) and when a selected object moves under it (a selected ship flying away), rather than following the object — which may leave it beside nothing, or beside an object that has left the view entirely. It likewise does not move when the selection is **expanded** by adding objects or reduced by removing them (REQ-UI-MULTI-SELECT), nor when a selected object is destroyed or deconstructed. Starting a **new** selection — clicking a different object, or a box drag that replaces the selection — places the panel anew against the new anchor rectangle.
- **Resizing in place.** Only the anchor rectangle and the chosen side are fixed for the life of the selection (or, once the panel has been dragged, the dragged desired position — REQ-UI-SELECTION-PANEL-DRAG); the panel's geometry is **re-solved from them** whenever its content size changes (a section appearing or disappearing as the selection's state changes), the view is resized, or the build button bar's or controls panel's rectangle changes. Re-solving keeps the two edges the panel was placed by — its top edge, and the edge facing the anchor rectangle (its left edge when it sits to the right of the selection, its right edge when it sits to the left) — so the panel grows away from the selection rather than over it, and it never switches sides for as long as the selection lasts. What re-solving may change is the vertical result: growth that would take the panel outside the view or into either of those two widgets is resolved as in **Vertical placement** above, by shifting it up and capping its height, and a panel that shrinks again regains the room. - **Resizing in place.** Only the anchor rectangle, the selection gap, and the chosen side are fixed for the life of the selection (or, once the panel has been dragged, the dragged desired position — REQ-UI-SELECTION-PANEL-DRAG); the panel's geometry is **re-solved from them** whenever its content size changes (a section appearing or disappearing as the selection's state changes), the view is resized, or the build button bar's or controls panel's rectangle changes. Re-solving keeps the two edges the panel was placed by — its top edge, and the edge facing the anchor rectangle (its left edge when it sits to the right of the selection, its right edge when it sits to the left) — so the panel grows away from the selection rather than over it, and it never switches sides for as long as the selection lasts. What re-solving may change is the vertical result: growth that would take the panel outside the view or into either of those two widgets is resolved as in **Vertical placement** above, by shifting it up and capping its height, and a panel that shrinks again regains the room.
- **Visibility.** The panel is shown only while at least one object is selected. With an empty selection it is not shown at all (REQ-UI-EMPTY-SELECTION), leaving the full game world view visible. - **Visibility.** The panel is shown only while at least one object is selected. With an empty selection it is not shown at all (REQ-UI-EMPTY-SELECTION), leaving the full game world view visible.
- **Overlay behavior.** As for the build button bar (REQ-UI-BUILD-BAR): the panel 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. It is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the panel. The panel never overlaps the build button bar or the controls panel, because it stays above both wherever their rectangles meet its own; neither of them ever moves on the panel's account (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL). - **Overlay behavior.** As for the build button bar (REQ-UI-BUILD-BAR): the panel 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. It is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the panel. The panel never overlaps the build button bar or the controls panel, because it stays above both wherever their rectangles meet its own; neither of them ever moves on the panel's account (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL).
- **Input.** Mouse events over the panel are consumed by the panel 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 panel does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG). Beside the controls its content offers, the panel's own chrome offers one gesture: the header drag that moves it (REQ-UI-SELECTION-PANEL-DRAG). - **Input.** Mouse events over the panel are consumed by the panel 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 panel does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG). Beside the controls its content offers, the panel's own chrome offers one gesture: the header drag that moves it (REQ-UI-SELECTION-PANEL-DRAG).
- REQ-UI-SELECTION-PANEL-DRAG: **Moving the panel by its header.** The player can move the selection panel by pressing the left mouse button on the panel's **header** (REQ-UI-SELECTION-CARD) and dragging: the panel follows the cursor for the duration of the drag and stays where it is dropped on release. The header is the whole drag handle, and no other part of the panel starts a drag. - REQ-UI-SELECTION-PANEL-DRAG: **Moving the panel by its header.** The player can move the selection panel by pressing the left mouse button on the panel's **header** (REQ-UI-SELECTION-CARD) and dragging: the panel follows the cursor for the duration of the drag and stays where it is dropped on release. The header is the whole drag handle, and no other part of the panel starts a drag.
- **Desired position, not resolved position.** A drag sets only the panel's **desired top-left corner** in view coordinates. Where the panel actually lands is resolved from that desired position by the rules of REQ-UI-SELECTION-PANEL, exactly as an anchor-derived position is: the panel keeps its margin from the view's edges; its bottom is limited by the top edge, less that margin, of the build button bar (REQ-UI-BUILD-BAR) and of the controls panel (REQ-UI-CONTROLS-PANEL), but each only where the panel's own horizontal extent actually overlaps that widget's current rectangle; and a panel that does not fit above that limit is shifted up as far as the view's top margin and, failing that, capped in height with its content scrolling. The player therefore cannot park the panel over either widget, and neither widget ever moves on the panel's account (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL) — stepping around them stays entirely the panel's job. - **Desired position, not resolved position.** A drag sets only the panel's **desired top-left corner** in view coordinates. Where the panel actually lands is resolved from that desired position by the rules of REQ-UI-SELECTION-PANEL, exactly as an anchor-derived position is: the panel keeps its edge margin from the view's edges; its bottom is limited by the top edge, less that same margin, of the build button bar (REQ-UI-BUILD-BAR) and of the controls panel (REQ-UI-CONTROLS-PANEL), but each only where the panel's own horizontal extent actually overlaps that widget's current rectangle; and a panel that does not fit above that limit is shifted up as far as the edge margin at the view's top and, failing that, capped in height with its content scrolling. The player therefore cannot park the panel over either widget, and neither widget ever moves on the panel's account (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL) — stepping around them stays entirely the panel's job.
- **The desired position survives the resolution.** Resolving does not overwrite what the player set: the desired position is retained as dropped, so a panel that had to be shifted up or shortened returns to it as soon as the obstruction stops overlapping it — its content shrinks, the bar's button set changes (REQ-LOCK-BUILDING), the controls panel's context changes, or the view is resized. A desired position that the current view cannot honour at all is likewise kept, so enlarging the window brings the panel back to it. - **The desired position survives the resolution.** Resolving does not overwrite what the player set: the desired position is retained as dropped, so a panel that had to be shifted up or shortened returns to it as soon as the obstruction stops overlapping it — its content shrinks, the bar's button set changes (REQ-LOCK-BUILDING), the controls panel's context changes, or the view is resized. A desired position that the current view cannot honour at all is likewise kept, so enlarging the window brings the panel back to it.
- **What the drag replaces.** From the first drag on, the desired position replaces the anchor rectangle and the side (REQ-UI-SELECTION-PANEL) for the rest of the current selection; the panel no longer has a side and never switches to one. Re-solving (the **Resizing in place** rule of REQ-UI-SELECTION-PANEL) then keeps the top and left edges of the desired position, in place of the top edge and the edge facing the anchor, so the panel still grows away from where the player put it rather than over it. The panel may be dragged repeatedly; each drag replaces the previous desired position. - **What the drag replaces.** From the first drag on, the desired position replaces the anchor rectangle and the side (REQ-UI-SELECTION-PANEL) for the rest of the current selection; the panel no longer has a side and never switches to one. The selection gap goes with the anchor rectangle it was measured from and plays no further part: a player who drags the panel onto the selection is free to put it there. Re-solving (the **Resizing in place** rule of REQ-UI-SELECTION-PANEL) then keeps the top and left edges of the desired position, in place of the top edge and the edge facing the anchor, so the panel still grows away from where the player put it rather than over it. The panel may be dragged repeatedly; each drag replaces the previous desired position.
- **Scope: the current selection.** The desired position lasts as long as the selection it was set in — across the panel's own resizing, view resizes, and view scrolling (REQ-UI-SELECTION-PANEL), and across the selection being expanded or reduced (REQ-UI-MULTI-SELECT). Starting a **new** selection discards it: the panel is placed anew against the new anchor rectangle (REQ-UI-SELECTION-PANEL), and the player drags it again if they want it elsewhere. - **Scope: the current selection.** The desired position lasts as long as the selection it was set in — across the panel's own resizing, view resizes, and view scrolling (REQ-UI-SELECTION-PANEL), and across the selection being expanded or reduced (REQ-UI-MULTI-SELECT). Starting a **new** selection discards it: the panel is placed anew against the new anchor rectangle (REQ-UI-SELECTION-PANEL), and the player drags it again if they want it elsewhere.
- **Input.** The drag consumes its mouse events like every other event over the panel (REQ-UI-SELECTION-PANEL): the press, the movement, and the release never reach the game world, so dragging the header neither box-selects (REQ-UI-MULTI-SELECT) nor places belts (REQ-BLD-BELT-DRAG). The drag continues while the cursor moves outside the panel or outside the view, and ends when the left button is released, wherever that happens. A press and release on the header without movement moves nothing and has no other effect. - **Input.** The drag consumes its mouse events like every other event over the panel (REQ-UI-SELECTION-PANEL): the press, the movement, and the release never reach the game world, so dragging the header neither box-selects (REQ-UI-MULTI-SELECT) nor places belts (REQ-BLD-BELT-DRAG). The drag continues while the cursor moves outside the panel or outside the view, and ends when the left button is released, wherever that happens. A press and release on the header without movement moves nothing and has no other effect.
- **Presentation only.** Moving the panel is not a player command: it never enters the replay stream and has no effect on the simulation, consistent with the controls panel's collapsed state (REQ-UI-CONTROLS-PANEL). The desired position is not saved to disk. - **Presentation only.** Moving the panel is not a player command: it never enters the replay stream and has no effect on the simulation, consistent with the controls panel's collapsed state (REQ-UI-CONTROLS-PANEL). The desired position is not saved to disk.
- REQ-UI-PANEL-MODAL: **A modal opened from the selection panel opens on the panel.** A modal the player opens from a control inside the selection panel is placed **centered on the panel's current rectangle** rather than centered on the game window, so it appears where the player is already looking and under the cursor that just clicked the control. This is the same reason the panel itself is placed beside the selection instead of at a fixed corner (REQ-UI-SELECTION-PANEL): a modal centered on the window sends the cursor back across the view and then back again. Two modals are opened from the panel — the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), from the selection button; and the layout configuration dialog (REQ-MOD-UI-DIALOG), both from the "Configure" button (REQ-MOD-UI-PREVIEW) and when it opens automatically after a schematic change (REQ-MOD-UI-AUTO-DIALOG). - REQ-UI-PANEL-MODAL: **A modal opened from the selection panel opens on the panel.** A modal the player opens from a control inside the selection panel is placed **centered on the panel's current rectangle** rather than centered on the game window, so it appears where the player is already looking and under the cursor that just clicked the control. This is the same reason the panel itself is placed beside the selection instead of at a fixed corner (REQ-UI-SELECTION-PANEL): a modal centered on the window sends the cursor back across the view and then back again. Two modals are opened from the panel — the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), from the selection button; and the layout configuration dialog (REQ-MOD-UI-DIALOG), both from the "Configure" button (REQ-MOD-UI-PREVIEW) and when it opens automatically after a schematic change (REQ-MOD-UI-AUTO-DIALOG).
- **The panel's rectangle as it currently stands.** The modal is centered on where the panel actually is when the modal opens: the position resolved from the anchor rectangle (REQ-UI-SELECTION-PANEL) or, once the player has dragged the panel, the position they dragged it to (REQ-UI-SELECTION-PANEL-DRAG). The panel is always shown when one of these modals opens, since the modal is opened from a control within it (REQ-UI-EMPTY-SELECTION). - **The panel's rectangle as it currently stands.** The modal is centered on where the panel actually is when the modal opens: the position resolved from the anchor rectangle (REQ-UI-SELECTION-PANEL) or, once the player has dragged the panel, the position they dragged it to (REQ-UI-SELECTION-PANEL-DRAG). The panel is always shown when one of these modals opens, since the modal is opened from a control within it (REQ-UI-EMPTY-SELECTION).
- **Kept inside the game window.** Should the modal, centered that way, extend past an edge of the game window, it is pushed back inside; its size is never changed to make it fit. A modal larger than the window in a dimension is instead aligned with the window's top or left edge in that dimension, so the part read first stays visible. - **Kept inside the game window.** Should the modal, centered that way, extend past an edge of the game window, it is pushed back inside. A modal that cannot fit the window in a dimension is aligned with the window's top or left edge in that dimension, so the part read first stays visible, and the content that does not fit is reached by scrolling within the modal rather than by the modal growing past the window (REQ-UI-MODAL-CHROME).
- **Placement is all that changes.** The modal is modal as before, pauses the game and restores the speed on close as before (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-DIALOG), shows the dim over the entire window including the panel it sits on (REQ-UI-MODAL-DIM), and is dismissed the same way. That it covers the panel costs nothing: while it is open the panel is dimmed and takes no input anyway. - **Placement is all that changes.** The modal is modal as before, pauses the game and restores the speed on close as before (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-DIALOG), shows the dim over the entire window including the panel it sits on (REQ-UI-MODAL-DIM), and is dismissed the same way. That it covers the panel costs nothing: while it is open the panel is dimmed and takes no input anyway — a click on the part of the panel the modal leaves uncovered is a click outside the modal and dismisses it, like any other click beyond its frame (REQ-UI-DIALOG-DISMISS).
- **Only these modals.** Every other modal is placed as before, centered on the game window: the escape menu (REQ-UI-GAME-MENU), the blueprint save and selection dialogs (REQ-UI-BLUEPRINT-CREATE, REQ-UI-BLUEPRINT-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), none of which is opened from the panel. So is a modal opened from another modal rather than from the panel — the Create Blueprint name dialog within the layout configuration dialog (REQ-MOD-UI-BLUEPRINT-CREATE) — which is placed against the modal that opened it. - **Only these modals.** Every other modal is placed as before, centered on the game window: the escape menu (REQ-UI-GAME-MENU), the blueprint save and selection dialogs (REQ-UI-BLUEPRINT-CREATE, REQ-UI-BLUEPRINT-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), none of which is opened from the panel. So is a modal opened from another modal rather than from the panel — the Create Blueprint name dialog within the layout configuration dialog (REQ-MOD-UI-BLUEPRINT-CREATE) — which is placed against the modal that opened it.
- **Presentation only.** The placement is computed once, when the modal opens, and is not revisited while it is open; the panel cannot move meanwhile, being behind the modal and receiving no input. It is not a player command, never enters the replay stream, and has no effect on the simulation. - **Presentation only.** The placement is computed once, when the modal opens, and is not revisited while it is open; the panel cannot move meanwhile, being behind the modal and receiving no input. It is not a player command, never enters the replay stream, and has no effect on the simulation.
- 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 widgets floating over it (the build button bar, REQ-UI-BUILD-BAR, the selection panel, REQ-UI-SELECTION-PANEL, and the controls panel, REQ-UI-CONTROLS-PANEL) — 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), the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE), and the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) — 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 same applies when one modal hands directly off to another — the blueprint save dialog opening the blueprint selection dialog on confirm (REQ-UI-BLUEPRINT-CREATE): the dim persists across the handoff rather than flickering off and back on, and the simulation is not resumed in between. 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. - REQ-UI-MODAL-CHROME: **Every modal the player meets while playing is drawn by the game.** A modal is not an operating-system window: it has no title bar, no window border, and no window-manager close button, and the player can neither move it, resize it, nor drag it out of the game window. It is drawn inside the game window, on the dim that covers that window (REQ-UI-MODAL-DIM), which is the surface it sits on rather than a scrim behind a separate window. This holds for every one of them: the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), the blueprint save and selection dialogs (REQ-UI-BLUEPRINT-CREATE, REQ-UI-BLUEPRINT-DIALOG), the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE), the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), the escape menu (REQ-UI-GAME-MENU), and the game-over and win screens (REQ-HQ-GAME-OVER, REQ-WIN-SCREEN). One look for all of them, and one place the player's attention stays.
- **Each draws its own header.** Where a modal has a title it is a line the modal draws, as the blueprint selection dialog already draws `Blueprints` (REQ-UI-BLUEPRINT-DIALOG); where it has a close control it is a drawn button, as that dialog's `×` is. A modal without either shows neither — the schematic choice dialog has no way out but choosing (REQ-DEF-SCHEMATIC-DROP), and nothing about being a drawn modal gives it one.
- **It never leaves the window.** A modal is placed inside the game window (REQ-UI-PANEL-MODAL) and stays there. Content too large for the window **scrolls within the modal** rather than extending past the window's edge, so nothing is placed where the player cannot reach it.
- **The exception is failure reporting.** A failure that leaves nothing to draw on — a config file that fails to load at startup or on the reload a Restart performs (REQ-CFG-RELOAD), a blueprint file that cannot be read (REQ-UI-BLUEPRINT-LOAD) — is reported by a plain system message box instead. Such a message must reach the player when the game window is not in a state to host anything, so it cannot be built on the machinery whose failure it is reporting.
- **Presentation only.** How a modal is framed has no effect on the simulation and never enters the replay stream.
- 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 widgets floating over it (the build button bar, REQ-UI-BUILD-BAR, the selection panel, REQ-UI-SELECTION-PANEL, and the controls panel, REQ-UI-CONTROLS-PANEL) — behind that modal, so the game reads as inactive while the modal holds focus. The modal is drawn on that overlay rather than in a window of its own (REQ-UI-MODAL-CHROME), so the dim is the surface it sits on and not a separate layer that has to be kept in step with it. 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), the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE), and the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) — 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 same applies when one modal hands directly off to another — the blueprint save dialog opening the blueprint selection dialog on confirm (REQ-UI-BLUEPRINT-CREATE): the dim persists across the handoff rather than flickering off and back on, and the simulation is not resumed in between. 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.
- REQ-UI-DIALOG-DISMISS: **An open dialog is dismissed by Q or by a click outside it.** Either gesture does exactly what that dialog's own Cancel or close already does and nothing else, and both reach the same three dialogs. Q is the key that backs the player out in the game world (REQ-UI-HOTKEYS), doing the same on a dialog: a second key alongside Escape rather than a new behavior, so the player backs out of a dialog with the key their hand is already on. The click is the gesture that already puts a thing away by landing beside it — a click on empty world space clears the selection and with it the selection panel (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-EMPTY-SELECTION) — so a dialog is left the same way, by clicking away from it. The three dialogs that take both:
- **The recipe/schematic selection dialog** (REQ-UI-SELECT-BUTTON) — closes with the current recipe or schematic unchanged.
- **The blueprint selection dialog** (REQ-UI-BLUEPRINT-DIALOG) — closes with no other effect, as its close button does.
- **The layout configuration dialog** (REQ-MOD-UI-DIALOG) — the dismissal is its Cancel, discarding every change made in this dialog session. Here it backs out **one step at a time**, as Q does in the game world: while a module is selected for placement (REQ-MOD-PLACEMENT) it clears that selection, while remove mode is active (REQ-MOD-REMOVE) it leaves remove mode, and only with neither active does it cancel the dialog. A single press or click therefore never both leaves a mode and discards the session — which is what keeps a mis-aimed click outside the frame from costing the player a layout they were in the middle of placing.
In each case the simulation speed is restored as it is on any other close (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-DIALOG, REQ-UI-BLUEPRINT-DIALOG), and a dialog opened from another (REQ-MOD-UI-BLUEPRINT-CREATE) is dismissed before the one that opened it, Q reaching whichever holds focus.
**What counts as a click outside.** A left button press and release that both land outside the dialog's own rectangle. The dialog is modal and dims the whole window (REQ-UI-MODAL-DIM), so "outside" is everything beyond that rectangle without distinction — the dimmed game world, the floating panels, the header bar — including the selection panel a panel-centered modal is sitting on (REQ-UI-PANEL-MODAL). A gesture that *begins* inside the dialog and ends outside it — a scrollbar dragged past the frame, the cursor leaving the layout grid before the button comes up — is not a click outside and dismisses nothing, so no drag can end by discarding the dialog it was made in. Only the left button does this; a right click outside has no effect.
**The click is spent on the dismissal.** It closes the dialog and does nothing to whatever it landed on: it selects no building and clears no selection, presses no button, and places nothing. The window behind a modal takes no input while the modal is open, and dismissing the modal does not turn that click into one — the player clicks away once to close, and again if they then want to act on what is underneath.
**Where neither gesture is a dismissal.** Q stays an ordinary character in the two dialogs that take a typed name — the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE) and the Create Blueprint name dialog of the layout configuration dialog (REQ-MOD-UI-BLUEPRINT-CREATE) — where a dismissal key would be unable to tell a name from a command; a click outside them dismisses nothing either, for the related reason that a half-typed name is work in progress that no stray gesture may discard. They are left by their own Cancel or Escape. While one of them holds focus nothing behind it is dismissible either: the dialog that opened it becomes reachable again only once the one on top is gone. The escape menu (REQ-UI-GAME-MENU) keeps Escape and its Continue button, the game-over and win screens (REQ-HQ-GAME-OVER, REQ-WIN-SCREEN) are ended by their own buttons, and the schematic choice dialog is not dismissible at all — by key, by click outside, or otherwise (REQ-DEF-SCHEMATIC-DROP).
### Game World ### Game World
@@ -512,13 +546,19 @@ The screen is a single column: a header bar across the top and the game world vi
- **W** — increases game speed by one step in the sequence 0×, 0.5×, 1×, 2×, 10× (no wrap-around past 10×). - **W** — increases game speed by one step in the sequence 0×, 0.5×, 1×, 2×, 10× (no wrap-around past 10×).
- **S** — decreases game speed by one step in the same sequence (no wrap-around past 0×). - **S** — decreases game speed by one step in the same sequence (no wrap-around past 0×).
- **A / D** — scroll the view left / right (REQ-UI-SCROLL). - **A / D** — scroll the view left / right (REQ-UI-SCROLL).
- **Q** — context-sensitive. If a build mode is active (builder mode or blueprint placement mode), pressing Q exits it. Otherwise, pressing Q toggles deconstruct mode: it enters deconstruct mode if inactive, or exits deconstruct mode if already active. (See also REQ-UI-DECONSTRUCT-BUTTON for the equivalent button.) - **Q** — context-sensitive: one key that backs out of whatever the player is currently in, and enters deconstruct mode when they are in nothing. Its cases are evaluated in order:
- A dialog that takes Q is open — Q dismisses it, and the cases below do not apply: the dialog holds focus, and the game world is not what the player is backing out of (REQ-UI-DIALOG-DISMISS).
- A build mode is active (builder mode, blueprint placement mode, or deconstruct mode) — Q exits it.
- Something is selected — Q clears the selection (REQ-UI-EMPTY-SELECTION). The two cases never both apply, a selection and a build mode being mutually exclusive (REQ-UI-SELECTION-EXCLUSIVE); the order is stated only to fix the reading.
- Neither — Q enters deconstruct mode (see also REQ-UI-DECONSTRUCT-BUTTON for the equivalent button, which enters it from any of these states and clears the selection in doing so).
Entering deconstruct mode by keyboard while holding a selection therefore takes two presses — the first clears it, the second enters the mode — whereas the Deconstruct button does it in one click.
- **R / Shift+R** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE). - **R / Shift+R** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE).
- **C** — create a temporary blueprint from the current selection and enter its placement mode (REQ-UI-BLUEPRINT-TEMP). Has effect only when at least one player-placeable building is selected; otherwise it does nothing. - **C** — create a temporary blueprint from the current selection and enter its placement mode (REQ-UI-BLUEPRINT-TEMP). Has effect only when at least one player-placeable building is selected; otherwise it does nothing.
- **V** — re-enter placement mode for the last temporary blueprint created with C (REQ-UI-BLUEPRINT-TEMP). Does nothing when no temporary blueprint exists. - **V** — re-enter placement mode for the last temporary blueprint created with C (REQ-UI-BLUEPRINT-TEMP). Does nothing when no temporary blueprint exists.
- **Ctrl+C** — save the current selection as a named blueprint: opens the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE). Has effect only when at least one player-placeable building is selected; otherwise it does nothing. - **Ctrl+C** — save the current selection as a named blueprint: opens the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE). Has effect only when at least one player-placeable building is selected; otherwise it does nothing.
- **Ctrl+V** — opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG), from which a saved blueprint is picked for placement. It is available whenever the game is being played, regardless of the current selection or of which build mode is active, and opens the dialog even when no blueprints are saved yet. - **Ctrl+V** — opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG), from which a saved blueprint is picked for placement. It is available whenever the game is being played, regardless of the current selection or of which build mode is active, and opens the dialog even when no blueprints are saved yet.
- **Escape** — opens the escape menu (REQ-UI-GAME-MENU). While a blueprint dialog is open, Escape closes that dialog instead (REQ-UI-BLUEPRINT-DIALOG). - **Escape** — opens the escape menu (REQ-UI-GAME-MENU). While a dismissible dialog is open, Escape closes that dialog instead — the blueprint dialogs (REQ-UI-BLUEPRINT-DIALOG) and the two dialogs Q also dismisses (REQ-UI-DIALOG-DISMISS). The schematic choice dialog is the exception it cannot close (REQ-DEF-SCHEMATIC-DROP).
- **Build mode selection** — pressing a build hotkey activates builder mode for the corresponding building type, equivalent to clicking its build button (REQ-BLD-BUILDER-MODE): - **Build mode selection** — pressing a build hotkey activates builder mode for the corresponding building type, equivalent to clicking its build button (REQ-BLD-BUILDER-MODE):
- **1** — Belt, **2** — Splitter, **3** — Tunnel (the unified tunnel build mode, REQ-BLD-TUNNEL-MODE). Hotkey 4 is unused. - **1** — Belt, **2** — Splitter, **3** — Tunnel (the unified tunnel build mode, REQ-BLD-TUNNEL-MODE). Hotkey 4 is unused.
- **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant. - **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant.
@@ -538,7 +578,7 @@ The screen is a single column: a header bar across the top and the game world vi
### Escape Menu ### Escape Menu
- REQ-UI-GAME-MENU: Pressing Escape at any time opens the escape menu as a modal dialog and pauses the simulation (sets speed to 0×). On close, the simulation speed is restored to what it was before the menu was opened — so if the game was already paused, it remains paused. The menu contains three buttons: - REQ-UI-GAME-MENU: Pressing Escape at any time opens the escape menu as a modal dialog drawn by the game (REQ-UI-MODAL-CHROME) and pauses the simulation (sets speed to 0×). On close, the simulation speed is restored to what it was before the menu was opened — so if the game was already paused, it remains paused. The menu contains three buttons:
- **Continue** — closes the menu and returns to the game. - **Continue** — closes the menu and returns to the game.
- **Restart** — resets the simulation to its initial state and closes the menu at 1× speed. - **Restart** — resets the simulation to its initial state and closes the menu at 1× speed.
- **Quit** — closes the application. - **Quit** — closes the application.
@@ -552,6 +592,9 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
- REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or piece of debris), the selection panel is not shown at all — it is hidden rather than shown empty, so the full game world view is visible (REQ-UI-SELECTION-PANEL). It reappears as soon as an object is selected. - REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or piece of debris), the selection panel is not shown at all — it is hidden rather than shown empty, so the full game world view is visible (REQ-UI-SELECTION-PANEL). It reappears as soon as an object is selected.
- REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with debris). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus debris, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident piece of debris (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT). - REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with debris). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus debris, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident piece of debris (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT).
- REQ-UI-SELECTION-EXCLUSIVE: **A selection and a build mode are mutually exclusive.** At any moment the player is either holding a selection or in one of the build modes — builder mode (REQ-BLD-BUILDER-MODE), blueprint placement mode (REQ-UI-BLUEPRINT-MODE), or deconstruct mode (REQ-UI-DECONSTRUCT-BUTTON) — never both. **Entering any build mode clears the selection**, whichever way the mode is entered: a build button, a build hotkey, the Deconstruct button, the Q deconstruct toggle, C, or picking a blueprint card (REQ-UI-HOTKEYS, REQ-UI-BLUEPRINT-TEMP, REQ-UI-BLUEPRINT-DIALOG). Switching directly from one build mode to another therefore has no selection left to clear. The converse direction needs no rule of its own: while a build mode is active there is no gesture that selects — a left click places, marks for demolition, or transfers settings instead (REQ-BLD-BUILDER-MODE, REQ-BLD-DECONSTRUCT-CLICK, REQ-UI-BLUEPRINT-TRANSFER) — so a selection can only be made after the mode is left.
- **A selection is read before it is cleared.** The gestures that act on the selection and then enter a mode capture it first: C builds its temporary blueprint from the selection and only then enters placement mode (REQ-UI-BLUEPRINT-TEMP), and Ctrl+C's save dialog creates its blueprint before the selection dialog it opens can start a placement (REQ-UI-BLUEPRINT-CREATE). Neither loses what it was invoked on.
- **What follows from it.** The selection panel is never shown while a build mode is active, being hidden on an empty selection (REQ-UI-EMPTY-SELECTION), and the world draws no selection outlines there. The controls panel's contexts (REQ-UI-CONTROLS-CONTENT) become a partition of the state rather than a precedence rule: exactly one of General, Selection, Build, Blueprint, and Deconstruct applies, and the Selection context's "no build mode active" clause restates the exclusivity instead of resolving an overlap. Nothing here reaches the simulation — selection and build mode are both presentation state, and clearing a selection is not a player command, never enters the replay stream, and has no effect on the simulation.
- REQ-UI-SELECTION-CARD: **Card structure.** Every panel content is a card with the same three parts, top to bottom: - REQ-UI-SELECTION-CARD: **Card structure.** Every panel content is a card with the same three parts, top to bottom:
- **Header** — always shown. It holds the selection's identity symbol on the left — the building's icon glyph (REQ-UI-WORLD-ICON), a ship's schematic color swatch, or the kind symbol of a defence station or piece of debris — the selection's name beside it, and one optional **right slot**. The right slot holds a status indicator (REQ-UI-SELECTION-STATUS), a ship's current behavior (REQ-UI-SHIP-BEHAVIOR), or an object count — never more than one of them; which one applies is stated per content in REQ-UI-SELECTION-CONTENT. The header carries no control of its own, and doubles as the panel's drag handle (REQ-UI-SELECTION-PANEL-DRAG). - **Header** — always shown. It holds the selection's identity symbol on the left — the building's icon glyph (REQ-UI-WORLD-ICON), a ship's schematic color swatch, or the kind symbol of a defence station or piece of debris — the selection's name beside it, and one optional **right slot**. The right slot holds a status indicator (REQ-UI-SELECTION-STATUS), a ship's current behavior (REQ-UI-SHIP-BEHAVIOR), or an object count — never more than one of them; which one applies is stated per content in REQ-UI-SELECTION-CONTENT. The header carries no control of its own, and doubles as the panel's drag handle (REQ-UI-SELECTION-PANEL-DRAG).
- **Configuration group** — the controls that change how the selected object is set up: the recipe/schematic selection control (REQ-UI-SELECT-BUTTON), a shipyard's layout preview and Configure button (REQ-MOD-UI-PREVIEW), and a splitter's output filters (REQ-BLD-SPLITTER). It is shown identically for an operational building and for a construction site of the same type (REQ-BLD-SITE-CONFIG). - **Configuration group** — the controls that change how the selected object is set up: the recipe/schematic selection control (REQ-UI-SELECT-BUTTON), a shipyard's layout preview and Configure button (REQ-MOD-UI-PREVIEW), and a splitter's output filters (REQ-BLD-SPLITTER). It is shown identically for an operational building and for a construction site of the same type (REQ-BLD-SITE-CONFIG).
@@ -581,26 +624,30 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
- **Debris** — several pieces of debris and nothing else. Their remaining scrap sums into one value (REQ-UI-DEBRIS-PANEL). - **Debris** — several pieces of debris and nothing else. Their remaining scrap sums into one value (REQ-UI-DEBRIS-PANEL).
Every other multi-selection falls back to the count summary. In particular a selection mixing a splitter with belts does not aggregate (a splitter carries per-object output filters, which have no aggregate), and neither do several production buildings of one type (per-building buffers and cycle progress have no aggregate). Every other multi-selection falls back to the count summary. In particular a selection mixing a splitter with belts does not aggregate (a splitter carries per-object output filters, which have no aggregate), and neither do several production buildings of one type (per-building buffers and cycle progress have no aggregate).
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows its symbol and name in the header (REQ-UI-SELECTION-CARD), its current recipe or schematic selection (REQ-UI-SELECT-BUTTON) and recipe summary (REQ-UI-RECIPE-SUMMARY) in the configuration group, and its input and output buffer contents in the runtime group. Each buffered item is shown as an **item chip** bearing that item's icon on its colored square (REQ-UI-ITEM-ICON) and its current count, and hovering a chip shows that item's production tooltip (REQ-UI-ITEM-TOOLTIP): - REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows its symbol and name in the header (REQ-UI-SELECTION-CARD), its current recipe or schematic selection (REQ-UI-SELECT-BUTTON) and recipe summary (REQ-UI-RECIPE-SUMMARY) in the configuration group, and its input and output buffer contents in the runtime group. Each buffered item is shown as an **item chip** bearing that item's icon on its colored square (REQ-UI-ITEM-ICON) and its current count, and hovering or clicking a chip shows that item's production tooltip (REQ-UI-ITEM-TOOLTIP):
- an **input** chip shows the per-cycle amount below the count (the items consumed per run, e.g. `/ 2 per cycle`), or the count alone when the building has no selected recipe or schematic to give one; - an **input** chip shows the per-cycle amount below the count (the items consumed per run, e.g. `/ 2 per cycle`), or the count alone when the building has no selected recipe or schematic to give one;
- an **output** chip shows the count against **that item's own** output buffer capacity as `a / b` (REQ-MAT-OUTPUT-BUFFER), with the item's name below. Each output chip therefore stands for one buffer, and a Reprocessing Plant shows one per possible roll. - an **output** chip shows the count against **that item's own** output buffer capacity as `a / b` (REQ-MAT-OUTPUT-BUFFER), with the item's name below. Each output chip therefore stands for one buffer, and a Reprocessing Plant shows one for every item any of its output groups can produce (REQ-MAT-OUTPUT-GROUP).
Input and output chips form separately captioned sections (REQ-UI-SELECTION-CARD). A section lists a chip for **every item the building's cycle involves**, whether or not the buffer currently holds any: an empty buffer reads `0` rather than its chip disappearing, so the card keeps one shape while the building runs. A section left with no chips at all is not shown. The production section (REQ-UI-PRODUCTION-PROGRESS) sits **between them**, so the card reads in the direction the materials flow: what goes in, what is being made of it, what has come out. For a selected construction site the buffer sections are omitted (REQ-BLD-SITE-CONFIG). Input and output chips form separately captioned sections (REQ-UI-SELECTION-CARD). A section lists a chip for **every item the building's cycle involves**, whether or not the buffer currently holds any: an empty buffer reads `0` rather than its chip disappearing, so the card keeps one shape while the building runs. A section left with no chips at all is not shown. The production section (REQ-UI-PRODUCTION-PROGRESS) sits **between them**, so the card reads in the direction the materials flow: what goes in, what is being made of it, what has come out. For a selected construction site the buffer sections are omitted (REQ-BLD-SITE-CONFIG).
**Only unlocked items are listed.** Should a building's buffers carry an entry for an item the player cannot make yet, it is left out of both sections, consistent with the rest of the UI hiding what is not unlocked yet (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SPLITTER). **Only unlocked items are listed.** Should a building's buffers carry an entry for an item the player cannot make yet, it is left out of both sections, consistent with the rest of the UI hiding what is not unlocked yet (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SPLITTER).
- REQ-UI-RECIPE-SUMMARY: Below the recipe/schematic selection control, a building running a recipe or schematic shows a one-line **recipe summary**: each input item's icon on its colored square (REQ-UI-ITEM-ICON) with its per-cycle amount and the inputs separated by `+`, an arrow, each output item's icon on its colored square with its per-cycle amount, and the cycle time in seconds. It restates what the building will do without opening the selection dialog, and it is the panel's only display of the cycle time. For a Shipyard the summary is built from the schematic's materials and production time including the placed modules' contributions (REQ-BLD-SHIPYARD, REQ-MOD-STAT-CALC), matching the buffers beneath it. A building with no recipe or schematic selected shows no summary — including an auto-recipe building that has yet to select one (REQ-BLD-AUTO-RECIPE), which shows none until it does and keeps it from then on, so the card does not resize in step with the building's status (REQ-UI-SELECTION-STATUS, REQ-UI-SELECTION-PANEL). - REQ-UI-RECIPE-SUMMARY: Below the recipe/schematic selection control, a building running a recipe or schematic shows a one-line **recipe summary**: each input item's icon on its colored square (REQ-UI-ITEM-ICON) with its per-cycle amount and the inputs separated by `+`, an arrow, then what the recipe produces, and the cycle time in seconds. The output side lists each item of an output group with its icon and per-cycle amount, the items within a group separated by `+` as the inputs are — they are produced together — and the **groups separated by `/`**, since only one of them happens (REQ-MAT-OUTPUT-GROUP). A recipe with a single group therefore reads exactly as before, and a Reprocessing Plant's reads as the alternatives it is rather than as one combined yield. It restates what the building will do without opening the selection dialog, and it is the panel's only display of the cycle time. For a Shipyard the summary is built from the schematic's materials and production time including the placed modules' contributions (REQ-BLD-SHIPYARD, REQ-MOD-STAT-CALC), matching the buffers beneath it. A building with no recipe or schematic selected shows no summary — including an auto-recipe building that has yet to select one (REQ-BLD-AUTO-RECIPE), which shows none until it does and keeps it from then on, so the card does not resize in step with the building's status (REQ-UI-SELECTION-STATUS, REQ-UI-SELECTION-PANEL).
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the panel's runtime group shows a captioned **production section** between the input and output buffer sections (REQ-UI-SINGLE-SELECTION): a horizontal progress bar filled to the completion of the active production cycle, with that completion beside the caption as an integer percentage (e.g. `72%`), or the text `idle` in place of the percentage and an empty bar when no production cycle is active. The cycle time is shown in the recipe summary (REQ-UI-RECIPE-SUMMARY) rather than repeated here. When no recipe or schematic is selected, the production section is not shown at all. - REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the panel's runtime group shows a captioned **production section** between the input and output buffer sections (REQ-UI-SINGLE-SELECTION): a horizontal progress bar filled to the completion of the active production cycle, with that completion beside the caption as an integer percentage (e.g. `72%`), or the text `idle` in place of the percentage and an empty bar when no production cycle is active. The cycle time is shown in the recipe summary (REQ-UI-RECIPE-SUMMARY) rather than repeated here. When no recipe or schematic is selected, the production section is not shown at all.
- 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-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).
- **Rectangle geometry.** The box is **not** snapped to tiles: its two corners are the exact world positions the button went down at and the cursor is at now, so the rectangle is drawn where the mouse actually went and follows it pixel by pixel. The corners are held in world coordinates rather than screen ones, so the anchor stays on the spot in the world it was placed on when the view scrolls under a held button (REQ-UI-SCROLL).
- **Coverage.** What the box covers follows the same rectangle, not the tiles it touches. An object that occupies whole tiles — a building, a construction site, a defence station — is covered when the rectangle overlaps any of its body cells, so grazing a building's tile selects it. An object that has a position rather than a footprint — a ship, a piece of debris — is covered when the rectangle contains its centre, so what the player sees enclosed by the rectangle is exactly what the release selects. This is also what makes box and click agree: a click already hit-tests ships and debris against their world positions (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT), not against the tile they stand on.
- **When the rectangle appears.** The rectangle is drawn only once the cursor has moved at least **2 pixels** from the position the button went down at — a press alone draws nothing, so a plain click does not flash a rectangle. The threshold is in screen pixels because it only separates a click from a drag, which is a question of hand steadiness. Once shown, the rectangle stays shown for the rest of the drag, including when the cursor comes back to where it started. It is measured against where the anchor sits on screen at that moment, so scrolling the view while the button is held moves no cursor but still crosses the threshold, the box having grown all the same. Below the threshold the gesture is a click, and the box it resolves on release is the **whole tile** the button went down on — that is what makes a click select or mark what it points at (REQ-UI-SELECTION-CATEGORIES, REQ-BLD-DECONSTRUCT-CLICK) rather than the empty rectangle a motionless cursor spans.
- **Rectangle color.** While dragging, the selection rectangle is drawn as an outline in `visuals.toml [overlays].selected_outline` — the same color and config entry as the outline drawn in the world around the objects that end up selected, so the box and the selection it produces read as one thing. **Exception:** while deconstruct mode is active (REQ-UI-DECONSTRUCT-BUTTON, REQ-UI-HOTKEYS), the box drag marks buildings for demolition instead (REQ-BLD-DECONSTRUCT-BOX) and the rectangle is drawn in the deconstruct color — the RGB of `visuals.toml [overlays].deconstruct_tint`, drawn **fully opaque**. That entry's alpha channel governs only the fills it tints (the deconstruct-mode hover tint and queued buildings, REQ-UI-DECONSTRUCT-BORDER, REQ-BLD-DECON-QUEUE) and is not applied to this outline, which would otherwise be too faint to see. The rectangle's geometry is the same in both modes.
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected and the selection does not aggregate (REQ-UI-SELECTION-AGGREGATE), the panel shows a count summary. Its header names the size of the selection as `<n> buildings` in place of an object name, and carries no symbol and nothing in its right slot. Below it is one row per selected building type — the type's symbol, its name, and the number selected as `x<count>` — one type per row, and no per-building detail. A final row shows the **total building block cost** of the selection, captioned `Total cost` with the value followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON): 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-CARD). Construction sites count at their building type's full placement cost regardless of construction progress. - REQ-UI-MULTI-SELECTION: When multiple buildings are selected and the selection does not aggregate (REQ-UI-SELECTION-AGGREGATE), the panel shows a count summary. Its header names the size of the selection as `<n> buildings` in place of an object name, and carries no symbol and nothing in its right slot. Below it is one row per selected building type — the type's symbol, its name, and the number selected as `x<count>` — one type per row, and no per-building detail. A final row shows the **total building block cost** of the selection, captioned `Total cost` with the value followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON): 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-CARD). 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, in its configuration group (REQ-UI-SELECTION-CARD). Recipe selection (miner, assembler, smelter, reprocessing plant) 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-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel, in its configuration group (REQ-UI-SELECTION-CARD). Recipe selection (miner, assembler, smelter, reprocessing plant) 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 the Smelter's and Reprocessing Plant's recipe per REQ-BLD-AUTO-RECIPE) and schematic selection (Shipyard) are each presented in the selection 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) and that opens centered on the selection panel, as every modal opened from the panel does (REQ-UI-PANEL-MODAL). The dialog contains a vertical list of option buttons, one per selectable option, each describing itself (REQ-UI-SELECT-OPTIONS) — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Neither the option buttons nor the selection button carries a tooltip: an option button states what it makes on its own face, and what the building has selected is drawn beneath the selection button as the recipe summary (REQ-UI-RECIPE-SUMMARY), with the production paths of the items involved reachable by hovering the card's item chips (REQ-UI-ITEM-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selection 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-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe, and the Smelter's and Reprocessing Plant's recipe per REQ-BLD-AUTO-RECIPE) and schematic selection (Shipyard) are each presented in the selection 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) and that opens centered on the selection panel, as every modal opened from the panel does (REQ-UI-PANEL-MODAL). The dialog contains a vertical list of option buttons, one per selectable option, each describing itself (REQ-UI-SELECT-OPTIONS) — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Neither the option buttons nor the selection button carries a tooltip: an option button states what it makes on its own face, and what the building has selected is drawn beneath the selection button as the recipe summary (REQ-UI-RECIPE-SUMMARY), with the production paths of the items involved reachable by hovering the card's item chips (REQ-UI-ITEM-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selection panel. The dialog can be dismissed without changing the current selection — by closing it without clicking an option, by Escape, by Q, or by clicking outside it (REQ-UI-DIALOG-DISMISS). 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-OPTIONS: **Option list of the selection dialog.** The selection dialog (REQ-UI-SELECT-BUTTON) lists its options as a **single vertical column** of buttons, one per option, rather than a grid: each button is as wide as the dialog and states what the option does, which needs a line of its own. No option button carries a tooltip — the button face is the whole description, so the player reads every option's inputs, product, and time from the list itself without hovering anything. Should the column be taller than the space the window leaves the dialog — a fully unlocked Assembler offers more options than any window can hold, and a modal is never resized to fit (REQ-UI-PANEL-MODAL) — the list **scrolls** within the dialog rather than the dialog growing past the window. - REQ-UI-SELECT-OPTIONS: **Option list of the selection dialog.** The selection dialog (REQ-UI-SELECT-BUTTON) lists its options as a **single vertical column** of buttons, one per option, rather than a grid: each button is as wide as the dialog and states what the option does, which needs a line of its own. No option button carries a tooltip — the button face is the whole description, so the player reads every option's inputs, product, and time from the list itself without hovering anything. Should the column be taller than the space the window leaves the dialog — a fully unlocked Assembler offers more options than any window can hold, and a modal is never resized to fit (REQ-UI-PANEL-MODAL) — the list **scrolls** within the dialog rather than the dialog growing past the window.
- A **recipe** option (Miner, Assembler) shows the **recipe name** on its first line and, beneath it, that recipe drawn as the recipe summary line draws it (REQ-UI-RECIPE-SUMMARY): each input item's icon on its colored square with its per-cycle amount, an arrow, each output item's icon with its amount, and the cycle time. A miner recipe consumes nothing, so its line begins at the arrow. - A **recipe** option shows the **recipe name** on its first line and, beneath it, that recipe drawn as the recipe summary line draws it (REQ-UI-RECIPE-SUMMARY): each input item's icon on its colored square with its per-cycle amount, an arrow, its output groups with their amounts, and the cycle time. A miner recipe consumes nothing, so its line begins at the arrow.
- A **ship schematic** option (Shipyard) shows the ship's `display_name` on its first line and, beneath it, the icons and quantities of its base required materials (`[ship.schematic].materials`, excluding any module contributions) with the base production time (`[ship.schematic].production_time_seconds`). A ship is not an item and has no icon of its own, so this line names no output: the button's caption is what it produces. - A **ship schematic** option (Shipyard) shows the ship's `display_name` on its first line and, beneath it, the icons and quantities of its base required materials (`[ship.schematic].materials`, excluding any module contributions) with the base production time (`[ship.schematic].production_time_seconds`). A ship is not an item and has no icon of its own, so this line names no output: the button's caption is what it produces.
- The `(None)` option shows its text caption alone. On an auto-recipe building it is captioned `(Auto)` instead, because there it does not leave the building idle but returns it to automatic selection (REQ-BLD-AUTO-RECIPE). - The `(None)` option shows its text caption alone. On an auto-recipe building it is captioned `(Auto)` instead, because there it does not leave the building idle but returns it to automatic selection (REQ-BLD-AUTO-RECIPE).
- REQ-UI-ITEM-TOOLTIP: **Item production tooltip.** Hovering an item chip in the selection panel — an input or output buffer chip (REQ-UI-SINGLE-SELECTION) or the HQ's block stock chip (REQ-UI-HQ-PANEL) — shows a tooltip telling the player where that item comes from. It has a heading and a body: - REQ-UI-ITEM-TOOLTIP: **Item production tooltip.** Hovering an item chip in the selection panel — an input or output buffer chip (REQ-UI-SINGLE-SELECTION) or the HQ's block stock chip (REQ-UI-HQ-PANEL) — shows a tooltip telling the player where that item comes from. A chip has no click action of its own, so clicking one shows the same tooltip at once (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS). It has a heading and a body:
- the heading is the **hovered item's name**. For an input chip this is the only place the item is named at all, since such a chip carries a count and no name (REQ-UI-SINGLE-SELECTION). - the heading is the **chip's item name**. For an input chip this is the only place the item is named at all, since such a chip carries a count and no name (REQ-UI-SINGLE-SELECTION).
- the body is the caption `Produced by` followed by one **recipe line** per unlocked recipe that produces the item. A recipe line is a small **card** of two rows: the icon of the building that runs the recipe (REQ-UI-BUILD-ICON) and the recipe's name on the first, and the recipe itself on the second, drawn as the recipe summary draws it (REQ-UI-RECIPE-SUMMARY) — each input item's icon on its colored square with its per-cycle amount, the inputs separated by `+`, an arrow, each output item's icon with its amount, and the cycle time. Two rows rather than one because an identity and a cycle read as different things, and a single row of icons, names and numbers runs too long to scan; a card around each because several producers stacked as bare lines read as one field of icons and numbers rather than as separate recipes. A line drawn where its surroundings already frame it — on an option button (REQ-UI-SELECT-OPTIONS), or as the panel's recipe summary — takes no card of its own. An item may have several producers — an iron ingot is smelted from ore, smelted from scrap, and recovered by reprocessing — and the building icon and recipe name are what tell those lines apart and tell the player which building to place for which path. - the body is the caption `Produced by` followed by one **recipe line** per unlocked recipe that produces the item. A recipe line is a small **card** of two rows: the icon of the building that runs the recipe (REQ-UI-BUILD-ICON) and the recipe's name on the first, and the recipe itself on the second, drawn as the recipe summary draws it (REQ-UI-RECIPE-SUMMARY) — each input item's icon on its colored square with its per-cycle amount, the inputs separated by `+`, an arrow, its output groups with their amounts, and the cycle time. Two rows rather than one because an identity and a cycle read as different things, and a single row of icons, names and numbers runs too long to scan; a card around each because several producers stacked as bare lines read as one field of icons and numbers rather than as separate recipes. A line drawn where its surroundings already frame it — on an option button (REQ-UI-SELECT-OPTIONS), or as the panel's recipe summary — takes no card of its own. An item may have several producers — an iron ingot is smelted from ore, smelted from scrap, and recovered by reprocessing — and the building icon and recipe name are what tell those lines apart and tell the player which building to place for which path.
**Only recipes the player can run are listed**, consistent with the rest of the UI hiding what the player cannot make yet. What that means differs by building, because only Miner and Assembler recipes are unlocked individually (REQ-LOCK-UI-RECIPE): those are listed once unlocked, while a Smelter's or Reprocessing Plant's recipes (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are listed once **their building** is unlocked (REQ-LOCK-BUILDING) — there is no sense in naming a path through a plant the player cannot place. Two cases have no recipe line to show, and each says so in place of the list rather than leaving the tooltip bare: **Only recipes the player can run are listed**, consistent with the rest of the UI hiding what the player cannot make yet. What that means differs by building, because only Miner and Assembler recipes are unlocked individually (REQ-LOCK-UI-RECIPE): those are listed once unlocked, while a Smelter's or Reprocessing Plant's recipes (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are listed once **their building** is unlocked (REQ-LOCK-BUILDING) — there is no sense in naming a path through a plant the player cannot place. Two cases have no recipe line to show, and each says so in place of the list rather than leaving the tooltip bare:
- **An item no recipe produces at all.** Scrap is salvaged from debris (REQ-RES-DEBRIS-DROP) rather than crafted, so its tooltip reads `Salvaged from debris` in place of the `Produced by` caption and lists nothing beneath it. - **An item no recipe produces at all.** Scrap is salvaged from debris (REQ-RES-DEBRIS-DROP) rather than crafted, so its tooltip reads `Salvaged from debris` in place of the `Produced by` caption and lists nothing beneath it.
@@ -608,7 +655,7 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
The tooltip belongs to item chips only. The selection dialog shows no tooltips at all, its buttons describing themselves (REQ-UI-SELECT-OPTIONS), and the icons of the recipe summary line (REQ-UI-RECIPE-SUMMARY) show none either: they are parts of one line that already describes one recipe, rather than standalone item displays. The tooltip belongs to item chips only. The selection dialog shows no tooltips at all, its buttons describing themselves (REQ-UI-SELECT-OPTIONS), and the icons of the recipe summary line (REQ-UI-RECIPE-SUMMARY) show none either: they are parts of one line that already describes one recipe, rather than standalone item displays.
- REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel's runtime group shows a **"Clear stuck items"** button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels. The button acts on every selected tile, which is why a selection of belts and tunnel ends aggregates into one content rather than a count summary (REQ-UI-SELECTION-AGGREGATE). - REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel's runtime group shows a **"Clear stuck items"** button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels. The button acts on every selected tile, which is why a selection of belts and tunnel ends aggregates into one content rather than a count summary (REQ-UI-SELECTION-AGGREGATE).
- REQ-UI-HQ-PANEL: When the HQ is selected, the panel shows the HQ's **HP** as a bar labelled `current / maximum` (REQ-HQ-STATS, REQ-UI-HP-BARS) and, beneath it, the **global building blocks stock** — the same value as the header bar's stock display (REQ-UI-BLOCKS-ICON), rendered as an item chip (REQ-UI-SINGLE-SELECTION) carrying the `building_block` icon on its colored square — a chip is an item display, so it takes the square even though the header bar's inline block icon does not (REQ-UI-ITEM-ICON, REQ-UI-BLOCKS-ICON), and hovering it shows the item production tooltip as any other chip does (REQ-UI-ITEM-TOOLTIP). The HP comes first, as it does on every card that has it (REQ-UI-SELECTION-CARD). The HQ has no input or output buffers of its own: building blocks delivered by belt go straight into the global stock (REQ-HQ-BELT-INPUT), and showing that stock on the HQ is what tells the player to route blocks there. The HQ has no configuration group and no status indicator (REQ-UI-SELECTION-STATUS), and it is never a construction site. - REQ-UI-HQ-PANEL: When the HQ is selected, the panel shows the HQ's **HP** as a bar labelled `current / maximum` (REQ-HQ-STATS, REQ-UI-HP-BARS) and, beneath it, the **global building blocks stock** — the same value as the header bar's stock display (REQ-UI-BLOCKS-ICON), rendered as an item chip (REQ-UI-SINGLE-SELECTION) carrying the `building_block` icon on its colored square — a chip is an item display, so it takes the square even though the header bar's inline block icon does not (REQ-UI-ITEM-ICON, REQ-UI-BLOCKS-ICON), and hovering or clicking it shows the item production tooltip as any other chip does (REQ-UI-ITEM-TOOLTIP). The HP comes first, as it does on every card that has it (REQ-UI-SELECTION-CARD). The HQ has no input or output buffers of its own: building blocks delivered by belt go straight into the global stock (REQ-HQ-BELT-INPUT), and showing that stock on the HQ is what tells the player to route blocks there. The HQ has no configuration group and no status indicator (REQ-UI-SELECTION-STATUS), and it is never a construction site.
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with debris and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a piece of debris adds to or establishes a field selection (REQ-UI-DEBRIS-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or piece of debris) clears the selection. - REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with debris and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a piece of debris adds to or establishes a field selection (REQ-UI-DEBRIS-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or piece of debris) clears the selection.
- REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **ship stats panel**. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. Its header (REQ-UI-SELECTION-CARD) carries the schematic's color swatch and display name, with the ship's current behavior in the right slot (REQ-UI-SHIP-BEHAVIOR). The panel always shows all hull stats: HP (current / maximum) as a **bar** with the two values beside its caption, then max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed as label/value rows. In addition, capability module summaries are shown below the hull stats, each as its own outlined row, conditioned on which module types are installed and using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT). - REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **ship stats panel**. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. Its header (REQ-UI-SELECTION-CARD) carries the schematic's color swatch and display name, with the ship's current behavior in the right slot (REQ-UI-SHIP-BEHAVIOR). The panel always shows all hull stats: HP (current / maximum) as a **bar** with the two values beside its caption, then max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed as label/value rows. In addition, capability module summaries are shown below the hull stats, each as its own outlined row, conditioned on which module types are installed and using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
- REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** in its header's right slot (REQ-UI-SELECTION-CARD) — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are: - REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** in its header's right slot (REQ-UI-SELECTION-CARD) — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are:
@@ -634,9 +681,9 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
- 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. - 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. - **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-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 item production tooltip of the selection panel's item chips (REQ-UI-ITEM-TOOLTIP); the recipe/schematic selection dialog has no tooltip at all (REQ-UI-SELECT-OPTIONS). 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-TOOLTIP: Each building-type button shows a hover tooltip — hover only, its click entering builder mode instead (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS) — 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 item production tooltip of the selection panel's item chips (REQ-UI-ITEM-TOOLTIP); the recipe/schematic selection dialog has no tooltip at all (REQ-UI-SELECT-OPTIONS). 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-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 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; entering the mode clears the selection (REQ-UI-SELECTION-EXCLUSIVE), which is the one way in which it differs from the Q key (REQ-UI-HOTKEYS): Q clears a selection before it enters the mode, so with something selected the button gets there in one click and Q in two. The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip — hover only, its click toggling the mode instead (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS) — 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.
### Controls Panel ### Controls Panel
@@ -650,7 +697,7 @@ The controls panel tells the player which controls are available right now. It i
- **Input.** Mouse events over the panel are consumed by the panel 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 panel does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG). The only control the panel itself offers is the header click that collapses and expands it. - **Input.** Mouse events over the panel are consumed by the panel 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 panel does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG). The only control the panel itself offers is the header click that collapses and expands it.
- REQ-UI-CONTROLS-CARD: **Card structure.** The panel is a card with two parts, top to bottom: - REQ-UI-CONTROLS-CARD: **Card structure.** The panel is a card with two parts, top to bottom:
- **Header** — always shown, and the panel's only interactive element (REQ-UI-CONTROLS-PANEL). It holds a colored context dot on the left, the context's name beside it in upper case, and, for contexts that define one, a **detail suffix** separated by a middle dot (`BUILD MODE · Assembler`). The name and detail per context are given in REQ-UI-CONTROLS-CONTENT. - **Header** — always shown, and the panel's only interactive element (REQ-UI-CONTROLS-PANEL). It holds a colored context dot on the left, the context's name beside it in upper case, and, for contexts that define one, a **detail suffix** separated by a middle dot (`BUILD MODE · Assembler`). The name and detail per context are given in REQ-UI-CONTROLS-CONTENT.
- **Rows** — one per available control, shown only while the panel is expanded. Each row is one or more **key badges** on the left — the key or mouse button drawn as a small bordered chip — and a **label** beside them naming what it does. An action reachable two ways carries both badges in the same row (`RMB` `Q` — Exit placement) rather than occupying two rows. A row whose action leaves the current mode is drawn with the destructive badge styling, distinguishing it from the rows that act within the mode. No row is ever drawn greyed or otherwise disabled: a control the player cannot currently use is not shown at all (REQ-UI-CONTROLS-ACCURACY). - **Rows** — one per available control, shown only while the panel is expanded. Each row is one or more **key badges** on the left — the key or mouse button drawn as a small bordered chip — and a **label** beside them naming what it does. An action reachable two ways carries both badges in the same row (`RMB` `Q` — Exit placement) rather than occupying two rows. A row whose action hands the current context back — leaving a build mode, or clearing the selection (REQ-UI-SELECTION-EXCLUSIVE) — is drawn with the destructive badge styling, distinguishing it from the rows that act within the context; to the player both are the one key that backs out, so both are marked alike. No row is ever drawn greyed or otherwise disabled: a control the player cannot currently use is not shown at all (REQ-UI-CONTROLS-ACCURACY).
The rows that are live in every context (REQ-UI-CONTROLS-CONTENT) are shown last, under a divider and the caption `ALWAYS AVAILABLE`. This holds in every context including the General one, which has context rows of its own above the divider like any other, so the card is read the same way wherever the player is. The rows that are live in every context (REQ-UI-CONTROLS-CONTENT) are shown last, under a divider and the caption `ALWAYS AVAILABLE`. This holds in every context including the General one, which has context rows of its own above the divider like any other, so the card is read the same way wherever the player is.
- REQ-UI-CONTROLS-CONTENT: **Content catalog.** The control context follows from the active build mode and the selection alone. Build modes are mutually exclusive (REQ-BLD-BUILDER-MODE), so exactly one context applies at any moment: - REQ-UI-CONTROLS-CONTENT: **Content catalog.** The control context follows from the active build mode and the selection alone. Build modes are mutually exclusive (REQ-BLD-BUILDER-MODE), so exactly one context applies at any moment:
@@ -659,12 +706,14 @@ The controls panel tells the player which controls are available right now. It i
|---|---|---|---| |---|---|---|---|
| General | no build mode active, nothing selected | `GENERAL` | — | | General | no build mode active, nothing selected | `GENERAL` | — |
| Selection | no build mode active, at least one object selected | `SELECTION` | `<n> buildings` or `<n> objects` | | Selection | no build mode active, at least one object selected | `SELECTION` | `<n> buildings` or `<n> objects` |
| Build | builder mode active (REQ-BLD-BUILDER-MODE) | `BUILD MODE` | the building type's name | | Build | builder mode active (REQ-BLD-BUILDER-MODE) | `BUILD MODE` | the name of the building type that would be placed at the hovered position, so in tunnel mode it follows the resolved end and reads `Tunnel Entry` or `Tunnel Exit` (REQ-BLD-TUNNEL-MODE) |
| Blueprint | blueprint placement mode active (REQ-UI-BLUEPRINT-MODE) | `BLUEPRINT MODE` | the blueprint's name, or `Temporary` for a temporary blueprint (REQ-UI-BLUEPRINT-TEMP) | | Blueprint | blueprint placement mode active (REQ-UI-BLUEPRINT-MODE) | `BLUEPRINT MODE` | the blueprint's name, or `Temporary` for a temporary blueprint (REQ-UI-BLUEPRINT-TEMP) |
| Deconstruct | deconstruct mode active (REQ-UI-DECONSTRUCT-BUTTON) | `DECONSTRUCT MODE` | — | | Deconstruct | deconstruct mode active (REQ-UI-DECONSTRUCT-BUTTON) | `DECONSTRUCT MODE` | — |
The Selection context's detail counts the selection and names its category (REQ-UI-SELECTION-CATEGORIES): `<n> buildings` for a building selection, `<n> objects` for a field selection, in the singular at a count of one. The Build and Blueprint contexts show **the same rows** and differ only in their header. The Selection context's detail counts the selection and names its category (REQ-UI-SELECTION-CATEGORIES): `<n> buildings` for a building selection, `<n> objects` for a field selection, in the singular at a count of one. The Build and Blueprint contexts show **the same rows** and differ only in their header.
**The row that hands the context back is last.** Where a context has one — `Q` — Clear selection in the Selection context, `RMB` `Q` — Exit placement in Build and Blueprint, `RMB` `Q` — Exit deconstruct mode in Deconstruct — it is the final context row, below the rows that act within the context, and it carries the destructive badge styling (REQ-UI-CONTROLS-CARD). The General context has none: with no mode to leave and nothing selected, its `Q` row enters deconstruct mode rather than leaving anything, and is an ordinary row that happens to come last.
**Always-available rows**, shown in every context: **Always-available rows**, shown in every context:
| Badges | Label | Shown | | Badges | Label | Shown |
@@ -687,9 +736,9 @@ The controls panel tells the player which controls are available right now. It i
| | `LMB` drag | Select area | | | `LMB` drag | Select area |
| | `Ctrl` `LMB` | Add / remove from selection | | | `Ctrl` `LMB` | Add / remove from selection |
| | `Ctrl` `LMB` drag | Add area to selection | | | `Ctrl` `LMB` drag | Add area to selection |
| | `Q` | Deconstruct mode |
| | `C` | Copy to temporary blueprint | | | `C` | Copy to temporary blueprint |
| | `Ctrl` `C` | Create blueprint | | | `Ctrl` `C` | Create blueprint |
| | `Q` | Clear selection |
| Build, Blueprint | `LMB` | Place | | Build, Blueprint | `LMB` | Place |
| | `LMB` drag | Place belt line | | | `LMB` drag | Place belt line |
| | `R` / `Shift` `R` | Rotate | | | `R` / `Shift` `R` | Rotate |
@@ -705,7 +754,6 @@ The controls panel tells the player which controls are available right now. It i
- **`LMB` — Place** reads **Apply settings** instead whenever the ghost under the cursor resolves to a configuration transfer (REQ-UI-BLUEPRINT-TRANSFER) — a single-building blueprint hovering a same-type building of a configurable type, which is the case in which clicking hands over settings rather than placing anything. A blueprint holding more than one building keeps the `Place` label, since its click both places and transfers (REQ-UI-BLUEPRINT-PLACE). - **`LMB` — Place** reads **Apply settings** instead whenever the ghost under the cursor resolves to a configuration transfer (REQ-UI-BLUEPRINT-TRANSFER) — a single-building blueprint hovering a same-type building of a configurable type, which is the case in which clicking hands over settings rather than placing anything. A blueprint holding more than one building keeps the `Place` label, since its click both places and transfers (REQ-UI-BLUEPRINT-PLACE).
- REQ-UI-CONTROLS-ACCURACY: **The panel never advertises a binding that would do nothing.** Every row shown must, if triggered in the situation the panel is showing it in, have the effect its label names; a binding that is inert in the current context is omitted rather than shown greyed (REQ-UI-CONTROLS-CARD). The relation holds in one direction only: the panel may omit a binding that is available, and deliberately does so in three cases: - REQ-UI-CONTROLS-ACCURACY: **The panel never advertises a binding that would do nothing.** Every row shown must, if triggered in the situation the panel is showing it in, have the effect its label names; a binding that is inert in the current context is omitted rather than shown greyed (REQ-UI-CONTROLS-CARD). The relation holds in one direction only: the panel may omit a binding that is available, and deliberately does so in three cases:
- **Build hotkeys** (REQ-UI-HOTKEYS) are live in every context, but are advertised on the build buttons' badges (REQ-UI-BUILD-COST) instead of taking eleven rows in every context of this panel. - **Build hotkeys** (REQ-UI-HOTKEYS) are live in every context, but are advertised on the build buttons' badges (REQ-UI-BUILD-COST) instead of taking eleven rows in every context of this panel.
- **`C` and `Ctrl` `C`** are omitted from the Build, Blueprint, and Deconstruct contexts even though a selection surviving into a build mode keeps them working. They belong to the Selection context, and repeating them in every mode would defeat the panel's purpose of showing what the player's current situation affords.
- **`Ctrl` `LMB` and `Ctrl` `LMB` drag** work with nothing selected — they select the object under the cursor much as a plain click would — but are shown only in the Selection context. "Add / remove from selection" names an operation on a selection, and there is none to operate on until something is selected; the plain `LMB` row already covers what the gesture does before then. - **`Ctrl` `LMB` and `Ctrl` `LMB` drag** work with nothing selected — they select the object under the cursor much as a plain click would — but are shown only in the Selection context. "Add / remove from selection" names an operation on a selection, and there is none to operate on until something is selected; the plain `LMB` row already covers what the gesture does before then.
- **`F3` and `F4`** (REQ-UI-DEBUG-DRAW) are development controls rather than player controls and appear in no context. - **`F3` and `F4`** (REQ-UI-DEBUG-DRAW) are development controls rather than player controls and appear in no context.
@@ -715,14 +763,14 @@ The controls panel tells the player which controls are available right now. It i
Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from the current selection (REQ-UI-BLUEPRINT-CREATE) and picked for placement from the blueprint selection dialog, opened with **Ctrl+V** (REQ-UI-BLUEPRINT-DIALOG). The unmodified **C** and **V** keys are the throwaway counterparts of the same two gestures: they capture and re-place a single unnamed temporary blueprint that is never saved and never listed (REQ-UI-BLUEPRINT-TEMP). Blueprints have no widget on the game screen at all. (The ship layout blueprint panel of the layout configuration dialog, REQ-MOD-UI-BLUEPRINT-PANEL, is a separate feature and is unaffected.) Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from the current selection (REQ-UI-BLUEPRINT-CREATE) and picked for placement from the blueprint selection dialog, opened with **Ctrl+V** (REQ-UI-BLUEPRINT-DIALOG). The unmodified **C** and **V** keys are the throwaway counterparts of the same two gestures: they capture and re-place a single unnamed temporary blueprint that is never saved and never listed (REQ-UI-BLUEPRINT-TEMP). Blueprints have no widget on the game screen at all. (The ship layout blueprint panel of the layout configuration dialog, REQ-MOD-UI-BLUEPRINT-PANEL, is a separate feature and is unaffected.)
- REQ-UI-BLUEPRINT-CREATE: Pressing **Ctrl+C** (REQ-UI-HOTKEYS) opens the modal **blueprint save dialog**, which pauses the simulation and dims the game window (REQ-UI-MODAL-DIM). It has effect 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, and pressing Ctrl+C with an empty selection or a selection of only non-player-placeable buildings does nothing (no dialog opens). 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). The dialog prompts the player to enter a name and has Confirm and Cancel buttons. Clicking Cancel — or pressing Escape, or closing the dialog — closes it with no effect and does not open the blueprint selection dialog. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, appends it to the blueprint list, closes the save dialog, and immediately opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) showing the new blueprint among the others. - REQ-UI-BLUEPRINT-CREATE: Pressing **Ctrl+C** (REQ-UI-HOTKEYS) opens the modal **blueprint save dialog**, which pauses the simulation and dims the game window (REQ-UI-MODAL-DIM). It has effect 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, and pressing Ctrl+C with an empty selection or a selection of only non-player-placeable buildings does nothing (no dialog opens). 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). The dialog is drawn by the game like every other modal (REQ-UI-MODAL-CHROME): it prompts the player to enter a name under its own title and has Confirm and Cancel buttons. Clicking Cancel — or pressing Escape — closes it with no effect and does not open the blueprint selection dialog. It is not dismissed by Q or by a click outside it, a half-typed name being work in progress (REQ-UI-DIALOG-DISMISS). Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, appends it to the blueprint list, closes the save dialog, and immediately opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) showing the new blueprint among the others.
- REQ-UI-BLUEPRINT-DIALOG: The **blueprint selection dialog** is the only place saved blueprints are shown. It is opened by pressing **Ctrl+V** (REQ-UI-HOTKEYS) and by confirming a save (REQ-UI-BLUEPRINT-CREATE). It is modal, pauses the simulation, and dims the game window (REQ-UI-MODAL-DIM). The dialog has a fixed size and consists of: - REQ-UI-BLUEPRINT-DIALOG: The **blueprint selection dialog** is the only place saved blueprints are shown. It is opened by pressing **Ctrl+V** (REQ-UI-HOTKEYS) and by confirming a save (REQ-UI-BLUEPRINT-CREATE). It is modal, pauses the simulation, and dims the game window (REQ-UI-MODAL-DIM). The dialog has a fixed size and consists of:
- A **title bar** reading `Blueprints`, followed by a small dimmed **hotkey badge** reading `Ctrl+V` — the same "learn the shortcut from the widget" device as the build button badges (REQ-UI-BUILD-COST) — and, at the far right, a **close ("×") button**. - A **title bar** the dialog draws itself (REQ-UI-MODAL-CHROME) reading `Blueprints`, followed by a small dimmed **hotkey badge** reading `Ctrl+V` — the same "learn the shortcut from the widget" device as the build button badges (REQ-UI-BUILD-COST) — and, at the far right, a **close ("×") button**.
- Below it, a **scrollable two-column grid of blueprint cards** (REQ-UI-BLUEPRINT-CARD), one per saved blueprint, filling the grid left to right and top to bottom in creation order. The column count is fixed at two; the grid scrolls vertically when the cards do not fit, and does not scroll horizontally. - Below it, a **scrollable two-column grid of blueprint cards** (REQ-UI-BLUEPRINT-CARD), one per saved blueprint, filling the grid left to right and top to bottom in creation order. The column count is fixed at two; the grid scrolls vertically when the cards do not fit, and does not scroll horizontally.
- When no blueprints are saved, the dialog still opens and shows an empty-state message in place of the grid, telling the player that blueprints are created with Ctrl+C from a selection of buildings. - When no blueprints are saved, the dialog still opens and shows an empty-state message in place of the grid, telling the player that blueprints are created with Ctrl+C from a selection of buildings.
Clicking the close button, pressing Escape, or closing the dialog through the window manager closes it with no other effect: the current selection, build mode, and blueprint list are unchanged, and the simulation speed is restored to what it was before the dialog was opened. While the dialog is open, Escape closes it rather than opening the escape menu (REQ-UI-GAME-MENU). Clicking the close button, pressing Escape, pressing Q, or clicking outside the dialog (REQ-UI-DIALOG-DISMISS) closes it with no other effect: the current selection, build mode, and blueprint list are unchanged, and the simulation speed is restored to what it was before the dialog was opened. While the dialog is open, Escape closes it rather than opening the escape menu (REQ-UI-GAME-MENU), and Q closes it rather than acting on the game world beneath — as does the click, which is spent on closing the dialog and does not reach what it landed on.
``` ```
+------------------------------------------------------+ +------------------------------------------------------+
@@ -738,7 +786,7 @@ Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from
+------------------------------------------------------+ +------------------------------------------------------+
``` ```
- REQ-UI-BLUEPRINT-TEMP: Pressing the **C** 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 C with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing at all, and in particular leaves any existing temporary blueprint in place. 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 selection dialog (REQ-UI-BLUEPRINT-DIALOG), 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; unlike the mode, the temporary blueprint itself survives, so it can be entered again with V. - REQ-UI-BLUEPRINT-TEMP: Pressing the **C** 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 C with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing at all, and in particular leaves any existing temporary blueprint in place. Entering this mode replaces any currently active build, blueprint placement, or deconstruct mode, and clears the selection — the blueprint is captured from it first, so C loses nothing (REQ-UI-SELECTION-EXCLUSIVE). 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 selection dialog (REQ-UI-BLUEPRINT-DIALOG), 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; unlike the mode, the temporary blueprint itself survives, so it can be entered again with V.
Pressing the **V** key re-enters blueprint placement mode for the temporary blueprint, capturing nothing new: it is independent of the current selection, can be pressed any number of times, and yields exactly the mode described above. Like C it replaces any currently active build, blueprint placement, or deconstruct mode, and it does not test whether the player can currently afford the blueprint — cost is enforced at placement (REQ-UI-BLUEPRINT-PLACE), consistently with C. Pressing V when no temporary blueprint exists does nothing: no mode is entered and any currently active mode is left untouched. Pressing the **V** key re-enters blueprint placement mode for the temporary blueprint, capturing nothing new: it is independent of the current selection, can be pressed any number of times, and yields exactly the mode described above. Like C it replaces any currently active build, blueprint placement, or deconstruct mode, and it does not test whether the player can currently afford the blueprint — cost is enforced at placement (REQ-UI-BLUEPRINT-PLACE), consistently with C. Pressing V when no temporary blueprint exists does nothing: no mode is entered and any currently active mode is left untouched.
@@ -790,7 +838,7 @@ Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from
- After the transfer the game stays in blueprint placement mode, so further same-type buildings can be clicked in turn. - After the transfer the game stays in blueprint placement mode, so further same-type buildings can be clicked in turn.
- A blueprint placement applies every transfer among its ghosts in the same click that places its new construction sites (REQ-UI-BLUEPRINT-PLACE); the placement is all-or-nothing, so if any ghost is invalid nothing is placed and nothing is transferred. - A blueprint placement applies every transfer among its ghosts in the same click that places its new construction sites (REQ-UI-BLUEPRINT-PLACE); the placement is all-or-nothing, so if any ghost is invalid nothing is placed and nothing is transferred.
- REQ-UI-BLUEPRINT-DELETE: Clicking the delete icon ("×") on a blueprint card (REQ-UI-BLUEPRINT-CARD) immediately removes that blueprint from the list, without a confirmation prompt. The blueprint selection dialog stays open and its card grid reflows to close the gap. If the deleted blueprint was active in blueprint placement mode, that mode is exited. - REQ-UI-BLUEPRINT-DELETE: Clicking the delete icon ("×") on a blueprint card (REQ-UI-BLUEPRINT-CARD) immediately removes that blueprint from the list, without a confirmation prompt. The icon carries a hover tooltip naming what it does, since a bare "×" on a card could as easily mean closing something (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS). The blueprint selection dialog stays open and its card grid reflows to close the gap. If the deleted blueprint was active in blueprint placement mode, that mode is exited.
- REQ-UI-BLUEPRINT-SAVE: On application shutdown, all current blueprints are serialized to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. Write errors are silently ignored on shutdown (no button, no dialog). - REQ-UI-BLUEPRINT-SAVE: On application shutdown, all current blueprints are serialized to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. Write errors are silently ignored on shutdown (no button, no dialog).

View File

@@ -32,15 +32,46 @@ std::vector<RecipeOutput> parseRecipeOutputs(const toml::array& arr,
RecipeOutput out; RecipeOutput out;
out.item = utility::requireString(mt["item"], file, elemPath + ".item"); out.item = utility::requireString(mt["item"], file, elemPath + ".item");
out.amount = static_cast<int>(utility::requireInt(mt["amount"], file, elemPath + ".amount")); out.amount = static_cast<int>(utility::requireInt(mt["amount"], file, elemPath + ".amount"));
result.push_back(std::move(out));
}
return result;
}
// The several-group form: one [[recipe.output_group]] entry per possible result, each
// carrying its weight and the items it yields together (REQ-MAT-OUTPUT-GROUP).
std::vector<RecipeOutputGroup> parseOutputGroups(const toml::array& arr,
const std::string& file,
const std::string& path)
{
std::vector<RecipeOutputGroup> result;
result.reserve(arr.size());
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = path + "[" + std::to_string(i) + "]";
const toml::table* t = arr[i].as_table();
if (t == nullptr)
{
throw utility::makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*t);
RecipeOutputGroup group;
const toml::array& items =
utility::requireArray(mt["items"], file, elemPath + ".items");
group.items = parseRecipeOutputs(items, file, elemPath + ".items");
if (group.items.empty())
{
throw utility::makeError(file, elemPath + ".items", "produces nothing");
}
if (const std::optional<double> p = mt["probability"].value<double>()) if (const std::optional<double> p = mt["probability"].value<double>())
{ {
out.probability = *p; group.probability = *p;
} }
else if (const std::optional<int64_t> p = mt["probability"].value<int64_t>()) else if (const std::optional<int64_t> p = mt["probability"].value<int64_t>())
{ {
out.probability = static_cast<double>(*p); group.probability = static_cast<double>(*p);
} }
result.push_back(std::move(out)); result.push_back(std::move(group));
} }
return result; return result;
} }
@@ -91,8 +122,34 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
def.inputs = utility::parseIngredients(inputs, file, elemPath + ".inputs"); def.inputs = utility::parseIngredients(inputs, file, elemPath + ".inputs");
} }
const toml::array& outputs = utility::requireArray(mt["outputs"], file, elemPath + ".outputs"); // Either form, never both (REQ-MAT-OUTPUT-GROUP): `outputs` is the single-group
def.outputs = parseRecipeOutputs(outputs, file, elemPath + ".outputs"); // shorthand that all but the reprocessing recipes use, `output_group` the several-
// group form. The shorthand carries no weight -- with one group nothing is picked.
const bool hasOutputs = mt.contains("outputs");
const bool hasGroups = mt.contains("output_group");
if (hasOutputs && hasGroups)
{
throw utility::makeError(file, elemPath,
"has both 'outputs' and 'output_group'; use one or the other");
}
if (hasGroups)
{
const toml::array& groups =
utility::requireArray(mt["output_group"], file, elemPath + ".output_group");
def.outputGroups = parseOutputGroups(groups, file, elemPath + ".output_group");
if (def.outputGroups.empty())
{
throw utility::makeError(file, elemPath + ".output_group", "is empty");
}
}
else
{
const toml::array& outputs =
utility::requireArray(mt["outputs"], file, elemPath + ".outputs");
RecipeOutputGroup group;
group.items = parseRecipeOutputs(outputs, file, elemPath + ".outputs");
def.outputGroups.push_back(std::move(group));
}
cfg.recipes.push_back(std::move(def)); cfg.recipes.push_back(std::move(def));
} }

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <algorithm>
#include <optional> #include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -14,14 +15,22 @@ struct RecipeIngredient
int amount; int amount;
}; };
// One entry in [[recipe]].outputs. For reprocessing_plant recipes, probability // One item produced by an output group -- amount units of a named item
// is populated and outputs are rolled with weighted pick at cycle start // (REQ-MAT-OUTPUT-GROUP).
// (REQ-BLD-REPROCESSING, REQ-MAT-CYCLE). For other buildings, probability is
// std::nullopt and all outputs are produced on every cycle.
struct RecipeOutput struct RecipeOutput
{ {
std::string item; std::string item;
int amount; int amount;
};
// One possible result of a production cycle: the items it yields, produced together, and
// the weight this group is picked with among the recipe's groups (REQ-MAT-OUTPUT-GROUP).
// A recipe with a single group always produces it, so the weight is meaningful only where
// there are several -- which is the only difference between what used to be called a
// deterministic and a probabilistic recipe.
struct RecipeOutputGroup
{
std::vector<RecipeOutput> items;
std::optional<double> probability; std::optional<double> probability;
}; };
@@ -30,7 +39,8 @@ struct RecipeDef
std::string id; // Unique recipe id; used by UI for selection. std::string id; // Unique recipe id; used by UI for selection.
BuildingType building; // Which BuildingType can run this recipe. BuildingType building; // Which BuildingType can run this recipe.
std::vector<RecipeIngredient> inputs; std::vector<RecipeIngredient> inputs;
std::vector<RecipeOutput> outputs; // Never empty: one group is the ordinary recipe (REQ-MAT-OUTPUT-GROUP).
std::vector<RecipeOutputGroup> outputGroups;
double durationSeconds; double durationSeconds;
// Assembler only. When true, this recipe is available from game start // Assembler only. When true, this recipe is available from game start
// regardless of the implicit item graph — used for base recipes that no // regardless of the implicit item graph — used for base recipes that no
@@ -40,6 +50,38 @@ struct RecipeDef
bool unlockedAtStart = false; bool unlockedAtStart = false;
}; };
// Every distinct item any group of this recipe can produce, in config order. Most callers
// only want to know what a recipe can make at all -- which items it has buffers for, which
// recipes produce an item -- and not which group yields what.
inline std::vector<std::string> getProducibleItems(const RecipeDef& recipe)
{
std::vector<std::string> items;
for (const RecipeOutputGroup& group : recipe.outputGroups)
{
for (const RecipeOutput& out : group.items)
{
if (std::find(items.begin(), items.end(), out.item) == items.end())
{
items.push_back(out.item);
}
}
}
return items;
}
// True when some group of this recipe yields the given item.
inline bool producesItem(const RecipeDef& recipe, const std::string& itemId)
{
for (const RecipeOutputGroup& group : recipe.outputGroups)
{
for (const RecipeOutput& out : group.items)
{
if (out.item == itemId) { return true; }
}
}
return false;
}
struct RecipesConfig struct RecipesConfig
{ {
std::vector<RecipeDef> recipes; std::vector<RecipeDef> recipes;

View File

@@ -63,4 +63,19 @@ struct ShipsConfig
} }
return nullptr; return nullptr;
} }
// The definition to configure a layout against, or nullptr when there is no layout
// to configure: no schematic is selected at all (the empty id the "(None)" option
// sets, REQ-UI-SELECT-OPTIONS), the id names no ship, or the ship defines no layout
// grid. Every entry to the layout configuration dialog asks this before opening it
// and the panel asks it before offering the button that opens it, so the dialog
// cannot appear over a grid with no cells (REQ-MOD-UI-DIALOG, REQ-MOD-UI-PREVIEW,
// REQ-MOD-UI-AUTO-DIALOG).
const ShipDef* findLayoutShipDef(const std::string& id) const
{
if (id.empty()) { return nullptr; }
const ShipDef* def = findShipDef(id);
if (!def || def->layout.empty()) { return nullptr; }
return def;
}
}; };

View File

@@ -135,6 +135,17 @@ void BuildModeController::exitCurrentMode()
enterMode(BuildMode::None); enterMode(BuildMode::None);
} }
void BuildModeController::clearHover()
{
m_ghostTile.reset();
m_ghostValid = false;
m_tunnelGhostType = BuildingType::TunnelEntry;
m_tunnelPartnerTile.reset();
m_blueprintGhostTile.reset();
m_hoveredGhostIsTransfer = false;
m_deconstructHoverBuildingId.reset();
}
BuildingType BuildModeController::getBuilderType() const BuildingType BuildModeController::getBuilderType() const
{ {
return m_builderType; return m_builderType;
@@ -150,7 +161,7 @@ BuildingType BuildModeController::getEffectiveBuilderType() const
return isTunnelMode() ? m_tunnelGhostType : m_builderType; return isTunnelMode() ? m_tunnelGhostType : m_builderType;
} }
QPoint BuildModeController::getGhostTile() const const std::optional<QPoint>& BuildModeController::getGhostTile() const
{ {
return m_ghostTile; return m_ghostTile;
} }
@@ -240,7 +251,7 @@ Blueprint& BuildModeController::getMutableBlueprint()
return m_blueprint; return m_blueprint;
} }
QPoint BuildModeController::getBlueprintGhostTile() const const std::optional<QPoint>& BuildModeController::getBlueprintGhostTile() const
{ {
return m_blueprintGhostTile; return m_blueprintGhostTile;
} }

View File

@@ -54,6 +54,15 @@ public:
// Backs out of whichever mode is active, if any (the Q key and right-click). // Backs out of whichever mode is active, if any (the Q key and right-click).
void exitCurrentMode(); void exitCurrentMode();
// --- hover ----------------------------------------------------------------
// Drops everything that follows from a cursor pointing at the world — both
// ghost tiles, placement validity, the resolved tunnel end, the transfer flag,
// the deconstruct hover — for a cursor that points at no tile at all, because it
// rests on a panel or has left the window (REQ-BLD-GHOST). The active mode is
// untouched: the player is still building, just not over anything. A belt drag's
// path is untouched too, since a drag keeps hovering while the button is held.
void clearHover();
// --- builder mode --------------------------------------------------------- // --- builder mode ---------------------------------------------------------
// Only meaningful while isBuilderMode(). // Only meaningful while isBuilderMode().
BuildingType getBuilderType() const; BuildingType getBuilderType() const;
@@ -64,7 +73,9 @@ public:
// tunnel mode, the plain builder type otherwise. // tunnel mode, the plain builder type otherwise.
BuildingType getEffectiveBuilderType() const; BuildingType getEffectiveBuilderType() const;
QPoint getGhostTile() const; // Unset while the cursor points at no tile (clearHover), which is the one case
// where builder mode draws no ghost at all.
const std::optional<QPoint>& getGhostTile() const;
Rotation getGhostRotation() const; Rotation getGhostRotation() const;
bool isGhostValid() const; bool isGhostValid() const;
void setGhostTile(QPoint tile); void setGhostTile(QPoint tile);
@@ -92,7 +103,8 @@ public:
// Mutable so the caller can rotate the layout in place; rotating a blueprint // Mutable so the caller can rotate the layout in place; rotating a blueprint
// needs building footprints from the config, which does not belong here. // needs building footprints from the config, which does not belong here.
Blueprint& getMutableBlueprint(); Blueprint& getMutableBlueprint();
QPoint getBlueprintGhostTile() const; // Unset for a cursor pointing at no tile, as for the builder ghost above.
const std::optional<QPoint>& getBlueprintGhostTile() const;
void setBlueprintGhostTile(QPoint tile); void setBlueprintGhostTile(QPoint tile);
// Whether the ghost under the cursor would hand its settings to the building // Whether the ghost under the cursor would hand its settings to the building
@@ -115,7 +127,7 @@ private:
BuildMode m_mode = BuildMode::None; BuildMode m_mode = BuildMode::None;
BuildingType m_builderType = BuildingType::Belt; BuildingType m_builderType = BuildingType::Belt;
QPoint m_ghostTile; std::optional<QPoint> m_ghostTile;
Rotation m_ghostRotation = Rotation::East; Rotation m_ghostRotation = Rotation::East;
bool m_ghostValid = false; bool m_ghostValid = false;
BuildingType m_tunnelGhostType = BuildingType::TunnelEntry; BuildingType m_tunnelGhostType = BuildingType::TunnelEntry;
@@ -125,9 +137,9 @@ private:
QPoint m_beltDragAnchor; QPoint m_beltDragAnchor;
std::vector<BeltPathTile> m_beltDragPath; std::vector<BeltPathTile> m_beltDragPath;
Blueprint m_blueprint; Blueprint m_blueprint;
QPoint m_blueprintGhostTile; std::optional<QPoint> m_blueprintGhostTile;
bool m_hoveredGhostIsTransfer = false; bool m_hoveredGhostIsTransfer = false;
std::optional<BuildingId> m_deconstructHoverBuildingId; std::optional<BuildingId> m_deconstructHoverBuildingId;
}; };

View File

@@ -17,6 +17,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanelPlacement.h ${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanelPlacement.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionBox.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.h ${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.h

View File

@@ -31,7 +31,9 @@ struct MouseBindingEntry
// Resolution is first-match-wins over these tables, so an entry that must beat another // Resolution is first-match-wins over these tables, so an entry that must beat another
// on the same input is listed above it -- CancelBeltLine before ExitMode on the right // on the same input is listed above it -- CancelBeltLine before ExitMode on the right
// mouse button. Everything else is disjoint by availability. // mouse button. Everything else is disjoint by availability: Q carries three actions
// whose availability rules partition the situations between them, so their order here is
// the order the reader meets them and nothing more (REQ-UI-HOTKEYS).
const KeyBindingEntry KEY_BINDINGS[] = { const KeyBindingEntry KEY_BINDINGS[] = {
{ControlAction::Move, Qt::Key_A, Qt::NoModifier}, {ControlAction::Move, Qt::Key_A, Qt::NoModifier},
{ControlAction::Move, Qt::Key_D, Qt::NoModifier}, {ControlAction::Move, Qt::Key_D, Qt::NoModifier},
@@ -48,8 +50,9 @@ const KeyBindingEntry KEY_BINDINGS[] = {
// nothing and is what puts "R" and "Shift+R" on the row. // nothing and is what puts "R" and "Shift+R" on the row.
{ControlAction::Rotate, Qt::Key_R, Qt::NoModifier}, {ControlAction::Rotate, Qt::Key_R, Qt::NoModifier},
{ControlAction::Rotate, Qt::Key_R, Qt::ShiftModifier}, {ControlAction::Rotate, Qt::Key_R, Qt::ShiftModifier},
{ControlAction::EnterDeconstruct, Qt::Key_Q, Qt::NoModifier},
{ControlAction::ExitMode, Qt::Key_Q, Qt::NoModifier}, {ControlAction::ExitMode, Qt::Key_Q, Qt::NoModifier},
{ControlAction::ClearSelection, Qt::Key_Q, Qt::NoModifier},
{ControlAction::EnterDeconstruct, Qt::Key_Q, Qt::NoModifier},
{ControlAction::OpenMenu, Qt::Key_Escape, Qt::NoModifier}, {ControlAction::OpenMenu, Qt::Key_Escape, Qt::NoModifier},
}; };
@@ -114,9 +117,21 @@ bool isControlActionAvailable(ControlAction action, const ControlContext& contex
case ControlAction::SelectArea: case ControlAction::SelectArea:
case ControlAction::AddToSelection: case ControlAction::AddToSelection:
case ControlAction::AddAreaToSelection: case ControlAction::AddAreaToSelection:
case ControlAction::EnterDeconstruct:
return context.mode == BuildMode::None; return context.mode == BuildMode::None;
// The three cases of Q, in the order REQ-UI-HOTKEYS evaluates them: it leaves the
// active mode, else clears the selection, else enters deconstruct mode. A selection
// and a build mode never coexist (REQ-UI-SELECTION-EXCLUSIVE), so the first two are
// already disjoint; what the empty-selection condition below adds is the third step,
// which is why entering deconstruct mode by key takes two presses while something is
// selected -- the Deconstruct button still gets there in one click.
case ControlAction::ClearSelection:
return context.mode == BuildMode::None
&& context.selection != ControlSelection::None;
case ControlAction::EnterDeconstruct:
return context.mode == BuildMode::None
&& context.selection == ControlSelection::None;
// Both need something a blueprint can be made of; a selection of ships or debris // Both need something a blueprint can be made of; a selection of ships or debris
// leaves them inert (REQ-UI-HOTKEYS). // leaves them inert (REQ-UI-HOTKEYS).
case ControlAction::CopyTemporary: case ControlAction::CopyTemporary:
@@ -209,13 +224,16 @@ std::vector<ControlAction> getContextActions(const ControlContext& context)
return filterAvailable({ControlAction::ToggleDeconstruct, return filterAvailable({ControlAction::ToggleDeconstruct,
ControlAction::DeconstructArea, ControlAction::ExitMode}, ControlAction::DeconstructArea, ControlAction::ExitMode},
context); context);
// ClearSelection is listed last for the same reason ExitMode is above: the row that
// backs the player out of the context sits at the bottom of the context's rows
// wherever there is one (REQ-UI-CONTROLS-CONTENT).
case ControlContextKind::Selection: case ControlContextKind::Selection:
return filterAvailable({ControlAction::Select, ControlAction::SelectArea, return filterAvailable({ControlAction::Select, ControlAction::SelectArea,
ControlAction::AddToSelection, ControlAction::AddToSelection,
ControlAction::AddAreaToSelection, ControlAction::AddAreaToSelection,
ControlAction::EnterDeconstruct,
ControlAction::CopyTemporary, ControlAction::CopyTemporary,
ControlAction::CreateBlueprint}, ControlAction::CreateBlueprint,
ControlAction::ClearSelection},
context); context);
case ControlContextKind::General: case ControlContextKind::General:
break; break;

View File

@@ -54,6 +54,7 @@ enum class ControlAction
// No build mode active, with something selected. // No build mode active, with something selected.
CopyTemporary, CopyTemporary,
CreateBlueprint, CreateBlueprint,
ClearSelection,
// Builder and blueprint placement mode. // Builder and blueprint placement mode.
Place, Place,
@@ -109,7 +110,10 @@ enum class ControlContextKind
struct ControlContext struct ControlContext
{ {
BuildMode mode = BuildMode::None; BuildMode mode = BuildMode::None;
BuildingType builderType = BuildingType::Belt; // while mode == Builder // While mode == Builder: the type a click would place at the current hover position,
// not the type the mode was entered with — tunnel mode resolves to either end
// (REQ-BLD-TUNNEL-MODE).
BuildingType builderType = BuildingType::Belt;
bool draggingBelt = false; bool draggingBelt = false;
// A single-building blueprint whose ghost is over a configuration-transfer target, // A single-building blueprint whose ghost is over a configuration-transfer target,
// so clicking hands over settings rather than placing (REQ-UI-BLUEPRINT-TRANSFER). // so clicking hands over settings rather than placing (REQ-UI-BLUEPRINT-TRANSFER).

View File

@@ -57,12 +57,13 @@ int getAvailableBottomPx(const QRect& band, const std::vector<QRect>& occupiedRe
} }
PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx, PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx,
int marginPx) int selectionGapPx)
{ {
// What each side offers: the gap between the anchor and that edge of the band, less // What each side offers: the room between the anchor and that edge of the band, less
// the margin the panel keeps from the anchor. // the gap the panel keeps from the anchor. The band's own inset from the view has
const int roomRightPx = band.right() - anchorRect.right() - marginPx; // already taken the edge margin off.
const int roomLeftPx = anchorRect.left() - band.left() - marginPx; const int roomRightPx = band.right() - anchorRect.right() - selectionGapPx;
const int roomLeftPx = anchorRect.left() - band.left() - selectionGapPx;
if (roomRightPx >= widthPx) if (roomRightPx >= widthPx)
{ {
@@ -79,20 +80,20 @@ PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx,
QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side, QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side,
QSize wantedSize, const std::vector<QRect>& occupiedRects, QSize wantedSize, const std::vector<QRect>& occupiedRects,
int marginPx) int selectionGapPx, int marginPx)
{ {
const int widthPx = std::min(wantedSize.width(), band.width()); const int widthPx = std::min(wantedSize.width(), band.width());
// Against the anchor on the chosen side, growing away from it: the edge facing the // A gap from the anchor on the chosen side, growing away from it: the edge facing the
// selection is the one that stays put as the panel's content resizes. A panel that // selection is the one that stays put as the panel's content resizes. A panel that
// does not fit there is pushed back inside the view rather than hanging off it, which // does not fit there is pushed back inside the view rather than hanging off it, which
// is what puts it over the selection when neither side had room. // is what puts it over the selection when neither side had room.
const int wantedLeftPx = (side == PanelSide::Right) const int wantedLeftPx = (side == PanelSide::Right)
? anchorRect.right() + marginPx + 1 ? anchorRect.right() + selectionGapPx + 1
: anchorRect.left() - marginPx - widthPx; : anchorRect.left() - selectionGapPx - widthPx;
// Top-aligned with the anchor, then lifted by however much of it hangs below what is // Top-aligned with the anchor -- the gap separates the two horizontally and plays no
// free. // part here -- then lifted by however much of the panel hangs below what is free.
return fitInBand(band, wantedLeftPx, anchorRect.top(), wantedSize, occupiedRects, return fitInBand(band, wantedLeftPx, anchorRect.top(), wantedSize, occupiedRects,
marginPx); marginPx);
} }

View File

@@ -10,6 +10,12 @@
// owner places them in one ordered pass, each into the space the earlier ones left free, // owner places them in one ordered pass, each into the space the earlier ones left free,
// and these are the rules they place themselves by. Pure geometry -- no widget is // and these are the rules they place themselves by. Pure geometry -- no widget is
// involved, which is what lets the rules be tested without a display. // involved, which is what lets the rules be tested without a display.
//
// Two distances run through these rules and are deliberately different (see
// REQ-UI-SELECTION-PANEL). marginPx is the edge margin: what a widget keeps from the
// view's edges and from the widgets it steps around. selectionGapPx is the gap the
// selection panel keeps from the selection it describes -- half a tile, which is the
// wider of the two, so the panel stands clear of the objects rather than touching them.
// The lowest bottom edge available to a widget occupying the horizontal span // The lowest bottom edge available to a widget occupying the horizontal span
// [leftPx, rightPx] inside band: the band's own bottom, or marginPx above the topmost // [leftPx, rightPx] inside band: the band's own bottom, or marginPx above the topmost
@@ -29,19 +35,21 @@ enum class PanelSide
// The side a panel widthPx wide takes beside anchorRect: the right of it where it fits // The side a panel widthPx wide takes beside anchorRect: the right of it where it fits
// within band, otherwise the left, and where it fits on neither, whichever side leaves // within band, otherwise the left, and where it fits on neither, whichever side leaves
// more room -- the one case in which the panel ends up over the selection // more room -- the one case in which the panel ends up over the selection
// (REQ-UI-SELECTION-PANEL). Decided once when the selection starts and kept for as long // (REQ-UI-SELECTION-PANEL). The room a side offers is what is left of it once the panel's
// as it lasts, so a card that grows later never flips the panel across the object. // gap from the selection is taken off. Decided once when the selection starts and kept for
// as long as it lasts, so a card that grows later never flips the panel across the object.
PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx, PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx,
int marginPx); int selectionGapPx);
// Where a panel of wantedSize stands beside anchorRect on the given side: separated from // Where a panel of wantedSize stands beside anchorRect on the given side: separated from
// it by marginPx and growing away from it, its top edge on the anchor's top edge, pushed // it by selectionGapPx and growing away from it, its top edge on the anchor's top edge,
// inside band and above whatever occupies it. The returned height is short of // pushed inside band and above whatever occupies it. The gap is horizontal only -- the
// wantedSize's when there was not enough room, which is the caller's cue to scroll its // panel's top sits level with the anchor's, however wide the gap. The returned height is
// content (REQ-UI-SELECTION-PANEL). // short of wantedSize's when there was not enough room, which is the caller's cue to
// scroll its content (REQ-UI-SELECTION-PANEL).
QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side, QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side,
QSize wantedSize, const std::vector<QRect>& occupiedRects, QSize wantedSize, const std::vector<QRect>& occupiedRects,
int marginPx); int selectionGapPx, int marginPx);
// Where a panel of wantedSize stands once the player has dragged it to desiredTopLeftPx: // Where a panel of wantedSize stands once the player has dragged it to desiredTopLeftPx:
// at that point, by the same rules that place it beside a selection -- pushed inside band, // at that point, by the same rules that place it beside a selection -- pushed inside band,

View File

@@ -0,0 +1,34 @@
#pragma once
#include <QPoint>
#include <QRectF>
#include <QVector2D>
// The coverage rules of a selection box (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-BOX).
//
// The box is a rectangle in world coordinates — tiles as the unit, but fractional,
// because the drag follows the mouse and is not snapped to the tile grid. The two
// rules below are the whole of what "covered by the box" means; they live here so the
// building query and the entity queries answer it identically.
//
// Both callers pass a normalized rectangle: neither rule normalizes on its own.
// Whether the box overlaps the unit square of `tile` — the rule for anything that
// occupies whole tiles (buildings, construction sites, defence station bodies). The
// comparisons are inclusive, so a box that only grazes the tile's edge still covers
// it, and a box with no area covers the tile it lies on.
inline bool boxCoversTile(const QRectF& worldBox, QPoint tile)
{
return worldBox.left() <= static_cast<qreal>(tile.x()) + 1.0
&& worldBox.right() >= static_cast<qreal>(tile.x())
&& worldBox.top() <= static_cast<qreal>(tile.y()) + 1.0
&& worldBox.bottom() >= static_cast<qreal>(tile.y());
}
// Whether the box contains `worldPos` — the rule for anything that has a position
// rather than a footprint (ships, debris). Their centre is what the box must enclose,
// so that what the rectangle visibly holds is what the drag selects.
inline bool boxCoversPoint(const QRectF& worldBox, QVector2D worldPos)
{
return worldBox.contains(QPointF(worldPos.x(), worldPos.y()));
}

View File

@@ -25,6 +25,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/SpeedStepRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/SpeedStepRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GhostRotationRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/GhostRotationRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ModeCancelRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/ModeCancelRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionClearRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggleRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggleRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h

View File

@@ -15,11 +15,17 @@
// is while the selection grows; and because the rectangle is screen space frozen at that // is while the selection grows; and because the rectangle is screen space frozen at that
// moment, scrolling the view or a selected ship flying off does not move the panel // moment, scrolling the view or a selected ship flying off does not move the panel
// either. // either.
//
// The gap the panel keeps from that rectangle travels with it, for the same reason: it is
// half a tile as the tile stood in this moment (REQ-UI-SELECTION-PANEL, REQ-GW-TILE-SIZE)
// and stays that for as long as the selection lasts, a rectangle frozen in one moment
// having no meaningful distance to a tile size measured in another.
class SelectionAnchorChangedEvent : public Event class SelectionAnchorChangedEvent : public Event
{ {
public: public:
explicit SelectionAnchorChangedEvent(QRect rectPx) SelectionAnchorChangedEvent(QRect rectPx, int selectionGapPx)
: rectPx(rectPx) {} : rectPx(rectPx), selectionGapPx(selectionGapPx) {}
const QRect rectPx; const QRect rectPx;
const int selectionGapPx;
}; };

View File

@@ -0,0 +1,11 @@
#pragma once
#include "Event.h"
// The player pressed the key that drops the current selection (REQ-UI-HOTKEYS). Separate
// from ModeCancelRequestedEvent although both are Q: which of the two a press means is
// settled by the action table, and an event that meant either would force the receiver to
// decide it a second time.
class SelectionClearRequestedEvent : public Event
{
};

View File

@@ -12,27 +12,27 @@
namespace namespace
{ {
// Folds the output capacities one recipe implies into `caps`: twice each produced // Folds the output capacities one recipe implies into `caps`: twice each produced item's
// item's per-cycle amount (REQ-MAT-OUTPUT-BUFFER). A Reprocessing Plant rolls exactly // per-cycle amount (REQ-MAT-OUTPUT-BUFFER). A cycle yields exactly one output group
// one of its outputs per cycle (REQ-BLD-REPROCESSING), so its per-cycle amount for an // (REQ-MAT-OUTPUT-GROUP), so an item's per-cycle amount is the largest total any single
// item is that one outcome's amount rather than a sum over the entries. // group produces of it -- summed within a group, whose items come together, and taken at
// its maximum across groups, of which only one ever happens.
// //
// Where a cap is already present the larger wins, which is how an auto-recipe building // Where a cap is already present the larger wins, which is how a cap unions across the
// unions the recipes of its type -- the same rule its input caps follow. // recipes it could be sized over -- the same rule the input caps follow.
void addOutputCaps(std::map<ItemType, int>& caps, BuildingType type, void addOutputCaps(std::map<ItemType, int>& caps, const RecipeDef& recipe)
const RecipeDef& recipe)
{ {
std::map<ItemType, int> perCycle; std::map<ItemType, int> perCycle;
for (const RecipeOutput& out : recipe.outputs) for (const RecipeOutputGroup& group : recipe.outputGroups)
{ {
const ItemType item{out.item}; std::map<ItemType, int> inGroup;
if (type == BuildingType::ReprocessingPlant) for (const RecipeOutput& out : group.items)
{ {
perCycle[item] = std::max(perCycle[item], out.amount); inGroup[ItemType{out.item}] += out.amount;
} }
else for (const std::pair<const ItemType, int>& entry : inGroup)
{ {
perCycle[item] += out.amount; perCycle[entry.first] = std::max(perCycle[entry.first], entry.second);
} }
} }
@@ -57,7 +57,7 @@ void initBuffers(Building& b, const RecipeDef& recipe)
b.outputBuffer.items.clear(); b.outputBuffer.items.clear();
b.outputBuffer.caps.clear(); b.outputBuffer.caps.clear();
addOutputCaps(b.outputBuffer.caps, b.type, recipe); addOutputCaps(b.outputBuffer.caps, recipe);
} }
void initShipyardBuffers(const GameConfig& config, Building& b) void initShipyardBuffers(const GameConfig& config, Building& b)

View File

@@ -48,29 +48,62 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe) namespace
{ {
std::vector<const RecipeOutput*> eligible; // The items of one group, produced together (REQ-MAT-OUTPUT-GROUP).
std::vector<double> weights; std::vector<Item> itemsOf(const RecipeOutputGroup& group)
for (const RecipeOutput& out : recipe.outputs) {
std::vector<Item> result;
for (const RecipeOutput& out : group.items)
{ {
if (!m_isItemUnlocked(out.item)) { continue; } Item item;
eligible.push_back(&out); item.type.id = out.item;
weights.push_back(out.probability.value_or(1.0)); for (int i = 0; i < out.amount; ++i)
{
result.push_back(item);
}
}
return result;
}
} // namespace
std::vector<Item> BuildingSystem::rollOutputGroup(const RecipeDef& recipe)
{
// One group: nothing to choose, so no weight is read, no draw is made, and no
// eligibility is tested (REQ-MAT-OUTPUT-GROUP, REQ-LOCK-OUTPUT-POOL).
//
// Not drawing matters beyond speed. A draw here would consume entropy for every
// ordinary recipe, shifting every later random outcome and invalidating recorded
// replays. And eligibility must not apply either: implicit unlocking is derived from
// demand, so an ordinary recipe's output can be perfectly producible while nothing
// yet calls for it -- testing it here would stop the building producing at all.
if (recipe.outputGroups.size() == 1)
{
return itemsOf(recipe.outputGroups.front());
}
// Several groups: only those whose items are all unlocked can be picked, and a group
// holding any locked item is dropped whole, since its items come together
// (REQ-LOCK-OUTPUT-POOL). Weights are renormalized over what is left by
// discrete_distribution.
std::vector<const RecipeOutputGroup*> eligible;
std::vector<double> weights;
for (const RecipeOutputGroup& group : recipe.outputGroups)
{
bool allUnlocked = true;
for (const RecipeOutput& out : group.items)
{
if (!m_isItemUnlocked(out.item)) { allUnlocked = false; break; }
}
if (!allUnlocked) { continue; }
eligible.push_back(&group);
weights.push_back(group.probability.value_or(1.0));
} }
if (eligible.empty()) { return {}; } if (eligible.empty()) { return {}; }
std::discrete_distribution<int> dist(weights.begin(), weights.end()); std::discrete_distribution<int> dist(weights.begin(), weights.end());
const RecipeOutput& chosen = *eligible[static_cast<std::size_t>(dist(m_rng))]; return itemsOf(*eligible[static_cast<std::size_t>(dist(m_rng))]);
std::vector<Item> result;
Item item;
item.type.id = chosen.item;
for (int i = 0; i < chosen.amount; ++i)
{
result.push_back(item);
}
return result;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -572,25 +605,11 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick)
continue; continue;
} }
// 3. Determine chosen outputs (roll for reprocessing). // 3. Settle what this cycle produces: its one output group, picked by weight only
std::vector<Item> chosen; // where the recipe has several (REQ-MAT-OUTPUT-GROUP). Empty means every group
if (building.type == BuildingType::ReprocessingPlant) // was ineligible, so there is nothing to run.
{ std::vector<Item> chosen = rollOutputGroup(*recipe);
chosen = rollReprocessingOutput(*recipe); if (chosen.empty()) { continue; }
if (chosen.empty()) { continue; }
}
else
{
for (const RecipeOutput& out : recipe->outputs)
{
Item item;
item.type.id = out.item;
for (int i = 0; i < out.amount; ++i)
{
chosen.push_back(item);
}
}
}
// 4. Consume inputs and start cycle. // 4. Consume inputs and start cycle.
for (const RecipeIngredient& ing : recipe->inputs) for (const RecipeIngredient& ing : recipe->inputs)

View File

@@ -224,11 +224,11 @@ private:
// (ignoring output-buffer space); drives the Starved/Blocked distinction of // (ignoring output-buffer space); drives the Starved/Blocked distinction of
// the status light (REQ-UI-STATUS-LIGHT). // the status light (REQ-UI-STATUS-LIGHT).
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input // What one cycle of this recipe produces: the items of its one output group
// caps span the union of every recipe of the building's type; no player // (REQ-MAT-OUTPUT-GROUP). Where the recipe has several, one is picked by weight from
// recipe is selected (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). // those currently eligible (REQ-LOCK-OUTPUT-POOL) and the result is empty if none is;
// Core input-edge scan shared by operational buildings and construction sites. // where it has one, that group is returned with no draw and no eligibility test.
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe); std::vector<Item> rollOutputGroup(const RecipeDef& recipe);
const GameConfig& m_config; const GameConfig& m_config;

View File

@@ -13,7 +13,7 @@
#include "Rotation.h" #include "Rotation.h"
#include "ShipLayout.h" #include "ShipLayout.h"
class GameConfig; struct GameConfig;
// Player intent, resolved to domain ids / tile coordinates and serializable, that // Player intent, resolved to domain ids / tile coordinates and serializable, that
// mutates the simulation. Every sim mutation during play flows through a Command // mutates the simulation. Every sim mutation during play flows through a Command

View File

@@ -4,6 +4,7 @@
#include <cmath> #include <cmath>
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "SelectionBox.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "DebrisComponent.h" #include "DebrisComponent.h"
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
@@ -82,45 +83,29 @@ entt::entity debrisAtWorldPos(EntityAdmin& admin, QVector2D worldPos)
return bestDebris; return bestDebris;
} }
std::vector<entt::entity> debrisInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB) std::vector<entt::entity> debrisInBox(EntityAdmin& admin, const QRectF& worldBox)
{ {
const int minX = std::min(tileA.x(), tileB.x());
const int maxX = std::max(tileA.x(), tileB.x());
const int minY = std::min(tileA.y(), tileB.y());
const int maxY = std::max(tileA.y(), tileB.y());
std::vector<entt::entity> result; std::vector<entt::entity> result;
admin.forEach<DebrisComponent, PositionComponent>( admin.forEach<DebrisComponent, PositionComponent>(
[&](entt::entity entity, const DebrisComponent& /*sd*/, const PositionComponent& pos) [&](entt::entity entity, const DebrisComponent& /*sd*/, const PositionComponent& pos)
{ {
const int tileX = static_cast<int>(std::floor(pos.value.x())); if (boxCoversPoint(worldBox, pos.value)) { result.push_back(entity); }
const int tileY = static_cast<int>(std::floor(pos.value.y()));
if (tileX >= minX && tileX <= maxX && tileY >= minY && tileY <= maxY)
{
result.push_back(entity);
}
}); });
return result; return result;
} }
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB) std::vector<entt::entity> actorsInBox(EntityAdmin& admin, const QRectF& worldBox)
{ {
const int minX = std::min(tileA.x(), tileB.x());
const int maxX = std::max(tileA.x(), tileB.x());
const int minY = std::min(tileA.y(), tileB.y());
const int maxY = std::max(tileA.y(), tileB.y());
std::vector<entt::entity> result; std::vector<entt::entity> result;
// Stations: included when any occupied body cell lies in the box. // Stations occupy whole tiles: included when the box overlaps any occupied cell.
admin.forEach<StationBodyComponent, HealthComponent>( admin.forEach<StationBodyComponent, HealthComponent>(
[&](entt::entity entity, const StationBodyComponent& sb, const HealthComponent& h) [&](entt::entity entity, const StationBodyComponent& sb, const HealthComponent& h)
{ {
if (h.hp <= 0.0f) { return; } if (h.hp <= 0.0f) { return; }
for (const QPoint& cell : sb.bodyCells) for (const QPoint& cell : sb.bodyCells)
{ {
if (cell.x() >= minX && cell.x() <= maxX if (boxCoversTile(worldBox, cell))
&& cell.y() >= minY && cell.y() <= maxY)
{ {
result.push_back(entity); result.push_back(entity);
return; return;
@@ -128,19 +113,15 @@ std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint t
} }
}); });
// Ships: included when the floored position tile lies in the box. Requiring // Ships have a position rather than a footprint: included when the box contains
// ShipIdentityComponent excludes the HQ proxy and any station bodies. // that position. Requiring ShipIdentityComponent excludes the HQ proxy and any
// station bodies.
admin.forEach<ShipIdentityComponent, PositionComponent, HealthComponent>( admin.forEach<ShipIdentityComponent, PositionComponent, HealthComponent>(
[&](entt::entity entity, const ShipIdentityComponent& /*id*/, [&](entt::entity entity, const ShipIdentityComponent& /*id*/,
const PositionComponent& pos, const HealthComponent& h) const PositionComponent& pos, const HealthComponent& h)
{ {
if (h.hp <= 0.0f) { return; } if (h.hp <= 0.0f) { return; }
const int tileX = static_cast<int>(std::floor(pos.value.x())); if (boxCoversPoint(worldBox, pos.value)) { result.push_back(entity); }
const int tileY = static_cast<int>(std::floor(pos.value.y()));
if (tileX >= minX && tileX <= maxX && tileY >= minY && tileY <= maxY)
{
result.push_back(entity);
}
}); });
return result; return result;

View File

@@ -3,6 +3,7 @@
#include <vector> #include <vector>
#include <QPoint> #include <QPoint>
#include <QRectF>
#include <QVector2D> #include <QVector2D>
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
@@ -16,13 +17,13 @@ entt::entity entityAtWorldPos(EntityAdmin& admin, QVector2D worldPos);
// after actors: entityAtWorldPos never returns debris (debris has no HealthComponent). // after actors: entityAtWorldPos never returns debris (debris has no HealthComponent).
entt::entity debrisAtWorldPos(EntityAdmin& admin, QVector2D worldPos); entt::entity debrisAtWorldPos(EntityAdmin& admin, QVector2D worldPos);
// Returns every piece of debris whose position falls within the inclusive tile rectangle // Returns every piece of debris the selection box covers — that is, whose position it
// spanned by tileA and tileB, in any corner order (REQ-UI-DEBRIS-MULTI-SELECT). // contains, per boxCoversPoint (REQ-UI-DEBRIS-MULTI-SELECT). `worldBox` is in world
std::vector<entt::entity> debrisInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB); // coordinates and normalized; it is not snapped to tiles.
std::vector<entt::entity> debrisInBox(EntityAdmin& admin, const QRectF& worldBox);
// Returns every living actor (ship or defence station, player or enemy) that falls // Returns every living actor (ship or defence station, player or enemy) the selection
// within the inclusive tile rectangle spanned by tileA and tileB, in any corner order // box covers (REQ-UI-MULTI-SELECT, REQ-UI-ENTITY-CLICK-SELECT): a ship when the box
// (REQ-UI-MULTI-SELECT, REQ-UI-ENTITY-CLICK-SELECT). A ship is included when its floored // contains its position, a station when the box overlaps any of its body cells — the
// position tile lies in the box; a station is included when any of its body cells does. // two rules of SelectionBox.h. Dead actors (hp <= 0) and the HQ proxy are excluded.
// Dead actors (hp <= 0) and the HQ proxy are excluded. std::vector<entt::entity> actorsInBox(EntityAdmin& admin, const QRectF& worldBox);
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);

View File

@@ -5,6 +5,7 @@
#include "PortGeometry.h" #include "PortGeometry.h"
#include "ProductionRules.h" #include "ProductionRules.h"
#include "SelectionBox.h"
#include "SurfaceMask.h" #include "SurfaceMask.h"
#include "Item.h" #include "Item.h"
@@ -185,22 +186,13 @@ getSiteSplitterInfo(const FactoryState& state, const GameConfig& config, Buildin
std::vector<BuildingId> buildingsInBox(const FactoryState& state, std::vector<BuildingId> buildingsInBox(const FactoryState& state,
QPoint cornerA, QPoint cornerB) const QRectF& worldBox)
{ {
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) const auto covers = [&](const std::vector<QPoint>& bodyCells)
{ {
for (const QPoint& cell : bodyCells) for (const QPoint& cell : bodyCells)
{ {
if (cell.x() >= x0 && cell.x() <= x1 if (boxCoversTile(worldBox, cell)) { return true; }
&& cell.y() >= y0 && cell.y() <= y1)
{
return true;
}
} }
return false; return false;
}; };

View File

@@ -3,6 +3,7 @@
#include <vector> #include <vector>
#include <QPoint> #include <QPoint>
#include <QRectF>
#include <QVector2D> #include <QVector2D>
#include "Building.h" #include "Building.h"
@@ -71,11 +72,12 @@ std::optional<BeltSystem::SplitterInfo> getSiteSplitterInfo(const FactoryState&
const GameConfig& config, const GameConfig& config,
BuildingId id); BuildingId id);
// Ids of all buildings and construction sites whose footprint intersects the tile // Ids of all buildings and construction sites the selection box covers — those with
// box spanned by the two (unordered) corner tiles (REQ-UI-MULTI-SELECT, // a body cell the box overlaps, per boxCoversTile (REQ-UI-MULTI-SELECT,
// REQ-BLD-DECONSTRUCT-BOX). // REQ-BLD-DECONSTRUCT-BOX). `worldBox` is in world coordinates and normalized; it is
// not snapped to tiles.
std::vector<BuildingId> buildingsInBox(const FactoryState& state, std::vector<BuildingId> buildingsInBox(const FactoryState& state,
QPoint cornerA, QPoint cornerB); const QRectF& worldBox);
// Every tunnel entry and exit, built or still a construction site, indexed by its // 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. // single-cell tile. Shared by the placement preview and the selection highlight.

View File

@@ -154,33 +154,22 @@ bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount)
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe) bool recipeOutputsFit(const Building& b, const RecipeDef& recipe)
{ {
if (b.type == BuildingType::ReprocessingPlant) for (const RecipeOutputGroup& group : recipe.outputGroups)
{ {
// One roll yields one of these, so each is measured on its own -- but all of them // A group's items come together, so an item listed twice in one is produced in the
// have to fit, since which one it will be is not known yet. // sum of those amounts and judged once, as a sum.
for (const RecipeOutput& out : recipe.outputs) std::map<ItemType, int> perCycle;
for (const RecipeOutput& out : group.items)
{ {
if (!outputBufferHasRoom(b, ItemType{out.item}, out.amount)) perCycle[ItemType{out.item}] += out.amount;
}
for (const std::pair<const ItemType, int>& entry : perCycle)
{
if (!outputBufferHasRoom(b, entry.first, entry.second))
{ {
return false; return false;
} }
} }
return true;
}
// A deterministic cycle deposits all of its outputs together. An item listed more
// than once is produced in the sum of those amounts, so it is judged once, as a sum.
std::map<ItemType, int> perCycle;
for (const RecipeOutput& out : recipe.outputs)
{
perCycle[ItemType{out.item}] += out.amount;
}
for (const std::pair<const ItemType, int>& entry : perCycle)
{
if (!outputBufferHasRoom(b, entry.first, entry.second))
{
return false;
}
} }
return true; return true;
} }

View File

@@ -64,12 +64,11 @@ bool hasInputsToStart(const GameConfig& config, const Building& b);
// counts against that material's capacity (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-OUTPUT-BUFFER). // counts against that material's capacity (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-OUTPUT-BUFFER).
bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount); bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount);
// True when every output a cycle of this recipe could produce would fit -- the gate a // True when every one of the recipe's output groups would fit -- the gate a cycle has to
// cycle has to pass before it may start (REQ-MAT-CYCLE). For a deterministic recipe that // pass before it may start (REQ-MAT-CYCLE, REQ-MAT-OUTPUT-GROUP). With a single group that
// is its own outputs. A Reprocessing Plant rolls one of its outputs per cycle // is simply that group. With several the pick is committed the moment the cycle starts, so
// (REQ-BLD-REPROCESSING), so each possibility is judged on its own and all must fit: the // every outcome must fit: testing all of them rather than the picked one is what keeps a
// roll is committed the moment the cycle starts, and testing every outcome rather than // stalled output belt from biasing the distribution.
// the rolled one is what keeps a stalled output belt from biasing the distribution.
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe); bool recipeOutputsFit(const Building& b, const RecipeDef& recipe);
// True when a production cycle could actually start right now: some candidate recipe // True when a production cycle could actually start right now: some candidate recipe

View File

@@ -97,35 +97,42 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
// values are raw from config; we normalize them per-recipe below). // values are raw from config; we normalize them per-recipe below).
std::map<std::string, std::vector<RecipeRef>> reprocessingRecipes; std::map<std::string, std::vector<RecipeRef>> reprocessingRecipes;
// What decides which model an item's threat follows is the recipe's shape, not the
// building running it (REQ-MAT-OUTPUT-GROUP): a recipe with several groups yields one
// of them by chance, so its items cost the cycle divided by their odds; a recipe with
// one group yields it every cycle, so its items cost the cycle outright.
for (const RecipeDef& recipe : config.recipes.recipes) for (const RecipeDef& recipe : config.recipes.recipes)
{ {
if (recipe.building == BuildingType::ReprocessingPlant) if (recipe.outputGroups.size() > 1)
{ {
// Compute the total weight across all outputs of this reprocessing recipe // Total weight across the groups, so each group's probability normalizes.
// so we can normalize each output's probability.
double totalWeight = 0.0; double totalWeight = 0.0;
for (const RecipeOutput& out : recipe.outputs) for (const RecipeOutputGroup& group : recipe.outputGroups)
{ {
totalWeight += out.probability.value_or(1.0); totalWeight += group.probability.value_or(1.0);
} }
if (totalWeight <= 0.0) if (totalWeight <= 0.0)
{ {
continue; continue;
} }
for (const RecipeOutput& out : recipe.outputs) for (const RecipeOutputGroup& group : recipe.outputGroups)
{ {
RecipeRef ref; const double probability = group.probability.value_or(1.0) / totalWeight;
ref.recipe = &recipe; for (const RecipeOutput& out : group.items)
ref.outputItem = out.item; {
ref.outputAmount = out.amount; RecipeRef ref;
ref.probability = out.probability.value_or(1.0) / totalWeight; ref.recipe = &recipe;
reprocessingRecipes[out.item].push_back(ref); ref.outputItem = out.item;
ref.outputAmount = out.amount;
ref.probability = probability;
reprocessingRecipes[out.item].push_back(ref);
}
} }
} }
else else
{ {
// Check whether this non-reprocessing recipe consumes scrap. // Check whether this single-group recipe consumes scrap.
bool consumesScrap = false; bool consumesScrap = false;
for (const RecipeIngredient& input : recipe.inputs) for (const RecipeIngredient& input : recipe.inputs)
{ {
@@ -136,7 +143,7 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
} }
} }
for (const RecipeOutput& out : recipe.outputs) for (const RecipeOutput& out : recipe.outputGroups.front().items)
{ {
if (!consumesScrap) if (!consumesScrap)
{ {
@@ -288,8 +295,13 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
scrapPerCycle += input.amount; scrapPerCycle += input.amount;
} }
// Per unit: the cycle's cost, divided by the odds of getting this group
// at all and then by how many units that group yields (REQ-THREAT-ITEM).
const double perUnitDivisor =
ref.probability * static_cast<double>(ref.outputAmount);
if (perUnitDivisor <= 0.0) { continue; }
double threat = (table.scrapThreat * scrapPerCycle double threat = (table.scrapThreat * scrapPerCycle
+ ref.recipe->durationSeconds) / ref.probability; + ref.recipe->durationSeconds) / perUnitDivisor;
std::map<std::string, double>::iterator existing = resolved.find(item); std::map<std::string, double>::iterator existing = resolved.find(item);
if (existing == resolved.end() || threat > existing->second) if (existing == resolved.end() || threat > existing->second)

View File

@@ -244,9 +244,9 @@ UnlockState::UnlockedSets UnlockState::computeUnlockedSets(
if (def.building == BuildingType::Assembler if (def.building == BuildingType::Assembler
&& (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0)) && (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0))
{ {
for (const RecipeOutput& out : def.outputs) for (const std::string& item : getProducibleItems(def))
{ {
result.itemIds.insert(out.item); result.itemIds.insert(item);
} }
} }
} }
@@ -272,9 +272,9 @@ UnlockState::UnlockedSets UnlockState::computeUnlockedSets(
continue; continue;
} }
bool producesUnlocked = false; bool producesUnlocked = false;
for (const RecipeOutput& out : recipe.outputs) for (const std::string& item : getProducibleItems(recipe))
{ {
if (result.itemIds.count(out.item) > 0) if (result.itemIds.count(item) > 0)
{ {
producesUnlocked = true; producesUnlocked = true;
break; break;

View File

@@ -288,6 +288,66 @@ TEST_CASE("The effective builder type follows the resolved tunnel end", "[buildm
REQUIRE(controller.getTunnelPartnerTile() == QPoint(5, 5)); REQUIRE(controller.getTunnelPartnerTile() == QPoint(5, 5));
} }
TEST_CASE("Clearing the hover drops everything the cursor pointed at", "[buildmode]")
{
// A cursor that leaves the world hovers nothing, so the ghost and its resolved
// tunnel end go with it, while the mode itself stays active (REQ-BLD-GHOST).
BuildModeController controller;
controller.enterBuilderMode(BuildingType::TunnelEntry);
controller.setGhostTile(QPoint(7, 2));
controller.setGhostValidity(true);
controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 2));
controller.clearHover();
REQUIRE(controller.isBuilderMode());
REQUIRE_FALSE(controller.getGhostTile().has_value());
REQUIRE_FALSE(controller.isGhostValid());
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelEntry);
REQUIRE_FALSE(controller.getTunnelPartnerTile().has_value());
}
TEST_CASE("Clearing the hover keeps a belt drag's path", "[buildmode]")
{
// A drag holds the button and goes on hovering wherever the cursor travels, so
// nothing clears it short of releasing or cancelling (REQ-BLD-BELT-DRAG).
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.beginBeltDrag(QPoint(3, 4));
controller.setBeltDragPath({BeltPathTile{QPoint(3, 4), Rotation::East}});
controller.clearHover();
REQUIRE(controller.isDraggingBelt());
REQUIRE(controller.getBeltDragPath().size() == 1);
}
TEST_CASE("Clearing the hover drops the blueprint ghost and its transfer", "[buildmode]")
{
BuildModeController controller;
controller.enterBlueprintMode(makeBlueprint());
controller.setBlueprintGhostTile(QPoint(9, 9));
controller.setHoveredGhostTransfer(true);
controller.clearHover();
REQUIRE(controller.isBlueprintMode());
REQUIRE_FALSE(controller.getBlueprintGhostTile().has_value());
REQUIRE_FALSE(controller.isHoveredGhostTransfer());
}
TEST_CASE("Clearing the hover drops the deconstruct hover", "[buildmode]")
{
BuildModeController controller;
controller.toggleDeconstructMode();
controller.setDeconstructHoverBuildingId(BuildingId(4));
controller.clearHover();
REQUIRE(controller.isDeconstructMode());
REQUIRE_FALSE(controller.getDeconstructHoverBuildingId().has_value());
}
TEST_CASE("A non-tunnel builder ignores any resolved tunnel end", "[buildmode]") TEST_CASE("A non-tunnel builder ignores any resolved tunnel end", "[buildmode]")
{ {
BuildModeController controller; BuildModeController controller;

View File

@@ -11,6 +11,7 @@
#include <vector> #include <vector>
#include <QPoint> #include <QPoint>
#include <QRectF>
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
@@ -103,14 +104,20 @@ struct PlacementFixture
BuildingSystem bs; BuildingSystem bs;
// Defaults to the configured belt speed; pass kFastBeltSpeed_tps where the test // Defaults to the configured belt speed; pass kFastBeltSpeed_tps where the test
// needs items to arrive immediately. // needs items to arrive immediately. Everything counts as unlocked unless the test
explicit PlacementFixture(std::optional<double> beltSpeed_tps = std::nullopt) // says otherwise, which is what the output-group eligibility rule turns on
// (REQ-LOCK-OUTPUT-POOL).
explicit PlacementFixture(
std::optional<double> beltSpeed_tps = std::nullopt,
std::function<bool(const std::string&)> isItemUnlocked = nullptr)
: belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps)) : belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps))
, bs(cfg, belts, , bs(cfg, belts,
[this]() { return nextBuildingId++; }, [this]() { return nextBuildingId++; },
[this](int n) { stock += n; }, [this](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; }, isItemUnlocked ? std::move(isItemUnlocked)
: std::function<bool(const std::string&)>(
[](const std::string&) { return true; }),
rng) rng)
{ {
} }
@@ -135,6 +142,26 @@ TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building
REQUIRE_FALSE(isTileOccupied(f.state, QPoint(1, 1))); REQUIRE_FALSE(isTileOccupied(f.state, QPoint(1, 1)));
} }
TEST_CASE("buildingsInBox covers a body cell the box only reaches into", "[building]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0),
Rotation::East, 0).value();
// Body at (0,0),(1,0),(0,1). The box is unsnapped and lies wholly within cell
// (1,0) without filling it, which is enough: a building is covered when the box
// overlaps any of its body cells (REQ-UI-MULTI-SELECT, Coverage).
const std::vector<BuildingId> grazed =
buildingsInBox(f.state, QRectF(1.6, 0.4, 0.2, 0.2));
REQUIRE(grazed.size() == 1);
REQUIRE(grazed.front() == id);
// (1,1) is the output-port tile, not a body cell, so a box inside it covers
// nothing even though it is surrounded by the miner's cells.
REQUIRE(buildingsInBox(f.state, QRectF(1.2, 1.2, 0.5, 0.5)).empty());
}
// -- World-bounds rejection (REQ-BLD-PLACE-VALID) --------------------------- // -- World-bounds rejection (REQ-BLD-PLACE-VALID) ---------------------------
TEST_CASE("BuildingSystem: place rejects a building above the world (y < 0)", "[building]") TEST_CASE("BuildingSystem: place rejects a building above the world (y < 0)", "[building]")
@@ -927,6 +954,135 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
// Reprocessing plant -- per-item output buffers (REQ-MAT-OUTPUT-BUFFER) // Reprocessing plant -- per-item output buffers (REQ-MAT-OUTPUT-BUFFER)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("ConfigLoader: the outputs shorthand and one output_group load alike",
"[config]")
{
// `outputs = [...]` is exactly one group holding those items (REQ-MAT-OUTPUT-GROUP),
// so a recipe written either way behaves identically.
PlacementFixture f;
const RecipeDef* shorthand =
f.cfg.recipes.findRecipeDef("iron_ingot", BuildingType::Smelter);
REQUIRE(shorthand != nullptr);
REQUIRE(shorthand->outputGroups.size() == 1);
REQUIRE_FALSE(shorthand->outputGroups.front().probability.has_value());
// Sizing and the cycle gate read it as one group like any other.
Building smelter; smelter.type = BuildingType::Smelter;
smelter.recipeId = shorthand->id;
initBuffers(smelter, *shorthand);
REQUIRE(smelter.outputBuffer.caps.at(ItemType{"iron_ingot"})
== 2 * shorthand->outputGroups.front().items.front().amount);
REQUIRE(recipeOutputsFit(smelter, *shorthand));
}
TEST_CASE("BuildingSystem: a single-group recipe consumes no randomness", "[building]")
{
// Nothing is picked where there is one group, so no draw is made (REQ-MAT-OUTPUT-GROUP).
// Drawing here would consume entropy for every ordinary recipe and shift every later
// random outcome, which is what the two fixtures below would expose: they differ only
// in how far their generators have been advanced.
PlacementFixture quiet;
PlacementFixture advanced;
for (int i = 0; i < 50; ++i) { (void)advanced.rng(); }
Tick tickA = 0;
Tick tickB = 0;
const BuildingId a =
quiet.bs.place(quiet.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
const BuildingId b =
advanced.bs.place(advanced.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
quiet.bs.setRecipe(quiet.state, a, "mine_iron_ore");
advanced.bs.setRecipe(advanced.state, b, "mine_iron_ore");
const int ticks = static_cast<int>(secondsToTicks(10.0)) + 40;
runTicks(quiet.bs, quiet.cfg, quiet.state, quiet.belts, quiet.stock, ticks, tickA);
runTicks(advanced.bs, advanced.cfg, advanced.state, advanced.belts, advanced.stock,
ticks, tickB);
const Building* minerA = findBuilding(quiet.state, a);
const Building* minerB = findBuilding(advanced.state, b);
REQUIRE(minerA != nullptr);
REQUIRE(minerB != nullptr);
REQUIRE(minerA->getOutputItemCount() > 0);
REQUIRE(minerA->getOutputItemCount() == minerB->getOutputItemCount());
REQUIRE(minerA->production.has_value() == minerB->production.has_value());
}
TEST_CASE("BuildingSystem: a group's items are sized and gated together", "[building]")
{
// A group yields all of its items at once (REQ-MAT-OUTPUT-GROUP), so each is buffered
// at twice its own amount and the cycle needs room for all of them at once. No config
// recipe has a multi-item group yet, so one is built here.
PlacementFixture f;
RecipeDef recipe;
recipe.id = "multi_item_group";
recipe.building = BuildingType::Assembler;
recipe.durationSeconds = 1.0;
recipe.inputs.push_back(RecipeIngredient{"iron_ore", 1});
RecipeOutputGroup group;
group.items.push_back(RecipeOutput{"iron_ingot", 2});
group.items.push_back(RecipeOutput{"silicon", 1});
recipe.outputGroups.push_back(group);
Building assembler; assembler.type = BuildingType::Assembler;
initBuffers(assembler, recipe);
REQUIRE(assembler.outputBuffer.caps.at(ItemType{"iron_ingot"}) == 4);
REQUIRE(assembler.outputBuffer.caps.at(ItemType{"silicon"}) == 2);
// Both fit while both have room.
REQUIRE(recipeOutputsFit(assembler, recipe));
// One item of the group short of room blocks the whole cycle, even though the other
// still has plenty: the group cannot be produced in halves.
assembler.outputBuffer.items.push_back(makeItem("silicon"));
assembler.outputBuffer.items.push_back(makeItem("silicon"));
REQUIRE(outputBufferHasRoom(assembler, ItemType{"iron_ingot"}, 2));
REQUIRE_FALSE(outputBufferHasRoom(assembler, ItemType{"silicon"}, 1));
REQUIRE_FALSE(recipeOutputsFit(assembler, recipe));
}
TEST_CASE("BuildingSystem: a group with a locked item is never picked", "[building]")
{
// A group's items come together, so a group holding any locked item is dropped whole
// (REQ-LOCK-OUTPUT-POOL). Here only circuit_board is unlocked, so every cycle must
// yield that group however the weights are stacked -- iron_ingot's group carries the
// largest weight of the three and would dominate were the filter not applied.
PlacementFixture f(std::nullopt,
[](const std::string& id) { return id == "circuit_board"; });
Tick tick = 0;
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
f.bs.setRecipe(f.state, id, "reprocessing_cycle");
// Run many cycles, refilling the scrap and draining the output each time so the plant
// never stalls. A broken filter would show iron_ingot within a few rounds.
int produced = 0;
for (int round = 0; round < 20; ++round)
{
f.bs.forEachBuilding(f.state, [](Building& building) {
if (building.type != BuildingType::ReprocessingPlant) { return; }
building.inputBuffer.counts[ItemType{"scrap"}] =
building.inputBuffer.caps.at(ItemType{"scrap"});
building.outputBuffer.items.clear();
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
});
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(3.0)) + 1, tick);
for (const Item& item : outputSideItems(*findBuilding(f.state, id)))
{
CHECK(item.type.id == "circuit_board");
++produced;
}
}
REQUIRE(produced > 0);
}
TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possible roll", TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possible roll",
"[building]") "[building]")
{ {
@@ -1834,9 +1990,10 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
// Sized the way the simulation sizes it (REQ-MAT-OUTPUT-BUFFER). // Sized the way the simulation sizes it (REQ-MAT-OUTPUT-BUFFER).
initBuffers(assembler, *assemblerRecipe); initBuffers(assembler, *assemblerRecipe);
const std::string outputItemId = assemblerRecipe->outputs.front().item; const std::string outputItemId =
assemblerRecipe->outputGroups.front().items.front().item;
int cycleOutput = 0; int cycleOutput = 0;
for (const RecipeOutput& out : assemblerRecipe->outputs) for (const RecipeOutput& out : assemblerRecipe->outputGroups.front().items)
{ {
cycleOutput += out.amount; cycleOutput += out.amount;
} }
@@ -1875,17 +2032,21 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
{ {
if (r.building != BuildingType::Assembler || r.inputs.empty()) { continue; } if (r.building != BuildingType::Assembler || r.inputs.empty()) { continue; }
int total = 0; int total = 0;
for (const RecipeOutput& out : r.outputs) { total += out.amount; } for (const RecipeOutput& out : r.outputGroups.front().items)
{
total += out.amount;
}
if (total >= 2) { multiOutputRecipe = &r; break; } if (total >= 2) { multiOutputRecipe = &r; break; }
} }
REQUIRE(multiOutputRecipe != nullptr); REQUIRE(multiOutputRecipe != nullptr);
int cycleOutput = 0; int cycleOutput = 0;
for (const RecipeOutput& out : multiOutputRecipe->outputs) for (const RecipeOutput& out : multiOutputRecipe->outputGroups.front().items)
{ {
cycleOutput += out.amount; cycleOutput += out.amount;
} }
const std::string outputItemId = multiOutputRecipe->outputs.front().item; const std::string outputItemId =
multiOutputRecipe->outputGroups.front().items.front().item;
Building assembler; assembler.type = BuildingType::Assembler; Building assembler; assembler.type = BuildingType::Assembler;
assembler.recipeId = multiOutputRecipe->id; assembler.recipeId = multiOutputRecipe->id;
@@ -1924,7 +2085,7 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
} }
} }
REQUIRE(reprocessingRecipe != nullptr); REQUIRE(reprocessingRecipe != nullptr);
REQUIRE(reprocessingRecipe->outputs.size() >= 2); REQUIRE(reprocessingRecipe->outputGroups.size() >= 2);
Building plant; plant.type = BuildingType::ReprocessingPlant; Building plant; plant.type = BuildingType::ReprocessingPlant;
plant.recipeId = reprocessingRecipe->id; plant.recipeId = reprocessingRecipe->id;
@@ -1939,8 +2100,10 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
// Fill one outcome's buffer and leave the rest untouched -> yellow, even though // Fill one outcome's buffer and leave the rest untouched -> yellow, even though
// the other outcomes still have room. // the other outcomes still have room.
const std::string firstItemId = reprocessingRecipe->outputs.front().item; const std::string firstItemId =
const std::string lastItemId = reprocessingRecipe->outputs.back().item; reprocessingRecipe->outputGroups.front().items.front().item;
const std::string lastItemId =
reprocessingRecipe->outputGroups.back().items.front().item;
for (int i = 0; i < plant.outputBuffer.caps.at(ItemType{firstItemId}); ++i) for (int i = 0; i < plant.outputBuffer.caps.at(ItemType{firstItemId}); ++i)
{ {
plant.outputBuffer.items.push_back(makeItem(firstItemId)); plant.outputBuffer.items.push_back(makeItem(firstItemId));

View File

@@ -130,22 +130,26 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
REQUIRE(*salvageBayIt->tooltip == "Drop-off point for salvage ships."); REQUIRE(*salvageBayIt->tooltip == "Drop-off point for salvage ships.");
REQUIRE_FALSE(minerIt->tooltip.has_value()); REQUIRE_FALSE(minerIt->tooltip.has_value());
// recipes.toml reprocessing cycle has three weighted outputs. // recipes.toml -- the reprocessing cycle is written as three weighted output groups,
// each yielding one item (REQ-MAT-OUTPUT-GROUP).
const auto reproIt = std::find_if( const auto reproIt = std::find_if(
cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(), cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
[](const RecipeDef& r) { return r.id == "reprocessing_cycle"; }); [](const RecipeDef& r) { return r.id == "reprocessing_cycle"; });
REQUIRE(reproIt != cfg.recipes.recipes.end()); REQUIRE(reproIt != cfg.recipes.recipes.end());
REQUIRE(reproIt->building == BuildingType::ReprocessingPlant); REQUIRE(reproIt->building == BuildingType::ReprocessingPlant);
REQUIRE(reproIt->outputs.size() == 3); REQUIRE(reproIt->outputGroups.size() == 3);
REQUIRE(reproIt->outputs[0].probability.has_value()); REQUIRE(reproIt->outputGroups[0].probability.has_value());
REQUIRE(reproIt->outputGroups[0].items.size() == 1);
// Non-reprocessing recipes don't carry probability. // The `outputs = [...]` shorthand loads as one group carrying no weight: with a single
// group nothing is picked, so there is nothing to weigh.
const auto ironIngotIt = std::find_if( const auto ironIngotIt = std::find_if(
cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(), cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
[](const RecipeDef& r) { return r.id == "iron_ingot"; }); [](const RecipeDef& r) { return r.id == "iron_ingot"; });
REQUIRE(ironIngotIt != cfg.recipes.recipes.end()); REQUIRE(ironIngotIt != cfg.recipes.recipes.end());
REQUIRE(ironIngotIt->outputs.size() == 1); REQUIRE(ironIngotIt->outputGroups.size() == 1);
REQUIRE_FALSE(ironIngotIt->outputs[0].probability.has_value()); REQUIRE(ironIngotIt->outputGroups[0].items.size() == 1);
REQUIRE_FALSE(ironIngotIt->outputGroups[0].probability.has_value());
// ships.toml — combat ships have default_modules with a weapon; salvage ships don't. // ships.toml — combat ships have default_modules with a weapon; salvage ships don't.
const auto interceptorIt = std::find_if( const auto interceptorIt = std::find_if(

View File

@@ -198,19 +198,29 @@ TEST_CASE("ControlAction: contexts are named by mode and selection", "[controls]
== ControlContextKind::Deconstruct); == ControlContextKind::Deconstruct);
} }
TEST_CASE("ControlAction: Q enters deconstruct mode, or leaves the active one", // The three cases of Q, in the order REQ-UI-HOTKEYS evaluates them.
TEST_CASE("ControlAction: Q leaves the active mode, else clears, else deconstructs",
"[controls]") "[controls]")
{ {
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, generalContext())
== ControlAction::EnterDeconstruct);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, selectionContext())
== ControlAction::EnterDeconstruct);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, builderContext(BuildingType::Belt)) REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, builderContext(BuildingType::Belt))
== ControlAction::ExitMode); == ControlAction::ExitMode);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, blueprintContext()) REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, blueprintContext())
== ControlAction::ExitMode); == ControlAction::ExitMode);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, deconstructContext()) REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, deconstructContext())
== ControlAction::ExitMode); == ControlAction::ExitMode);
// Whatever the selection holds: clearing it is not a buildings-only action.
ControlContext fieldSelection = selectionContext(false);
fieldSelection.selection = ControlSelection::FieldObjects;
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, selectionContext())
== ControlAction::ClearSelection);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, fieldSelection)
== ControlAction::ClearSelection);
// Only with nothing to clear and no mode to leave does Q enter deconstruct mode --
// which is what makes it two presses from a selection.
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, generalContext())
== ControlAction::EnterDeconstruct);
} }
TEST_CASE("ControlAction: Ctrl distinguishes the chords, other modifiers do not", TEST_CASE("ControlAction: Ctrl distinguishes the chords, other modifiers do not",
@@ -320,9 +330,14 @@ TEST_CASE("ControlAction: selection rows appear only once something is selected"
REQUIRE(contains(general, ControlAction::EnterDeconstruct)); REQUIRE(contains(general, ControlAction::EnterDeconstruct));
REQUIRE_FALSE(contains(general, ControlAction::AddToSelection)); REQUIRE_FALSE(contains(general, ControlAction::AddToSelection));
REQUIRE_FALSE(contains(general, ControlAction::CreateBlueprint)); REQUIRE_FALSE(contains(general, ControlAction::CreateBlueprint));
REQUIRE_FALSE(contains(general, ControlAction::ClearSelection));
REQUIRE(contains(selection, ControlAction::AddToSelection)); REQUIRE(contains(selection, ControlAction::AddToSelection));
REQUIRE(contains(selection, ControlAction::AddAreaToSelection)); REQUIRE(contains(selection, ControlAction::AddAreaToSelection));
REQUIRE(contains(selection, ControlAction::CreateBlueprint)); REQUIRE(contains(selection, ControlAction::CreateBlueprint));
REQUIRE(contains(selection, ControlAction::EnterDeconstruct)); // Q clears here instead of entering deconstruct mode, and its row sits last, as the
// row that hands the context back does in every context (REQ-UI-CONTROLS-CONTENT).
REQUIRE(contains(selection, ControlAction::ClearSelection));
REQUIRE_FALSE(contains(selection, ControlAction::EnterDeconstruct));
REQUIRE(selection.back() == ControlAction::ClearSelection);
} }

View File

@@ -1,5 +1,6 @@
#include "catch.hpp" #include "catch.hpp"
#include <QRectF>
#include <QSize> #include <QSize>
#include <QVector2D> #include <QVector2D>
@@ -210,17 +211,16 @@ TEST_CASE("entityAtWorldPos never returns debris", "[debris]")
REQUIRE((entityAtWorldPos(admin, QVector2D(3.0f, 4.0f)) == entt::null)); REQUIRE((entityAtWorldPos(admin, QVector2D(3.0f, 4.0f)) == entt::null));
} }
TEST_CASE("debrisInBox returns exactly the debris inside the tile rectangle", "[debris]") TEST_CASE("debrisInBox returns exactly the debris the box encloses", "[debris]")
{ {
EntityAdmin admin; EntityAdmin admin;
DebrisSystem ss(admin); DebrisSystem ss(admin);
const entt::entity inA = ss.spawn(QVector2D(1.2f, 2.7f), 1, 100); // tile (1,2) const entt::entity inA = ss.spawn(QVector2D(1.2f, 2.7f), 1, 100);
const entt::entity inB = ss.spawn(QVector2D(4.9f, 5.1f), 1, 100); // tile (4,5) const entt::entity inB = ss.spawn(QVector2D(4.9f, 5.1f), 1, 100);
const entt::entity outX = ss.spawn(QVector2D(10.0f, 10.0f), 1, 100); const entt::entity outX = ss.spawn(QVector2D(10.0f, 10.0f), 1, 100);
// Box given in reversed corner order to confirm normalization. const std::vector<entt::entity> hit = debrisInBox(admin, QRectF(0.0, 0.0, 6.0, 6.0));
const std::vector<entt::entity> hit = debrisInBox(admin, QPoint(5, 5), QPoint(0, 0));
REQUIRE(hit.size() == 2); REQUIRE(hit.size() == 2);
REQUIRE(contains(hit, inA)); REQUIRE(contains(hit, inA));
@@ -228,6 +228,23 @@ TEST_CASE("debrisInBox returns exactly the debris inside the tile rectangle", "[
REQUIRE_FALSE(contains(hit, outX)); REQUIRE_FALSE(contains(hit, outX));
} }
TEST_CASE("debrisInBox cuts within a tile, not along the tile grid", "[debris]")
{
EntityAdmin admin;
DebrisSystem ss(admin);
const entt::entity inTile = ss.spawn(QVector2D(1.8f, 2.5f), 1, 100);
// Same tile (1,2) as the piece above, but on the far side of the box's left edge:
// a tile-snapped box would take both (REQ-UI-MULTI-SELECT, Coverage).
const entt::entity outTile = ss.spawn(QVector2D(1.2f, 2.5f), 1, 100);
const std::vector<entt::entity> hit = debrisInBox(admin, QRectF(1.5, 2.0, 4.0, 4.0));
REQUIRE(hit.size() == 1);
REQUIRE(contains(hit, inTile));
REQUIRE_FALSE(contains(hit, outTile));
}
TEST_CASE("actorsInBox returns living ships and stations, excluding debris and dead actors", TEST_CASE("actorsInBox returns living ships and stations, excluding debris and dead actors",
"[actor]") "[actor]")
{ {
@@ -236,10 +253,10 @@ TEST_CASE("actorsInBox returns living ships and stations, excluding debris and d
// Two living ships inside the box: one player, one enemy. // Two living ships inside the box: one player, one enemy.
const entt::entity playerShip = admin.spawnShip( const entt::entity playerShip = admin.spawnShip(
QVector2D(1.5f, 2.5f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f, QVector2D(1.5f, 2.5f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"fighter", false); // tile (1,2) "fighter", false);
const entt::entity enemyShip = admin.spawnShip( const entt::entity enemyShip = admin.spawnShip(
QVector2D(4.2f, 5.8f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f, QVector2D(4.2f, 5.8f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"raider", true); // tile (4,5) "raider", true);
// A dead ship inside the box is excluded. // A dead ship inside the box is excluded.
const entt::entity deadShip = admin.spawnShip( const entt::entity deadShip = admin.spawnShip(
@@ -251,7 +268,7 @@ TEST_CASE("actorsInBox returns living ships and stations, excluding debris and d
QVector2D(20.0f, 20.0f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f, QVector2D(20.0f, 20.0f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"fighter", false); "fighter", false);
// A station is included when any body cell lies inside the box. // A station is included when the box overlaps any body cell.
const std::vector<QPoint> stationCells{ QPoint(2, 2), QPoint(3, 2) }; const std::vector<QPoint> stationCells{ QPoint(2, 2), QPoint(3, 2) };
const entt::entity station = admin.spawnStation( const entt::entity station = admin.spawnStation(
QPoint(2, 2), QSize(2, 1), stationCells, 200.0f, 200.0f, true); QPoint(2, 2), QSize(2, 1), stationCells, 200.0f, 200.0f, true);
@@ -260,7 +277,7 @@ TEST_CASE("actorsInBox returns living ships and stations, excluding debris and d
admin.spawnDebris(QVector2D(1.0f, 1.0f), 5, Tick(1000)); admin.spawnDebris(QVector2D(1.0f, 1.0f), 5, Tick(1000));
admin.spawnHqProxy(QVector2D(0.5f, 0.5f), 500.0f, 500.0f); admin.spawnHqProxy(QVector2D(0.5f, 0.5f), 500.0f, 500.0f);
const std::vector<entt::entity> hit = actorsInBox(admin, QPoint(5, 5), QPoint(0, 0)); const std::vector<entt::entity> hit = actorsInBox(admin, QRectF(0.0, 0.0, 6.0, 6.0));
REQUIRE(hit.size() == 3); REQUIRE(hit.size() == 3);
REQUIRE(contains(hit, playerShip)); REQUIRE(contains(hit, playerShip));
@@ -269,3 +286,32 @@ TEST_CASE("actorsInBox returns living ships and stations, excluding debris and d
REQUIRE_FALSE(contains(hit, deadShip)); REQUIRE_FALSE(contains(hit, deadShip));
REQUIRE_FALSE(contains(hit, outsideShip)); REQUIRE_FALSE(contains(hit, outsideShip));
} }
TEST_CASE("actorsInBox takes a ship by its position and a station by its footprint",
"[actor]")
{
EntityAdmin admin;
// Ship inside the tile the box only reaches into: taken, because the box contains
// its position (REQ-UI-MULTI-SELECT, Coverage).
const entt::entity shipInside = admin.spawnShip(
QVector2D(3.9f, 3.9f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"fighter", false);
// Same tile (3,3), outside the box: a tile-snapped box would take it too.
const entt::entity shipOutside = admin.spawnShip(
QVector2D(3.1f, 3.1f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"fighter", false);
// The box reaches 0.5 tiles into the station's only cell, which is enough: a
// station is covered when the box overlaps its footprint.
const std::vector<QPoint> stationCells{ QPoint(4, 3) };
const entt::entity station = admin.spawnStation(
QPoint(4, 3), QSize(1, 1), stationCells, 200.0f, 200.0f, true);
const std::vector<entt::entity> hit = actorsInBox(admin, QRectF(3.5, 3.5, 1.0, 1.0));
REQUIRE(hit.size() == 2);
REQUIRE(contains(hit, shipInside));
REQUIRE(contains(hit, station));
REQUIRE_FALSE(contains(hit, shipOutside));
}

View File

@@ -12,7 +12,11 @@ static QRect makeBand()
return QRect(0, 0, 1000, 600); return QRect(0, 0, 1000, 600);
} }
static const int kMarginPx = 8; // The two distances the rules run on, deliberately different (REQ-UI-SELECTION-PANEL):
// the edge margin the panel keeps from the view and from the widgets it steps around, and
// the wider gap it keeps from the selection -- half a tile, so 20 px at a 40 px tile.
static const int kMarginPx = 8;
static const int kSelectionGapPx = 20;
TEST_CASE("With nothing in the way a widget may use the whole band", "[layout]") TEST_CASE("With nothing in the way a widget may use the whole band", "[layout]")
{ {
@@ -74,25 +78,34 @@ TEST_CASE("A widget filling the column leaves nothing", "[layout]")
TEST_CASE("The panel stands to the right of the selection where it fits", "[layout]") TEST_CASE("The panel stands to the right of the selection where it fits", "[layout]")
{ {
// REQ-UI-SELECTION-PANEL: right of the anchor is the first choice. // REQ-UI-SELECTION-PANEL: right of the anchor is the first choice.
REQUIRE(chooseSide(makeBand(), QRect(100, 100, 60, 60), 300, kMarginPx) REQUIRE(chooseSide(makeBand(), QRect(100, 100, 60, 60), 300, kSelectionGapPx)
== PanelSide::Right); == PanelSide::Right);
} }
TEST_CASE("The panel goes left when the right cannot hold it", "[layout]") TEST_CASE("The panel goes left when the right cannot hold it", "[layout]")
{ {
// A selection near the right edge leaves 100 px there, not enough for a 300 px // A selection near the right edge leaves 80 px there once the gap it keeps from the
// panel, and the left is wide open. // selection is taken off, not enough for a 300 px panel, and the left is wide open.
REQUIRE(chooseSide(makeBand(), QRect(880, 100, 20, 60), 300, kMarginPx) REQUIRE(chooseSide(makeBand(), QRect(880, 100, 20, 60), 300, kSelectionGapPx)
== PanelSide::Left); == PanelSide::Left);
} }
TEST_CASE("The room a side offers is measured less the gap", "[layout]")
{
// A 200 px panel beside a selection whose right edge leaves 214 px to the band's: it
// fits there on the margin alone, but not once the wider gap is taken off.
const QRect anchorRect(766, 100, 20, 60);
REQUIRE(chooseSide(makeBand(), anchorRect, 200, kMarginPx) == PanelSide::Right);
REQUIRE(chooseSide(makeBand(), anchorRect, 200, kSelectionGapPx) == PanelSide::Left);
}
TEST_CASE("Fitting on neither side, the panel takes the roomier one", "[layout]") TEST_CASE("Fitting on neither side, the panel takes the roomier one", "[layout]")
{ {
// A bounding box spanning most of the view: 192 px free on the left, 92 on the // A bounding box spanning most of the view: 180 px free on the left, 80 on the
// right, and a 300 px panel fits in neither. It covers as little as it can. // right, and a 300 px panel fits in neither. It covers as little as it can.
REQUIRE(chooseSide(makeBand(), QRect(200, 100, 700, 200), 300, kMarginPx) REQUIRE(chooseSide(makeBand(), QRect(200, 100, 700, 200), 300, kSelectionGapPx)
== PanelSide::Left); == PanelSide::Left);
REQUIRE(chooseSide(makeBand(), QRect(100, 100, 700, 200), 300, kMarginPx) REQUIRE(chooseSide(makeBand(), QRect(100, 100, 700, 200), 300, kSelectionGapPx)
== PanelSide::Right); == PanelSide::Right);
} }
@@ -102,17 +115,30 @@ TEST_CASE("Fitting on neither side, the panel takes the roomier one", "[layout]"
TEST_CASE("The panel sits beside the anchor with its top edges aligned", "[layout]") TEST_CASE("The panel sits beside the anchor with its top edges aligned", "[layout]")
{ {
// REQ-UI-SELECTION-PANEL: separated by the margin, growing away from the selection, // REQ-UI-SELECTION-PANEL: separated by the gap it keeps from the selection, growing
// top edge on the anchor's top edge. // away from it, top edge on the anchor's top edge.
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 120, 60, 60), const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 120, 60, 60),
PanelSide::Right, QSize(300, 200), {}, PanelSide::Right, QSize(300, 200), {},
kMarginPx); kSelectionGapPx, kMarginPx);
REQUIRE(placed == QRect(168, 120, 300, 200)); REQUIRE(placed == QRect(180, 120, 300, 200));
const QRect placedLeft = placeBesideAnchor(makeBand(), QRect(500, 120, 60, 60), const QRect placedLeft = placeBesideAnchor(makeBand(), QRect(520, 120, 60, 60),
PanelSide::Left, QSize(300, 200), {}, PanelSide::Left, QSize(300, 200), {},
kMarginPx); kSelectionGapPx, kMarginPx);
REQUIRE(placedLeft == QRect(192, 120, 300, 200)); REQUIRE(placedLeft == QRect(200, 120, 300, 200));
}
TEST_CASE("The gap separates the panel horizontally only", "[layout]")
{
// REQ-UI-SELECTION-PANEL: widening the gap moves the panel further from the selection
// sideways and nowhere else -- its top stays level with the top of what it describes.
const QRect anchorRect(100, 120, 60, 60);
REQUIRE(placeBesideAnchor(makeBand(), anchorRect, PanelSide::Right, QSize(300, 200),
{}, 0, kMarginPx)
== QRect(160, 120, 300, 200));
REQUIRE(placeBesideAnchor(makeBand(), anchorRect, PanelSide::Right, QSize(300, 200),
{}, kSelectionGapPx, kMarginPx)
== QRect(180, 120, 300, 200));
} }
TEST_CASE("A panel that would hang below the view is lifted", "[layout]") TEST_CASE("A panel that would hang below the view is lifted", "[layout]")
@@ -121,19 +147,20 @@ TEST_CASE("A panel that would hang below the view is lifted", "[layout]")
// the band, so it rises until it fits rather than overrunning it. // the band, so it rises until it fits rather than overrunning it.
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 500, 60, 60), const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 500, 60, 60),
PanelSide::Right, QSize(300, 200), {}, PanelSide::Right, QSize(300, 200), {},
kMarginPx); kSelectionGapPx, kMarginPx);
REQUIRE(placed == QRect(168, 400, 300, 200)); REQUIRE(placed == QRect(180, 400, 300, 200));
} }
TEST_CASE("A panel standing over another widget rises above it", "[layout]") TEST_CASE("A panel standing over another widget rises above it", "[layout]")
{ {
// The controls panel in the bottom-left is in the way of a panel placed to the left // The controls panel in the bottom-left is in the way of a panel placed to the left
// of a selection: it clears the top of it by the margin (REQ-UI-CONTROLS-PANEL). // of a selection: it clears the top of it by the edge margin, the gap from the
// selection having settled its left edge (REQ-UI-CONTROLS-PANEL).
const std::vector<QRect> occupied = { QRect(0, 300, 260, 300) }; const std::vector<QRect> occupied = { QRect(0, 300, 260, 300) };
const QRect placed = placeBesideAnchor(makeBand(), QRect(500, 250, 60, 60), const QRect placed = placeBesideAnchor(makeBand(), QRect(500, 250, 60, 60),
PanelSide::Left, QSize(300, 200), occupied, PanelSide::Left, QSize(300, 200), occupied,
kMarginPx); kSelectionGapPx, kMarginPx);
REQUIRE(placed == QRect(192, 92, 300, 200)); REQUIRE(placed == QRect(180, 92, 300, 200));
} }
TEST_CASE("A panel taller than the space left is capped", "[layout]") TEST_CASE("A panel taller than the space left is capped", "[layout]")
@@ -141,8 +168,8 @@ TEST_CASE("A panel taller than the space left is capped", "[layout]")
// Capping is the caller's cue to scroll: it asked for 700 and got what there was. // Capping is the caller's cue to scroll: it asked for 700 and got what there was.
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 100, 60, 60), const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 100, 60, 60),
PanelSide::Right, QSize(300, 700), {}, PanelSide::Right, QSize(300, 700), {},
kMarginPx); kSelectionGapPx, kMarginPx);
REQUIRE(placed == QRect(168, 0, 300, 600)); REQUIRE(placed == QRect(180, 0, 300, 600));
} }
TEST_CASE("A panel that fits on neither side is pushed inside the view", "[layout]") TEST_CASE("A panel that fits on neither side is pushed inside the view", "[layout]")
@@ -151,7 +178,7 @@ TEST_CASE("A panel that fits on neither side is pushed inside the view", "[layou
// stands as far from the anchor as the band allows, not off the edge of it. // stands as far from the anchor as the band allows, not off the edge of it.
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 100, 700, 200), const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 100, 700, 200),
PanelSide::Right, QSize(300, 200), {}, PanelSide::Right, QSize(300, 200), {},
kMarginPx); kSelectionGapPx, kMarginPx);
REQUIRE(placed == QRect(700, 100, 300, 200)); REQUIRE(placed == QRect(700, 100, 300, 200));
} }

View File

@@ -690,3 +690,31 @@ TEST_CASE("calculateShipStats: maneuvering_thrusters additive maneuvering_accele
const float expected = (base_mpss + 10.0f) / tileSize; const float expected = (base_mpss + 10.0f) / tileSize;
CHECK(stats.maneuveringAcceleration_tpss == Approx(expected)); CHECK(stats.maneuveringAcceleration_tpss == Approx(expected));
} }
// The precondition every entry to the layout configuration dialog checks
// (REQ-MOD-UI-DIALOG, REQ-MOD-UI-AUTO-DIALOG, REQ-MOD-UI-PREVIEW). The empty id is the
// one that used to slip through: "(None)" clears a shipyard and differs from whatever
// was set, which is not the same as naming a schematic to configure.
TEST_CASE("ShipsConfig: a layout is configurable only for a ship that has one",
"[modules][config]")
{
const GameConfig cfg = loadTestConfig();
const ShipDef* interceptor = cfg.ships.findLayoutShipDef("interceptor");
REQUIRE(interceptor != nullptr);
CHECK(interceptor->id == "interceptor");
CHECK_FALSE(interceptor->layout.empty());
// The "(None)" option's id, and an id naming no ship at all.
CHECK(cfg.ships.findLayoutShipDef("") == nullptr);
CHECK(cfg.ships.findLayoutShipDef("no_such_ship") == nullptr);
// A ship that exists but defines no grid has nothing to place modules on either,
// where plain findShipDef still finds it.
ShipsConfig gridless;
ShipDef def;
def.id = "hull_only";
gridless.ships.push_back(def);
CHECK(gridless.findShipDef("hull_only") != nullptr);
CHECK(gridless.findLayoutShipDef("hull_only") == nullptr);
}

View File

@@ -30,6 +30,7 @@
#include "BlueprintLibrary.h" #include "BlueprintLibrary.h"
#include "IconCaption.h" #include "IconCaption.h"
#include "ItemIconCache.h" #include "ItemIconCache.h"
#include "TooltipTrigger.h"
namespace namespace
{ {
@@ -149,54 +150,28 @@ namespace
BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library, BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library,
ItemIconCache* itemIcons, ItemIconCache* itemIcons,
QWidget* parent) QWidget* parent)
: QDialog(parent) : ModalDialog(parent)
, m_library(library) , m_library(library)
, m_itemIcons(itemIcons) , m_itemIcons(itemIcons)
{ {
setWindowTitle(tr("Blueprints"));
setModal(true);
// Frameless: the dialog draws its own header row, so an OS title bar would only
// repeat it (REQ-UI-BLUEPRINT-DIALOG). Square corners rather than rounded ones --
// rounding a top-level window needs a translucent background, which is unreliable
// on Windows. The border matches the build bar and the selection panel.
setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint);
setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral(
"BlueprintSelectionDialog { background-color: palette(window);"
" border: 1px solid palette(mid); }"));
QVBoxLayout* mainLayout = new QVBoxLayout(this); QVBoxLayout* mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx); mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx);
mainLayout->setSpacing(kSpacingPx); mainLayout->setSpacing(kSpacingPx);
QHBoxLayout* headerLayout = new QHBoxLayout(); QHBoxLayout* headerLayout = addHeader(mainLayout, tr("Blueprints"), true);
headerLayout->setSpacing(kSpacingPx);
QLabel* titleLabel = new QLabel(tr("Blueprints"), this);
QFont headerFont = titleLabel->font();
headerFont.setBold(true);
titleLabel->setFont(headerFont);
headerLayout->addWidget(titleLabel);
// Dimmed and bold, the treatment the build button hotkey badges use, so the // Dimmed and bold, the treatment the build button hotkey badges use, so the
// shortcut reads as a reminder rather than a second title (REQ-UI-BUILD-COST). // shortcut reads as a reminder rather than a second title (REQ-UI-BUILD-COST).
// Inserted right after the title, left of the stretch the header ends with.
QLabel* hotkeyBadge = new QLabel(tr("Ctrl+V"), this); QLabel* hotkeyBadge = new QLabel(tr("Ctrl+V"), this);
hotkeyBadge->setFont(headerFont); QFont badgeFont = hotkeyBadge->font();
badgeFont.setBold(true);
hotkeyBadge->setFont(badgeFont);
QPalette badgePalette = hotkeyBadge->palette(); QPalette badgePalette = hotkeyBadge->palette();
badgePalette.setColor(hotkeyBadge->foregroundRole(), badgePalette.setColor(hotkeyBadge->foregroundRole(),
palette().color(QPalette::Disabled, QPalette::WindowText)); palette().color(QPalette::Disabled, QPalette::WindowText));
hotkeyBadge->setPalette(badgePalette); hotkeyBadge->setPalette(badgePalette);
headerLayout->addWidget(hotkeyBadge); headerLayout->insertWidget(1, hotkeyBadge);
headerLayout->addStretch();
QPushButton* closeButton = new QPushButton(QString(kCrossGlyph), this);
closeButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
closeButton->setToolTip(tr("Close"));
connect(closeButton, &QPushButton::clicked, this, &QDialog::reject);
headerLayout->addWidget(closeButton);
mainLayout->addLayout(headerLayout);
QScrollArea* scrollArea = new QScrollArea(this); QScrollArea* scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true); scrollArea->setWidgetResizable(true);
@@ -222,13 +197,11 @@ BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library,
setFixedSize(gridWidth + style()->pixelMetric(QStyle::PM_ScrollBarExtent) setFixedSize(gridWidth + style()->pixelMetric(QStyle::PM_ScrollBarExtent)
+ 2 * kSpacingPx, + 2 * kSpacingPx,
gridHeight + kSmallButtonSizePx + 3 * kSpacingPx); gridHeight + kSmallButtonSizePx + 3 * kSpacingPx);
}
// A frameless dialog does not get Qt's automatic centering on its parent. bool BlueprintSelectionDialog::isDismissible() const
if (parent) {
{ return true;
const QRect parentRect = parent->window()->geometry();
move(parentRect.center() - QPoint(width() / 2, height() / 2));
}
} }
std::optional<int> BlueprintSelectionDialog::getChosenIndex() const std::optional<int> BlueprintSelectionDialog::getChosenIndex() const
@@ -296,7 +269,9 @@ void BlueprintSelectionDialog::rebuildGrid()
// must stay enabled on an unaffordable card (REQ-UI-BLUEPRINT-DELETE). // must stay enabled on an unaffordable card (REQ-UI-BLUEPRINT-DELETE).
QPushButton* deleteButton = new QPushButton(QString(kCrossGlyph), card); QPushButton* deleteButton = new QPushButton(QString(kCrossGlyph), card);
deleteButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx); deleteButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
deleteButton->setToolTip(tr("Delete blueprint")); // Hover only: the click deletes the blueprint (REQ-UI-TOOLTIP-TRIGGER).
TooltipTrigger::attachText(*deleteButton, tr("Delete blueprint"),
TooltipTrigger::Trigger::HoverOnly);
deleteButton->move(cardSize.width() - kSmallButtonSizePx - kCardPaddingPx / 2, deleteButton->move(cardSize.width() - kSmallButtonSizePx - kCardPaddingPx / 2,
cardSize.height() - kSmallButtonSizePx - kCardPaddingPx / 2); cardSize.height() - kSmallButtonSizePx - kCardPaddingPx / 2);
deleteButton->raise(); deleteButton->raise();

View File

@@ -2,23 +2,23 @@
#include <optional> #include <optional>
#include <QDialog> #include "ModalDialog.h"
class BlueprintLibrary; class BlueprintLibrary;
class ItemIconCache; class ItemIconCache;
class QGridLayout; class QGridLayout;
class QWidget; class QWidget;
// The blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG): a frameless modal panel // The blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG): a modal panel showing every
// showing every saved blueprint as a card in a scrolling two-column grid. The caller // saved blueprint as a card in a scrolling two-column grid. The caller pauses the game
// pauses the game and raises the dim overlay while it is open. // while it is open.
// //
// Clicking a card accepts the dialog and reports that blueprint's index; the caller // Clicking a card accepts the dialog and reports that blueprint's index; the caller
// enters placement mode afterwards, so the dialog is already closed by then // enters placement mode afterwards, so the dialog is already closed by then
// (REQ-UI-BLUEPRINT-CARD). Deleting acts on the library immediately and leaves the // (REQ-UI-BLUEPRINT-CARD). Deleting acts on the library immediately and leaves the
// dialog open (REQ-UI-BLUEPRINT-DELETE). Escape and the close button dismiss it with // dialog open (REQ-UI-BLUEPRINT-DELETE). Escape, Q, the close button, and a click
// no other effect. // outside dismiss it with no other effect (REQ-UI-DIALOG-DISMISS).
class BlueprintSelectionDialog : public QDialog class BlueprintSelectionDialog : public ModalDialog
{ {
Q_OBJECT Q_OBJECT
@@ -29,6 +29,8 @@ public:
std::optional<int> getChosenIndex() const; std::optional<int> getChosenIndex() const;
bool isDismissible() const override;
private: private:
void rebuildGrid(); void rebuildGrid();
void onCardClicked(int index); void onCardClicked(int index);

View File

@@ -29,6 +29,7 @@
#include "InputMapper.h" #include "InputMapper.h"
#include "ItemIconCache.h" #include "ItemIconCache.h"
#include "Simulation.h" #include "Simulation.h"
#include "TooltipTrigger.h"
namespace namespace
{ {
@@ -211,10 +212,12 @@ BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
btn->setCheckable(true); btn->setCheckable(true);
// The button face carries no name (REQ-UI-BUILD-COST), so the tooltip always // 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 // leads with it and adds the config description when there is one
// (REQ-UI-BUILD-TOOLTIP). // (REQ-UI-BUILD-TOOLTIP). Hover only: the click enters builder mode
btn->setToolTip(def.tooltip // (REQ-UI-TOOLTIP-TRIGGER).
TooltipTrigger::attachText(*btn, def.tooltip
? QStringLiteral("%1\n%2").arg(name, QString::fromStdString(*def.tooltip)) ? QStringLiteral("%1\n%2").arg(name, QString::fromStdString(*def.tooltip))
: name); : name,
TooltipTrigger::Trigger::HoverOnly);
layout->addWidget(btn); layout->addWidget(btn);
const int idx = static_cast<int>(m_buttons.size()); const int idx = static_cast<int>(m_buttons.size());
@@ -253,7 +256,9 @@ BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
"block cost once removed; a construction site removed before it is built " "block cost once removed; a construction site removed before it is built "
"is refunded in full.") "is refunded in full.")
.arg(refundPercentage); .arg(refundPercentage);
m_deconstructButton->setToolTip(deconstructTooltip); // Hover only: the click toggles the mode (REQ-UI-TOOLTIP-TRIGGER).
TooltipTrigger::attachText(*m_deconstructButton, deconstructTooltip,
TooltipTrigger::Trigger::HoverOnly);
layout->addWidget(m_deconstructButton); layout->addWidget(m_deconstructButton);
connect(m_deconstructButton, &QPushButton::clicked, this, [this]() { connect(m_deconstructButton, &QPushButton::clicked, this, [this]() {
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(

View File

@@ -5,8 +5,11 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h ${CMAKE_CURRENT_SOURCE_DIR}/MessageDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalLayer.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h ${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h
${CMAKE_CURRENT_SOURCE_DIR}/NameInputDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.h ${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h
@@ -26,6 +29,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeLineRow.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeLineRow.h
${CMAKE_CURRENT_SOURCE_DIR}/Tooltip.h
${CMAKE_CURRENT_SOURCE_DIR}/TooltipTrigger.h
${CMAKE_CURRENT_SOURCE_DIR}/OptionButton.h ${CMAKE_CURRENT_SOURCE_DIR}/OptionButton.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemProducers.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemProducers.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h
@@ -43,7 +48,10 @@ SET(SRCS
${SRCS} ${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp ${CMAKE_CURRENT_SOURCE_DIR}/MessageDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ModalDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ModalLayer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/NameInputDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.cpp ${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.cpp
@@ -62,6 +70,8 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeLineRow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeLineRow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/Tooltip.cpp
${CMAKE_CURRENT_SOURCE_DIR}/TooltipTrigger.cpp
${CMAKE_CURRENT_SOURCE_DIR}/OptionButton.cpp ${CMAKE_CURRENT_SOURCE_DIR}/OptionButton.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ItemProducers.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ItemProducers.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp

View File

@@ -63,6 +63,7 @@ QString getControlActionLabel(ControlAction action, const ControlContext& contex
case ControlAction::EnterDeconstruct: return Strings::tr("Deconstruct mode"); case ControlAction::EnterDeconstruct: return Strings::tr("Deconstruct mode");
case ControlAction::CopyTemporary: return Strings::tr("Copy to temporary blueprint"); case ControlAction::CopyTemporary: return Strings::tr("Copy to temporary blueprint");
case ControlAction::CreateBlueprint: return Strings::tr("Create blueprint"); case ControlAction::CreateBlueprint: return Strings::tr("Create blueprint");
case ControlAction::ClearSelection: return Strings::tr("Clear selection");
case ControlAction::Place: return Strings::tr("Place"); case ControlAction::Place: return Strings::tr("Place");
case ControlAction::ApplySettings: return Strings::tr("Apply settings"); case ControlAction::ApplySettings: return Strings::tr("Apply settings");
case ControlAction::PlaceBeltLine: return Strings::tr("Place belt line"); case ControlAction::PlaceBeltLine: return Strings::tr("Place belt line");

View File

@@ -57,14 +57,17 @@ QWidget* makeRow(ControlAction action, const ControlContext& context, QWidget* p
// Every badge is rendered from the binding the resolver matches, so a chip cannot // Every badge is rendered from the binding the resolver matches, so a chip cannot
// claim a key that does nothing (REQ-UI-CONTROLS-ACCURACY). The chips of the row // claim a key that does nothing (REQ-UI-CONTROLS-ACCURACY). The chips of the row
// that leaves the mode are the ones marked, not its label (REQ-UI-CONTROLS-CARD). // that leaves the context are the ones marked, not its label (REQ-UI-CONTROLS-CARD):
const bool exitsMode = (action == ControlAction::ExitMode); // leaving a build mode and clearing the selection are the same gesture to the player,
// one key that hands the context back, so they are marked alike.
const bool backsOut = (action == ControlAction::ExitMode
|| action == ControlAction::ClearSelection);
const std::vector<ControlBinding> bindings = getControlActionBindings(action, context); const std::vector<ControlBinding> bindings = getControlActionBindings(action, context);
for (const ControlBinding& binding : bindings) for (const ControlBinding& binding : bindings)
{ {
QLabel* badge = new QLabel(getControlBindingBadge(binding), row); QLabel* badge = new QLabel(getControlBindingBadge(binding), row);
badge->setObjectName(exitsMode ? QStringLiteral("controlBadgeExit") badge->setObjectName(backsOut ? QStringLiteral("controlBadgeExit")
: QStringLiteral("controlBadge")); : QStringLiteral("controlBadge"));
layout->addWidget(badge); layout->addWidget(badge);
} }

View File

@@ -148,6 +148,7 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
, m_debugDraw(false) , m_debugDraw(false)
, m_rng(std::random_device{}()) , m_rng(std::random_device{}())
, m_boxSelecting(false) , m_boxSelecting(false)
, m_boxDragMoved(false)
, m_gameOverShown(false) , m_gameOverShown(false)
, m_schematicChoiceShown(false) , m_schematicChoiceShown(false)
{ {
@@ -294,12 +295,12 @@ void GameWorldView::onFrame()
const bool viewMoved = const bool viewMoved =
m_camera.advance(m_panDirection, elapsed, getScrollBounds()); m_camera.advance(m_panDirection, elapsed, getScrollBounds());
// While the view scrolls, the tile under a stationary cursor changes, // Two things no mouse move reports: the world position under a stationary
// so refresh the box-select rectangle even though no mouse move fires. // cursor changing as the view scrolls, and the cursor crossing onto a panel
if (m_boxSelecting && viewMoved) // or out of the window, which ends the hover (REQ-BLD-GHOST).
if (viewMoved || isHoverLive() != m_hoverLive)
{ {
m_boxCurrentTile = refreshHover();
getCoordinates().widgetToTile(mapFromGlobal(QCursor::pos()));
} }
} }
@@ -420,8 +421,14 @@ void GameWorldView::paintGL()
WorldRenderFrame GameWorldView::makeRenderFrame() const WorldRenderFrame GameWorldView::makeRenderFrame() const
{ {
return WorldRenderFrame{m_selection, m_buildMode, m_activeBeams, m_boxSelecting, // A box only reaches the renderer once the gesture reads as a drag: below the
m_boxStartTile, m_boxCurrentTile, m_debugDraw}; // threshold there is nothing to draw and nothing the box marks that hovering does
// not mark already (REQ-UI-MULTI-SELECT).
std::optional<QRectF> boxWorldRect;
if (m_boxSelecting && m_boxDragMoved) { boxWorldRect = getBoxWorldRect(); }
return WorldRenderFrame{m_selection, m_buildMode, m_activeBeams, boxWorldRect,
m_debugDraw};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -743,8 +750,10 @@ void GameWorldView::transferConfigTo(BuildingId id, const BlueprintBuilding& sou
void GameWorldView::updateTunnelGhost() void GameWorldView::updateTunnelGhost()
{ {
// The connection preview and entry/exit switch only apply at a valid placement // The connection preview and entry/exit switch only apply at a valid placement
// (REQ-BLD-TUNNEL-MODE); at an invalid position the ghost stays a plain entry. // (REQ-BLD-TUNNEL-MODE); at an invalid position, and where the cursor points at
if (!m_buildMode.isGhostValid()) // no tile at all, the ghost stays a plain entry.
const std::optional<QPoint>& ghostTile = m_buildMode.getGhostTile();
if (!ghostTile.has_value() || !m_buildMode.isGhostValid())
{ {
m_buildMode.setTunnelGhost(BuildingType::TunnelEntry, std::nullopt); m_buildMode.setTunnelGhost(BuildingType::TunnelEntry, std::nullopt);
return; return;
@@ -754,7 +763,7 @@ void GameWorldView::updateTunnelGhost()
const TunnelLookup lookup = makeTunnelLookup(tunnels); const TunnelLookup lookup = makeTunnelLookup(tunnels);
const TunnelCompletion completion = const TunnelCompletion completion =
resolveTunnelCompletion(lookup, m_buildMode.getGhostTile(), resolveTunnelCompletion(lookup, *ghostTile,
m_buildMode.getGhostRotation(), m_buildMode.getGhostRotation(),
m_config->world.tunnelMaxDistance_tiles, m_cursorWorldPos); m_config->world.tunnelMaxDistance_tiles, m_cursorWorldPos);
m_buildMode.setTunnelGhost(completion.resolvedType, completion.partnerTile); m_buildMode.setTunnelGhost(completion.resolvedType, completion.partnerTile);
@@ -1132,7 +1141,12 @@ ControlContext GameWorldView::getControlContext() const
context.mode = m_buildMode.getMode(); context.mode = m_buildMode.getMode();
context.draggingBelt = m_buildMode.isDraggingBelt(); context.draggingBelt = m_buildMode.isDraggingBelt();
context.hoveredGhostIsTransfer = m_buildMode.isHoveredGhostTransfer(); context.hoveredGhostIsTransfer = m_buildMode.isHoveredGhostTransfer();
if (m_buildMode.isBuilderMode()) { context.builderType = m_buildMode.getBuilderType(); } // The type a click would actually place, so the panel agrees with the ghost when
// tunnel mode resolves to an exit (REQ-BLD-TUNNEL-MODE).
if (m_buildMode.isBuilderMode())
{
context.builderType = m_buildMode.getEffectiveBuilderType();
}
// Buildings win over field objects, so the two are never both non-empty // Buildings win over field objects, so the two are never both non-empty
// (REQ-UI-SELECTION-CATEGORIES). // (REQ-UI-SELECTION-CATEGORIES).
@@ -1221,9 +1235,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
case ControlAction::ToggleDeconstruct: case ControlAction::ToggleDeconstruct:
// Start a deconstruct box drag; a plain click resolves as a 1x1 box on // Start a deconstruct box drag; a plain click resolves as a 1x1 box on
// release (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX). // release (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX).
m_boxSelecting = true; m_boxSelecting = true;
m_boxStartTile = tile; m_boxStartWorld = coordinates.widgetToWorld(event->pos());
m_boxCurrentTile = tile; m_boxCurrentWorld = m_boxStartWorld;
m_boxDragMoved = false;
break; break;
case ControlAction::Select: case ControlAction::Select:
@@ -1236,8 +1251,9 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
// selectAtPoint has already cleared the selection unless Ctrl is // selectAtPoint has already cleared the selection unless Ctrl is
// preserving it for an additive drag. // preserving it for an additive drag.
m_boxSelecting = true; m_boxSelecting = true;
m_boxStartTile = tile; m_boxStartWorld = coordinates.widgetToWorld(event->pos());
m_boxCurrentTile = tile; m_boxCurrentWorld = m_boxStartWorld;
m_boxDragMoved = false;
} }
break; break;
@@ -1290,8 +1306,10 @@ void GameWorldView::selectInBox(bool additive)
// mode: a Ctrl box adds and never deselects, where a Ctrl click toggles. // mode: a Ctrl box adds and never deselects, where a Ctrl click toggles.
const SelectionMode mode = additive ? SelectionMode::Add : SelectionMode::Replace; const SelectionMode mode = additive ? SelectionMode::Add : SelectionMode::Replace;
const QRectF worldBox = getBoxWorldRect();
const std::vector<BuildingId> boxIds = const std::vector<BuildingId> boxIds =
buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile); buildingsInBox(m_sim->getFactoryState(), worldBox);
if (!boxIds.empty()) if (!boxIds.empty())
{ {
publishSelectionAnchor(mode, boxIds, {}, {}); publishSelectionAnchor(mode, boxIds, {}, {});
@@ -1299,10 +1317,8 @@ void GameWorldView::selectInBox(bool additive)
return; return;
} }
const std::vector<entt::entity> boxActors = const std::vector<entt::entity> boxActors = actorsInBox(m_sim->getAdmin(), worldBox);
actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile); const std::vector<entt::entity> boxDebris = debrisInBox(m_sim->getAdmin(), worldBox);
const std::vector<entt::entity> boxDebris =
debrisInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
if (!boxActors.empty() || !boxDebris.empty()) if (!boxActors.empty() || !boxDebris.empty())
{ {
publishSelectionAnchor(mode, {}, boxActors, boxDebris); publishSelectionAnchor(mode, {}, boxActors, boxDebris);
@@ -1314,40 +1330,43 @@ void GameWorldView::selectInBox(bool additive)
if (!additive) { m_selection.clearAll(); } if (!additive) { m_selection.clearAll(); }
} }
void GameWorldView::publishSelectionAnchor(SelectionMode mode, void GameWorldView::clearSelectionForBuildMode()
const std::vector<BuildingId>& buildings,
const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris)
{ {
// An additive gesture onto something already selected is growing that selection, not m_selection.clearAll();
// starting one, and the panel stays where it was put (REQ-UI-SELECTION-PANEL).
const bool startsSelection =
(mode == SelectionMode::Replace) || (m_selection.getSelectedBuildings().empty()
&& m_selection.getSelectedActors().empty()
&& m_selection.getSelectedDebris().empty());
if (!startsSelection)
{
return;
}
// Screen space, frozen here: the panel is placed against where the selection is at
// this moment and stays there, however far the view scrolls or the objects move
// afterwards.
const QRect anchorRect = getSelectionWidgetRect(*m_sim, getCoordinates(),
buildings, actors, debris);
if (anchorRect.isNull())
{
return;
}
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionAnchorChangedEvent>(anchorRect));
} }
void GameWorldView::mouseMoveEvent(QMouseEvent* event) bool GameWorldView::isHoverLive() const
{ {
// underMouse() is false while the cursor sits on one of the floating panels,
// which are siblings of this widget rather than children, and while it is
// outside the window. A drag holding the button is the exception: it tracks the
// cursor wherever it goes until the button comes back up (REQ-UI-MULTI-SELECT,
// REQ-BLD-BELT-DRAG).
return underMouse() || m_boxSelecting || m_buildMode.isDraggingBelt();
}
void GameWorldView::refreshHover()
{
if (isHoverLive())
{
updateHoverAt(mapFromGlobal(QCursor::pos()));
}
else
{
m_buildMode.clearHover();
m_hoverLive = false;
}
}
void GameWorldView::updateHoverAt(QPoint cursorWidgetPos)
{
// Reached either from a mouse move, which only this widget receives, or from a
// hover refresh that has already established the cursor is on the world.
m_hoverLive = true;
const WorldCoordinates coordinates = getCoordinates(); const WorldCoordinates coordinates = getCoordinates();
const QPoint tile = coordinates.widgetToTile(event->pos()); const QPoint tile = coordinates.widgetToTile(cursorWidgetPos);
m_cursorWorldPos = coordinates.widgetToWorld(event->pos()); m_cursorWorldPos = coordinates.widgetToWorld(cursorWidgetPos);
if (m_buildMode.isBuilderMode()) if (m_buildMode.isBuilderMode())
{ {
@@ -1392,14 +1411,82 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event)
else if (m_buildMode.isDeconstructMode()) else if (m_buildMode.isDeconstructMode())
{ {
m_buildMode.setDeconstructHoverBuildingId(buildingAtTile(tile)); m_buildMode.setDeconstructHoverBuildingId(buildingAtTile(tile));
if (m_boxSelecting) { m_boxCurrentTile = tile; } if (m_boxSelecting) { updateBoxDrag(cursorWidgetPos); }
} }
else if (m_boxSelecting) else if (m_boxSelecting)
{ {
m_boxCurrentTile = tile; updateBoxDrag(cursorWidgetPos);
} }
} }
void GameWorldView::updateBoxDrag(QPoint cursorWidgetPos)
{
const WorldCoordinates coordinates = getCoordinates();
m_boxCurrentWorld = coordinates.widgetToWorld(cursorWidgetPos);
// Measured against where the anchor sits on screen right now, not against where
// the button went down: a view that scrolls under a held button moves the anchor
// away from a motionless cursor, and that is a drag as much as moving the mouse
// is (REQ-UI-MULTI-SELECT).
const QPointF anchorWidgetPos = coordinates.worldToWidget(m_boxStartWorld);
const qreal travel_px = std::abs(cursorWidgetPos.x() - anchorWidgetPos.x())
+ std::abs(cursorWidgetPos.y() - anchorWidgetPos.y());
if (travel_px >= kBoxDragThresholdPixels) { m_boxDragMoved = true; }
}
QRectF GameWorldView::getBoxWorldRect() const
{
if (!m_boxDragMoved)
{
// Still a click: the rectangle it spans has no area and would cover nothing,
// so the box is the whole tile the button went down on instead — what the
// click points at (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-CLICK).
return QRectF(std::floor(m_boxStartWorld.x()), std::floor(m_boxStartWorld.y()),
1.0, 1.0);
}
return QRectF(QPointF(m_boxStartWorld.x(), m_boxStartWorld.y()),
QPointF(m_boxCurrentWorld.x(), m_boxCurrentWorld.y())).normalized();
}
void GameWorldView::publishSelectionAnchor(SelectionMode mode,
const std::vector<BuildingId>& buildings,
const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris)
{
// An additive gesture onto something already selected is growing that selection, not
// starting one, and the panel stays where it was put (REQ-UI-SELECTION-PANEL).
const bool startsSelection =
(mode == SelectionMode::Replace) || (m_selection.getSelectedBuildings().empty()
&& m_selection.getSelectedActors().empty()
&& m_selection.getSelectedDebris().empty());
if (!startsSelection)
{
return;
}
// Screen space, frozen here: the panel is placed against where the selection is at
// this moment and stays there, however far the view scrolls or the objects move
// afterwards.
const QRect anchorRect = getSelectionWidgetRect(*m_sim, getCoordinates(),
buildings, actors, debris);
if (anchorRect.isNull())
{
return;
}
// The gap the panel keeps from that rectangle is half a tile (REQ-UI-SELECTION-PANEL),
// and this is where the tile size is known. It is sampled in the same moment as the
// rectangle and travels with it, so both describe the view as it stood when the
// selection started.
const int selectionGapPx = qRound(getCoordinates().getTilePx() / 2.0f);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionAnchorChangedEvent>(anchorRect, selectionGapPx));
}
void GameWorldView::mouseMoveEvent(QMouseEvent* event)
{
updateHoverAt(event->pos());
}
void GameWorldView::mouseReleaseEvent(QMouseEvent* event) void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
{ {
if (event->button() != Qt::LeftButton) { return; } if (event->button() != Qt::LeftButton) { return; }
@@ -1417,7 +1504,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
m_boxSelecting = false; m_boxSelecting = false;
const std::vector<BuildingId> boxIds = const std::vector<BuildingId> boxIds =
buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile); buildingsInBox(m_sim->getFactoryState(), getBoxWorldRect());
const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0; const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0;
const ControlAction dragAction = const ControlAction dragAction =
@@ -1500,8 +1587,14 @@ void GameWorldView::rotateGhost(bool clockwise)
if (m_buildMode.isBuilderMode()) if (m_buildMode.isBuilderMode())
{ {
m_buildMode.rotateGhost(clockwise); m_buildMode.rotateGhost(clockwise);
// The new facing is kept whatever the cursor is over; what it means for the
// world is only re-resolved while the cursor points at a tile (REQ-BLD-GHOST).
const std::optional<QPoint>& ghostTile = m_buildMode.getGhostTile();
if (!ghostTile.has_value()) { return; }
m_buildMode.setGhostValidity( m_buildMode.setGhostValidity(
canPlaceBuildingHere(m_buildMode.getBuilderType(), m_buildMode.getGhostTile(), canPlaceBuildingHere(m_buildMode.getBuilderType(), *ghostTile,
m_buildMode.getGhostRotation())); m_buildMode.getGhostRotation()));
// A new facing changes which tunnels the ghost could complete (REQ-BLD-TUNNEL-MODE). // A new facing changes which tunnels the ghost could complete (REQ-BLD-TUNNEL-MODE).
if (m_buildMode.isTunnelMode()) { updateTunnelGhost(); } if (m_buildMode.isTunnelMode()) { updateTunnelGhost(); }
@@ -1509,7 +1602,7 @@ void GameWorldView::rotateGhost(bool clockwise)
// without waiting for the next mouse move (REQ-BLD-BELT-DRAG). // without waiting for the next mouse move (REQ-BLD-BELT-DRAG).
if (m_buildMode.isDraggingBelt()) if (m_buildMode.isDraggingBelt())
{ {
recomputeBeltDragPath(m_buildMode.getGhostTile()); recomputeBeltDragPath(*ghostTile);
} }
} }
else if (m_buildMode.isBlueprintMode()) else if (m_buildMode.isBlueprintMode())
@@ -1638,7 +1731,12 @@ void GameWorldView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
void GameWorldView::handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event) void GameWorldView::handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event)
{ {
clearSelectionForBuildMode();
m_buildMode.enterBuilderMode(event->type); m_buildMode.enterBuilderMode(event->type);
// A mode entered by hotkey usually leaves the cursor exactly where it was, and no
// mouse move follows to place the ghost; entered from a build button it leaves the
// cursor on the bar, where there is nothing to hover (REQ-BLD-GHOST).
refreshHover();
} }
void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> /*event*/) void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> /*event*/)
@@ -1648,12 +1746,18 @@ void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEv
void GameWorldView::handleEvent(std::shared_ptr<const DeconstructModeToggleRequestedEvent> /*event*/) void GameWorldView::handleEvent(std::shared_ptr<const DeconstructModeToggleRequestedEvent> /*event*/)
{ {
clearSelectionForBuildMode();
m_buildMode.toggleDeconstructMode(); m_buildMode.toggleDeconstructMode();
refreshHover();
} }
void GameWorldView::handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event) void GameWorldView::handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event)
{ {
// The blueprint arrives already built from the selection this drops
// (REQ-UI-BLUEPRINT-TEMP, REQ-UI-SELECTION-EXCLUSIVE).
clearSelectionForBuildMode();
m_buildMode.enterBlueprintMode(event->blueprint); m_buildMode.enterBlueprintMode(event->blueprint);
refreshHover();
} }
void GameWorldView::handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> /*event*/) void GameWorldView::handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> /*event*/)
@@ -1696,14 +1800,19 @@ void GameWorldView::handleEvent(std::shared_ptr<const GhostRotationRequestedEven
void GameWorldView::handleEvent(std::shared_ptr<const ModeCancelRequestedEvent> /*event*/) void GameWorldView::handleEvent(std::shared_ptr<const ModeCancelRequestedEvent> /*event*/)
{ {
// One key backs out of whichever mode is active, and enters deconstruct mode // One key backs out of whichever mode is active, and enters deconstruct mode when
// when none is (REQ-UI-HOTKEYS). // none is (REQ-UI-HOTKEYS). The selection case of that key never reaches here: it
// One key backs out of whichever mode is active, and enters deconstruct mode // resolves to its own event, so there is nothing left to clear by the time the
// when none is (REQ-UI-HOTKEYS). // fallthrough enters deconstruct mode (REQ-UI-SELECTION-EXCLUSIVE).
if (m_buildMode.getMode() == BuildMode::None) { m_buildMode.toggleDeconstructMode(); } if (m_buildMode.getMode() == BuildMode::None) { m_buildMode.toggleDeconstructMode(); }
else { m_buildMode.exitCurrentMode(); } else { m_buildMode.exitCurrentMode(); }
} }
void GameWorldView::handleEvent(std::shared_ptr<const SelectionClearRequestedEvent> /*event*/)
{
m_selection.clearAll();
}
void GameWorldView::handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEvent> /*event*/) void GameWorldView::handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEvent> /*event*/)
{ {
m_debugDraw = !m_debugDraw; m_debugDraw = !m_debugDraw;

View File

@@ -37,6 +37,7 @@
#include "ModeCancelRequestedEvent.h" #include "ModeCancelRequestedEvent.h"
#include "PanDirectionChangedEvent.h" #include "PanDirectionChangedEvent.h"
#include "PauseToggleRequestedEvent.h" #include "PauseToggleRequestedEvent.h"
#include "SelectionClearRequestedEvent.h"
#include "SpeedStepRequestedEvent.h" #include "SpeedStepRequestedEvent.h"
#include "DebugDrawToggledEvent.h" #include "DebugDrawToggledEvent.h"
#include "ArtifactCountChangedEvent.h" #include "ArtifactCountChangedEvent.h"
@@ -83,6 +84,7 @@ class GameWorldView : public QOpenGLWidget,
SpeedStepRequestedEvent, SpeedStepRequestedEvent,
GhostRotationRequestedEvent, GhostRotationRequestedEvent,
ModeCancelRequestedEvent, ModeCancelRequestedEvent,
SelectionClearRequestedEvent,
DebugDrawToggleRequestedEvent, DebugDrawToggleRequestedEvent,
CommandRequestedEvent> CommandRequestedEvent>
{ {
@@ -149,6 +151,7 @@ private:
void handleEvent(std::shared_ptr<const SpeedStepRequestedEvent> 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 GhostRotationRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const ModeCancelRequestedEvent> event) override; void handleEvent(std::shared_ptr<const ModeCancelRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionClearRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEvent> event) override; void handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const CommandRequestedEvent> event) override; void handleEvent(std::shared_ptr<const CommandRequestedEvent> event) override;
@@ -226,6 +229,38 @@ private:
// which is the only case that goes on to start a box drag. // which is the only case that goes on to start a box drag.
bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive); bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive);
void selectInBox(bool additive); void selectInBox(bool additive);
// A build mode and a selection are mutually exclusive, so entering any mode drops
// the selection (REQ-UI-SELECTION-EXCLUSIVE). Called from each of the three events
// that enter a mode -- the only ways in, whichever button or key the player used.
// Clearing an empty selection publishes nothing, so the exiting half of the
// deconstruct toggle costs nothing.
void clearSelectionForBuildMode();
// Whether the cursor points at the game world at all: it does while it is over
// this widget, and while a belt or box drag holds the button, which goes on
// following the cursor onto the floating panels and past the window edge.
bool isHoverLive() const;
// Re-derives the hover from wherever the cursor is now, or drops it when the
// cursor points at nothing (REQ-BLD-GHOST). The entry point for everything a
// mouse move does not cover: a scrolling view, a cursor crossing onto a panel or
// out of the window, and a mode just entered under a cursor that has not moved.
void refreshHover();
// Re-resolves everything that follows from where the cursor points into the world:
// the ghost tile and its validity, the tunnel ends, a running belt or box drag, the
// deconstruct hover. Called for every mouse move, and once per frame while the view
// scrolls under a cursor that has not moved, since that changes the world position
// the cursor points at just as moving the mouse does (REQ-BLD-GHOST).
void updateHoverAt(QPoint cursorWidgetPos);
// Moves the running box drag's far corner to the world position under
// `cursorWidgetPos` and, once the cursor sits far enough from where the anchor is
// drawn, promotes the gesture from a click to a drag. Every corner update goes
// through here, including the ones a scrolling view causes under a cursor that
// has not moved (REQ-UI-MULTI-SELECT).
void updateBoxDrag(QPoint cursorWidgetPos);
// The box the drag currently spans, in world coordinates and normalized: the
// rectangle between its two corners once it reads as a drag, and the whole tile
// the button went down on before that (REQ-UI-MULTI-SELECT). Both what is drawn
// and what is selected come from here, so they can never disagree.
QRectF getBoxWorldRect() const;
// Publishes where on the screen the selection about to be made sits, so the // Publishes where on the screen the selection about to be made sits, so the
// selection panel can be placed beside it (REQ-UI-SELECTION-PANEL). Called with // selection panel can be placed beside it (REQ-UI-SELECTION-PANEL). Called with
// what is about to be selected, immediately before selecting it, and publishes // what is about to be selected, immediately before selecting it, and publishes
@@ -260,6 +295,11 @@ private:
// paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM). // paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM).
static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3); static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3);
// How far the cursor must travel from the press position, in widget pixels
// (Manhattan distance), before a box drag shows its rectangle
// (REQ-UI-MULTI-SELECT).
static constexpr int kBoxDragThresholdPixels = 2;
Simulation* m_sim; Simulation* m_sim;
const GameConfig* m_config; const GameConfig* m_config;
const VisualsConfig* m_visuals; const VisualsConfig* m_visuals;
@@ -298,6 +338,11 @@ private:
// end tile closest to the cursor when snapping to a building (REQ-BLD-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). // and to resolve the tunnel ghost sub-tile (REQ-BLD-TUNNEL-MODE).
QVector2D m_cursorWorldPos; QVector2D m_cursorWorldPos;
// Whether the hover state currently stands for a cursor pointing at the world,
// so that losing it is noticed once rather than every frame. Kept here rather
// than asked of Qt per reader: it has to agree with what was last written to the
// build mode controller, not with where the cursor happens to be mid-frame.
bool m_hoverLive = false;
bool m_debugDraw; bool m_debugDraw;
@@ -308,8 +353,17 @@ private:
// Not owned; set after construction, so null until MainWindow has built it. // Not owned; set after construction, so null until MainWindow has built it.
const BlueprintLibrary* m_blueprintLibrary = nullptr; const BlueprintLibrary* m_blueprintLibrary = nullptr;
bool m_boxSelecting; bool m_boxSelecting;
QPoint m_boxStartTile; // The drag's two corners in world coordinates, unsnapped: where the button went
QPoint m_boxCurrentTile; // down and where the cursor is now (REQ-UI-MULTI-SELECT). World rather than
// widget coordinates so the anchor keeps the spot in the world it was placed on
// when the view scrolls under a held button.
QVector2D m_boxStartWorld;
QVector2D m_boxCurrentWorld;
// Whether the cursor has moved far enough from the anchor for this to read as a
// drag. Until it has, the rectangle is not drawn and the box resolves as the
// whole anchor tile (REQ-UI-MULTI-SELECT). Sticky for the rest of the drag, so
// coming back to the press position does not hide the rectangle again.
bool m_boxDragMoved;
// Interprets this widget's key events into semantic actions and publishes them // 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 // (REQ-UI-HOTKEYS). Owned here for now because this is the widget that holds

View File

@@ -19,6 +19,7 @@
#include "Simulation.h" #include "Simulation.h"
#include "SpeedChangeRequestedEvent.h" #include "SpeedChangeRequestedEvent.h"
#include "Tick.h" #include "Tick.h"
#include "TooltipTrigger.h"
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 }; const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
const int HeaderBar::kSpeedCount = 5; const int HeaderBar::kSpeedCount = 5;
@@ -34,18 +35,24 @@ HeaderBar::HeaderBar(const Simulation* sim, const GameConfig* config,
layout->setSpacing(8); layout->setSpacing(8);
m_timeLabel = new QLabel("00:00", this); m_timeLabel = new QLabel("00:00", this);
// Both displays state a value and do nothing when clicked, so a click brings the
// tooltip up at once rather than waiting the hover out (REQ-UI-TOOLTIP-TRIGGER).
m_blocksLabel = new QLabel(this); m_blocksLabel = new QLabel(this);
if (config->world.buildingBlocksTooltip) if (config->world.buildingBlocksTooltip)
{ {
m_blocksLabel->setToolTip( TooltipTrigger::attachText(
QString::fromStdString(*config->world.buildingBlocksTooltip)); *m_blocksLabel,
QString::fromStdString(*config->world.buildingBlocksTooltip),
TooltipTrigger::Trigger::HoverAndClick);
} }
updateBlocksLabel(); updateBlocksLabel();
m_artifactsLabel = new QLabel(tr("Artifacts: 0/?"), this); m_artifactsLabel = new QLabel(tr("Artifacts: 0/?"), this);
if (config->world.artifactTooltip) if (config->world.artifactTooltip)
{ {
m_artifactsLabel->setToolTip( TooltipTrigger::attachText(
QString::fromStdString(*config->world.artifactTooltip)); *m_artifactsLabel,
QString::fromStdString(*config->world.artifactTooltip),
TooltipTrigger::Trigger::HoverAndClick);
} }
m_bossWaveLabel = new QLabel(tr("Boss Wave #1"), this); m_bossWaveLabel = new QLabel(tr("Boss Wave #1"), this);
m_nextBossLabel = new QLabel(tr("Next boss: 5:00"), this); m_nextBossLabel = new QLabel(tr("Next boss: 5:00"), this);

View File

@@ -17,6 +17,7 @@
#include "ModeCancelRequestedEvent.h" #include "ModeCancelRequestedEvent.h"
#include "PanDirectionChangedEvent.h" #include "PanDirectionChangedEvent.h"
#include "PauseToggleRequestedEvent.h" #include "PauseToggleRequestedEvent.h"
#include "SelectionClearRequestedEvent.h"
#include "SpeedStepRequestedEvent.h" #include "SpeedStepRequestedEvent.h"
#include "TemporaryBlueprintCaptureRequestedEvent.h" #include "TemporaryBlueprintCaptureRequestedEvent.h"
#include "TemporaryBlueprintPlaceRequestedEvent.h" #include "TemporaryBlueprintPlaceRequestedEvent.h"
@@ -153,6 +154,10 @@ bool InputMapper::handleKeyPress(QKeyEvent* event, const ControlContext& context
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<ModeCancelRequestedEvent>()); std::make_shared<ModeCancelRequestedEvent>());
return true; return true;
case ControlAction::ClearSelection:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionClearRequestedEvent>());
return true;
case ControlAction::CopyTemporary: case ControlAction::CopyTemporary:
// The BlueprintLibrary owns the selection and blueprint-capture logic; it drives // The BlueprintLibrary owns the selection and blueprint-capture logic; it drives
// placement mode from there (REQ-UI-BLUEPRINT-TEMP). // placement mode from there (REQ-UI-BLUEPRINT-TEMP).

View File

@@ -7,18 +7,6 @@
namespace namespace
{ {
bool producesItem(const RecipeDef& recipe, const std::string& itemId)
{
for (const RecipeOutput& output : recipe.outputs)
{
if (output.item == itemId)
{
return true;
}
}
return false;
}
bool isAvailable(const RecipeDef& recipe, const Simulation& sim) bool isAvailable(const RecipeDef& recipe, const Simulation& sim)
{ {
if (recipe.building == BuildingType::Miner if (recipe.building == BuildingType::Miner

View File

@@ -10,10 +10,7 @@
#include <QDialog> #include <QDialog>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QInputDialog>
#include <QLineEdit>
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton>
#include <QResizeEvent> #include <QResizeEvent>
#include <QVBoxLayout> #include <QVBoxLayout>
@@ -35,7 +32,10 @@
#include "ShipLayoutDialog.h" #include "ShipLayoutDialog.h"
#include "BuildingIconCache.h" #include "BuildingIconCache.h"
#include "ItemIconCache.h" #include "ItemIconCache.h"
#include "MessageDialog.h"
#include "ModalLayer.h"
#include "ModalPauseScope.h" #include "ModalPauseScope.h"
#include "NameInputDialog.h"
#include "Simulation.h" #include "Simulation.h"
#include "Tick.h" #include "Tick.h"
#include "VisualsLoader.h" #include "VisualsLoader.h"
@@ -98,14 +98,18 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
// after the view for the same stacking reason as the panels above it. // after the view for the same stacking reason as the panels above it.
m_controlsPanel = new ControlsPanel(m_gameWorldView, this); m_controlsPanel = new ControlsPanel(m_gameWorldView, this);
// Created last so it stacks above the other children; covers the whole window and // Created last so it stacks above the other children; covers the whole window,
// dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM). // dims the game behind every modal, and is what those modals are drawn on
m_dimOverlay = new ModalDimOverlay(m_visuals.overlays.modalDim, this); // (REQ-UI-MODAL-DIM, REQ-UI-MODAL-CHROME).
m_modalLayer = new ModalLayer(m_visuals.overlays.modalDim, this);
m_gameWorldView->setFocus(); m_gameWorldView->setFocus();
connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* newWidget) { connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* newWidget) {
if (newWidget && newWidget != m_gameWorldView && !QApplication::activeModalWidget()) // A modal holds the focus while it is open, whether it is one of ours on the
// layer or a system message box (REQ-UI-MODAL-CHROME).
if (newWidget && newWidget != m_gameWorldView && !QApplication::activeModalWidget()
&& !m_modalLayer->isActive())
{ {
m_gameWorldView->setFocus(); m_gameWorldView->setFocus();
} }
@@ -145,6 +149,15 @@ void MainWindow::resizeEvent(QResizeEvent* event)
void MainWindow::closeEvent(QCloseEvent* event) void MainWindow::closeEvent(QCloseEvent* event)
{ {
// A modal on the layer runs a nested event loop over widgets this window owns, so
// the window must outlive it. A modal window used to block the close outright; this
// does the same for a modal that is only a widget (REQ-UI-MODAL-CHROME).
if (m_modalLayer->isActive())
{
event->ignore();
return;
}
const QString path = QCoreApplication::applicationDirPath() + "/ship_layouts.toml"; const QString path = QCoreApplication::applicationDirPath() + "/ship_layouts.toml";
QFile file(path); QFile file(path);
if (file.open(QIODevice::WriteOnly | QIODevice::Text)) if (file.open(QIODevice::WriteOnly | QIODevice::Text))
@@ -173,7 +186,7 @@ void MainWindow::layoutPanels()
const QRect worldRect(0, headerH, totalW, totalH - headerH); const QRect worldRect(0, headerH, totalW, totalH - headerH);
m_headerBar->setGeometry(0, 0, totalW, headerH); m_headerBar->setGeometry(0, 0, totalW, headerH);
m_gameWorldView->setGeometry(worldRect); m_gameWorldView->setGeometry(worldRect);
m_dimOverlay->setGeometry(0, 0, totalW, totalH); m_modalLayer->setGeometry(0, 0, totalW, totalH);
// The floating widgets are placed in one ordered pass, each into the space the // The floating widgets are placed in one ordered pass, each into the space the
// earlier ones have not taken (FloatingPanel.h). The order is the priority the // earlier ones have not taken (FloatingPanel.h). The order is the priority the
@@ -223,11 +236,15 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
{ {
ModalPauseScope pause(*m_gameWorldView); ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay);
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes,
m_itemIcons.get(), m_buildingIcons.get(), this); m_itemIcons.get(), m_buildingIcons.get(), m_modalLayer);
dialog.exec(); m_modalLayer->execute(dialog);
// The command goes out unconditionally because the dialog cannot be dismissed: it
// returns only once an option was clicked, so the index always names that option
// (REQ-DEF-SCHEMATIC-DROP). It is also the only thing that resolves the drop -- the
// poll that opened this dialog will not open it again while the choices stay pending
// (GameWorldView::onFrame) -- so a path that skipped the command would strand it.
std::shared_ptr<ApplySchematicChoiceCommand> command = std::shared_ptr<ApplySchematicChoiceCommand> command =
std::make_shared<ApplySchematicChoiceCommand>(); std::make_shared<ApplySchematicChoiceCommand>();
command->choiceIndex = dialog.getChosenIndex(); command->choiceIndex = dialog.getChosenIndex();
@@ -239,17 +256,17 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
{ {
ModalPauseScope pause(*m_gameWorldView); ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay); MessageDialog box(tr("Paused"), QString(), m_modalLayer);
QMessageBox box(this); const int continueIndex = box.addButton(tr("Continue"));
box.setWindowTitle(tr("Paused")); const int restartIndex = box.addButton(tr("Restart"));
QPushButton* continueBtn = box.addButton(tr("Continue"), QMessageBox::AcceptRole); const int quitIndex = box.addButton(tr("Quit"));
QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::ResetRole); // Escape stands for Continue, as it did when this was a system box
QPushButton* quitBtn = box.addButton(tr("Quit"), QMessageBox::DestructiveRole); // (REQ-UI-GAME-MENU).
box.setEscapeButton(continueBtn); box.setEscapeButtonIndex(continueIndex);
box.exec(); m_modalLayer->execute(box);
QAbstractButton* clicked = box.clickedButton(); const std::optional<int> clicked = box.getClickedButtonIndex();
if (clicked == restartBtn) if (clicked == restartIndex)
{ {
std::optional<GameConfig> newConfig = reloadConfig(); std::optional<GameConfig> newConfig = reloadConfig();
if (!newConfig.has_value()) if (!newConfig.has_value())
@@ -267,7 +284,7 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command)); std::make_shared<CommandRequestedEvent>(command));
} }
else if (clicked == quitBtn) else if (clicked == quitIndex)
{ {
pause.release(); pause.release();
close(); close();
@@ -284,7 +301,7 @@ std::optional<GameConfig> MainWindow::reloadConfig()
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir); GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals); m_visuals = std::move(newVisuals);
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim); m_modalLayer->setDimColor(m_visuals.overlays.modalDim);
// The composed item squares carry the colors they were painted with, so they // The composed item squares carry the colors they were painted with, so they
// are dropped for the new ones to take effect (REQ-UI-ITEM-ICON). // are dropped for the new ones to take effect (REQ-UI-ITEM-ICON).
m_itemIcons->clearPixmapCache(); m_itemIcons->clearPixmapCache();
@@ -313,16 +330,15 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
} }
} }
ModalDimScope dim(*m_dimOverlay);
ShipLayoutDialog dialog(&m_sim->getConfig(), schematicId, currentLayout, ShipLayoutDialog dialog(&m_sim->getConfig(), schematicId, currentLayout,
m_layoutBlueprints, m_layoutBlueprints,
std::move(unlockedModuleIds), std::move(unlockedModuleIds),
m_gameWorldView->isDebugDrawEnabled(), m_gameWorldView->isDebugDrawEnabled(),
m_itemIcons.get(), this); m_itemIcons.get(), m_modalLayer);
// Opened from the panel's "Configure" button (REQ-MOD-UI-PREVIEW) or straight after // Opened from the panel's "Configure" button (REQ-MOD-UI-PREVIEW) or straight after
// a schematic change (REQ-MOD-UI-AUTO-DIALOG), so it opens on the panel either way. // a schematic change (REQ-MOD-UI-AUTO-DIALOG), so it opens on the panel either way.
placeOnSelectionPanel(dialog); if (m_modalLayer->execute(dialog, getSelectionPanelAnchor()) == QDialog::Accepted
if (dialog.exec() == QDialog::Accepted && dialog.getResult().has_value()) && dialog.getResult().has_value())
{ {
std::shared_ptr<SetShipLayoutCommand> command = std::shared_ptr<SetShipLayoutCommand> command =
std::make_shared<SetShipLayoutCommand>(); std::make_shared<SetShipLayoutCommand>();
@@ -333,43 +349,18 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
} }
} }
void MainWindow::placeOnSelectionPanel(QDialog& dialog) const QRect MainWindow::getSelectionPanelAnchor() const
{ {
// The panel is up whenever one of these modals opens -- they are opened from its own // The panel is up whenever one of these modals opens -- they are opened from its own
// controls, and it is shown whenever anything is selected (REQ-UI-EMPTY-SELECTION). // controls, and it is shown whenever anything is selected (REQ-UI-EMPTY-SELECTION).
// Were it not, there would be no rectangle to center on and Qt's own centering on // Were it not, there would be no rectangle to center on and the layer's own centering
// this window stands. // stands (REQ-UI-PANEL-MODAL).
if (!m_selectionPanel->isVisible()) { return; } if (!m_selectionPanel->isVisible()) { return QRect(); }
// The dialog has never been shown, so it is still at its default size until its
// layout has run; centering it before that would use the wrong extent.
dialog.adjustSize();
const QSize dialogSize = dialog.size();
// The panel's live geometry, so a panel the player has dragged // The panel's live geometry, so a panel the player has dragged
// (REQ-UI-SELECTION-PANEL-DRAG) carries the modal with it. // (REQ-UI-SELECTION-PANEL-DRAG) carries the modal with it. Panel and layer are both
const QRect panelRect(m_selectionPanel->mapToGlobal(QPoint(0, 0)), // children of this window, so the panel's geometry needs no mapping.
m_selectionPanel->size()); return m_selectionPanel->geometry();
const QRect windowRect(mapToGlobal(QPoint(0, 0)), size());
QPoint topLeft(panelRect.center().x() - dialogSize.width() / 2,
panelRect.center().y() - dialogSize.height() / 2);
// Pushed back inside the window, never resized to fit (REQ-UI-PANEL-MODAL). The far
// edge is clamped first and the near edge second, which is what aligns a dialog too
// large for the window with the window's top-left corner rather than pushing it off
// the opposite edge.
topLeft.setX(qMax(windowRect.left(),
qMin(topLeft.x(), windowRect.right() - dialogSize.width() + 1)));
topLeft.setY(qMax(windowRect.top(),
qMin(topLeft.y(), windowRect.bottom() - dialogSize.height() + 1)));
// Positions the dialog's frame, whose size is not known until it is first shown, so
// the result sits low by the title bar height against a true center -- measuring it
// would mean showing the dialog at the wrong place first. The move also marks the
// dialog as positioned, which is what stops QDialog from centering it on this window
// when it is shown.
dialog.move(topLeft);
} }
void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event)
@@ -385,6 +376,15 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
} }
const std::string& schematicId = b ? b->recipeId : s->recipeId; const std::string& schematicId = b ? b->recipeId : s->recipeId;
// Nothing to configure without a schematic and a grid to place modules on. The
// Configure button that publishes this is already disabled then (REQ-MOD-UI-PREVIEW),
// so this guards the event rather than the button: the dialog would otherwise open
// over a grid of no cells.
if (!m_sim->getConfig().ships.findLayoutShipDef(schematicId))
{
return;
}
const std::optional<ShipLayoutConfig>& layoutOpt = const std::optional<ShipLayoutConfig>& layoutOpt =
b ? b->shipLayout : s->shipLayout; b ? b->shipLayout : s->shipLayout;
@@ -413,7 +413,7 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
// Held across both the selection dialog and any auto-opened layout dialog so the // Held across both the selection dialog and any auto-opened layout dialog so the
// dim stays continuously visible through that sequence (REQ-UI-MODAL-DIM). // dim stays continuously visible through that sequence (REQ-UI-MODAL-DIM).
ModalDimScope dim(*m_dimOverlay); ModalLayerHold dim(*m_modalLayer);
const BuildingType type = b ? b->type : s->type; const BuildingType type = b ? b->type : s->type;
// Captured as a copy: a queued command may drain during the modal dialog's // Captured as a copy: a queued command may drain during the modal dialog's
@@ -429,9 +429,9 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
bool autoOpenLayout = false; bool autoOpenLayout = false;
std::string chosenSchematic; std::string chosenSchematic;
RecipeSelectionDialog dialog(options, title, m_itemIcons.get(), RecipeSelectionDialog dialog(options, title, m_itemIcons.get(),
m_buildingIcons.get(), this); m_buildingIcons.get(), m_modalLayer);
placeOnSelectionPanel(dialog); if (m_modalLayer->execute(dialog, getSelectionPanelAnchor()) == QDialog::Accepted
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value()) && dialog.getChosenId().has_value())
{ {
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>(); std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
command->id = event->buildingId; command->id = event->buildingId;
@@ -440,8 +440,12 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
std::make_shared<CommandRequestedEvent>(command)); std::make_shared<CommandRequestedEvent>(command));
// REQ-MOD-UI-AUTO-DIALOG: picking a new schematic for a shipyard opens the // REQ-MOD-UI-AUTO-DIALOG: picking a new schematic for a shipyard opens the
// layout configuration dialog immediately. Only on an actual change. // layout configuration dialog immediately. Only on an actual change, and only
if (type == BuildingType::Shipyard && *dialog.getChosenId() != oldSchematic) // for an actual schematic: "(None)" clears the shipyard and carries the empty
// id (REQ-UI-SELECT-OPTIONS), which differs from whatever was set but is not a
// schematic to configure.
if (type == BuildingType::Shipyard && *dialog.getChosenId() != oldSchematic
&& m_sim->getConfig().ships.findLayoutShipDef(*dialog.getChosenId()))
{ {
autoOpenLayout = true; autoOpenLayout = true;
chosenSchematic = *dialog.getChosenId(); chosenSchematic = *dialog.getChosenId();
@@ -469,30 +473,30 @@ void MainWindow::handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent>
// over to, so the dim never blinks off and the simulation is not resumed in between // over to, so the dim never blinks off and the simulation is not resumed in between
// (REQ-UI-MODAL-DIM). // (REQ-UI-MODAL-DIM).
ModalPauseScope pause(*m_gameWorldView); ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay); ModalLayerHold dim(*m_modalLayer);
bool ok = false; NameInputDialog nameDialog(tr("Create Blueprint"), tr("Blueprint name:"),
const QString name = QInputDialog::getText( m_modalLayer);
this, tr("Create Blueprint"), tr("Blueprint name:"), QLineEdit::Normal, const int result = m_modalLayer->execute(nameDialog);
QString(), &ok);
// Cancel, Escape, or an empty name: no blueprint, and no selection dialog. // Cancel, Escape, or an empty name: no blueprint, and no selection dialog.
if (!ok || name.trimmed().isEmpty()) { return; } if (result != QDialog::Accepted || nameDialog.getName().isEmpty()) { return; }
m_blueprintLibrary->saveSelectionAs(name.trimmed()); m_blueprintLibrary->saveSelectionAs(nameDialog.getName());
showBlueprintSelectionDialog(); showBlueprintSelectionDialog();
} }
void MainWindow::handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> /*event*/) void MainWindow::handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> /*event*/)
{ {
ModalPauseScope pause(*m_gameWorldView); ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay);
showBlueprintSelectionDialog(); showBlueprintSelectionDialog();
} }
void MainWindow::showBlueprintSelectionDialog() void MainWindow::showBlueprintSelectionDialog()
{ {
BlueprintSelectionDialog dialog(m_blueprintLibrary.get(), m_itemIcons.get(), this); BlueprintSelectionDialog dialog(m_blueprintLibrary.get(), m_itemIcons.get(),
if (dialog.exec() == QDialog::Accepted && dialog.getChosenIndex().has_value()) m_modalLayer);
if (m_modalLayer->execute(dialog) == QDialog::Accepted
&& dialog.getChosenIndex().has_value())
{ {
// Entered after the dialog has closed, which is the order REQ-UI-BLUEPRINT-CARD // Entered after the dialog has closed, which is the order REQ-UI-BLUEPRINT-CARD
// describes: clicking a card closes the dialog and enters placement mode. // describes: clicking a card closes the dialog and enters placement mode.
@@ -507,17 +511,18 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
const int minutes = totalSeconds / 60; const int minutes = totalSeconds / 60;
const int seconds = totalSeconds % 60; const int seconds = totalSeconds % 60;
ModalDimScope dim(*m_dimOverlay); MessageDialog box(tr("Game Over"),
QMessageBox box(this); tr("HQ destroyed!\nSurvival time: %1:%2")
box.setWindowTitle(tr("Game Over")); .arg(minutes, 2, 10, QChar('0'))
box.setText(tr("HQ destroyed!\nSurvival time: %1:%2") .arg(seconds, 2, 10, QChar('0')),
.arg(minutes, 2, 10, QChar('0')) m_modalLayer);
.arg(seconds, 2, 10, QChar('0'))); const int restartIndex = box.addButton(tr("Restart"));
QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::AcceptRole); const int quitIndex = box.addButton(tr("Quit"));
box.addButton(tr("Quit"), QMessageBox::RejectRole); // Escape quits, which is where the system box's reject role sent it.
box.exec(); box.setEscapeButtonIndex(quitIndex);
m_modalLayer->execute(box);
if (box.clickedButton() == restartBtn) if (box.getClickedButtonIndex() == restartIndex)
{ {
std::optional<GameConfig> newConfig = reloadConfig(); std::optional<GameConfig> newConfig = reloadConfig();
if (!newConfig.has_value()) if (!newConfig.has_value())
@@ -544,17 +549,18 @@ void MainWindow::handleEvent(std::shared_ptr<const WinEvent> /*event*/)
const int minutes = totalSeconds / 60; const int minutes = totalSeconds / 60;
const int seconds = totalSeconds % 60; const int seconds = totalSeconds % 60;
ModalDimScope dim(*m_dimOverlay); MessageDialog box(tr("Won!"),
QMessageBox box(this); tr("You collected all artifacts!\nSurvival time: %1:%2")
box.setWindowTitle(tr("Won!")); .arg(minutes, 2, 10, QChar('0'))
box.setText(tr("You collected all artifacts!\nSurvival time: %1:%2") .arg(seconds, 2, 10, QChar('0')),
.arg(minutes, 2, 10, QChar('0')) m_modalLayer);
.arg(seconds, 2, 10, QChar('0'))); const int restartIndex = box.addButton(tr("Restart"));
QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::AcceptRole); const int quitIndex = box.addButton(tr("Quit"));
box.addButton(tr("Quit"), QMessageBox::RejectRole); // Escape quits, which is where the system box's reject role sent it.
box.exec(); box.setEscapeButtonIndex(quitIndex);
m_modalLayer->execute(box);
if (box.clickedButton() == restartBtn) if (box.getClickedButtonIndex() == restartIndex)
{ {
std::optional<GameConfig> newConfig = reloadConfig(); std::optional<GameConfig> newConfig = reloadConfig();
if (!newConfig.has_value()) if (!newConfig.has_value())

View File

@@ -16,7 +16,7 @@
#include "GameConfig.h" #include "GameConfig.h"
#include "GameOverEvent.h" #include "GameOverEvent.h"
#include "LayoutDialogRequestedEvent.h" #include "LayoutDialogRequestedEvent.h"
#include "ModalDimOverlay.h" #include "ModalLayer.h"
#include "WinEvent.h" #include "WinEvent.h"
#include "RecipeSelectionRequestedEvent.h" #include "RecipeSelectionRequestedEvent.h"
#include "SchematicChoicesAvailableEvent.h" #include "SchematicChoicesAvailableEvent.h"
@@ -36,7 +36,6 @@ class BlueprintLibrary;
class BuildingIconCache; class BuildingIconCache;
class ItemIconCache; class ItemIconCache;
class QCloseEvent; class QCloseEvent;
class QDialog;
class QResizeEvent; class QResizeEvent;
class MainWindow : public QWidget, class MainWindow : public QWidget,
@@ -84,15 +83,15 @@ private:
const std::string& schematicId, const std::string& schematicId,
const ShipLayoutConfig& currentLayout); const ShipLayoutConfig& currentLayout);
// Centers a modal opened from the selection panel on that panel, kept inside this // The rectangle a modal opened from the selection panel is centered on, in this
// window (REQ-UI-PANEL-MODAL). Called on the constructed dialog before exec(), and // window's coordinates, or a null rect when the panel is not up (REQ-UI-PANEL-MODAL).
// only for the two modals the panel opens. QRect getSelectionPanelAnchor() const;
void placeOnSelectionPanel(QDialog& dialog) const;
// Runs the blueprint selection dialog and enters placement mode for whatever the // Runs the blueprint selection dialog and enters placement mode for whatever the
// player picked (REQ-UI-BLUEPRINT-DIALOG). Holds no pause or dim scope of its own: // player picked (REQ-UI-BLUEPRINT-DIALOG). Holds no pause scope of its own: both
// both callers already hold theirs, which is what keeps the dim continuous when a // callers already hold theirs, and the save path also holds the layer, which is what
// confirmed save hands straight over to this dialog (REQ-UI-MODAL-DIM). // keeps the dim continuous when a confirmed save hands straight over to this dialog
// (REQ-UI-MODAL-DIM).
void showBlueprintSelectionDialog(); void showBlueprintSelectionDialog();
// Places the widgets floating over the game world view, in one ordered pass // Places the widgets floating over the game world view, in one ordered pass
// (FloatingPanel.h). Runs on a resize and on every FloatingLayoutInvalidatedEvent. // (FloatingPanel.h). Runs on a resize and on every FloatingLayoutInvalidatedEvent.
@@ -116,7 +115,7 @@ private:
// The saved blueprints themselves; they have no widget of their own any more and // The saved blueprints themselves; they have no widget of their own any more and
// are reached through the two modal dialogs (REQ-UI-BLUEPRINT-DIALOG). // are reached through the two modal dialogs (REQ-UI-BLUEPRINT-DIALOG).
std::unique_ptr<BlueprintLibrary> m_blueprintLibrary; std::unique_ptr<BlueprintLibrary> m_blueprintLibrary;
ModalDimOverlay* m_dimOverlay = nullptr; ModalLayer* m_modalLayer = nullptr;
std::vector<ShipLayoutBlueprint> m_layoutBlueprints; std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback

75
src/ui/MessageDialog.cpp Normal file
View File

@@ -0,0 +1,75 @@
#include "MessageDialog.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QVBoxLayout>
namespace
{
const int kSpacingPx = 8;
}
MessageDialog::MessageDialog(const QString& title, const QString& text, QWidget* parent)
: ModalDialog(parent)
, m_buttonLayout(nullptr)
, m_buttonCount(0)
{
QVBoxLayout* mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx);
mainLayout->setSpacing(kSpacingPx);
if (!text.isEmpty())
{
QLabel* textLabel = new QLabel(text, this);
mainLayout->addWidget(textLabel);
}
// The buttons sit at the right of their row, the side a dialog is confirmed from.
m_buttonLayout = new QHBoxLayout();
m_buttonLayout->setSpacing(kSpacingPx);
m_buttonLayout->addStretch();
mainLayout->addLayout(m_buttonLayout);
// Last, so it becomes the first row (REQ-UI-MODAL-CHROME). No close button: closing
// is one of the buttons here, or nothing at all.
addHeader(mainLayout, title, false);
}
int MessageDialog::addButton(const QString& caption)
{
const int index = m_buttonCount++;
QPushButton* button = new QPushButton(caption, this);
m_buttonLayout->addWidget(button);
connect(button, &QPushButton::clicked, this, [this, index]() {
onButtonClicked(index);
});
return index;
}
void MessageDialog::setEscapeButtonIndex(int index)
{
m_escapeButtonIndex = index;
}
std::optional<int> MessageDialog::getClickedButtonIndex() const
{
return m_clickedButtonIndex;
}
void MessageDialog::reject()
{
// Escape stands for a button rather than for a dismissal of its own, so the caller
// reads one answer whichever way the player gave it (REQ-UI-GAME-MENU).
if (!m_escapeButtonIndex.has_value())
{
return;
}
onButtonClicked(*m_escapeButtonIndex);
}
void MessageDialog::onButtonClicked(int index)
{
m_clickedButtonIndex = index;
accept();
}

50
src/ui/MessageDialog.h Normal file
View File

@@ -0,0 +1,50 @@
#pragma once
#include <optional>
#include <vector>
#include <QString>
#include "ModalDialog.h"
class QHBoxLayout;
class QLabel;
// The game's own message box (REQ-UI-MODAL-CHROME): a title, an optional line or two of
// text, and a row of buttons the caller names. It stands in for QMessageBox at the three
// places the player is asked to decide something -- the escape menu (REQ-UI-GAME-MENU)
// and the game-over and win screens (REQ-HQ-GAME-OVER, REQ-WIN-SCREEN) -- so that those
// read as part of the game rather than as system alerts.
//
// The caller identifies buttons by the index addButton() hands back, and asks
// getClickedButtonIndex() afterwards; the QDialog result code says only whether a button
// was clicked at all. Q and a click outside do not dismiss it: every button here is a
// decision, and there is no "no change" among them to fall back on.
class MessageDialog : public ModalDialog
{
Q_OBJECT
public:
// text may be empty, for a dialog that is a question its buttons already state.
MessageDialog(const QString& title, const QString& text, QWidget* parent = nullptr);
// Appends a button and returns its index, left to right.
int addButton(const QString& caption);
// Which button Escape stands for. Without one, Escape does nothing -- a dialog whose
// buttons all commit to something has no dismissal to offer.
void setEscapeButtonIndex(int index);
std::optional<int> getClickedButtonIndex() const;
public slots:
void reject() override;
private:
void onButtonClicked(int index);
QHBoxLayout* m_buttonLayout;
int m_buttonCount;
std::optional<int> m_escapeButtonIndex;
std::optional<int> m_clickedButtonIndex;
};

96
src/ui/ModalDialog.cpp Normal file
View File

@@ -0,0 +1,96 @@
#include "ModalDialog.h"
#include <QBoxLayout>
#include <QChar>
#include <QFont>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QPushButton>
#include <QString>
namespace
{
// The header's own metrics, matching the row the blueprint selection dialog drew
// before this class existed (REQ-UI-BLUEPRINT-DIALOG).
const int kSpacingPx = 8;
const int kSmallButtonSizePx = 22;
const QChar kCrossGlyph(0x00D7); // U+00D7 MULTIPLICATION SIGN
// Q dismisses an open dialog, beside the Escape that QDialog already handles
// (REQ-UI-DIALOG-DISMISS). Ctrl must not be held, matching how the game world's
// table separates a chord from the bare key (resolveKeyAction in
// lib/core/ControlAction.cpp). This deliberately stays out of that table: the table
// answers what an input does in the player's current situation, and a dialog has no
// situation -- it holds focus and takes the key whatever the world beneath it is
// doing.
bool isDismissKey(const QKeyEvent& event)
{
return event.key() == Qt::Key_Q
&& (event.modifiers() & Qt::ControlModifier) == 0;
}
}
ModalDialog::ModalDialog(QWidget* parent)
: QDialog(parent)
{
// An ordinary child widget rather than a window: the layer places it and the dim
// behind it is the same widget's paint (REQ-UI-MODAL-CHROME). Square corners rather
// than rounded ones -- rounding needs a translucent background, which is unreliable
// on Windows. The border matches the build bar and the selection panel.
setWindowFlags(Qt::Widget);
setAttribute(Qt::WA_StyledBackground, true);
// A type selector, so it reaches every subclass but none of the child widgets
// inside them, which draw themselves.
setStyleSheet(QStringLiteral(
"ModalDialog { background-color: palette(window);"
" border: 1px solid palette(mid); }"));
}
bool ModalDialog::isDismissible() const
{
return false;
}
void ModalDialog::requestDismiss()
{
reject();
}
QHBoxLayout* ModalDialog::addHeader(QBoxLayout* mainLayout, const QString& title,
bool withCloseButton)
{
QHBoxLayout* headerLayout = new QHBoxLayout();
headerLayout->setSpacing(kSpacingPx);
QLabel* titleLabel = new QLabel(title, this);
QFont headerFont = titleLabel->font();
headerFont.setBold(true);
titleLabel->setFont(headerFont);
headerLayout->addWidget(titleLabel);
// The stretch is added before the close button so a subclass inserting after the
// title lands left of it, where the blueprint dialog's hotkey badge belongs.
headerLayout->addStretch();
if (withCloseButton)
{
QPushButton* closeButton = new QPushButton(QString(kCrossGlyph), this);
closeButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
connect(closeButton, &QPushButton::clicked, this, &QDialog::reject);
headerLayout->addWidget(closeButton);
}
mainLayout->insertLayout(0, headerLayout);
return headerLayout;
}
void ModalDialog::keyPressEvent(QKeyEvent* event)
{
if (isDismissible() && isDismissKey(*event))
{
requestDismiss();
return;
}
QDialog::keyPressEvent(event);
}

48
src/ui/ModalDialog.h Normal file
View File

@@ -0,0 +1,48 @@
#pragma once
#include <QDialog>
class QBoxLayout;
class QHBoxLayout;
class QKeyEvent;
class QString;
// Base of every modal the player meets while playing (REQ-UI-MODAL-CHROME). A modal is
// not an operating system window here: it is an ordinary child widget, hosted and placed
// by the ModalLayer that also paints the dim it sits on, so it has no title bar, no
// window border, and no window-manager close. What a window used to supply, this class
// supplies instead -- the panel background, the drawn header, and the dismissal gestures.
//
// It stays a QDialog for accept()/reject()/result() and the Escape handling built into
// them; only the window-ness is dropped (Qt::Widget flags). Nothing calls exec() on it:
// ModalLayer::execute() runs the modal loop, so that the layer knows what is open.
class ModalDialog : public QDialog
{
Q_OBJECT
public:
explicit ModalDialog(QWidget* parent = nullptr);
// Whether Q and a click outside this dialog dismiss it (REQ-UI-DIALOG-DISMISS). One
// predicate for both gestures because they reach exactly the same dialogs. The
// default refuses both, so a dialog takes the gestures only by saying so: the two
// name dialogs must let Q through as a character, and the schematic choice dialog
// has no way out but choosing (REQ-DEF-SCHEMATIC-DROP).
virtual bool isDismissible() const;
public slots:
// What a dismissal does, whichever gesture asked for it. Cancelling is the default;
// a dialog with modes of its own overrides this to back out of them one at a time
// (ShipLayoutDialog, REQ-UI-DIALOG-DISMISS).
virtual void requestDismiss();
protected:
// Adds the drawn header row -- the title, and a close button at the far right when
// asked for -- as the first row of mainLayout, and returns it so a subclass can
// insert its own widgets after the title (REQ-UI-MODAL-CHROME). The close button
// rejects the dialog, which is what the window-manager close used to do.
QHBoxLayout* addHeader(QBoxLayout* mainLayout, const QString& title,
bool withCloseButton);
void keyPressEvent(QKeyEvent* event) override;
};

View File

@@ -1,46 +0,0 @@
#include "ModalDimOverlay.h"
#include <QPainter>
ModalDimOverlay::ModalDimOverlay(const QColor& dimColor, QWidget* parent)
: QWidget(parent)
, m_dimColor(dimColor)
{
setAttribute(Qt::WA_TransparentForMouseEvents, true);
hide();
}
void ModalDimOverlay::pushModal()
{
if (m_modalDepth++ == 0)
{
raise();
show();
// Force an immediate synchronous paint so the scrim is visible before the
// caller enters a blocking dialog exec() (no undimmed frame flashes through).
repaint();
}
}
void ModalDimOverlay::popModal()
{
if (m_modalDepth > 0 && --m_modalDepth == 0)
{
hide();
}
}
void ModalDimOverlay::setDimColor(const QColor& dimColor)
{
m_dimColor = dimColor;
if (isVisible())
{
update();
}
}
void ModalDimOverlay::paintEvent(QPaintEvent* /*event*/)
{
QPainter painter(this);
painter.fillRect(rect(), m_dimColor);
}

View File

@@ -1,59 +0,0 @@
#pragma once
#include <QColor>
#include <QWidget>
class QPaintEvent;
// A window-wide, semi-transparent scrim drawn over the entire game window while a
// modal dialog or menu is open, behind that modal (REQ-UI-MODAL-DIM). It is a child
// of the main window covering its full rect and is transparent to mouse events, so it
// only dims the game presentation and never intercepts input.
//
// Visibility is reference-counted via pushModal()/popModal() so that a single dim is
// shown across nested or back-to-back modals (e.g. the recipe selection dialog that
// immediately opens the layout dialog) rather than flickering or stacking overlays.
class ModalDimOverlay : public QWidget
{
Q_OBJECT
public:
ModalDimOverlay(const QColor& dimColor, QWidget* parent);
// Raise + show on the first active modal; hide when the last one closes.
void pushModal();
void popModal();
// Update the dim color (e.g. after a config reload on Restart, REQ-CFG-RELOAD).
void setDimColor(const QColor& dimColor);
protected:
void paintEvent(QPaintEvent* event) override;
private:
QColor m_dimColor;
int m_modalDepth = 0;
};
// RAII guard: shows the dim overlay for the duration of a scope (typically around a
// blocking dialog exec()) and hides it (via reference count) on scope exit.
class ModalDimScope
{
public:
explicit ModalDimScope(ModalDimOverlay& overlay)
: m_overlay(overlay)
{
m_overlay.pushModal();
}
~ModalDimScope()
{
m_overlay.popModal();
}
ModalDimScope(const ModalDimScope&) = delete;
ModalDimScope& operator=(const ModalDimScope&) = delete;
private:
ModalDimOverlay& m_overlay;
};

220
src/ui/ModalLayer.cpp Normal file
View File

@@ -0,0 +1,220 @@
#include "ModalLayer.h"
#include <cstddef>
#include <QEventLoop>
#include <QFrame>
#include <QMetaObject>
#include <QMouseEvent>
#include <QPainter>
#include <QPoint>
#include <QScrollArea>
#include <QSize>
#include "ModalDialog.h"
ModalLayer* ModalLayer::findFor(const QWidget& widget)
{
for (QWidget* candidate = widget.parentWidget(); candidate != nullptr;
candidate = candidate->parentWidget())
{
ModalLayer* layer = qobject_cast<ModalLayer*>(candidate);
if (layer != nullptr)
{
return layer;
}
}
return nullptr;
}
ModalLayer::ModalLayer(const QColor& dimColor, QWidget* parent)
: QWidget(parent)
, m_dimColor(dimColor)
{
hide();
}
int ModalLayer::execute(ModalDialog& content, const QRect& anchorRect)
{
// Hosted in a scroll area so a modal larger than the window is reached by scrolling
// instead of being cut off by the window edge (REQ-UI-MODAL-CHROME). When the
// content fits -- which is the normal case -- the host is exactly its size and no
// scroll bar appears, so the player sees the modal alone.
QScrollArea* host = new QScrollArea(this);
host->setFrameShape(QFrame::NoFrame);
host->setWidgetResizable(false);
host->setWidget(&content);
// Resolved before the new modal joins the stack, so a null rect means the modal it
// was opened from -- the Create Blueprint dialog opens on the layout dialog beneath
// it (REQ-UI-PANEL-MODAL).
const QRect anchor = !anchorRect.isNull() ? anchorRect
: (m_stack.empty() ? rect()
: m_stack.back().host->geometry());
m_stack.push_back(HostedModal{ &content, host });
updateVisibility();
raise();
place(*host, content, anchor);
host->show();
content.show();
// The modal's own focus widget where it has one -- a name dialog puts the caret in
// its line edit -- and the modal itself otherwise, so keys reach it and not the game
// world behind (REQ-UI-MODAL-CHROME).
if (content.focusWidget() != nullptr)
{
content.focusWidget()->setFocus();
}
else
{
content.setFocus();
}
// The dialog's own loop, run here rather than by QDialog::exec(), so the layer knows
// what is open and can place it, dim behind it, and take the clicks beside it.
QEventLoop loop;
const QMetaObject::Connection connection =
connect(&content, &QDialog::finished, &loop, &QEventLoop::quit);
loop.exec();
disconnect(connection);
// takeWidget() before the host goes: the scroll area owns what it is given, and
// every modal here is a local of its caller. It hands the content back parentless.
host->takeWidget();
content.hide();
delete host;
for (std::size_t i = m_stack.size(); i > 0; --i)
{
if (m_stack[i - 1].content == &content)
{
m_stack.erase(m_stack.begin() + static_cast<std::ptrdiff_t>(i - 1));
break;
}
}
updateVisibility();
return content.result();
}
bool ModalLayer::isActive() const
{
return !m_stack.empty();
}
void ModalLayer::addHold()
{
++m_holdCount;
updateVisibility();
}
void ModalLayer::removeHold()
{
if (m_holdCount > 0)
{
--m_holdCount;
updateVisibility();
}
}
void ModalLayer::setDimColor(const QColor& dimColor)
{
m_dimColor = dimColor;
if (isVisible())
{
update();
}
}
void ModalLayer::paintEvent(QPaintEvent* /*event*/)
{
// One dim however many modals are stacked (REQ-UI-MODAL-DIM): the modals above it
// draw their own opaque background, so nesting darkens nothing twice.
QPainter painter(this);
painter.fillRect(rect(), m_dimColor);
}
void ModalLayer::mousePressEvent(QMouseEvent* event)
{
m_pressedOutside = event->button() == Qt::LeftButton
&& isOutsideOpenModal(event->pos());
// Taken whether or not it dismisses anything: the window behind a modal receives no
// input, and closing the modal does not turn this click into one for what lies under
// it (REQ-UI-DIALOG-DISMISS).
event->accept();
}
void ModalLayer::mouseReleaseEvent(QMouseEvent* event)
{
const bool pressedOutside = m_pressedOutside;
m_pressedOutside = false;
event->accept();
if (event->button() != Qt::LeftButton || !pressedOutside
|| !isOutsideOpenModal(event->pos()) || m_stack.empty())
{
return;
}
// A drag that began inside the modal never gets here -- the widget it began on keeps
// the release -- so no gesture ends by discarding the modal it was made in.
ModalDialog* openModal = m_stack.back().content;
if (openModal->isDismissible())
{
openModal->requestDismiss();
}
}
bool ModalLayer::isOutsideOpenModal(const QPoint& position) const
{
if (m_stack.empty())
{
return true;
}
return !m_stack.back().host->geometry().contains(position);
}
void ModalLayer::place(QScrollArea& host, ModalDialog& content,
const QRect& anchorRect) const
{
// The content has never been shown, so it is still at its default size until its
// layout has run; sizing it before that would use the wrong extent.
content.adjustSize();
const QSize hostSize = content.size().boundedTo(size());
host.resize(hostSize);
QPoint topLeft(anchorRect.center().x() - hostSize.width() / 2,
anchorRect.center().y() - hostSize.height() / 2);
// Pushed back inside the layer, which is the game window (REQ-UI-PANEL-MODAL). The
// far edge is clamped first and the near edge second, which is what aligns a modal
// as large as the window with the window's top-left corner rather than pushing it
// off the opposite edge.
topLeft.setX(qMax(0, qMin(topLeft.x(), width() - hostSize.width())));
topLeft.setY(qMax(0, qMin(topLeft.y(), height() - hostSize.height())));
host.move(topLeft);
}
void ModalLayer::updateVisibility()
{
const bool shouldShow = !m_stack.empty() || m_holdCount > 0;
if (shouldShow == isVisible())
{
return;
}
if (shouldShow)
{
raise();
show();
// Force an immediate synchronous paint so the dim is up before whatever the
// caller does next; no undimmed frame flashes through.
repaint();
}
else
{
hide();
}
}

110
src/ui/ModalLayer.h Normal file
View File

@@ -0,0 +1,110 @@
#pragma once
#include <vector>
#include <QColor>
#include <QPoint>
#include <QRect>
#include <QWidget>
class ModalDialog;
class QMouseEvent;
class QPaintEvent;
class QScrollArea;
// The one surface every modal is shown on (REQ-UI-MODAL-CHROME, REQ-UI-MODAL-DIM): a
// child of the main window covering its whole rect, which paints the dim, hosts the
// open modal, and runs its modal loop. Because it is a widget of the window rather than
// a window of its own, it receives the clicks that land beside the modal -- which a
// blocked window would never see -- and that is what makes the click dismissal possible
// (REQ-UI-DIALOG-DISMISS).
//
// Modals nest: the layer keeps a stack, shows one dim for all of them, and hides itself
// when the last one closes. It is shown while the stack is non-empty or a hold is taken.
class ModalLayer : public QWidget
{
Q_OBJECT
public:
// The layer a widget is shown on, found by walking up its parents, or nullptr when
// it is not on one. How a widget deep inside a modal reaches the layer to open a
// second modal on it, without every widget between them having to carry a pointer.
static ModalLayer* findFor(const QWidget& widget);
ModalLayer(const QColor& dimColor, QWidget* parent);
// Shows content on this layer and runs it until it accepts or rejects, returning its
// QDialog result code. anchorRect (in this layer's coordinates, which are the main
// window's) is what the content is centered on. A null rect centers it on the modal
// it was opened from, and on the layer itself when it is the first one open
// (REQ-UI-PANEL-MODAL).
int execute(ModalDialog& content, const QRect& anchorRect = QRect());
// Whether a modal is open. The main window asks before handing focus back to the
// game world, which it must not do while a modal holds it.
bool isActive() const;
// Keeps the layer shown with nothing on it, so that one modal handing straight over
// to another does not blink the dim off in between (REQ-UI-MODAL-DIM).
void addHold();
void removeHold();
// Update the dim color (e.g. after a config reload on Restart, REQ-CFG-RELOAD).
void setDimColor(const QColor& dimColor);
protected:
void paintEvent(QPaintEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
private:
// A modal and the scroll area it is shown in. The host is what the layer places and
// what the player sees the edges of, so it is also the rectangle "outside the modal"
// is measured against.
struct HostedModal
{
ModalDialog* content;
QScrollArea* host;
};
// Sizes content to what it asks for, capped at the layer, and centers it on
// anchorRect (REQ-UI-PANEL-MODAL).
void place(QScrollArea& host, ModalDialog& content, const QRect& anchorRect) const;
void updateVisibility();
// Whether a point in this layer's coordinates lies beyond the open modal. A click on
// an inert part of the modal -- a label, the space between two controls -- arrives
// here too, propagated by the widget that ignored it, so where the click landed is
// what decides, never that the layer received it.
bool isOutsideOpenModal(const QPoint& position) const;
QColor m_dimColor;
// Set by a left press that landed outside the open modal, so that only a press and a
// release both outside dismiss it (REQ-UI-DIALOG-DISMISS).
bool m_pressedOutside = false;
std::vector<HostedModal> m_stack; // bottom-most first; back() is the open one
int m_holdCount = 0;
};
// RAII guard for addHold()/removeHold().
class ModalLayerHold
{
public:
explicit ModalLayerHold(ModalLayer& layer)
: m_layer(layer)
{
m_layer.addHold();
}
~ModalLayerHold()
{
m_layer.removeHold();
}
ModalLayerHold(const ModalLayerHold&) = delete;
ModalLayerHold& operator=(const ModalLayerHold&) = delete;
private:
ModalLayer& m_layer;
};

View File

@@ -7,7 +7,7 @@
// exit it restores the snapshotted speed and rebases the render frame timer, so the // exit it restores the snapshotted speed and rebases the render frame timer, so the
// wall time the player spent in the dialog is not converted into simulation ticks. // wall time the player spent in the dialog is not converted into simulation ticks.
// //
// Pairs with ModalDimScope, which the same call sites use for the dim overlay. // Pairs with ModalLayer, which the same call sites use to show the modal itself.
// //
// Two escape hatches for the paths that must not simply restore at scope exit: // Two escape hatches for the paths that must not simply restore at scope exit:
// restore() — restore now instead of at scope exit, for when more work has to run // restore() — restore now instead of at scope exit, for when more work has to run

View File

@@ -0,0 +1,56 @@
#include "NameInputDialog.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QVBoxLayout>
namespace
{
const int kSpacingPx = 8;
const int kMinimumWidthPx = 280;
}
NameInputDialog::NameInputDialog(const QString& title, const QString& prompt,
QWidget* parent)
: ModalDialog(parent)
, m_nameEdit(nullptr)
{
QVBoxLayout* mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx);
mainLayout->setSpacing(kSpacingPx);
QLabel* promptLabel = new QLabel(prompt, this);
mainLayout->addWidget(promptLabel);
m_nameEdit = new QLineEdit(this);
m_nameEdit->setMinimumWidth(kMinimumWidthPx);
mainLayout->addWidget(m_nameEdit);
QHBoxLayout* buttonLayout = new QHBoxLayout();
buttonLayout->setSpacing(kSpacingPx);
buttonLayout->addStretch();
QPushButton* confirmButton = new QPushButton(tr("Confirm"), this);
QPushButton* cancelButton = new QPushButton(tr("Cancel"), this);
buttonLayout->addWidget(confirmButton);
buttonLayout->addWidget(cancelButton);
mainLayout->addLayout(buttonLayout);
connect(confirmButton, &QPushButton::clicked, this, &QDialog::accept);
connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject);
// Enter confirms from the line edit, as it did in the prompt this replaces.
connect(m_nameEdit, &QLineEdit::returnPressed, this, &QDialog::accept);
addHeader(mainLayout, title, false);
// The line edit is what the player came here to use, and it takes Q as a character
// because it holds the focus (REQ-UI-DIALOG-DISMISS).
m_nameEdit->setFocus();
}
QString NameInputDialog::getName() const
{
return m_nameEdit->text().trimmed();
}

30
src/ui/NameInputDialog.h Normal file
View File

@@ -0,0 +1,30 @@
#pragma once
#include <QString>
#include "ModalDialog.h"
class QLineEdit;
// The game's own name prompt (REQ-UI-MODAL-CHROME): a title, a prompt, a line edit, and
// Confirm and Cancel. It stands in for QInputDialog at the two places a blueprint is
// named -- the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE) and the Create Blueprint
// dialog of the layout configuration dialog (REQ-MOD-UI-BLUEPRINT-CREATE).
//
// Neither Q nor a click outside dismisses it (ModalDialog::isDismissible stays false):
// Q is a character the player may be typing, and a half-typed name is work in progress
// that no stray gesture may discard (REQ-UI-DIALOG-DISMISS). Escape and Cancel remain.
// The caller decides what an empty name means; the dialog reports it trimmed.
class NameInputDialog : public ModalDialog
{
Q_OBJECT
public:
NameInputDialog(const QString& title, const QString& prompt,
QWidget* parent = nullptr);
QString getName() const;
private:
QLineEdit* m_nameEdit;
};

View File

@@ -47,6 +47,24 @@ void clearRow(QHBoxLayout* layout)
} // namespace } // namespace
std::vector<std::vector<RecipeLineRow::Amount>> RecipeLineRow::toOutputGroups(
const RecipeDef& recipe)
{
std::vector<std::vector<Amount>> groups;
groups.reserve(recipe.outputGroups.size());
for (const RecipeOutputGroup& group : recipe.outputGroups)
{
std::vector<Amount> amounts;
amounts.reserve(group.items.size());
for (const RecipeOutput& out : group.items)
{
amounts.push_back(Amount{ out.item, out.amount });
}
groups.push_back(std::move(amounts));
}
return groups;
}
RecipeLineRow::RecipeLineRow(ItemIconCache* itemIcons, BuildingIconCache* buildingIcons, RecipeLineRow::RecipeLineRow(ItemIconCache* itemIcons, BuildingIconCache* buildingIcons,
QWidget* parent) QWidget* parent)
: QWidget(parent) : QWidget(parent)
@@ -143,12 +161,21 @@ void RecipeLineRow::rebuild(const Spec& spec)
// Second line: what the cycle costs, makes and takes. // Second line: what the cycle costs, makes and takes.
addAmounts(spec.inputs); addAmounts(spec.inputs);
if (!spec.inputs.empty() && !spec.outputs.empty()) if (!spec.inputs.empty() && !spec.outputGroups.empty())
{ {
const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW
addAndShow(m_amountsLayout, new QLabel(QString(rightArrow), m_amountsRow)); addAndShow(m_amountsLayout, new QLabel(QString(rightArrow), m_amountsRow));
} }
addAmounts(spec.outputs); for (std::size_t i = 0; i < spec.outputGroups.size(); ++i)
{
// Between one group and the next, so alternatives read as a choice rather than as
// one combined yield -- which is what a run of `+` would say (REQ-UI-RECIPE-SUMMARY).
if (i > 0)
{
addAndShow(m_amountsLayout, new QLabel(QStringLiteral("/"), m_amountsRow));
}
addAmounts(spec.outputGroups[i]);
}
if (spec.durationSeconds.has_value() && *spec.durationSeconds > 0.0) if (spec.durationSeconds.has_value() && *spec.durationSeconds > 0.0)
{ {

View File

@@ -8,6 +8,7 @@
#include <QWidget> #include <QWidget>
#include "BuildingType.h" #include "BuildingType.h"
#include "RecipesConfig.h"
class BuildingIconCache; class BuildingIconCache;
class ItemIconCache; class ItemIconCache;
@@ -59,9 +60,12 @@ public:
// name is the caption of the widget around this one instead. // name is the caption of the widget around this one instead.
QString name; QString name;
std::vector<Amount> inputs; std::vector<Amount> inputs;
// Empty for a line that produces no item of its own: a ship schematic, or a // What the recipe produces, one entry per output group (REQ-MAT-OUTPUT-GROUP).
// module's price. No arrow is drawn then. // The items of a group are drawn joined by `+` because they come together, and the
std::vector<Amount> outputs; // groups joined by `/` because only one of them happens. Empty for a line that
// produces no item of its own: a ship schematic, or a module's price. No arrow is
// drawn then.
std::vector<std::vector<Amount>> outputGroups;
std::optional<double> durationSeconds; std::optional<double> durationSeconds;
// True where the time is added to something else rather than being a cycle of // True where the time is added to something else rather than being a cycle of
// its own, and so reads "+3.0 s" (REQ-MOD-UI-DIALOG). // its own, and so reads "+3.0 s" (REQ-MOD-UI-DIALOG).
@@ -70,14 +74,19 @@ public:
bool operator==(const Spec& other) const bool operator==(const Spec& other) const
{ {
return building == other.building && name == other.name return building == other.building && name == other.name
&& inputs == other.inputs && outputs == other.outputs && inputs == other.inputs && outputGroups == other.outputGroups
&& durationSeconds == other.durationSeconds && durationSeconds == other.durationSeconds
&& durationIsAddition == other.durationIsAddition; && durationIsAddition == other.durationIsAddition;
} }
bool isEmpty() const { return inputs.empty() && outputs.empty(); } bool isEmpty() const { return inputs.empty() && outputGroups.empty(); }
}; };
// A recipe's output groups as this row states them (REQ-MAT-OUTPUT-GROUP). Shared, so
// that every place drawing a recipe -- the summary, the option buttons, the tooltip
// lines -- converts it the same way rather than each keeping its own copy.
static std::vector<std::vector<Amount>> toOutputGroups(const RecipeDef& recipe);
// Both caches may be null, which leaves the icons off: an item with no square and // Both caches may be null, which leaves the icons off: an item with no square and
// no icon falls back to its id, and a building with no chip to its name alone. // no icon falls back to its id, and a building with no chip to its name alone.
// Neither is owned. // Neither is owned.

View File

@@ -33,16 +33,6 @@ std::vector<RecipeLineRow::Amount> toAmounts(
return amounts; return amounts;
} }
std::vector<RecipeLineRow::Amount> toAmounts(const std::vector<RecipeOutput>& outputs)
{
std::vector<RecipeLineRow::Amount> amounts;
amounts.reserve(outputs.size());
for (const RecipeOutput& output : outputs)
{
amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount });
}
return amounts;
}
} // namespace } // namespace
@@ -90,7 +80,7 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
RecipeLineRow::Spec line; RecipeLineRow::Spec line;
line.inputs = toAmounts(recipe.inputs); line.inputs = toAmounts(recipe.inputs);
line.outputs = toAmounts(recipe.outputs); line.outputGroups = RecipeLineRow::toOutputGroups(recipe);
line.durationSeconds = recipe.durationSeconds; line.durationSeconds = recipe.durationSeconds;
options.push_back({recipe.id, options.push_back({recipe.id,
@@ -118,11 +108,8 @@ RecipeSelectionDialog::RecipeSelectionDialog(
const std::vector<RecipeSelectionOption>& options, const std::vector<RecipeSelectionOption>& options,
const QString& title, ItemIconCache* itemIcons, BuildingIconCache* buildingIcons, const QString& title, ItemIconCache* itemIcons, BuildingIconCache* buildingIcons,
QWidget* parent) QWidget* parent)
: QDialog(parent) : ModalDialog(parent)
{ {
setWindowTitle(title);
setModal(true);
QVBoxLayout* mainLayout = new QVBoxLayout(this); QVBoxLayout* mainLayout = new QVBoxLayout(this);
// One vertical column of buttons, each stating what it makes (REQ-UI-SELECT-OPTIONS). // One vertical column of buttons, each stating what it makes (REQ-UI-SELECT-OPTIONS).
@@ -186,6 +173,16 @@ RecipeSelectionDialog::RecipeSelectionDialog(
listLayout->addStretch(1); listLayout->addStretch(1);
scrollArea->setWidget(list); scrollArea->setWidget(list);
// Added last so the header sits above a list whose height is already settled; the
// modal draws its own title and close button, having no window frame to carry them
// (REQ-UI-MODAL-CHROME).
addHeader(mainLayout, title, true);
}
bool RecipeSelectionDialog::isDismissible() const
{
return true;
} }
std::optional<std::string> RecipeSelectionDialog::getChosenId() const std::optional<std::string> RecipeSelectionDialog::getChosenId() const

View File

@@ -4,10 +4,10 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include <QDialog>
#include <QString> #include <QString>
#include "BuildingType.h" #include "BuildingType.h"
#include "ModalDialog.h"
#include "RecipeLineRow.h" #include "RecipeLineRow.h"
struct GameConfig; struct GameConfig;
@@ -39,9 +39,10 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
BuildingType type, Simulation& sim, const GameConfig& config); BuildingType type, Simulation& sim, const GameConfig& config);
// Modal dialog listing the options in one vertical column (REQ-UI-SELECT-OPTIONS). The // Modal dialog listing the options in one vertical column (REQ-UI-SELECT-OPTIONS). The
// game is paused by the caller while it is open. Clicking an option selects it // game is paused by the caller while it is open. Clicking an option selects it and closes
// and closes the dialog; dismissing it (close/Esc) leaves no choice. // the dialog; dismissing it (close button, Escape, Q, or a click outside) leaves no
class RecipeSelectionDialog : public QDialog // choice (REQ-UI-DIALOG-DISMISS).
class RecipeSelectionDialog : public ModalDialog
{ {
Q_OBJECT Q_OBJECT
@@ -53,6 +54,8 @@ public:
std::optional<std::string> getChosenId() const; std::optional<std::string> getChosenId() const;
bool isDismissible() const override;
private: private:
void onOptionClicked(int index); void onOptionClicked(int index);

View File

@@ -24,16 +24,6 @@ std::vector<RecipeLineRow::Amount> toAmounts(
return amounts; return amounts;
} }
std::vector<RecipeLineRow::Amount> toAmounts(const std::vector<RecipeOutput>& outputs)
{
std::vector<RecipeLineRow::Amount> amounts;
amounts.reserve(outputs.size());
for (const RecipeOutput& output : outputs)
{
amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount });
}
return amounts;
}
QString grantKindLabel(SchematicType type) QString grantKindLabel(SchematicType type)
{ {
@@ -54,15 +44,14 @@ SchematicChoiceDialog::SchematicChoiceDialog(
const RecipesConfig& recipes, ItemIconCache* itemIcons, const RecipesConfig& recipes, ItemIconCache* itemIcons,
BuildingIconCache* buildingIcons, BuildingIconCache* buildingIcons,
QWidget* parent) QWidget* parent)
: QDialog(parent) : ModalDialog(parent)
, m_chosenIndex(0) , m_chosenIndex(0)
{ {
setWindowTitle(tr("Schematic Drop"));
setWindowFlags(windowFlags() & ~Qt::WindowCloseButtonHint);
setModal(true);
QVBoxLayout* mainLayout = new QVBoxLayout(this); QVBoxLayout* mainLayout = new QVBoxLayout(this);
// Its own title line, and the only one: a modal draws its title rather than wearing
// one (REQ-UI-MODAL-CHROME), and this dialog carries no close button because there
// is no way out but choosing (REQ-DEF-SCHEMATIC-DROP).
QLabel* titleLabel = new QLabel(tr("Choose a schematic to unlock:"), this); QLabel* titleLabel = new QLabel(tr("Choose a schematic to unlock:"), this);
QFont titleFont = titleLabel->font(); QFont titleFont = titleLabel->font();
titleFont.setPointSize(titleFont.pointSize() + 2); titleFont.setPointSize(titleFont.pointSize() + 2);
@@ -147,7 +136,7 @@ SchematicChoiceDialog::SchematicChoiceDialog(
spec.building = def->building; spec.building = def->building;
spec.name = QString::fromStdString(toDisplayName(def->id)); spec.name = QString::fromStdString(toDisplayName(def->id));
spec.inputs = toAmounts(def->inputs); spec.inputs = toAmounts(def->inputs);
spec.outputs = toAmounts(def->outputs); spec.outputGroups = RecipeLineRow::toOutputGroups(*def);
spec.durationSeconds = def->durationSeconds; spec.durationSeconds = def->durationSeconds;
RecipeLineRow* line = RecipeLineRow* line =
@@ -177,6 +166,12 @@ int SchematicChoiceDialog::getChosenIndex() const
return m_chosenIndex; return m_chosenIndex;
} }
void SchematicChoiceDialog::reject()
{
// Deliberately empty: the dialog stays open until an option is clicked
// (REQ-DEF-SCHEMATIC-DROP). The game is paused meanwhile, so nothing waits on it.
}
void SchematicChoiceDialog::onOptionClicked(int index) void SchematicChoiceDialog::onOptionClicked(int index)
{ {
m_chosenIndex = index; m_chosenIndex = index;

View File

@@ -2,15 +2,19 @@
#include <vector> #include <vector>
#include <QDialog> #include "ModalDialog.h"
#include "SchematicChoiceOption.h" #include "SchematicChoiceOption.h"
struct RecipesConfig; struct RecipesConfig;
class BuildingIconCache; class BuildingIconCache;
class ItemIconCache; class ItemIconCache;
class SchematicChoiceDialog : public QDialog // The drop's choice dialog (REQ-DEF-SCHEMATIC-DROP). Unlike every other dialog it cannot
// be dismissed: clicking an option is the only way out, so getChosenIndex() always names
// an option the player picked, and it only ever finishes Accepted. It inherits the base's
// refusal of Q and of a click outside (ModalDialog::isDismissible) and adds the refusal
// of Escape below.
class SchematicChoiceDialog : public ModalDialog
{ {
Q_OBJECT Q_OBJECT
@@ -24,6 +28,13 @@ public:
int getChosenIndex() const; int getChosenIndex() const;
public slots:
// Refuses the dismissal (REQ-DEF-SCHEMATIC-DROP): the drop is a reward the player
// has earned, and leaving without choosing would either forfeit it or award the
// option that happens to be first. Escape funnels through QDialog::reject(), so
// declining it here turns it away without swallowing keys one at a time.
void reject() override;
private: private:
void onOptionClicked(int index); void onOptionClicked(int index);

View File

@@ -17,8 +17,11 @@
namespace namespace
{ {
// Distance kept between the panel and the edges of the game world view, and between it // The edge margin: the distance kept between the panel and the edges of the game world
// and the widgets it steps around (REQ-UI-SELECTION-PANEL). // view, and between it and the widgets it steps around (REQ-UI-SELECTION-PANEL). The gap
// the panel keeps from the selection itself is the wider of the two and is not this: being
// half a tile, it is measured where the tile size is known and arrives with the anchor
// rectangle (SelectionAnchorChangedEvent).
const int kMarginPx = 8; const int kMarginPx = 8;
// Upper bound on the card width. The panel is content-sized, but several of the cards' // Upper bound on the card width. The panel is content-sized, but several of the cards'
@@ -170,12 +173,14 @@ void SelectionPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> ev
void SelectionPanel::handleEvent( void SelectionPanel::handleEvent(
std::shared_ptr<const SelectionAnchorChangedEvent> event) std::shared_ptr<const SelectionAnchorChangedEvent> event)
{ {
// A new selection is starting. Both the anchor and the side are settled against it // A new selection is starting. The anchor, the gap kept from it, and the side are all
// and then left alone for as long as it lasts (REQ-UI-SELECTION-PANEL); the side is // settled against it and then left alone for as long as it lasts
// only reset here, being resolved on the next placement once the card's width is // (REQ-UI-SELECTION-PANEL); the side is only reset here, being resolved on the next
// known. The rect arrives in the world view's coordinates and is translated when the // placement once the card's width is known. The rect arrives in the world view's
// panel is placed, the two widgets being siblings in the same parent. // coordinates and is translated when the panel is placed, the two widgets being
m_anchorRect = event->rectPx; // siblings in the same parent.
m_anchorRect = event->rectPx;
m_selectionGapPx = event->selectionGapPx;
m_side.reset(); m_side.reset();
// A position the player dragged the panel to belongs to the selection it was set in. // A position the player dragged the panel to belongs to the selection it was set in.
// A new selection places the panel anew against its own anchor // A new selection places the panel anew against its own anchor
@@ -354,7 +359,7 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& oc
wantedSize, occupiedRects, kMarginPx); wantedSize, occupiedRects, kMarginPx);
} }
return placeBesideAnchor(band, anchorRect, *m_side, wantedSize, occupiedRects, return placeBesideAnchor(band, anchorRect, *m_side, wantedSize, occupiedRects,
kMarginPx); m_selectionGapPx, kMarginPx);
}; };
// Run twice. Parts of a card report an unstyled size until the style has actually // Run twice. Parts of a card report an unstyled size until the style has actually
@@ -376,7 +381,7 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& oc
if (!m_side.has_value()) if (!m_side.has_value())
{ {
m_side = chooseSide(band, anchorRect, contentWidthPx + 2 * borderPx, m_side = chooseSide(band, anchorRect, contentWidthPx + 2 * borderPx,
kMarginPx); m_selectionGapPx);
} }
// How much height there is depends on where the panel ends up standing: of the // How much height there is depends on where the panel ends up standing: of the

View File

@@ -103,14 +103,16 @@ private:
SelectionContent* m_content = nullptr; SelectionContent* m_content = nullptr;
// Where the current selection was on the screen when it started, in the game world // Where the current selection was on the screen when it started, in the game world
// view's coordinates, and which side of it the panel took. Both are frozen for as // view's coordinates, the gap the panel keeps from it, and which side of it the panel
// long as the selection lasts: the anchor because the panel does not chase a // took. All three are frozen for as long as the selection lasts: the anchor because
// scrolling view or a moving ship, the side because a card that grows must not flip // the panel does not chase a scrolling view or a moving ship, the gap because it is
// the panel across the object (REQ-UI-SELECTION-PANEL). The side is resolved on the // measured against that frozen rectangle, the side because a card that grows must not
// first placement after a new anchor, being the first point at which the panel's // flip the panel across the object (REQ-UI-SELECTION-PANEL). The side is resolved on
// width is known. Dragging the panel supersedes the pair for the rest of the // the first placement after a new anchor, being the first point at which the panel's
// width is known. Dragging the panel supersedes all three for the rest of the
// selection (REQ-UI-SELECTION-PANEL-DRAG). // selection (REQ-UI-SELECTION-PANEL-DRAG).
QRect m_anchorRect; QRect m_anchorRect;
int m_selectionGapPx = 0;
std::optional<PanelSide> m_side; std::optional<PanelSide> m_side;
// Where the player dragged the panel, in the game world view's coordinates, and the // Where the player dragged the panel, in the game world view's coordinates, and the

View File

@@ -5,14 +5,16 @@
#include <functional> #include <functional>
#include "DisplayName.h" #include "DisplayName.h"
#include "ModalLayer.h"
#include "NameInputDialog.h"
#include "OptionButton.h" #include "OptionButton.h"
#include "ProductionRules.h" #include "ProductionRules.h"
#include "RecipeLineRow.h" #include "RecipeLineRow.h"
#include "SectionBox.h" #include "SectionBox.h"
#include "TooltipTrigger.h"
#include <QGridLayout> #include <QGridLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QInputDialog>
#include <QKeyEvent> #include <QKeyEvent>
#include <QLabel> #include <QLabel>
#include <QMouseEvent> #include <QMouseEvent>
@@ -336,14 +338,18 @@ public:
layout->addWidget(m_scrollArea, 1); layout->addWidget(m_scrollArea, 1);
connect(createBtn, &QPushButton::clicked, this, [this]() { connect(createBtn, &QPushButton::clicked, this, [this]() {
bool ok = false; // Opened on the same layer as the dialog this panel sits in, so it stacks
const QString name = QInputDialog::getText( // over it on the one dim (REQ-MOD-UI-BLUEPRINT-CREATE, REQ-UI-MODAL-DIM).
this, tr("Create Blueprint"), tr("Blueprint name:"), ModalLayer* layer = ModalLayer::findFor(*this);
QLineEdit::Normal, QString(), &ok); if (layer == nullptr) { return; }
if (!ok || name.trimmed().isEmpty()) { return; }
NameInputDialog nameDialog(tr("Create Blueprint"), tr("Blueprint name:"));
if (layer->execute(nameDialog) != QDialog::Accepted) { return; }
const QString name = nameDialog.getName();
if (name.isEmpty()) { return; }
ShipLayoutBlueprint bp; ShipLayoutBlueprint bp;
bp.name = name.trimmed(); bp.name = name;
bp.shipType = m_shipType; bp.shipType = m_shipType;
bp.modules = m_getModules(); bp.modules = m_getModules();
m_allBlueprints.push_back(std::move(bp)); m_allBlueprints.push_back(std::move(bp));
@@ -420,7 +426,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
bool debugDraw, bool debugDraw,
ItemIconCache* itemIcons, ItemIconCache* itemIcons,
QWidget* parent) QWidget* parent)
: QDialog(parent) : ModalDialog(parent)
, m_config(config) , m_config(config)
, m_itemIcons(itemIcons) , m_itemIcons(itemIcons)
, m_shipId(shipId) , m_shipId(shipId)
@@ -434,9 +440,6 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
, m_statsPanel(nullptr) , m_statsPanel(nullptr)
, m_debugDraw(debugDraw) , m_debugDraw(debugDraw)
{ {
setWindowTitle(tr("Configure Ship Layout"));
setModal(true);
// Find the ship's layout grid. // Find the ship's layout grid.
const ShipDef* shipDef = config->ships.findShipDef(shipId); const ShipDef* shipDef = config->ships.findShipDef(shipId);
if (shipDef) if (shipDef)
@@ -472,6 +475,10 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
// --- UI layout --- // --- UI layout ---
QVBoxLayout* outerLayout = new QVBoxLayout(this); QVBoxLayout* outerLayout = new QVBoxLayout(this);
// The dialog's own title line; it carries no close button, having Confirm and Cancel
// of its own at the bottom (REQ-UI-MODAL-CHROME, REQ-MOD-UI-DIALOG).
addHeader(outerLayout, tr("Configure Ship Layout"), false);
// Top: grid widget. // Top: grid widget.
LayoutGridWidget* gridW = new LayoutGridWidget(this, this); LayoutGridWidget* gridW = new LayoutGridWidget(this, this);
gridW->setGridData(&m_grid, m_rows, m_cols, &m_placedModules, m_config); gridW->setGridData(&m_grid, m_rows, m_cols, &m_placedModules, m_config);
@@ -545,10 +552,12 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
costLine->setLine(cost); costLine->setLine(cost);
// The config tooltip stays: it says what the module does, which the cost line // The config tooltip stays: it says what the module does, which the cost line
// does not (REQ-MOD-UI-MODULE-TOOLTIP). // does not (REQ-MOD-UI-MODULE-TOOLTIP). Hover only: the click selects the module
// (REQ-UI-TOOLTIP-TRIGGER).
if (def.tooltip) if (def.tooltip)
{ {
btn->setToolTip(QString::fromStdString(*def.tooltip)); TooltipTrigger::attachText(*btn, QString::fromStdString(*def.tooltip),
TooltipTrigger::Trigger::HoverOnly);
} }
buttonGrid->addWidget(btn, row, col); buttonGrid->addWidget(btn, row, col);
m_moduleButtons.push_back(btn); m_moduleButtons.push_back(btn);
@@ -575,24 +584,8 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
++row; ++row;
} }
buttonGrid->addWidget(m_removeButton, row, 0, 1, kCols); buttonGrid->addWidget(m_removeButton, row, 0, 1, kCols);
connect(m_removeButton, &QPushButton::clicked, this, [this]() { connect(m_removeButton, &QPushButton::clicked,
if (m_removeMode) this, &ShipLayoutDialog::onRemoveButtonClicked);
{
m_removeMode = false;
m_removeButton->setChecked(false);
}
else
{
for (QPushButton* btn : m_moduleButtons)
{
if (btn) { btn->setChecked(false); }
}
m_activeModuleIndex = std::nullopt;
m_removeMode = true;
m_removeButton->setChecked(true);
}
updateGridWidget();
});
centerLayout->addLayout(buttonGrid); centerLayout->addLayout(buttonGrid);
centerLayout->addStretch(); centerLayout->addStretch();
@@ -699,10 +692,25 @@ void ShipLayoutDialog::keyPressEvent(QKeyEvent* event)
} }
else else
{ {
QDialog::keyPressEvent(event); // Q among them, which the base turns into requestDismiss().
ModalDialog::keyPressEvent(event);
} }
} }
bool ShipLayoutDialog::isDismissible() const
{
return true;
}
void ShipLayoutDialog::requestDismiss()
{
// Both mode exits go through the handler the buttons use, so a dismissal and a
// button can never leave different state behind (REQ-MOD-PLACEMENT, REQ-MOD-REMOVE).
if (m_activeModuleIndex.has_value()) { onModuleButtonClicked(*m_activeModuleIndex); }
else if (m_removeMode) { onRemoveButtonClicked(); }
else { onCancel(); }
}
void ShipLayoutDialog::onModuleButtonClicked(int index) void ShipLayoutDialog::onModuleButtonClicked(int index)
{ {
if (m_activeModuleIndex == index) if (m_activeModuleIndex == index)
@@ -723,6 +731,26 @@ void ShipLayoutDialog::onModuleButtonClicked(int index)
updateGridWidget(); updateGridWidget();
} }
void ShipLayoutDialog::onRemoveButtonClicked()
{
if (m_removeMode)
{
m_removeMode = false;
m_removeButton->setChecked(false);
}
else
{
for (QPushButton* btn : m_moduleButtons)
{
if (btn) { btn->setChecked(false); }
}
m_activeModuleIndex = std::nullopt;
m_removeMode = true;
m_removeButton->setChecked(true);
}
updateGridWidget();
}
void ShipLayoutDialog::onConfirm() void ShipLayoutDialog::onConfirm()
{ {
ShipLayoutConfig layout; ShipLayoutConfig layout;

View File

@@ -6,10 +6,10 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include <QDialog>
#include <QPoint> #include <QPoint>
#include "GameConfig.h" #include "GameConfig.h"
#include "ModalDialog.h"
#include "Rotation.h" #include "Rotation.h"
#include "ShipLayout.h" #include "ShipLayout.h"
#include "ShipLayoutBlueprint.h" #include "ShipLayoutBlueprint.h"
@@ -20,7 +20,7 @@ class RecipeLineRow;
class SectionBox; class SectionBox;
class ShipStatsPanel; class ShipStatsPanel;
class ShipLayoutDialog : public QDialog class ShipLayoutDialog : public ModalDialog
{ {
Q_OBJECT Q_OBJECT
@@ -38,6 +38,15 @@ public:
std::optional<ShipLayoutConfig> getResult() const; std::optional<ShipLayoutConfig> getResult() const;
bool isDismissible() const override;
public slots:
// Backs out one level per dismissal, as Q does in the game world: the module being
// placed, then remove mode, then the dialog itself, which discards the session
// (REQ-UI-DIALOG-DISMISS). So a single press or click never both leaves a mode and
// throws the session away.
void requestDismiss() override;
protected: protected:
void keyPressEvent(QKeyEvent* event) override; void keyPressEvent(QKeyEvent* event) override;
@@ -46,6 +55,10 @@ signals:
private slots: private slots:
void onModuleButtonClicked(int index); void onModuleButtonClicked(int index);
// Enters remove mode, or leaves it when it is already active (REQ-MOD-REMOVE).
// Reached by the Remove button and by a dismissal, which leaves the mode on its way
// out of the dialog (REQ-UI-DIALOG-DISMISS).
void onRemoveButtonClicked();
void onConfirm(); void onConfirm();
void onCancel(); void onCancel();

176
src/ui/Tooltip.cpp Normal file
View File

@@ -0,0 +1,176 @@
#include "Tooltip.h"
#include <QFontMetrics>
#include <QGuiApplication>
#include <QLabel>
#include <QLayoutItem>
#include <QPalette>
#include <QRect>
#include <QScreen>
#include <QVBoxLayout>
namespace
{
// Widest a text tooltip gets before it wraps, in device-independent pixels. A config
// description runs to a sentence or two and would otherwise be laid out as one line
// across the whole window.
const int kMaxTextWidthPx = 360;
// Height the wrap is measured against: high enough that it never cuts the text short.
const int kMeasureHeightPx = 10000;
// The screen the given position is on, falling back to the primary screen when it is on
// none of them (a position between two screens of different heights).
QRect availableScreenRect(const QPoint& globalPos)
{
const QScreen* screen = QGuiApplication::screenAt(globalPos);
if (screen == nullptr)
{
screen = QGuiApplication::primaryScreen();
}
return (screen != nullptr) ? screen->availableGeometry() : QRect();
}
} // namespace
Tooltip* Tooltip::s_visibleTooltip = nullptr;
Tooltip* Tooltip::getVisibleTooltip()
{
return s_visibleTooltip;
}
void Tooltip::hideVisibleTooltip()
{
if (s_visibleTooltip != nullptr)
{
s_visibleTooltip->hide();
}
}
Tooltip::Tooltip(QWidget* parent)
: QFrame(parent, Qt::ToolTip)
{
// The palette's tooltip colors behind a themed border, so it reads as a tooltip
// rather than as a stray window.
setFrameShape(QFrame::StyledPanel);
setAutoFillBackground(true);
QPalette tooltipPalette = palette();
tooltipPalette.setColor(QPalette::Window,
tooltipPalette.color(QPalette::ToolTipBase));
tooltipPalette.setColor(QPalette::WindowText,
tooltipPalette.color(QPalette::ToolTipText));
setPalette(tooltipPalette);
m_layout = new QVBoxLayout(this);
m_layout->setContentsMargins(6, 4, 6, 4);
m_layout->setSpacing(2);
}
Tooltip::~Tooltip()
{
// Destroyed while on screen -- a chip whose panel is rebuilt takes its tooltip with
// it (REQ-UI-SINGLE-SELECTION).
if (s_visibleTooltip == this)
{
s_visibleTooltip = nullptr;
}
}
void Tooltip::setText(const QString& text)
{
clearContent();
QLabel* label = new QLabel(text, this);
// Plain text throughout: config strings are read as they are written, never as
// markup Qt happens to recognize.
label->setTextFormat(Qt::PlainText);
label->setWordWrap(true);
// The width the text needs, capped at the wrap measure. Measured rather than left to
// the layout, so the label is laid out at exactly the width its wrapping assumed and
// the tooltip ends up as tall as the wrapped text really is.
const QFontMetrics metrics(label->font());
const QRect wrapped = metrics.boundingRect(
QRect(0, 0, kMaxTextWidthPx, kMeasureHeightPx), Qt::TextWordWrap, text);
label->setFixedWidth(qMin(kMaxTextWidthPx, wrapped.width()));
// The height that wrapping really needs, so a label whose own hint was computed for
// the unwrapped text cannot leave the last lines clipped.
label->setMinimumHeight(wrapped.height());
m_layout->addWidget(label);
}
void Tooltip::showAt(const QPoint& globalPos)
{
// Only ever one tooltip on screen (REQ-UI-TOOLTIP-TRIGGER).
if (s_visibleTooltip != nullptr && s_visibleTooltip != this)
{
s_visibleTooltip->hide();
}
refreshContent();
adjustSize();
// The top-left corner goes on the pointer, so the pointer starts on the tooltip and
// reaches it without crossing a gap (REQ-UI-TOOLTIP-DISMISS). Kept on the screen the
// pointer is on: a tooltip near the right or bottom edge would otherwise be cut off.
// Clamping keeps the pointer inside the tooltip, since it only ever moves the
// tooltip back over the pointer.
const QRect screen = availableScreenRect(globalPos);
QPoint topLeft = globalPos;
if (screen.isValid())
{
topLeft.setX(qMax(screen.left(),
qMin(topLeft.x(), screen.right() - width() + 1)));
topLeft.setY(qMax(screen.top(),
qMin(topLeft.y(), screen.bottom() - height() + 1)));
}
move(topLeft);
show();
raise();
s_visibleTooltip = this;
}
bool Tooltip::getContainsGlobalPos(const QPoint& globalPos) const
{
return isVisible() && frameGeometry().contains(globalPos);
}
void Tooltip::refreshContent()
{
}
QVBoxLayout* Tooltip::getContentLayout()
{
return m_layout;
}
void Tooltip::clearContent()
{
while (QLayoutItem* item = m_layout->takeAt(0))
{
if (item->widget() != nullptr)
{
// Out of the widget tree at once and destroyed later: a child merely taken
// out of the layout would keep its geometry and show through the content
// that replaces it.
item->widget()->setParent(nullptr);
item->widget()->deleteLater();
}
delete item;
}
}
void Tooltip::hideEvent(QHideEvent* event)
{
if (s_visibleTooltip == this)
{
s_visibleTooltip = nullptr;
}
QFrame::hideEvent(event);
}

51
src/ui/Tooltip.h Normal file
View File

@@ -0,0 +1,51 @@
#pragma once
#include <QFrame>
#include <QPoint>
#include <QString>
class QHideEvent;
class QVBoxLayout;
// The popup every tooltip in the UI is drawn in (REQ-UI-TOOLTIP-TRIGGER,
// REQ-UI-TOOLTIP-DISMISS). Shown and hidden by a TooltipTrigger attached to the element
// it describes; it carries no trigger logic of its own.
//
// A window of its own rather than Qt's own tooltip, because ours must not time out, must
// stay up while the pointer is on it, and -- for the item production tooltip
// (REQ-UI-ITEM-TOOLTIP) -- must hold drawn content rather than text. A Qt::ToolTip window
// so it floats above the game window instead of being clipped by it.
class Tooltip : public QFrame
{
Q_OBJECT
public:
// The tooltip currently on screen, if any: only ever one, since the pointer can only
// be on one element.
static Tooltip* getVisibleTooltip();
static void hideVisibleTooltip();
explicit Tooltip(QWidget* parent = nullptr);
~Tooltip() override;
// Plain text content, wrapped at a readable measure. Newlines are kept.
void setText(const QString& text);
// Refreshes the content and shows the tooltip with its top-left corner on the given
// screen position, kept inside the screen (REQ-UI-TOOLTIP-DISMISS).
void showAt(const QPoint& globalPos);
bool getContainsGlobalPos(const QPoint& globalPos) const;
protected:
// Called before every show, for content that goes stale between them. The base
// tooltip's text does not, so this does nothing here.
virtual void refreshContent();
QVBoxLayout* getContentLayout();
void clearContent();
void hideEvent(QHideEvent* event) override;
private:
static Tooltip* s_visibleTooltip;
QVBoxLayout* m_layout;
};

170
src/ui/TooltipTrigger.cpp Normal file
View File

@@ -0,0 +1,170 @@
#include "TooltipTrigger.h"
#include <QApplication>
#include <QCursor>
#include <QEvent>
#include <QMouseEvent>
#include <QTimer>
#include <QWidget>
#include "Tooltip.h"
namespace
{
// How long the pointer rests on an element before its tooltip appears, in milliseconds.
// One delay for every tooltip in the UI, now that none of them is Qt's (REQ-UI-TOOLTIP-TRIGGER).
const int kHoverDelayMs = 500;
// How often the pointer is tested against the element and the tooltip while the tooltip
// is up, in milliseconds. Short enough to read as immediate, and it runs only then.
const int kPointerPollMs = 100;
} // namespace
void TooltipTrigger::attachText(QWidget& target, const QString& text, Trigger trigger)
{
Tooltip* tooltip = new Tooltip(&target);
tooltip->setText(text);
attach(target, *tooltip, trigger);
}
void TooltipTrigger::attach(QWidget& target, Tooltip& tooltip, Trigger trigger)
{
// Parented to the element, so both die with it.
new TooltipTrigger(target, tooltip, trigger);
}
bool TooltipTrigger::eventFilter(QObject* watched, QEvent* event)
{
if (watched != m_target)
{
return QObject::eventFilter(watched, event);
}
switch (event->type())
{
case QEvent::Enter:
{
m_hoverTimer->start();
break;
}
case QEvent::Leave:
{
m_hoverTimer->stop();
// Not a dismissal in itself: the pointer may be on its way onto the tooltip
// (REQ-UI-TOOLTIP-DISMISS).
hideUnlessPointerHeld();
break;
}
case QEvent::MouseButtonPress:
{
const QMouseEvent* mouseEvent = static_cast<QMouseEvent*>(event);
if (m_trigger != Trigger::HoverAndClick
|| mouseEvent->button() != Qt::LeftButton)
{
break;
}
m_hoverTimer->stop();
// A click on an element whose tooltip is already up leaves it where it is
// (REQ-UI-TOOLTIP-TRIGGER).
if (!m_tooltip->isVisible())
{
showTooltip();
}
// Swallowed: the element does nothing with a click, and the panel beneath it
// must not take it for one of its own (REQ-UI-SELECTION-PANEL-DRAG).
return true;
}
case QEvent::Hide:
{
m_hoverTimer->stop();
hideTooltip();
break;
}
default:
{
break;
}
}
return QObject::eventFilter(watched, event);
}
TooltipTrigger::TooltipTrigger(QWidget& target, Tooltip& tooltip, Trigger trigger)
: QObject(&target)
, m_target(&target)
, m_tooltip(&tooltip)
, m_trigger(trigger)
{
m_hoverTimer = new QTimer(this);
m_hoverTimer->setSingleShot(true);
m_hoverTimer->setInterval(kHoverDelayMs);
connect(m_hoverTimer, &QTimer::timeout, this, &TooltipTrigger::showTooltip);
m_pointerTimer = new QTimer(this);
m_pointerTimer->setInterval(kPointerPollMs);
connect(m_pointerTimer, &QTimer::timeout,
this, &TooltipTrigger::hideUnlessPointerHeld);
m_target->installEventFilter(this);
}
QRect TooltipTrigger::getTargetGlobalRect() const
{
return QRect(m_target->mapToGlobal(QPoint(0, 0)), m_target->size());
}
void TooltipTrigger::showTooltip()
{
// Already up: left where it is rather than moved to wherever the pointer has got to,
// which is what a pointer wandering from the tooltip back onto the element would
// otherwise do (REQ-UI-TOOLTIP-TRIGGER).
if (!m_target->isVisible() || m_tooltip->isVisible())
{
return;
}
m_tooltip->showAt(QCursor::pos());
m_pointerTimer->start();
}
void TooltipTrigger::hideTooltip()
{
m_pointerTimer->stop();
m_tooltip->hide();
}
void TooltipTrigger::hideUnlessPointerHeld()
{
if (!m_tooltip->isVisible())
{
m_pointerTimer->stop();
return;
}
// Gone with the element it explains. An element that merely moves -- a header label
// resized by the number it carries -- is not a dismissal: the pointer is still on the
// tooltip, and nothing but the pointer dismisses one (REQ-UI-TOOLTIP-DISMISS).
if (!m_target->isVisible())
{
hideTooltip();
return;
}
// Gone with the game window: a tooltip is a topmost window and would otherwise stay
// over whatever the player switched to.
if (QApplication::activeWindow() == nullptr)
{
hideTooltip();
return;
}
const QPoint pointer = QCursor::pos();
if (!getTargetGlobalRect().contains(pointer)
&& !m_tooltip->getContainsGlobalPos(pointer))
{
hideTooltip();
}
}

61
src/ui/TooltipTrigger.h Normal file
View File

@@ -0,0 +1,61 @@
#pragma once
#include <QObject>
#include <QRect>
#include <QString>
class QEvent;
class QTimer;
class QWidget;
class Tooltip;
// Shows and hides one element's tooltip (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS).
// Attached to the element as an event filter, so a plain QLabel or QPushButton needs no
// subclass of its own to carry a tooltip.
//
// It replaces QWidget::setToolTip throughout the UI: Qt's own tooltip times out, hides on
// the first mouse move, cannot be hovered, and cannot be brought up by a click.
class TooltipTrigger : public QObject
{
Q_OBJECT
public:
// What brings the tooltip up. An element whose click already does something keeps to
// HoverOnly, so one gesture never both acts and explains (REQ-UI-TOOLTIP-TRIGGER).
enum class Trigger
{
HoverOnly,
HoverAndClick
};
// Attaches a text tooltip, created and owned along with the trigger by the target.
static void attachText(QWidget& target, const QString& text, Trigger trigger);
// Attaches a tooltip built by the caller -- the item production tooltip, whose
// content is drawn rather than written (REQ-UI-ITEM-TOOLTIP).
static void attach(QWidget& target, Tooltip& tooltip, Trigger trigger);
protected:
bool eventFilter(QObject* watched, QEvent* event) override;
private:
TooltipTrigger(QWidget& target, Tooltip& tooltip, Trigger trigger);
QRect getTargetGlobalRect() const;
void showTooltip();
void hideTooltip();
// Hides the tooltip once the pointer is on neither the element nor the tooltip
// itself, which is the only thing that dismisses it (REQ-UI-TOOLTIP-DISMISS).
void hideUnlessPointerHeld();
QWidget* m_target;
Tooltip* m_tooltip;
Trigger m_trigger;
// Held back so a pointer crossing the element on its way elsewhere does not flash the
// tooltip. A click skips it (REQ-UI-TOOLTIP-TRIGGER).
QTimer* m_hoverTimer;
// Runs only while the tooltip is up, testing where the pointer is. Polled rather than
// read off enter/leave events: those arrive in an order that depends on which of the
// two widgets the pointer crosses first, and a popup window is not a reliable source
// of them.
QTimer* m_pointerTimer;
};

View File

@@ -45,7 +45,6 @@ struct OverlayVisuals
QColor ghostValid; QColor ghostValid;
QColor ghostInvalid; QColor ghostInvalid;
QColor deconstructTint; QColor deconstructTint;
QColor selectionRect;
QColor tileHighlight; QColor tileHighlight;
QColor selectedOutline; QColor selectedOutline;
QColor configTransfer; // blueprint ghost over a transfer target (REQ-UI-BLUEPRINT-TRANSFER) QColor configTransfer; // blueprint ghost over a transfer target (REQ-UI-BLUEPRINT-TRANSFER)

View File

@@ -221,7 +221,6 @@ VisualsConfig VisualsLoader::load(const std::string& path)
cfg.overlays.ghostValid = parseColor(requireString(ov, "ghost_valid", "overlays"), "overlays.ghost_valid"); cfg.overlays.ghostValid = parseColor(requireString(ov, "ghost_valid", "overlays"), "overlays.ghost_valid");
cfg.overlays.ghostInvalid = parseColor(requireString(ov, "ghost_invalid", "overlays"), "overlays.ghost_invalid"); cfg.overlays.ghostInvalid = parseColor(requireString(ov, "ghost_invalid", "overlays"), "overlays.ghost_invalid");
cfg.overlays.deconstructTint = parseColor(requireString(ov, "deconstruct_tint", "overlays"), "overlays.deconstruct_tint"); cfg.overlays.deconstructTint = parseColor(requireString(ov, "deconstruct_tint", "overlays"), "overlays.deconstruct_tint");
cfg.overlays.selectionRect = parseColor(requireString(ov, "selection_rect", "overlays"), "overlays.selection_rect");
cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight"); cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight");
cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline"); cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline");
cfg.overlays.configTransfer = parseColor(requireString(ov, "config_transfer", "overlays"), "overlays.config_transfer"); cfg.overlays.configTransfer = parseColor(requireString(ov, "config_transfer", "overlays"), "overlays.config_transfer");

View File

@@ -897,13 +897,16 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
/*showPortTargetGlyphs*/ true); /*showPortTargetGlyphs*/ true);
} }
} }
else // A cursor that points at no tile — resting on a floating panel, or outside
// the window — hovers nothing, and builder mode then shows no ghost at all
// (REQ-BLD-GHOST).
else if (frame.buildMode.getGhostTile().has_value())
{ {
// In tunnel mode the ghost shows the position-resolved type (entry or // In tunnel mode the ghost shows the position-resolved type (entry or
// exit) and, when it would complete an existing tunnel, the matched end // exit) and, when it would complete an existing tunnel, the matched end
// and the tiles between it and the ghost are tinted green // and the tiles between it and the ghost are tinted green
// (REQ-BLD-TUNNEL-MODE). // (REQ-BLD-TUNNEL-MODE).
const QPoint ghostTile = frame.buildMode.getGhostTile(); const QPoint ghostTile = *frame.buildMode.getGhostTile();
const std::optional<QPoint>& partnerTile = frame.buildMode.getTunnelPartnerTile(); const std::optional<QPoint>& partnerTile = frame.buildMode.getTunnelPartnerTile();
if (frame.buildMode.isTunnelMode() && frame.buildMode.isGhostValid() if (frame.buildMode.isTunnelMode() && frame.buildMode.isGhostValid()
&& partnerTile.has_value()) && partnerTile.has_value())
@@ -931,14 +934,16 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
} }
} }
// Blueprint placement ghost // Blueprint placement ghost, drawn only while the cursor points at a tile, as for
if (frame.buildMode.isBlueprintMode()) // the builder ghost above (REQ-BLD-GHOST).
if (frame.buildMode.isBlueprintMode()
&& frame.buildMode.getBlueprintGhostTile().has_value())
{ {
// A single-building blueprint hit-tests the cursor for its transfer target; a // A single-building blueprint hit-tests the cursor for its transfer target; a
// constellation does not (REQ-UI-BLUEPRINT-TRANSFER). The stored building count, // constellation does not (REQ-UI-BLUEPRINT-TRANSFER). The stored building count,
// not the count after locked types are dropped, so the rule does not shift as the // not the count after locked types are dropped, so the rule does not shift as the
// player unlocks things. // player unlocks things.
const QPoint cursorTile = frame.buildMode.getBlueprintGhostTile(); const QPoint cursorTile = *frame.buildMode.getBlueprintGhostTile();
const std::optional<QPoint> hoverTile = const std::optional<QPoint> hoverTile =
frame.buildMode.getBlueprint().buildings.size() == 1 frame.buildMode.getBlueprint().buildings.size() == 1
? std::make_optional(cursorTile) : std::nullopt; ? std::make_optional(cursorTile) : std::nullopt;
@@ -984,9 +989,9 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
// Deconstruct tint: while dragging a deconstruct box, tint every covered // Deconstruct tint: while dragging a deconstruct box, tint every covered
// building/site (REQ-BLD-DECONSTRUCT-BOX); otherwise tint the hovered one. // building/site (REQ-BLD-DECONSTRUCT-BOX); otherwise tint the hovered one.
if (frame.buildMode.isDeconstructMode() && frame.isBoxSelecting) if (frame.buildMode.isDeconstructMode() && frame.boxWorldRect.has_value())
{ {
for (BuildingId id : buildingsInBox(m_sim.getFactoryState(), frame.boxStartTile, frame.boxCurrentTile)) for (BuildingId id : buildingsInBox(m_sim.getFactoryState(), *frame.boxWorldRect))
{ {
const Building* b = findBuilding(m_sim.getFactoryState(), id); const Building* b = findBuilding(m_sim.getFactoryState(), id);
if (b && b->type == BuildingType::Hq) { continue; } if (b && b->type == BuildingType::Hq) { continue; }
@@ -1019,16 +1024,25 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
} }
} }
// Box-select rectangle // Box-select rectangle, drawn from the world rectangle itself and unsnapped, so
if (frame.isBoxSelecting) // the outline sits where the mouse went rather than on the tile grid
// (REQ-UI-MULTI-SELECT).
if (frame.boxWorldRect.has_value())
{ {
const QPoint tl(std::min(frame.boxStartTile.x(), frame.boxCurrentTile.x()), const QRectF selRect(
std::min(frame.boxStartTile.y(), frame.boxCurrentTile.y())); coordinates.worldToWidget(QVector2D(frame.boxWorldRect->topLeft())),
const QPoint br(std::max(frame.boxStartTile.x(), frame.boxCurrentTile.x()) + 1, coordinates.worldToWidget(QVector2D(frame.boxWorldRect->bottomRight())));
std::max(frame.boxStartTile.y(), frame.boxCurrentTile.y()) + 1); // In deconstruct mode the box marks buildings for demolition, so it is
const QRectF selRect(coordinates.tileToWidget(tl), // drawn in the deconstruct red instead of the selection color; the
coordinates.tileToWidget(br)); // tint's alpha governs only the fills it tints, never this outline
painter.setPen(QPen(m_visuals.overlays.selectionRect, 1)); // (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-BOX).
QColor rectColor = m_visuals.overlays.selectedOutline;
if (frame.buildMode.isDeconstructMode())
{
rectColor = m_visuals.overlays.deconstructTint;
rectColor.setAlpha(255);
}
painter.setPen(QPen(rectColor, 1));
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
painter.drawRect(selRect); painter.drawRect(selRect);
} }

View File

@@ -54,9 +54,11 @@ struct WorldRenderFrame
const SelectionController& selection; const SelectionController& selection;
const BuildModeController& buildMode; const BuildModeController& buildMode;
const std::vector<ActiveBeam>& beams; const std::vector<ActiveBeam>& beams;
bool isBoxSelecting; // The box being dragged, in world coordinates and normalized, or nullopt when no
QPoint boxStartTile; // drag is in progress — a press that has not passed the movement threshold is
QPoint boxCurrentTile; // still a click and offers none (REQ-UI-MULTI-SELECT). It is the same rectangle
// the view selects by, so what is drawn and what is selected cannot disagree.
std::optional<QRectF> boxWorldRect;
bool isDebugDrawEnabled; bool isDebugDrawEnabled;
}; };

View File

@@ -103,7 +103,7 @@ void BufferedBuildingContent::refreshConfiguration()
const CycleInfo cycle = getCycleInfo(target); const CycleInfo cycle = getCycleInfo(target);
RecipeLineRow::Spec summary; RecipeLineRow::Spec summary;
summary.inputs = toAmounts(cycle.perCycleInputs); summary.inputs = toAmounts(cycle.perCycleInputs);
summary.outputs = toAmounts(cycle.perCycleOutputs); summary.outputGroups = cycle.perCycleOutputGroups;
summary.durationSeconds = cycle.durationSeconds; summary.durationSeconds = cycle.durationSeconds;
m_recipeSummary->setLine(summary); m_recipeSummary->setLine(summary);
@@ -192,9 +192,20 @@ std::vector<ItemChipRow::Entry> BufferedBuildingContent::buildOutputEntries(
} }
} }
// A chip stands for a buffer, and a buffer exists for every item any group can
// produce (REQ-MAT-OUTPUT-BUFFER), so the groups are flattened here.
std::map<std::string, int> producible;
for (const std::vector<RecipeLineRow::Amount>& group : cycle.perCycleOutputGroups)
{
for (const RecipeLineRow::Amount& amount : group)
{
producible[amount.itemId] = std::max(producible[amount.itemId], amount.amount);
}
}
std::vector<ItemChipRow::Entry> entries; std::vector<ItemChipRow::Entry> entries;
for (const std::string& itemId : for (const std::string& itemId :
collectItemIds(buffered, cycle.perCycleOutputs, cycle.handledOutputs)) collectItemIds(buffered, producible, cycle.handledOutputs))
{ {
if (!getContext().sim->isItemUnlocked(itemId)) { continue; } if (!getContext().sim->isItemUnlocked(itemId)) { continue; }

View File

@@ -6,6 +6,7 @@
#include "BuildingId.h" #include "BuildingId.h"
#include "ItemChipRow.h" #include "ItemChipRow.h"
#include "RecipeLineRow.h"
#include "SelectionContent.h" #include "SelectionContent.h"
struct Building; struct Building;
@@ -31,7 +32,11 @@ protected:
struct CycleInfo struct CycleInfo
{ {
std::map<std::string, int> perCycleInputs; std::map<std::string, int> perCycleInputs;
std::map<std::string, int> perCycleOutputs; // What one cycle produces, one entry per output group (REQ-MAT-OUTPUT-GROUP), so
// the summary can state alternatives as such. The output chips are listed from
// this too, flattened: a chip stands for a buffer, and a buffer exists for every
// item any group can produce.
std::vector<std::vector<RecipeLineRow::Amount>> perCycleOutputGroups;
// Items the card lists whether or not they are currently in the buffers, for a // Items the card lists whether or not they are currently in the buffers, for a
// building whose recipe is implicit and so has nothing to name while it sits // building whose recipe is implicit and so has nothing to name while it sits

View File

@@ -1,14 +1,13 @@
#include "ItemChip.h" #include "ItemChip.h"
#include <QCursor>
#include <QFont> #include <QFont>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QPalette> #include <QPalette>
#include <QTimer>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "ItemTooltip.h" #include "ItemTooltip.h"
#include "TooltipTrigger.h"
namespace namespace
{ {
@@ -17,18 +16,12 @@ namespace
const int kCountSizeRisePt = 2; const int kCountSizeRisePt = 2;
const int kSubLineSizeDropPt = 1; const int kSubLineSizeDropPt = 1;
// How long the cursor rests on a chip before its tooltip appears, in milliseconds.
// Matches the feel of an ordinary tooltip, which this one replaces (REQ-UI-ITEM-TOOLTIP).
const int kHoverDelayMs = 500;
} // namespace } // namespace
ItemChip::ItemChip(const SelectionContext& context, const std::string& itemId, ItemChip::ItemChip(const SelectionContext& context, const std::string& itemId,
const QPixmap& icon, QWidget* parent) const QPixmap& icon, QWidget* parent)
: QWidget(parent) : QWidget(parent)
, m_context(context)
, m_itemId(itemId)
{ {
// Its own boxed chrome, drawn with palette colors like the rest of the panel's // Its own boxed chrome, drawn with palette colors like the rest of the panel's
// furniture rather than from visuals.toml, which is for world rendering. // furniture rather than from visuals.toml, which is for world rendering.
@@ -70,10 +63,18 @@ ItemChip::ItemChip(const SelectionContext& context, const std::string& itemId,
layout->addWidget(m_iconLabel); layout->addWidget(m_iconLabel);
layout->addWidget(text); layout->addWidget(text);
m_hoverTimer = new QTimer(this); // Every child is transparent to the mouse, so the chip is what the pointer is on
m_hoverTimer->setSingleShot(true); // wherever it rests on the face -- a child under it would take the chip's own enter,
m_hoverTimer->setInterval(kHoverDelayMs); // leave and press events (REQ-UI-TOOLTIP-TRIGGER).
connect(m_hoverTimer, &QTimer::timeout, this, &ItemChip::showTooltip); m_iconLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
m_countLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
m_subLineLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
text->setAttribute(Qt::WA_TransparentForMouseEvents, true);
// A chip displays a quantity and does nothing when clicked, so a click brings its
// tooltip up at once rather than waiting the hover out (REQ-UI-TOOLTIP-TRIGGER).
ItemTooltip* tooltip = new ItemTooltip(context, itemId, this);
TooltipTrigger::attach(*this, *tooltip, TooltipTrigger::Trigger::HoverAndClick);
} }
void ItemChip::setCount(const QString& count) void ItemChip::setCount(const QString& count)
@@ -86,28 +87,3 @@ void ItemChip::setSubLine(const QString& subLine)
m_subLineLabel->setText(subLine); m_subLineLabel->setText(subLine);
m_subLineLabel->setVisible(!subLine.isEmpty()); m_subLineLabel->setVisible(!subLine.isEmpty());
} }
void ItemChip::enterEvent(QEvent* event)
{
m_hoverTimer->start();
QWidget::enterEvent(event);
}
void ItemChip::leaveEvent(QEvent* event)
{
m_hoverTimer->stop();
if (m_tooltip != nullptr)
{
m_tooltip->hide();
}
QWidget::leaveEvent(event);
}
void ItemChip::showTooltip()
{
if (m_tooltip == nullptr)
{
m_tooltip = new ItemTooltip(m_context, m_itemId, this);
}
m_tooltip->showAt(QCursor::pos());
}

View File

@@ -8,16 +8,13 @@
#include "SelectionContext.h" #include "SelectionContext.h"
class ItemTooltip;
class QEvent;
class QLabel; class QLabel;
class QTimer;
// One buffered item: its icon, its count in a larger type, and a sub-line beneath the // One buffered item: its icon, its count in a larger type, and a sub-line beneath the
// count (REQ-UI-SINGLE-SELECTION). Boxed so a row of them reads as separate quantities // count (REQ-UI-SINGLE-SELECTION). Boxed so a row of them reads as separate quantities
// rather than as a run of text. // rather than as a run of text.
// //
// Hovering it explains where its item comes from (REQ-UI-ITEM-TOOLTIP). // Hovering or clicking it explains where its item comes from (REQ-UI-ITEM-TOOLTIP).
class ItemChip : public QWidget class ItemChip : public QWidget
{ {
Q_OBJECT Q_OBJECT
@@ -31,21 +28,8 @@ public:
// Left empty for an item with nothing to say beneath its count. // Left empty for an item with nothing to say beneath its count.
void setSubLine(const QString& subLine); void setSubLine(const QString& subLine);
protected:
void enterEvent(QEvent* event) override;
void leaveEvent(QEvent* event) override;
private: private:
void showTooltip(); QLabel* m_iconLabel;
QLabel* m_countLabel;
SelectionContext m_context; QLabel* m_subLineLabel;
std::string m_itemId;
QLabel* m_iconLabel;
QLabel* m_countLabel;
QLabel* m_subLineLabel;
// Held back so a cursor crossing the chip on its way elsewhere does not flash the
// tooltip, the way an ordinary tooltip is.
QTimer* m_hoverTimer;
// Built on the first hover, because most chips are never hovered at all.
ItemTooltip* m_tooltip = nullptr;
}; };

View File

@@ -1,12 +1,7 @@
#include "ItemTooltip.h" #include "ItemTooltip.h"
#include <QFont> #include <QFont>
#include <QGuiApplication>
#include <QLabel> #include <QLabel>
#include <QLayoutItem>
#include <QPalette>
#include <QRect>
#include <QScreen>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "DisplayName.h" #include "DisplayName.h"
@@ -19,10 +14,6 @@
namespace namespace
{ {
// Gap between the cursor and the tooltip's corner, in device-independent pixels. Wide
// enough that the cursor never covers the heading.
const int kCursorOffsetPx = 16;
std::vector<RecipeLineRow::Amount> toAmounts( std::vector<RecipeLineRow::Amount> toAmounts(
const std::vector<RecipeIngredient>& ingredients) const std::vector<RecipeIngredient>& ingredients)
{ {
@@ -35,85 +26,21 @@ std::vector<RecipeLineRow::Amount> toAmounts(
return amounts; return amounts;
} }
std::vector<RecipeLineRow::Amount> toAmounts(const std::vector<RecipeOutput>& outputs)
{
std::vector<RecipeLineRow::Amount> amounts;
amounts.reserve(outputs.size());
for (const RecipeOutput& output : outputs)
{
amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount });
}
return amounts;
}
// The screen the cursor is on, falling back to the primary screen when the position is
// on none of them (a cursor between two screens of different heights).
QRect availableScreenRect(const QPoint& globalPos)
{
const QScreen* screen = QGuiApplication::screenAt(globalPos);
if (screen == nullptr)
{
screen = QGuiApplication::primaryScreen();
}
return (screen != nullptr) ? screen->availableGeometry() : QRect();
}
} // namespace } // namespace
ItemTooltip::ItemTooltip(const SelectionContext& context, const std::string& itemId, ItemTooltip::ItemTooltip(const SelectionContext& context, const std::string& itemId,
QWidget* parent) QWidget* parent)
: QFrame(parent, Qt::ToolTip) : Tooltip(parent)
, m_context(context) , m_context(context)
, m_itemId(itemId) , m_itemId(itemId)
{ {
// The palette's tooltip colors behind a themed border, so it reads as a tooltip
// rather than as a stray window.
setFrameShape(QFrame::StyledPanel);
setAutoFillBackground(true);
QPalette tooltipPalette = palette();
tooltipPalette.setColor(QPalette::Window,
tooltipPalette.color(QPalette::ToolTipBase));
tooltipPalette.setColor(QPalette::WindowText,
tooltipPalette.color(QPalette::ToolTipText));
setPalette(tooltipPalette);
m_layout = new QVBoxLayout(this);
m_layout->setContentsMargins(6, 4, 6, 4);
m_layout->setSpacing(2);
} }
void ItemTooltip::showAt(const QPoint& globalPos) void ItemTooltip::refreshContent()
{ {
rebuild(); clearContent();
adjustSize(); QVBoxLayout* layout = getContentLayout();
// Kept on the screen the cursor is on: a tooltip for a chip near the right or
// bottom edge would otherwise be cut off.
const QRect screen = availableScreenRect(globalPos);
QPoint topLeft(globalPos.x() + kCursorOffsetPx, globalPos.y() + kCursorOffsetPx);
if (screen.isValid())
{
topLeft.setX(qMax(screen.left(),
qMin(topLeft.x(), screen.right() - width() + 1)));
topLeft.setY(qMax(screen.top(),
qMin(topLeft.y(), screen.bottom() - height() + 1)));
}
move(topLeft);
show();
}
void ItemTooltip::rebuild()
{
while (QLayoutItem* item = m_layout->takeAt(0))
{
if (item->widget())
{
item->widget()->deleteLater();
}
delete item;
}
// The heading names the item. For an input chip this is the only place it is named // The heading names the item. For an input chip this is the only place it is named
// at all, since such a chip carries a count and no name (REQ-UI-SINGLE-SELECTION). // at all, since such a chip carries a count and no name (REQ-UI-SINGLE-SELECTION).
@@ -122,7 +49,7 @@ void ItemTooltip::rebuild()
QFont headingFont = heading->font(); QFont headingFont = heading->font();
headingFont.setBold(true); headingFont.setBold(true);
heading->setFont(headingFont); heading->setFont(headingFont);
m_layout->addWidget(heading); layout->addWidget(heading);
heading->show(); heading->show();
const ItemProduction production = const ItemProduction production =
@@ -133,7 +60,7 @@ void ItemTooltip::rebuild()
QLabel* caption = new QLabel(production.origin == ItemOrigin::Salvaged QLabel* caption = new QLabel(production.origin == ItemOrigin::Salvaged
? tr("Salvaged from debris") ? tr("Salvaged from debris")
: tr("Produced by"), this); : tr("Produced by"), this);
m_layout->addWidget(caption); layout->addWidget(caption);
caption->show(); caption->show();
if (production.origin == ItemOrigin::Undiscovered) if (production.origin == ItemOrigin::Undiscovered)
@@ -141,7 +68,7 @@ void ItemTooltip::rebuild()
// Made somehow, but by nothing the player has unlocked -- named without being // Made somehow, but by nothing the player has unlocked -- named without being
// shown a path they cannot take yet (REQ-UI-ITEM-TOOLTIP). // shown a path they cannot take yet (REQ-UI-ITEM-TOOLTIP).
QLabel* undiscovered = new QLabel(tr("Undiscovered"), this); QLabel* undiscovered = new QLabel(tr("Undiscovered"), this);
m_layout->addWidget(undiscovered); layout->addWidget(undiscovered);
undiscovered->show(); undiscovered->show();
return; return;
} }
@@ -152,7 +79,7 @@ void ItemTooltip::rebuild()
spec.building = recipe->building; spec.building = recipe->building;
spec.name = QString::fromStdString(toDisplayName(recipe->id)); spec.name = QString::fromStdString(toDisplayName(recipe->id));
spec.inputs = toAmounts(recipe->inputs); spec.inputs = toAmounts(recipe->inputs);
spec.outputs = toAmounts(recipe->outputs); spec.outputGroups = RecipeLineRow::toOutputGroups(*recipe);
spec.durationSeconds = recipe->durationSeconds; spec.durationSeconds = recipe->durationSeconds;
// Boxed, because an item with several producers stacks several of these and a run // Boxed, because an item with several producers stacks several of these and a run
@@ -160,7 +87,7 @@ void ItemTooltip::rebuild()
RecipeLineRow* line = RecipeLineRow* line =
new RecipeLineRow(m_context.itemIcons, m_context.buildingIcons, this); new RecipeLineRow(m_context.itemIcons, m_context.buildingIcons, this);
line->setCardChrome(true); line->setCardChrome(true);
m_layout->addWidget(line); layout->addWidget(line);
line->setLine(spec); line->setLine(spec);
} }
} }

View File

@@ -2,19 +2,17 @@
#include <string> #include <string>
#include <QFrame>
#include "SelectionContext.h" #include "SelectionContext.h"
#include "Tooltip.h"
class QVBoxLayout; // The tooltip an item chip shows: the item's name, and every way the player can currently
// produce it, each drawn as a recipe line (REQ-UI-ITEM-TOOLTIP).
// The tooltip an item chip shows on hover: the item's name, and every way the player
// can currently produce it, each drawn as a recipe line (REQ-UI-ITEM-TOOLTIP).
// //
// A window of its own rather than a Qt tooltip, because Qt's are text and this one is // Its content is drawn -- item squares, building chips and all -- rather than written,
// drawn -- item squares, building chips and all. It is a Qt::ToolTip window, so it // which is what it adds to the plain text tooltip it derives from. Rebuilt on every show,
// floats above everything and takes no input. // because what produces an item changes as the player unlocks recipes
class ItemTooltip : public QFrame // (REQ-LOCK-UI-RECIPE).
class ItemTooltip : public Tooltip
{ {
Q_OBJECT Q_OBJECT
@@ -22,15 +20,10 @@ public:
ItemTooltip(const SelectionContext& context, const std::string& itemId, ItemTooltip(const SelectionContext& context, const std::string& itemId,
QWidget* parent = nullptr); QWidget* parent = nullptr);
// Rebuilds the content and shows the tooltip near the given screen position, kept protected:
// inside the screen. Rebuilt on every show because what produces an item changes as void refreshContent() override;
// the player unlocks recipes (REQ-LOCK-UI-RECIPE).
void showAt(const QPoint& globalPos);
private: private:
void rebuild();
SelectionContext m_context; SelectionContext m_context;
std::string m_itemId; std::string m_itemId;
QVBoxLayout* m_layout;
}; };

View File

@@ -41,10 +41,7 @@ BufferedBuildingContent::CycleInfo RecipeProductionContent::getCycleInfo(
{ {
info.perCycleInputs[ingredient.item] = ingredient.amount; info.perCycleInputs[ingredient.item] = ingredient.amount;
} }
for (const RecipeOutput& output : recipe->outputs) info.perCycleOutputGroups = RecipeLineRow::toOutputGroups(*recipe);
{
info.perCycleOutputs[output.item] = output.amount;
}
info.runsProduction = true; info.runsProduction = true;
info.durationSeconds = recipe->durationSeconds; info.durationSeconds = recipe->durationSeconds;
return info; return info;

View File

@@ -44,11 +44,10 @@ void ShipyardContent::refreshControls(const BuildingTarget& target)
// The preview and Configure button are always shown for a shipyard and are only // The preview and Configure button are always shown for a shipyard and are only
// enabled once a schematic is selected (REQ-MOD-UI-PREVIEW). The schematic arrives // enabled once a schematic is selected (REQ-MOD-UI-PREVIEW). The schematic arrives
// by queued command, so this refresh is what picks it up rather than the click that // by queued command, so this refresh is what picks it up rather than the click that
// chose it. // chose it. Same question the dialog's own entry points ask, asked once.
const ShipDef* shipDef = target.recipeId.empty() const ShipDef* shipDef =
? nullptr getContext().config->ships.findLayoutShipDef(target.recipeId);
: getContext().config->ships.findShipDef(target.recipeId); const bool hasSchematic = shipDef != nullptr;
const bool hasSchematic = shipDef && !shipDef->layout.empty();
if (hasSchematic) if (hasSchematic)
{ {
m_layoutPreview->setShipAndLayout( m_layoutPreview->setShipAndLayout(

View File

@@ -57,9 +57,9 @@ std::vector<std::string> getAllItemIds(const RecipesConfig& recipes)
{ {
seen.insert(ingredient.item); seen.insert(ingredient.item);
} }
for (const RecipeOutput& output : recipe.outputs) for (const std::string& item : getProducibleItems(recipe))
{ {
seen.insert(output.item); seen.insert(item);
} }
} }
return std::vector<std::string>(seen.begin(), seen.end()); return std::vector<std::string>(seen.begin(), seen.end());

View File

@@ -58,6 +58,22 @@ def consumes_scrap(recipe):
return any(inp["item"] == "scrap" for inp in recipe.get("inputs", [])) return any(inp["item"] == "scrap" for inp in recipe.get("inputs", []))
def output_groups(recipe):
"""The recipe's output groups, whichever form the config writes them in.
`outputs = [...]` is the single-group shorthand; `[[recipe.output_group]]` is the
several-group form (REQ-MAT-OUTPUT-GROUP). A cycle yields exactly one group.
"""
if "output_group" in recipe:
return recipe["output_group"]
return [{"items": recipe.get("outputs", [])}]
def picks_one_of_several(recipe):
"""True when a cycle picks between groups, which is what makes a yield random."""
return len(output_groups(recipe)) > 1
def recipe_threat_per_unit(recipe, output, item_threat): def recipe_threat_per_unit(recipe, output, item_threat):
threat = recipe["duration_seconds"] threat = recipe["duration_seconds"]
for inp in recipe.get("inputs", []): for inp in recipe.get("inputs", []):
@@ -69,15 +85,18 @@ def recipe_threat_per_unit(recipe, output, item_threat):
def resolve_items(recipes, scrap_threat): def resolve_items(recipes, scrap_threat):
"""Return {item: threat} resolved per REQ-THREAT-ITEM.""" """Return {item: threat} resolved per REQ-THREAT-ITEM."""
non_repro = [r for r in recipes if r["building"] != "reprocessing_plant"] # What decides the model is the recipe's shape, not the building running it: a recipe
repro = [r for r in recipes if r["building"] == "reprocessing_plant"] # picking between groups costs its items by their odds, one yielding a single group
# every cycle costs them outright (REQ-MAT-OUTPUT-GROUP, REQ-THREAT-ITEM).
non_repro = [r for r in recipes if not picks_one_of_several(r)]
repro = [r for r in recipes if picks_one_of_several(r)]
# Items with at least one scrap-free producer: their scrap-consuming # Items with at least one scrap-free producer: their scrap-consuming
# recipes never participate (fallback rule). # recipes never participate (fallback rule).
scrap_free_items = set() scrap_free_items = set()
for recipe in non_repro: for recipe in non_repro:
if not consumes_scrap(recipe): if not consumes_scrap(recipe):
for output in recipe.get("outputs", []): for output in output_groups(recipe)[0]["items"]:
scrap_free_items.add(output["item"]) scrap_free_items.add(output["item"])
def eligible(recipe, output): def eligible(recipe, output):
@@ -93,7 +112,7 @@ def resolve_items(recipes, scrap_threat):
# pass earlier than the base path would win and underprice the item. # pass earlier than the base path would win and underprice the item.
recipes_per_item = {} recipes_per_item = {}
for recipe in non_repro: for recipe in non_repro:
for output in recipe.get("outputs", []): for output in output_groups(recipe)[0]["items"]:
if eligible(recipe, output): if eligible(recipe, output):
recipes_per_item.setdefault(output["item"], []).append( recipes_per_item.setdefault(output["item"], []).append(
(recipe, output)) (recipe, output))
@@ -121,22 +140,28 @@ def resolve_items(recipes, scrap_threat):
for recipe in repro: for recipe in repro:
scrap_per_cycle = sum(inp["amount"] scrap_per_cycle = sum(inp["amount"]
for inp in recipe.get("inputs", [])) for inp in recipe.get("inputs", []))
total_weight = sum(out.get("probability", 1.0) groups = output_groups(recipe)
for out in recipe.get("outputs", [])) total_weight = sum(g.get("probability", 1.0) for g in groups)
for output in recipe.get("outputs", []): for group in groups:
# Reprocessing defines an item's threat only when nothing probability = group.get("probability", 1.0) / total_weight
# else produces it (REQ-THREAT-ITEM).
if output["item"] in item_threat:
continue
if output["item"] in scrap_free_items:
continue
probability = output.get("probability", 1.0) / total_weight
if probability <= 0.0: if probability <= 0.0:
continue continue
item_threat[output["item"]] = ( for output in group["items"]:
(scrap_threat * scrap_per_cycle # This model defines an item's threat only when nothing else
+ recipe["duration_seconds"]) / probability) # produces it (REQ-THREAT-ITEM).
progress = True if output["item"] in item_threat:
continue
if output["item"] in scrap_free_items:
continue
# Per unit: the cycle's cost over the odds of getting this group at
# all, then over how many units the group yields.
divisor = probability * output["amount"]
if divisor <= 0.0:
continue
item_threat[output["item"]] = (
(scrap_threat * scrap_per_cycle
+ recipe["duration_seconds"]) / divisor)
progress = True
return progress return progress
# Iterate to a fixpoint: items downstream of reprocessing-only items # Iterate to a fixpoint: items downstream of reprocessing-only items
@@ -225,9 +250,10 @@ def main():
" building) ==") " building) ==")
producers = {} # item -> [(recipe id, items/s per building)] producers = {} # item -> [(recipe id, items/s per building)]
for recipe in recipes: for recipe in recipes:
if recipe["building"] == "reprocessing_plant": # A recipe that picks between groups has no steady per-item rate to quote.
if picks_one_of_several(recipe):
continue continue
for output in recipe.get("outputs", []): for output in output_groups(recipe)[0]["items"]:
rate = output["amount"] / recipe["duration_seconds"] rate = output["amount"] / recipe["duration_seconds"]
producers.setdefault(output["item"], []).append((recipe["id"], rate)) producers.setdefault(output["item"], []).append((recipe["id"], rate))
for recipe in recipes: for recipe in recipes:

View File

@@ -47,6 +47,17 @@ def load_toml(path):
return toml.load(path) return toml.load(path)
def recipe_outputs(recipe):
"""Every item the recipe can produce, across all of its output groups.
`outputs = [...]` is the single-group shorthand; `[[recipe.output_group]]` is the
several-group form (REQ-MAT-OUTPUT-GROUP).
"""
if "output_group" in recipe:
return [out for group in recipe["output_group"] for out in group["items"]]
return recipe.get("outputs", [])
def main(): def main():
default_dir = os.path.normpath(os.path.join( default_dir = os.path.normpath(os.path.join(
os.path.dirname(os.path.abspath(__file__)), os.path.dirname(os.path.abspath(__file__)),
@@ -67,7 +78,7 @@ def main():
consumed = {} # item id -> [consumer descriptions] consumed = {} # item id -> [consumer descriptions]
for recipe in recipes: for recipe in recipes:
for output in recipe.get("outputs", []): for output in recipe_outputs(recipe):
produced.setdefault(output["item"], []).append( produced.setdefault(output["item"], []).append(
"recipe '{}'".format(recipe["id"])) "recipe '{}'".format(recipe["id"]))
for inp in recipe.get("inputs", []): for inp in recipe.get("inputs", []):