Compare commits
12 Commits
0.1.2
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| 392a2b8d00 | |||
| abaccc45b5 | |||
| b2148f00ac | |||
| 9490a96e12 | |||
| fd0c246bc0 | |||
| 9c275e283c | |||
| 41c45d73ce | |||
| f39e4f3506 | |||
| 3c63205c6c | |||
| a1c567715e | |||
| 1cf7c264c1 | |||
| d36e59fd26 |
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Config files use the TOML format. The following config files drive game paramete
|
|||||||
|
|
||||||
- **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 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).
|
||||||
- **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.
|
||||||
@@ -123,7 +123,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- 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. 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
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -387,12 +392,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).
|
||||||
|
|
||||||
@@ -458,19 +465,19 @@ The screen is a single column: a header bar across the top and the game world vi
|
|||||||
- 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.
|
||||||
@@ -583,24 +590,28 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
|
|||||||
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 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 (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-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. 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 **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 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.
|
||||||
@@ -659,7 +670,7 @@ 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` | — |
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -126,7 +138,7 @@ private:
|
|||||||
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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -109,7 +109,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).
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
34
src/lib/core/SelectionBox.h
Normal file
34
src/lib/core/SelectionBox.h
Normal 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()));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -154,24 +154,12 @@ 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)
|
|
||||||
{
|
|
||||||
if (!outputBufferHasRoom(b, ItemType{out.item}, out.amount))
|
|
||||||
{
|
|
||||||
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;
|
std::map<ItemType, int> perCycle;
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
for (const RecipeOutput& out : group.items)
|
||||||
{
|
{
|
||||||
perCycle[ItemType{out.item}] += out.amount;
|
perCycle[ItemType{out.item}] += out.amount;
|
||||||
}
|
}
|
||||||
@@ -182,6 +170,7 @@ bool recipeOutputsFit(const Building& b, const RecipeDef& recipe)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
{
|
||||||
|
const double probability = group.probability.value_or(1.0) / totalWeight;
|
||||||
|
for (const RecipeOutput& out : group.items)
|
||||||
{
|
{
|
||||||
RecipeRef ref;
|
RecipeRef ref;
|
||||||
ref.recipe = &recipe;
|
ref.recipe = &recipe;
|
||||||
ref.outputItem = out.item;
|
ref.outputItem = out.item;
|
||||||
ref.outputAmount = out.amount;
|
ref.outputAmount = out.amount;
|
||||||
ref.probability = out.probability.value_or(1.0) / totalWeight;
|
ref.probability = probability;
|
||||||
reprocessingRecipes[out.item].push_back(ref);
|
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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ static QRect makeBand()
|
|||||||
return QRect(0, 0, 1000, 600);
|
return QRect(0, 0, 1000, 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -1222,8 +1236,9 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
|||||||
// 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,38 @@ void GameWorldView::selectInBox(bool additive)
|
|||||||
if (!additive) { m_selection.clearAll(); }
|
if (!additive) { m_selection.clearAll(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameWorldView::publishSelectionAnchor(SelectionMode mode,
|
bool GameWorldView::isHoverLive() const
|
||||||
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
|
// underMouse() is false while the cursor sits on one of the floating panels,
|
||||||
// starting one, and the panel stays where it was put (REQ-UI-SELECTION-PANEL).
|
// which are siblings of this widget rather than children, and while it is
|
||||||
const bool startsSelection =
|
// outside the window. A drag holding the button is the exception: it tracks the
|
||||||
(mode == SelectionMode::Replace) || (m_selection.getSelectedBuildings().empty()
|
// cursor wherever it goes until the button comes back up (REQ-UI-MULTI-SELECT,
|
||||||
&& m_selection.getSelectedActors().empty()
|
// REQ-BLD-BELT-DRAG).
|
||||||
&& m_selection.getSelectedDebris().empty());
|
return underMouse() || m_boxSelecting || m_buildMode.isDraggingBelt();
|
||||||
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)
|
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 +1406,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 +1499,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 +1582,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 +1597,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())
|
||||||
@@ -1639,6 +1727,10 @@ 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)
|
||||||
{
|
{
|
||||||
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*/)
|
||||||
@@ -1649,11 +1741,13 @@ 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*/)
|
||||||
{
|
{
|
||||||
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)
|
||||||
{
|
{
|
||||||
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*/)
|
||||||
|
|||||||
@@ -226,6 +226,32 @@ 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);
|
||||||
|
// 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 +286,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 +329,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 +344,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
@@ -147,7 +137,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 =
|
||||||
|
|||||||
@@ -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
|
||||||
|
// siblings in the same parent.
|
||||||
m_anchorRect = event->rectPx;
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -35,16 +35,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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The screen the cursor is on, falling back to the primary screen when the position is
|
// 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).
|
// on none of them (a cursor between two screens of different heights).
|
||||||
@@ -152,7 +142,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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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,21 +140,27 @@ 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 probability <= 0.0:
|
||||||
|
continue
|
||||||
|
for output in group["items"]:
|
||||||
|
# This model defines an item's threat only when nothing else
|
||||||
|
# produces it (REQ-THREAT-ITEM).
|
||||||
if output["item"] in item_threat:
|
if output["item"] in item_threat:
|
||||||
continue
|
continue
|
||||||
if output["item"] in scrap_free_items:
|
if output["item"] in scrap_free_items:
|
||||||
continue
|
continue
|
||||||
probability = output.get("probability", 1.0) / total_weight
|
# Per unit: the cycle's cost over the odds of getting this group at
|
||||||
if probability <= 0.0:
|
# all, then over how many units the group yields.
|
||||||
|
divisor = probability * output["amount"]
|
||||||
|
if divisor <= 0.0:
|
||||||
continue
|
continue
|
||||||
item_threat[output["item"]] = (
|
item_threat[output["item"]] = (
|
||||||
(scrap_threat * scrap_per_cycle
|
(scrap_threat * scrap_per_cycle
|
||||||
+ recipe["duration_seconds"]) / probability)
|
+ recipe["duration_seconds"]) / divisor)
|
||||||
progress = True
|
progress = True
|
||||||
return progress
|
return progress
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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", []):
|
||||||
|
|||||||
Reference in New Issue
Block a user