Compare commits
107 Commits
370a3036c1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a1c567715e | |||
| 1cf7c264c1 | |||
| d36e59fd26 | |||
| 9bbade2420 | |||
| 4ee6438405 | |||
| 0b859bd1a4 | |||
| dd061082fb | |||
| b6b6acb99c | |||
| 7e670fc01c | |||
| 28656bea7e | |||
| bd8fe5157e | |||
| a3fddf63cc | |||
| 1ddd830476 | |||
| f255224ccd | |||
| 3274df91d4 | |||
| d1da4b2937 | |||
| 37378e3c1b | |||
| 37a7a7499a | |||
| 5794a51c9a | |||
| 09858f4b62 | |||
| ca49b9dbec | |||
| 79650ae211 | |||
| c0b009c548 | |||
| 7ae5f8c4dc | |||
| 9f574fa05e | |||
| e5d689c8d8 | |||
| 0b8f5eccd6 | |||
| 40415e663b | |||
| 7a098edf72 | |||
| 365641feb7 | |||
| 4b5e288632 | |||
| cab7244b00 | |||
| 980fad272f | |||
| 68a43a7d98 | |||
| 1f754de431 | |||
| f61f0bf761 | |||
| 075e44d295 | |||
| 2289277e12 | |||
| cc0ef856e3 | |||
| 603f8063e9 | |||
| 3b37b0ecf8 | |||
| 98deab932a | |||
| 08d8b0dd90 | |||
| 9a3b6c10d6 | |||
| cd31af2611 | |||
| fd6a7c5815 | |||
| 18cfe238f6 | |||
| 2cbcf1554f | |||
| c1af58d80c | |||
| f39fe9a118 | |||
| e5dcb9de5f | |||
| dc83add5c6 | |||
| 3a1951559d | |||
| 26d7448492 | |||
| 202f583067 | |||
| d5ab44b9bf | |||
| bb1ffab8fc | |||
| e1445fe508 | |||
| caa810f66d | |||
| f6df95abb2 | |||
| fa9dbd62ad | |||
| 2af09d9eb1 | |||
| 4f7fdb8a4c | |||
| 949937d2c2 | |||
| 4c166bf47f | |||
| 60260540cd | |||
| 1f4503176b | |||
| fd85e8e10a | |||
| 114a43b205 | |||
| d87d063b10 | |||
| 537597c854 | |||
| 9c3be0fbd0 | |||
| 58b94223f7 | |||
| 1fb63cce4e | |||
| 0b7e94b4e4 | |||
| 0408336cf9 | |||
| 46932e4abf | |||
| 0edea5d961 | |||
| 60cc187d92 | |||
| 3990351a16 | |||
| bd344e4fbe | |||
| 64c344c3a3 | |||
| 02c7fed9b4 | |||
| 5d4a975384 | |||
| 475df0e5fd | |||
| 61634f6fd2 | |||
| c8ff7da345 | |||
| d664ab54cc | |||
| 906000b0e9 | |||
| d9ef6aa728 | |||
| b44a85685e | |||
| edd1c31785 | |||
| 785ce3ebfe | |||
| 7017f8b4dc | |||
| 77a842f884 | |||
| d5ba72d554 | |||
| 83177729e9 | |||
| a8e933b7f2 | |||
| e02e323cb2 | |||
| b4e622daa5 | |||
| 1150985c1f | |||
| 594c3b93c5 | |||
| 932b57720c | |||
| ca727bef35 | |||
| 553a7e0701 | |||
| af6828c348 | |||
| 3671e1d7e6 |
@@ -5,6 +5,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Interaction
|
## Interaction
|
||||||
|
|
||||||
* ONLY modify code or other files if explicitly asked to do so
|
* ONLY modify code or other files if explicitly asked to do so
|
||||||
|
* keep the tone professional, brief and to the point — brevity applies to prose and
|
||||||
|
preamble, not to the substance of an objection or a design rationale
|
||||||
|
* be critical: where there is a concrete technical reason to disagree, name it once;
|
||||||
|
if the user reaffirms, proceed with their call without re-litigating
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -31,7 +35,23 @@ keep the citation accurate.
|
|||||||
## Coding Guidelines
|
## Coding Guidelines
|
||||||
|
|
||||||
* avoid duplicate code
|
* avoid duplicate code
|
||||||
* do not use the "auto" keyword
|
* when planning a change, weigh the long-term maintainability of the codebase instead of
|
||||||
|
defaulting to the lowest-effort patch — but no speculative generality: never build or
|
||||||
|
prepare for functionality we may never need. If the maintainable solution is much larger
|
||||||
|
than the request, say so and let the user decide the scope.
|
||||||
|
* class layout: static members first, then non-static; within each, public, then protected,
|
||||||
|
then private (Qt `slots:`/`signals:` are ordinary non-static access groups). Inside an
|
||||||
|
access group the order is: nested types, static constants, aliases, methods, fields,
|
||||||
|
friends. Out-of-line method definitions follow the declaration order. Applies to new
|
||||||
|
classes and to files being edited anyway — don't reorder existing headers just to comply.
|
||||||
|
* do not use the "auto" keyword, with two exceptions:
|
||||||
|
* **named local lambdas** — a lambda's type is unnameable, and `std::function`
|
||||||
|
is not an acceptable substitute in per-tick code because it adds a heap
|
||||||
|
allocation and an indirect call
|
||||||
|
* **iterator types** — `auto it = m_buildings.find(id)` is allowed where
|
||||||
|
spelling the iterator out adds length without adding information
|
||||||
|
* everywhere else the type is written out; in particular `auto` is not used
|
||||||
|
for plain values, return values, or range-for element types
|
||||||
* use Qt utility data types (like QPoint, QVector3D, QString, etc.)
|
* use Qt utility data types (like QPoint, QVector3D, QString, etc.)
|
||||||
* wrap strings that appear in the UI with Qt's "tr()"
|
* wrap strings that appear in the UI with Qt's "tr()"
|
||||||
* use the EventManager/EventHandler instead of defining own signals and slots
|
* use the EventManager/EventHandler instead of defining own signals and slots
|
||||||
@@ -40,6 +60,13 @@ keep the citation accurate.
|
|||||||
* don't use abbreviations, except very common ones ("s" for seconds, "min", "max", etc.)
|
* don't use abbreviations, except very common ones ("s" for seconds, "min", "max", etc.)
|
||||||
* if a variable holds a value that has a unit or if a function returns a value that has a unit, append that unit to the name (e.g. "m_shipVelocity_mps", "getAcceleration_mpss()")
|
* if a variable holds a value that has a unit or if a function returns a value that has a unit, append that unit to the name (e.g. "m_shipVelocity_mps", "getAcceleration_mpss()")
|
||||||
* always enclose scopes in braces
|
* always enclose scopes in braces
|
||||||
|
* keep source files ASCII-only. A non-ASCII character needed at runtime (a glyph in a
|
||||||
|
UI string, a symbol drawn on a widget) is written as its code point with a comment
|
||||||
|
naming it (`const QChar shiftGlyph(0x21E7); // U+21E7 UPWARDS WHITE ARROW`), never
|
||||||
|
as a literal character: MSVC 2017 does not read the sources as UTF-8 by default and
|
||||||
|
silently mangles them. Never round-trip a source file through
|
||||||
|
`Get-Content`/`Set-Content` either: Windows PowerShell reads it as ANSI and writes
|
||||||
|
it back double-encoded with a BOM. Use the Edit/Write tools.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -76,6 +103,15 @@ output directories and copies the Qt DLLs.
|
|||||||
Run the app: `build/DotaFactory/Debug/app/DotaFactory.exe`, optionally
|
Run the app: `build/DotaFactory/Debug/app/DotaFactory.exe`, optionally
|
||||||
`--replay <file>` for view-only playback of a recorded run.
|
`--replay <file>` for view-only playback of a recorded run.
|
||||||
|
|
||||||
|
**Visual verification is the user's job.** Screen-capturing the app window does not
|
||||||
|
work here: `CopyFromScreen` and `PrintWindow` both return a blank white client area
|
||||||
|
even while the app is running and rendering normally, because the capture cannot read
|
||||||
|
the composited surface of the `QOpenGLWidget`-backed window. A blank capture therefore
|
||||||
|
says nothing about whether the UI works, so do not read one as a regression and do not
|
||||||
|
try to work around it. To check a UI change: build, run the tests, launch the app, and
|
||||||
|
ask the user to look at it. Redirecting the process's stdout/stderr to a file does
|
||||||
|
work and is worth checking for Qt warnings.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Catch2, single executable, links `lib` only — no QApplication, no display.
|
Catch2, single executable, links `lib` only — no QApplication, no display.
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
# realized: tier 1 ratios are 1:1, tier 2 ratios are 2:3, tier 3+ ratios
|
# realized: tier 1 ratios are 1:1, tier 2 ratios are 2:3, tier 3+ ratios
|
||||||
# are deliberately strange.
|
# are deliberately strange.
|
||||||
#
|
#
|
||||||
|
# A recipe id names the process, not the product — the display name shown in
|
||||||
|
# the UI is derived from it — so an item and the recipe that makes it never
|
||||||
|
# carry the same name.
|
||||||
|
#
|
||||||
# Input chain per game phase — each phase transition adds exactly one new
|
# Input chain per game phase — each phase transition adds exactly one new
|
||||||
# base input:
|
# base input:
|
||||||
#
|
#
|
||||||
@@ -27,21 +31,21 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "mine_iron_ore"
|
id = "iron_mining"
|
||||||
building = "miner"
|
building = "miner"
|
||||||
inputs = []
|
inputs = []
|
||||||
outputs = [{item = "iron_ore", amount = 1}]
|
outputs = [{item = "iron_ore", amount = 1}]
|
||||||
duration_seconds = 1.0
|
duration_seconds = 1.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "mine_copper_ore"
|
id = "copper_mining"
|
||||||
building = "miner"
|
building = "miner"
|
||||||
inputs = []
|
inputs = []
|
||||||
outputs = [{item = "copper_ore", amount = 1}]
|
outputs = [{item = "copper_ore", amount = 1}]
|
||||||
duration_seconds = 1.0
|
duration_seconds = 1.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "mine_quartz"
|
id = "quartz_mining"
|
||||||
building = "miner"
|
building = "miner"
|
||||||
inputs = []
|
inputs = []
|
||||||
outputs = [{item = "quartz", amount = 1}]
|
outputs = [{item = "quartz", amount = 1}]
|
||||||
@@ -52,21 +56,21 @@ duration_seconds = 2.0
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "iron_ingot"
|
id = "iron_smelting"
|
||||||
building = "smelter"
|
building = "smelter"
|
||||||
inputs = [{item = "iron_ore", amount = 1}]
|
inputs = [{item = "iron_ore", amount = 1}]
|
||||||
outputs = [{item = "iron_ingot", amount = 1}]
|
outputs = [{item = "iron_ingot", amount = 1}]
|
||||||
duration_seconds = 1.0
|
duration_seconds = 1.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "copper_ingot"
|
id = "copper_smelting"
|
||||||
building = "smelter"
|
building = "smelter"
|
||||||
inputs = [{item = "copper_ore", amount = 1}]
|
inputs = [{item = "copper_ore", amount = 1}]
|
||||||
outputs = [{item = "copper_ingot", amount = 1}]
|
outputs = [{item = "copper_ingot", amount = 1}]
|
||||||
duration_seconds = 1.0
|
duration_seconds = 1.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "silicon"
|
id = "quartz_reduction"
|
||||||
building = "smelter"
|
building = "smelter"
|
||||||
inputs = [{item = "quartz", amount = 1}]
|
inputs = [{item = "quartz", amount = 1}]
|
||||||
outputs = [{item = "silicon", amount = 1}]
|
outputs = [{item = "silicon", amount = 1}]
|
||||||
@@ -90,7 +94,7 @@ duration_seconds = 1.0
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "reprocessing_cycle"
|
id = "scrap_reprocessing"
|
||||||
building = "reprocessing_plant"
|
building = "reprocessing_plant"
|
||||||
inputs = [{item = "scrap", amount = 4}]
|
inputs = [{item = "scrap", amount = 4}]
|
||||||
duration_seconds = 4.0
|
duration_seconds = 4.0
|
||||||
@@ -120,21 +124,21 @@ duration_seconds = 4.0
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "steel_plate"
|
id = "steel_rolling"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "iron_ingot", amount = 2}]
|
inputs = [{item = "iron_ingot", amount = 2}]
|
||||||
outputs = [{item = "steel_plate", amount = 1}]
|
outputs = [{item = "steel_plate", amount = 1}]
|
||||||
duration_seconds = 3.0
|
duration_seconds = 3.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "copper_wire"
|
id = "wire_drawing"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "copper_ingot", amount = 1}]
|
inputs = [{item = "copper_ingot", amount = 1}]
|
||||||
outputs = [{item = "copper_wire", amount = 2}]
|
outputs = [{item = "copper_wire", amount = 2}]
|
||||||
duration_seconds = 1.0
|
duration_seconds = 1.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "copper_coil"
|
id = "coil_winding"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "copper_wire", amount = 2}]
|
inputs = [{item = "copper_wire", amount = 2}]
|
||||||
outputs = [{item = "copper_coil", amount = 1}]
|
outputs = [{item = "copper_coil", amount = 1}]
|
||||||
@@ -145,7 +149,7 @@ duration_seconds = 1.5
|
|||||||
# unlocked_at_start: building blocks appear in no schematic's materials, so the
|
# unlocked_at_start: building blocks appear in no schematic's materials, so the
|
||||||
# implicit item graph can never reach this recipe (REQ-LOCK-IMPLICIT).
|
# implicit item graph can never reach this recipe (REQ-LOCK-IMPLICIT).
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "building_block"
|
id = "block_pressing"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
unlocked_at_start = true
|
unlocked_at_start = true
|
||||||
inputs = [{item = "steel_plate", amount = 2}]
|
inputs = [{item = "steel_plate", amount = 2}]
|
||||||
@@ -157,14 +161,14 @@ duration_seconds = 2.0
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "control_chip"
|
id = "chip_etching"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "silicon", amount = 1}, {item = "copper_wire", amount = 2}]
|
inputs = [{item = "silicon", amount = 1}, {item = "copper_wire", amount = 2}]
|
||||||
outputs = [{item = "control_chip", amount = 1}]
|
outputs = [{item = "control_chip", amount = 1}]
|
||||||
duration_seconds = 5.0
|
duration_seconds = 5.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "capacitor_bank"
|
id = "capacitor_assembly"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "copper_coil", amount = 2}, {item = "silicon", amount = 1}]
|
inputs = [{item = "copper_coil", amount = 2}, {item = "silicon", amount = 1}]
|
||||||
outputs = [{item = "capacitor_bank", amount = 1}]
|
outputs = [{item = "capacitor_bank", amount = 1}]
|
||||||
@@ -173,21 +177,21 @@ duration_seconds = 5.0
|
|||||||
# The quality gate for m+ hulls: a deliberately long-running recipe
|
# The quality gate for m+ hulls: a deliberately long-running recipe
|
||||||
# (time-heavy archetype).
|
# (time-heavy archetype).
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "hardened_steel"
|
id = "steel_hardening"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "steel_plate", amount = 3}]
|
inputs = [{item = "steel_plate", amount = 3}]
|
||||||
outputs = [{item = "hardened_steel", amount = 1}]
|
outputs = [{item = "hardened_steel", amount = 1}]
|
||||||
duration_seconds = 12.0
|
duration_seconds = 12.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "ceramic_plate"
|
id = "ceramic_firing"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "quartz", amount = 2}]
|
inputs = [{item = "quartz", amount = 2}]
|
||||||
outputs = [{item = "ceramic_plate", amount = 1}]
|
outputs = [{item = "ceramic_plate", amount = 1}]
|
||||||
duration_seconds = 4.0
|
duration_seconds = 4.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "drive_unit"
|
id = "drive_assembly"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [
|
inputs = [
|
||||||
{item = "steel_plate", amount = 2},
|
{item = "steel_plate", amount = 2},
|
||||||
@@ -202,14 +206,14 @@ duration_seconds = 8.0
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "voidsteel_plate"
|
id = "voidsteel_forging"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "voidsteel", amount = 1}, {item = "hardened_steel", amount = 1}]
|
inputs = [{item = "voidsteel", amount = 1}, {item = "hardened_steel", amount = 1}]
|
||||||
outputs = [{item = "voidsteel_plate", amount = 1}]
|
outputs = [{item = "voidsteel_plate", amount = 1}]
|
||||||
duration_seconds = 8.0
|
duration_seconds = 8.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "capital_core"
|
id = "core_assembly"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [
|
inputs = [
|
||||||
{item = "voidsteel", amount = 2},
|
{item = "voidsteel", amount = 2},
|
||||||
@@ -223,25 +227,26 @@ duration_seconds = 10.0
|
|||||||
# Shortcut recipes — drop-only assembler recipes, gated by unlock groups in
|
# Shortcut recipes — drop-only assembler recipes, gated by unlock groups in
|
||||||
# unlocks.toml (REQ-LOCK-EXPLICIT). Pure rewards: item threat stays defined by
|
# unlocks.toml (REQ-LOCK-EXPLICIT). Pure rewards: item threat stays defined by
|
||||||
# the base (expensive) path via the max rule, so shortcuts give real factory
|
# the base (expensive) path via the max rule, so shortcuts give real factory
|
||||||
# efficiency without shifting any balance.
|
# efficiency without shifting any balance. Named "direct_*" after the base
|
||||||
|
# process they skip a step of, so the reward reads as what it is.
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "shortcut_steel_plate"
|
id = "direct_rolling"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "iron_ore", amount = 3}]
|
inputs = [{item = "iron_ore", amount = 3}]
|
||||||
outputs = [{item = "steel_plate", amount = 1}]
|
outputs = [{item = "steel_plate", amount = 1}]
|
||||||
duration_seconds = 2.0
|
duration_seconds = 2.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "shortcut_control_chip"
|
id = "direct_etching"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "quartz", amount = 2}]
|
inputs = [{item = "quartz", amount = 2}]
|
||||||
outputs = [{item = "control_chip", amount = 1}]
|
outputs = [{item = "control_chip", amount = 1}]
|
||||||
duration_seconds = 4.0
|
duration_seconds = 4.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "shortcut_hardened_steel"
|
id = "direct_hardening"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "iron_ingot", amount = 4}]
|
inputs = [{item = "iron_ingot", amount = 4}]
|
||||||
outputs = [{item = "hardened_steel", amount = 1}]
|
outputs = [{item = "hardened_steel", amount = 1}]
|
||||||
@@ -252,35 +257,35 @@ duration_seconds = 8.0
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "drone_hull"
|
id = "drone_framing"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "iron_ingot", amount = 1}]
|
inputs = [{item = "iron_ingot", amount = 1}]
|
||||||
outputs = [{item = "drone_hull", amount = 1}]
|
outputs = [{item = "drone_hull", amount = 1}]
|
||||||
duration_seconds = 1.0
|
duration_seconds = 1.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "frigate_hull"
|
id = "frigate_framing"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "steel_plate", amount = 2}, {item = "copper_wire", amount = 1}]
|
inputs = [{item = "steel_plate", amount = 2}, {item = "copper_wire", amount = 1}]
|
||||||
outputs = [{item = "frigate_hull", amount = 1}]
|
outputs = [{item = "frigate_hull", amount = 1}]
|
||||||
duration_seconds = 2.0
|
duration_seconds = 2.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "destroyer_hull"
|
id = "destroyer_framing"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "steel_plate", amount = 3}, {item = "copper_coil", amount = 2}]
|
inputs = [{item = "steel_plate", amount = 3}, {item = "copper_coil", amount = 2}]
|
||||||
outputs = [{item = "destroyer_hull", amount = 1}]
|
outputs = [{item = "destroyer_hull", amount = 1}]
|
||||||
duration_seconds = 4.0
|
duration_seconds = 4.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "cruiser_hull"
|
id = "cruiser_framing"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "hardened_steel", amount = 2}, {item = "control_chip", amount = 2}]
|
inputs = [{item = "hardened_steel", amount = 2}, {item = "control_chip", amount = 2}]
|
||||||
outputs = [{item = "cruiser_hull", amount = 1}]
|
outputs = [{item = "cruiser_hull", amount = 1}]
|
||||||
duration_seconds = 6.0
|
duration_seconds = 6.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "battlecruiser_hull"
|
id = "battlecruiser_framing"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [
|
inputs = [
|
||||||
{item = "hardened_steel", amount = 3},
|
{item = "hardened_steel", amount = 3},
|
||||||
@@ -291,7 +296,7 @@ outputs = [{item = "battlecruiser_hull", amount = 1}]
|
|||||||
duration_seconds = 8.0
|
duration_seconds = 8.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "battleship_hull"
|
id = "battleship_framing"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [
|
inputs = [
|
||||||
{item = "voidsteel_plate", amount = 3},
|
{item = "voidsteel_plate", amount = 3},
|
||||||
@@ -302,7 +307,7 @@ outputs = [{item = "battleship_hull", amount = 1}]
|
|||||||
duration_seconds = 10.0
|
duration_seconds = 10.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "dreadnought_hull"
|
id = "dreadnought_framing"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [
|
inputs = [
|
||||||
{item = "voidsteel_plate", amount = 5},
|
{item = "voidsteel_plate", amount = 5},
|
||||||
@@ -313,7 +318,7 @@ outputs = [{item = "dreadnought_hull", amount = 1}]
|
|||||||
duration_seconds = 12.0
|
duration_seconds = 12.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "carrier_hull"
|
id = "carrier_framing"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [
|
inputs = [
|
||||||
{item = "voidsteel_plate", amount = 5},
|
{item = "voidsteel_plate", amount = 5},
|
||||||
@@ -328,21 +333,21 @@ duration_seconds = 12.0
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "railgun_s_module"
|
id = "railgun_s_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "copper_coil", amount = 1}]
|
inputs = [{item = "copper_coil", amount = 1}]
|
||||||
outputs = [{item = "railgun_s_module", amount = 1}]
|
outputs = [{item = "railgun_s_module", amount = 1}]
|
||||||
duration_seconds = 1.0
|
duration_seconds = 1.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "salvager_module"
|
id = "salvager_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_wire", amount = 2}]
|
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_wire", amount = 2}]
|
||||||
outputs = [{item = "salvager_module", amount = 1}]
|
outputs = [{item = "salvager_module", amount = 1}]
|
||||||
duration_seconds = 2.0
|
duration_seconds = 2.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "repair_tool_module"
|
id = "repair_tool_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_wire", amount = 2}]
|
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_wire", amount = 2}]
|
||||||
outputs = [{item = "repair_tool_module", amount = 1}]
|
outputs = [{item = "repair_tool_module", amount = 1}]
|
||||||
@@ -350,56 +355,56 @@ duration_seconds = 2.0
|
|||||||
|
|
||||||
# Material-heavy, fast: the armor archetype.
|
# Material-heavy, fast: the armor archetype.
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "armor_plates_module"
|
id = "armor_plates_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "steel_plate", amount = 4}]
|
inputs = [{item = "steel_plate", amount = 4}]
|
||||||
outputs = [{item = "armor_plates_module", amount = 1}]
|
outputs = [{item = "armor_plates_module", amount = 1}]
|
||||||
duration_seconds = 3.0
|
duration_seconds = 3.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "maneuvering_thrusters_module"
|
id = "maneuvering_thrusters_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_coil", amount = 1}]
|
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_coil", amount = 1}]
|
||||||
outputs = [{item = "maneuvering_thrusters_module", amount = 1}]
|
outputs = [{item = "maneuvering_thrusters_module", amount = 1}]
|
||||||
duration_seconds = 2.0
|
duration_seconds = 2.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "sensor_booster_module"
|
id = "sensor_booster_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "copper_wire", amount = 2}, {item = "copper_coil", amount = 1}]
|
inputs = [{item = "copper_wire", amount = 2}, {item = "copper_coil", amount = 1}]
|
||||||
outputs = [{item = "sensor_booster_module", amount = 1}]
|
outputs = [{item = "sensor_booster_module", amount = 1}]
|
||||||
duration_seconds = 2.0
|
duration_seconds = 2.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "afterburner_module"
|
id = "afterburner_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "copper_coil", amount = 2}, {item = "steel_plate", amount = 1}]
|
inputs = [{item = "copper_coil", amount = 2}, {item = "steel_plate", amount = 1}]
|
||||||
outputs = [{item = "afterburner_module", amount = 1}]
|
outputs = [{item = "afterburner_module", amount = 1}]
|
||||||
duration_seconds = 3.0
|
duration_seconds = 3.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "weapon_stabilizer_module"
|
id = "weapon_stabilizer_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_coil", amount = 1}]
|
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_coil", amount = 1}]
|
||||||
outputs = [{item = "weapon_stabilizer_module", amount = 1}]
|
outputs = [{item = "weapon_stabilizer_module", amount = 1}]
|
||||||
duration_seconds = 2.0
|
duration_seconds = 2.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "weapon_primer_module"
|
id = "weapon_primer_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "capacitor_bank", amount = 1}, {item = "copper_coil", amount = 1}]
|
inputs = [{item = "capacitor_bank", amount = 1}, {item = "copper_coil", amount = 1}]
|
||||||
outputs = [{item = "weapon_primer_module", amount = 1}]
|
outputs = [{item = "weapon_primer_module", amount = 1}]
|
||||||
duration_seconds = 4.0
|
duration_seconds = 4.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "weapon_upgrade_module"
|
id = "weapon_upgrade_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "control_chip", amount = 1}, {item = "copper_coil", amount = 1}]
|
inputs = [{item = "control_chip", amount = 1}, {item = "copper_coil", amount = 1}]
|
||||||
outputs = [{item = "weapon_upgrade_module", amount = 1}]
|
outputs = [{item = "weapon_upgrade_module", amount = 1}]
|
||||||
duration_seconds = 4.0
|
duration_seconds = 4.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "railgun_m_module"
|
id = "railgun_m_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [
|
inputs = [
|
||||||
{item = "capacitor_bank", amount = 1},
|
{item = "capacitor_bank", amount = 1},
|
||||||
@@ -410,7 +415,7 @@ outputs = [{item = "railgun_m_module", amount = 1}]
|
|||||||
duration_seconds = 4.0
|
duration_seconds = 4.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "drone_bay_module"
|
id = "drone_bay_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [
|
inputs = [
|
||||||
{item = "control_chip", amount = 1},
|
{item = "control_chip", amount = 1},
|
||||||
@@ -421,7 +426,7 @@ outputs = [{item = "drone_bay_module", amount = 1}]
|
|||||||
duration_seconds = 4.0
|
duration_seconds = 4.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "railgun_l_module"
|
id = "railgun_l_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [
|
inputs = [
|
||||||
{item = "capacitor_bank", amount = 1},
|
{item = "capacitor_bank", amount = 1},
|
||||||
@@ -432,7 +437,7 @@ outputs = [{item = "railgun_l_module", amount = 1}]
|
|||||||
duration_seconds = 6.0
|
duration_seconds = 6.0
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "drone_hangar_module"
|
id = "drone_hangar_kit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [
|
inputs = [
|
||||||
{item = "voidsteel_plate", amount = 1},
|
{item = "voidsteel_plate", amount = 1},
|
||||||
|
|||||||
@@ -125,16 +125,16 @@ modules = ["drone_hangar"]
|
|||||||
# --- Assembler recipes -----------------------------------------------------
|
# --- Assembler recipes -----------------------------------------------------
|
||||||
|
|
||||||
[[unlock]]
|
[[unlock]]
|
||||||
id = "shortcut_steel_plate"
|
id = "direct_rolling"
|
||||||
station_level = 1
|
station_level = 1
|
||||||
recipes = ["shortcut_steel_plate"]
|
recipes = ["direct_rolling"]
|
||||||
|
|
||||||
[[unlock]]
|
[[unlock]]
|
||||||
id = "shortcut_control_chip"
|
id = "direct_etching"
|
||||||
station_level = 2
|
station_level = 2
|
||||||
recipes = ["shortcut_control_chip"]
|
recipes = ["direct_etching"]
|
||||||
|
|
||||||
[[unlock]]
|
[[unlock]]
|
||||||
id = "shortcut_hardened_steel"
|
id = "direct_hardening"
|
||||||
station_level = 2
|
station_level = 2
|
||||||
recipes = ["shortcut_hardened_steel"]
|
recipes = ["direct_hardening"]
|
||||||
|
|||||||
@@ -109,40 +109,40 @@ glyph = "E"
|
|||||||
# --- ores ---
|
# --- ores ---
|
||||||
|
|
||||||
[items.iron_ore]
|
[items.iron_ore]
|
||||||
fill = "#8a5a4a"
|
fill = "#47271c"
|
||||||
outline = "#201010"
|
outline = "#a65b42"
|
||||||
|
|
||||||
[items.copper_ore]
|
[items.copper_ore]
|
||||||
fill = "#c47a3a"
|
fill = "#deb592"
|
||||||
outline = "#3a1a0a"
|
outline = "#826a56"
|
||||||
|
|
||||||
[items.quartz]
|
[items.quartz]
|
||||||
fill = "#e0d4f0"
|
fill = "#4f3d66"
|
||||||
outline = "#40345a"
|
outline = "#9776c4"
|
||||||
|
|
||||||
# --- smelted basics ---
|
# --- smelted basics ---
|
||||||
|
|
||||||
[items.iron_ingot]
|
[items.iron_ingot]
|
||||||
fill = "#b0b0b8"
|
fill = "#535357"
|
||||||
outline = "#202028"
|
outline = "#97979e"
|
||||||
|
|
||||||
[items.copper_ingot]
|
[items.copper_ingot]
|
||||||
fill = "#d48a4a"
|
fill = "#663810"
|
||||||
outline = "#402010"
|
outline = "#b07746"
|
||||||
|
|
||||||
[items.silicon]
|
[items.silicon]
|
||||||
fill = "#33415e"
|
fill = "#737f99"
|
||||||
outline = "#0e1420"
|
outline = "#373f4f"
|
||||||
|
|
||||||
# --- salvage loop ---
|
# --- salvage loop ---
|
||||||
|
|
||||||
[items.scrap]
|
[items.scrap]
|
||||||
fill = "#7a7268"
|
fill = "#998e81"
|
||||||
outline = "#201a14"
|
outline = "#574b3d"
|
||||||
|
|
||||||
[items.voidsteel]
|
[items.voidsteel]
|
||||||
fill = "#4a3a6a"
|
fill = "#82759c"
|
||||||
outline = "#151020"
|
outline = "#3f374f"
|
||||||
|
|
||||||
# --- basic components ---
|
# --- basic components ---
|
||||||
|
|
||||||
@@ -151,106 +151,106 @@ fill = "#e09a50"
|
|||||||
outline = "#3a2008"
|
outline = "#3a2008"
|
||||||
|
|
||||||
[items.steel_plate]
|
[items.steel_plate]
|
||||||
fill = "#8a92a0"
|
fill = "#202836"
|
||||||
outline = "#22262c"
|
outline = "#54698c"
|
||||||
|
|
||||||
[items.copper_coil]
|
[items.copper_coil]
|
||||||
fill = "#d07030"
|
fill = "#cca287"
|
||||||
outline = "#381808"
|
outline = "#755d4d"
|
||||||
|
|
||||||
[items.building_block]
|
[items.building_block]
|
||||||
fill = "#c8b070"
|
fill = "#544724"
|
||||||
outline = "#302810"
|
outline = "#a18845"
|
||||||
|
|
||||||
# --- advanced components ---
|
# --- advanced components ---
|
||||||
|
|
||||||
[items.control_chip]
|
[items.control_chip]
|
||||||
fill = "#2ea35a"
|
fill = "#74b08b"
|
||||||
outline = "#0a2a14"
|
outline = "#42634e"
|
||||||
|
|
||||||
[items.capacitor_bank]
|
[items.capacitor_bank]
|
||||||
fill = "#d0a030"
|
fill = "#d0a030"
|
||||||
outline = "#302408"
|
outline = "#302408"
|
||||||
|
|
||||||
[items.hardened_steel]
|
[items.hardened_steel]
|
||||||
fill = "#6a7280"
|
fill = "#818999"
|
||||||
outline = "#181c22"
|
outline = "#3e4859"
|
||||||
|
|
||||||
[items.ceramic_plate]
|
[items.ceramic_plate]
|
||||||
fill = "#e0d8c8"
|
fill = "#e0d8c8"
|
||||||
outline = "#3a3428"
|
outline = "#3a3428"
|
||||||
|
|
||||||
[items.drive_unit]
|
[items.drive_unit]
|
||||||
fill = "#4a6ad0"
|
fill = "#92a4de"
|
||||||
outline = "#101a38"
|
outline = "#545f80"
|
||||||
|
|
||||||
# --- capital components ---
|
# --- capital components ---
|
||||||
|
|
||||||
[items.voidsteel_plate]
|
[items.voidsteel_plate]
|
||||||
fill = "#7a5aaa"
|
fill = "#9986b5"
|
||||||
outline = "#1c1038"
|
outline = "#544766"
|
||||||
|
|
||||||
[items.capital_core]
|
[items.capital_core]
|
||||||
fill = "#b040d0"
|
fill = "#420c52"
|
||||||
outline = "#280c30"
|
outline = "#9444ab"
|
||||||
|
|
||||||
# --- module items ---
|
# --- module items ---
|
||||||
|
|
||||||
[items.railgun_s_module]
|
[items.railgun_s_module]
|
||||||
fill = "#691313"
|
fill = "#bf7e7e"
|
||||||
outline = "#f3ff4f"
|
outline = "#664343"
|
||||||
|
|
||||||
[items.railgun_m_module]
|
[items.railgun_m_module]
|
||||||
fill = "#892020"
|
fill = "#b07474"
|
||||||
outline = "#f3ff4f"
|
outline = "#593b3b"
|
||||||
|
|
||||||
[items.railgun_l_module]
|
[items.railgun_l_module]
|
||||||
fill = "#a92d2d"
|
fill = "#b07474"
|
||||||
outline = "#f3ff4f"
|
outline = "#593b3b"
|
||||||
|
|
||||||
[items.salvager_module]
|
[items.salvager_module]
|
||||||
fill = "#b2cfdd"
|
fill = "#b2cfdd"
|
||||||
outline = "#236137"
|
outline = "#236137"
|
||||||
|
|
||||||
[items.repair_tool_module]
|
[items.repair_tool_module]
|
||||||
fill = "#2e9ba3"
|
fill = "#0b4347"
|
||||||
outline = "#689275"
|
outline = "#38868c"
|
||||||
|
|
||||||
[items.armor_plates_module]
|
[items.armor_plates_module]
|
||||||
fill = "#808080"
|
fill = "#999999"
|
||||||
outline = "#202020"
|
outline = "#545454"
|
||||||
|
|
||||||
[items.sensor_booster_module]
|
[items.sensor_booster_module]
|
||||||
fill = "#40a0ff"
|
fill = "#a0c9f2"
|
||||||
outline = "#102840"
|
outline = "#607991"
|
||||||
|
|
||||||
[items.maneuvering_thrusters_module]
|
[items.maneuvering_thrusters_module]
|
||||||
fill = "#5090e0"
|
fill = "#92b4de"
|
||||||
outline = "#142438"
|
outline = "#566982"
|
||||||
|
|
||||||
[items.afterburner_module]
|
[items.afterburner_module]
|
||||||
fill = "#6080c0"
|
fill = "#1d2e52"
|
||||||
outline = "#182030"
|
outline = "#486db5"
|
||||||
|
|
||||||
[items.weapon_upgrade_module]
|
[items.weapon_upgrade_module]
|
||||||
fill = "#ff4040"
|
fill = "#ff4040"
|
||||||
outline = "#401010"
|
outline = "#401010"
|
||||||
|
|
||||||
[items.weapon_primer_module]
|
[items.weapon_primer_module]
|
||||||
fill = "#e03838"
|
fill = "#e69797"
|
||||||
outline = "#380e0e"
|
outline = "#855858"
|
||||||
|
|
||||||
[items.weapon_stabilizer_module]
|
[items.weapon_stabilizer_module]
|
||||||
fill = "#c03030"
|
fill = "#c78383"
|
||||||
outline = "#300c0c"
|
outline = "#6e4848"
|
||||||
|
|
||||||
[items.drone_bay_module]
|
[items.drone_bay_module]
|
||||||
fill = "#cc66ff"
|
fill = "#cc66ff"
|
||||||
outline = "#331040"
|
outline = "#331040"
|
||||||
|
|
||||||
[items.drone_hangar_module]
|
[items.drone_hangar_module]
|
||||||
fill = "#9933cc"
|
fill = "#8c689e"
|
||||||
outline = "#260c33"
|
outline = "#42314a"
|
||||||
|
|
||||||
# --- ship hulls (outline matches the ship's fleet color in [ships.*]) ---
|
# --- ship hulls (outline matches the ship's fleet color in [ships.*]) ---
|
||||||
|
|
||||||
@@ -283,8 +283,8 @@ fill = "#1b1b1b"
|
|||||||
outline = "#ff5533"
|
outline = "#ff5533"
|
||||||
|
|
||||||
[items.carrier_hull]
|
[items.carrier_hull]
|
||||||
fill = "#1b1b1b"
|
fill = "#310f42"
|
||||||
outline = "#cc66ff"
|
outline = "#8542a6"
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Ships
|
# Ships
|
||||||
@@ -344,11 +344,13 @@ 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
|
||||||
copy_config = "#33ccff66" # copy-settings eligible-target tint + copy/paste flash (REQ-BLD-COPY-CONFIG-FEEDBACK)
|
# box-drag selection rectangle (REQ-UI-MULTI-SELECT)
|
||||||
|
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)
|
||||||
tunnel_preview = "#00ff0055" # tunnel connection preview: matched end + tiles between (REQ-BLD-TUNNEL-MODE)
|
tunnel_preview = "#00ff0055" # tunnel connection preview: matched end + tiles between (REQ-BLD-TUNNEL-MODE)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[world]
|
[world]
|
||||||
height_tiles = 40
|
height_tiles = 30
|
||||||
refund_percentage = 100
|
refund_percentage = 100
|
||||||
deconstruction_time_seconds = 0.1
|
deconstruction_time_seconds = 0.1
|
||||||
starting_building_blocks = 200
|
starting_building_blocks = 200
|
||||||
@@ -15,7 +15,7 @@ building_blocks_tooltip = "Building blocks are the currency for construction. Sp
|
|||||||
artifact_tooltip = "Artifacts are the key to victory. Earn one by choosing the artifact reward when you destroy a set of enemy defence stations. Collect enough of them to win the game."
|
artifact_tooltip = "Artifacts are the key to victory. Earn one by choosing the artifact reward when you destroy a set of enemy defence stations. Collect enough of them to win the game."
|
||||||
|
|
||||||
[regions]
|
[regions]
|
||||||
asteroid_width_tiles = 60
|
asteroid_width_tiles = 40
|
||||||
player_buffer_width_tiles = 20
|
player_buffer_width_tiles = 20
|
||||||
contest_zone_width_tiles = 60
|
contest_zone_width_tiles = 60
|
||||||
enemy_buffer_width_tiles = 20
|
enemy_buffer_width_tiles = 20
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ Schematic drops: when an enemy station set is destroyed, the simulation generate
|
|||||||
|
|
||||||
All UI interactions — building selection, builder/blueprint mode transitions, speed changes, deconstruct mode, escape menu, layout dialog requests — are communicated via EventManager events rather than Qt signals/slots. Each event is a small struct inheriting `Event` (e.g., `SelectionChangedEvent`, `BuildingTypeSelectedEvent`, `SpeedChangeRequestedEvent`). Widgets register as `CombinedEventHandler` for the events they care about and emit events via `EventManager::sendEventImmediately()`.
|
All UI interactions — building selection, builder/blueprint mode transitions, speed changes, deconstruct mode, escape menu, layout dialog requests — are communicated via EventManager events rather than Qt signals/slots. Each event is a small struct inheriting `Event` (e.g., `SelectionChangedEvent`, `BuildingTypeSelectedEvent`, `SpeedChangeRequestedEvent`). Widgets register as `CombinedEventHandler` for the events they care about and emit events via `EventManager::sendEventImmediately()`.
|
||||||
|
|
||||||
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonGrid` → `GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView` → `BuildButtonGrid`).
|
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonBar` → `GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView` → `BuildButtonBar`).
|
||||||
|
|
||||||
### Reading Simulation State
|
### Reading Simulation State
|
||||||
|
|
||||||
@@ -124,29 +124,74 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
|
|||||||
Three product targets plus tests:
|
Three product targets plus tests:
|
||||||
|
|
||||||
- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
|
- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
|
||||||
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selected building panel, build button grid. Depends on `lib` and on Qt's OpenGL widgets module.
|
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selection panel, build button bar, controls panel. Depends on `lib` and on Qt's OpenGL widgets module.
|
||||||
|
- `ui/selection/` — the selection panel's contents. `SelectionPanel` itself only arbitrates between the two selection categories, picks a card from the catalog (`SelectionContentFactory`), and hosts one at a time; each kind of selection has its own `SelectionContent` subclass assembled from shared parts (REQ-UI-SELECTION-CARD, REQ-UI-SELECTION-CONTENT).
|
||||||
- `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
|
- `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
|
||||||
- `tests/` — Catch2 tests. Links only against `lib`.
|
- `tests/` — Catch2 tests. Links only against `lib`.
|
||||||
|
|
||||||
Directory discipline inside `lib/` keeps the internal sim/config seam clear; sim code must not reach into config parsing and vice versa.
|
Directory discipline inside `lib/` keeps the internal sim/config seam clear; sim code must not reach into config parsing and vice versa.
|
||||||
|
|
||||||
|
## Player Input
|
||||||
|
|
||||||
|
Every player control is declared once, in `lib/core/ControlAction.h`, and read by three consumers that must never disagree about it:
|
||||||
|
|
||||||
|
* **`ControlsPanel`** asks which actions apply and draws a row per action (REQ-UI-CONTROLS-CONTENT).
|
||||||
|
* **`InputMapper`** resolves a key press to an action and fires the event that action stands for.
|
||||||
|
* **`GameWorldView`** resolves a mouse gesture to an action and runs the branch that carries it out.
|
||||||
|
|
||||||
|
The file declares; it never performs. It holds no simulation access, fires no events, and names nothing — display strings live in `ui/ControlActionText.h`, which renders each badge from the binding the resolver actually matches, so a chip cannot claim a key that does nothing. What an action *does* stays in the widget that always did it: the drag state machines, hit-testing, and command enqueuing were not moved.
|
||||||
|
|
||||||
|
Three invariants are easy to break here:
|
||||||
|
|
||||||
|
* **Do not add a shortcut straight to `InputMapper`'s switch or `mousePressEvent`'s branches.** Add the action and its binding to the table; the handler switches on the resolved action. A binding added directly is invisible to the panel, which is the drift the table exists to prevent. (Build hotkeys and `F3`/`F4` are deliberate exceptions, documented in the header and in REQ-UI-CONTROLS-ACCURACY.)
|
||||||
|
* **Availability and display are different questions.** An action can be live in a context the panel does not advertise it in — `Ctrl`+click with an empty selection is the standing example. `isControlActionAvailable` answers the first, the per-context row lists answer the second, and `ControlActionTest` asserts the pairing that matters: every row's bindings resolve back to that row's action.
|
||||||
|
* **Gesture state is shared, not owned by an action.** Whether a belt drag is in progress decides what the right mouse button means, so it lives on `BuildModeController` where the resolver can see it — as does the hovered-transfer flag. `m_boxSelecting` is likewise one gesture serving two actions (box select and deconstruct area).
|
||||||
|
|
||||||
|
`ControlContext` is a plain snapshot rather than references to the live controllers, which is what keeps the rules testable without a world and stops an action reaching into the simulation: if a rule needs a fact, the fact is named in the struct and the caller supplies it. When bindings become player-configurable, only the binding tables in `ControlAction.cpp` turn from hard-coded data into loaded data.
|
||||||
|
|
||||||
## Belt Subsystem
|
## Belt Subsystem
|
||||||
|
|
||||||
Belts and splitters are their own specialized subsystem. Belt items are **not** entities — they are transient data flowing through the belt representation. They do not have identities that persist across ticks.
|
Belts and splitters are their own specialized subsystem. Belt items are **not** entities — they are transient data flowing through the belt representation. They do not have identities that persist across ticks.
|
||||||
|
|
||||||
### Public Interface
|
### Public Interface
|
||||||
|
|
||||||
Narrow and representation-agnostic:
|
`BeltSystem.h` is authoritative. The surface is wider than the original design sketch — 15 public methods in five groups, not the 5-method port interface this section used to describe:
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
class BeltSystem {
|
class BeltSystem {
|
||||||
public:
|
public:
|
||||||
bool tryPutItem(Port port, Item item);
|
// Placement — belts/splitters/tunnels are Buildings for cost and
|
||||||
|
// construction, so BuildingSystem registers and unregisters their tiles.
|
||||||
|
void placeBelt(QPoint tile, Rotation direction);
|
||||||
|
void placeTunnelEntry(QPoint tile, Rotation direction, int maxDistance);
|
||||||
|
void placeTunnelExit(QPoint tile, Rotation direction);
|
||||||
|
void placeSplitter(QPoint tile, Rotation outputA, Rotation outputB);
|
||||||
|
void removeTile(QPoint tile);
|
||||||
|
|
||||||
|
// Splitter filter configuration (REQ-BLD-SPLITTER). A splitter's filters
|
||||||
|
// live here, not on Building, so callers that re-register a tile must
|
||||||
|
// carry them across (see BuildingSystem::reregisterBeltTile).
|
||||||
|
void setSplitterFilters(QPoint tile, const std::vector<ItemType>& filterA,
|
||||||
|
const std::vector<ItemType>& filterB);
|
||||||
|
std::optional<SplitterInfo> getSplitterInfo(QPoint tile) const;
|
||||||
|
|
||||||
|
// Port interface (buildings <-> belts)
|
||||||
|
bool tryPutItem(QPoint tile, Item item, Rotation fromDir = Rotation::West);
|
||||||
std::optional<Item> tryTakeItem(Port port);
|
std::optional<Item> tryTakeItem(Port port);
|
||||||
|
std::optional<ItemType> peekItem(Port port) const;
|
||||||
|
double getProgressPerTick_tpt() const; // shared so building output items
|
||||||
|
// travel at belt speed (REQ-MAT-OUTPUT-EMERGE)
|
||||||
|
|
||||||
|
// Maintenance
|
||||||
void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR
|
void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR
|
||||||
void tick();
|
void tick();
|
||||||
|
|
||||||
|
// Rendering
|
||||||
void forEachVisualItem(QRect viewportTiles,
|
void forEachVisualItem(QRect viewportTiles,
|
||||||
std::function<void(VisualItem)> visit) const;
|
std::function<void(VisualItem)> visit) const;
|
||||||
|
|
||||||
|
// Determinism (docs/replay_design.md)
|
||||||
|
void appendChecksum(Hasher& hasher) const;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct VisualItem {
|
struct VisualItem {
|
||||||
@@ -155,12 +200,12 @@ struct VisualItem {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Buildings interact with belts only through port-level push and pull. Rendering reads only through `forEachVisualItem`. No other system ever asks "what is on tile X".
|
Item *transport* is still reached only through push and pull: `tryPutItem` / `tryTakeItem` move items, `peekItem` reveals the leading item's type but never an identity, and rendering reads only through `forEachVisualItem`. The growth is in tile **topology** — placement, removal and splitter filters — which `BuildingSystem` drives because belts are `Building`s for cost, construction and deconstruction. That coupling is real and is not going away.
|
||||||
|
|
||||||
### Implementation Strategy
|
### Implementation Strategy
|
||||||
|
|
||||||
- v1: per-tile representation. Each belt tile stores up to 2 items with a progress value in `[0, 1]` along the tile's belt direction. Sufficient for the scale this game targets.
|
- v1: per-tile representation. Each belt tile stores up to 2 items with a progress value in `[0, 1]` along the tile's belt direction. Sufficient for the scale this game targets.
|
||||||
- v2 (optional, only if v1 profiles poorly): Factorio-style belt-segment compression. Because the public interface never exposes tile-level item identity, migration is internal to the subsystem.
|
- v2 (optional, only if v1 profiles poorly): Factorio-style belt-segment compression. The migration argument still holds for the item representation, since no method exposes tile-level item identity — but a v2 would have to keep the placement and splitter-filter methods working per tile, which is a stronger constraint than this section originally implied.
|
||||||
|
|
||||||
### Rendering Note
|
### Rendering Note
|
||||||
|
|
||||||
@@ -303,7 +348,11 @@ Buildings and the belt subsystem stay outside any entity model regardless of wha
|
|||||||
|
|
||||||
## Rendering
|
## Rendering
|
||||||
|
|
||||||
The game world is rendered by a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization.
|
The game world is drawn into a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization.
|
||||||
|
|
||||||
|
The drawing itself lives in `WorldRenderer`, not in the widget. `paintGL` is a call sequence: build the frame's `WorldCoordinates`, hand the renderer a `WorldRenderFrame`, then draw the screen-anchored chrome. The split is the world-space / screen-space line, and it is exact: the renderer draws everything positioned in tiles, while everything positioned in pixels — the pause and deconstruct vignettes, the replay overlay, the debug stats panel — stays with the widget. A useful consequence is that the renderer draws no translatable text at all (its text is config-driven glyphs, ASCII port arrows, and numbers), so it needs no `tr()` and no tie to the meta-object system.
|
||||||
|
|
||||||
|
`WorldRenderFrame` is what makes the renderer independent of the widget. The renderer reads the simulation directly, but everything else it draws is interaction state the widget owns — the selection, the active build mode, live beams, the box-select rectangle. Those are gathered into the frame each `paintGL` and passed by reference, so the renderer keeps no copy that a later click could invalidate. The renderer knows nothing about input: the widget resolves clicks and hit-tests, and the renderer only draws the result.
|
||||||
|
|
||||||
### Render Loop
|
### Render Loop
|
||||||
|
|
||||||
@@ -331,9 +380,11 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly
|
|||||||
|
|
||||||
### Coordinates and Scrolling
|
### Coordinates and Scrolling
|
||||||
|
|
||||||
- `GameWorldView` holds a continuous `scrollXTiles` (float). A / D input pans this smoothly (REQ-UI-SCROLL).
|
- The horizontal view position lives in `WorldCamera` (`lib/core/`) as a continuous view-center X in tiles. A / D input pans it smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED). The camera works purely in world units — tiles and tiles/second, never pixels — which is what keeps it independent of `WorldCoordinates`; the two meet only where `GameWorldView` feeds `getViewCenterXTiles()` into the transform.
|
||||||
- At the start of `paintEvent`, a single `painter.translate(-scrollXTiles * tilePx, 0)` maps world tile units into widget pixels (`tilePx = 20`, per REQ-GW-TILE-SIZE).
|
- The camera takes no simulation dependency. Its pan limits move with asteroid expansion and with pushes, so `GameWorldView` reads them from the sim each frame and passes them in as `ScrollBounds`; the camera clamps on every `advance()`, not only when panning, so the view follows the bounds inward when they shrink. Pan *intent* is likewise passed in as a `PanDirection` rather than read from key state, so the camera is unaffected if controls later become rebindable. Both properties are what make it a plain value with unit tests (`WorldCameraTest`) — notably over the two-ramp pan-speed curve, whose overlapping-band and zero-width-band cases are otherwise easy to break unnoticed.
|
||||||
- Mouse input converts the other way: `worldX = mouseX / tilePx + scrollXTiles`; apply `floor` for a tile. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles.
|
- The world↔widget transform itself lives in `WorldCoordinates` (`lib/core/`), not in the view. It is an immutable value, built through one of two named factories that differ only in how `tilePx` and the left edge are derived; everything downstream is shared. `scrolling(...)` is the game world: `tilePx` makes the world height fill the viewport (REQ-GW-TILE-SIZE) and the view pans horizontally. `fitToWorld(...)` is the balancing tool's arena: a fixed world shown whole, so `tilePx` is the tighter of the two axis fits and there is no scroll. Being a plain value with no Qt Widgets dependency, it is unit-tested (`WorldCoordinatesTest`) even though the widgets around it are not.
|
||||||
|
- `GameWorldView::getCoordinates()` and `ArenaView::getCoordinates()` each build one per frame in `paintGL` and per event in the mouse handlers, and pass it down: every world-space `draw<X>` takes a `const WorldCoordinates&`, while the screen-space draws (vignette borders, replay overlay, debug text) take none. The snapshot is deliberately never cached in a member — a resize or a scroll would silently invalidate it.
|
||||||
|
- Conversions are per-call arithmetic rather than a `painter.translate`, because hit-testing needs the inverse (`widgetToWorld` / `widgetToTile`, flooring for a tile) as often as drawing needs the forward direction. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles, which is why the flooring must not be truncation.
|
||||||
|
|
||||||
### Culling
|
### Culling
|
||||||
|
|
||||||
@@ -343,6 +394,8 @@ The renderer iterates only entities and tiles whose world X lies within the visi
|
|||||||
|
|
||||||
Shapes are hardcoded in the renderer — a building is a rectangle per footprint tile, a ship is an oriented arrow/triangle, a belt item is a 10×10 square, scrap is a small circle, a beam is a line. These structural choices live in the `draw<X>(painter, entity)` functions of the UI and are not expected to change frequently.
|
Shapes are hardcoded in the renderer — a building is a rectangle per footprint tile, a ship is an oriented arrow/triangle, a belt item is a 10×10 square, scrap is a small circle, a beam is a line. These structural choices live in the `draw<X>(painter, entity)` functions of the UI and are not expected to change frequently.
|
||||||
|
|
||||||
|
The few shapes the game view and the balancing tool's arena view draw *identically* — the ship body, the health bar, the debris marker, the sensor-range circle — live in `ui/WorldPrimitives` as free functions over explicit values. The arena exists to eyeball combat, so it only works while a ship there looks like a ship in the game; keeping these in one place means a retuned ship shape cannot silently stop applying to the tool that measures it. The balancing target does not link the `ui` library, so it compiles that file into itself, the same way it already does for `VisualsLoader` and `ShipStatsPanel` (see `balancing/CMakeLists.txt`). Everything the two views draw differently — selection highlights, beams, target lines, and all of the factory — stays with each view; the shared set is deliberately not grown beyond shapes that are genuinely the same.
|
||||||
|
|
||||||
Colors, outline widths, glyph text, and tile tints live in a separate config file, `visuals.toml`, loaded once by the UI at startup using the same pattern and lifetime as the sim config files (see Config Loading). The file is UI-scoped: the sim does not read it and does not depend on it.
|
Colors, outline widths, glyph text, and tile tints live in a separate config file, `visuals.toml`, loaded once by the UI at startup using the same pattern and lifetime as the sim config files (see Config Loading). The file is UI-scoped: the sim does not read it and does not depend on it.
|
||||||
|
|
||||||
Sketch of `visuals.toml`:
|
Sketch of `visuals.toml`:
|
||||||
@@ -374,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"
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ move. Combat stats were tuned empirically against the arena suite in
|
|||||||
| capital_core | 2 voidsteel + 1 capacitor_bank + 1 control_chip | 10 | 1 | 240 |
|
| capital_core | 2 voidsteel + 1 capacitor_bank + 1 control_chip | 10 | 1 | 240 |
|
||||||
|
|
||||||
Shortcut recipes (drop-only; item threat stays defined by the base path
|
Shortcut recipes (drop-only; item threat stays defined by the base path
|
||||||
via the max rule): `shortcut_steel_plate` 3 iron_ore → 1 plate (2 s,
|
via the max rule): `direct_rolling` 3 iron_ore → 1 plate (2 s,
|
||||||
level 1), `shortcut_control_chip` 2 quartz → 1 chip (4 s, level 2),
|
level 1), `direct_etching` 2 quartz → 1 chip (4 s, level 2),
|
||||||
`shortcut_hardened_steel` 4 iron_ingot → 1 hardened (8 s, level 2).
|
`direct_hardening` 4 iron_ingot → 1 hardened (8 s, level 2).
|
||||||
|
|
||||||
Ratio curve realized: t1 all 1:1 (miner:smelter); t2 clean 2:3
|
Ratio curve realized: t1 all 1:1 (miner:smelter); t2 clean 2:3
|
||||||
(ingot→plate, wire→coil); t3 strange — 2:5 (silicon→chip), 3:5
|
(ingot→plate, wire→coil); t3 strange — 2:5 (silicon→chip), 3:5
|
||||||
@@ -122,13 +122,13 @@ range 120, scrap 40. Enemy station: 3000+1500x HP, 25+12x dmg,
|
|||||||
## Pacing
|
## Pacing
|
||||||
|
|
||||||
**Unlock ladder** (level → unlocks; ← marks `unlock_requires`; starting
|
**Unlock ladder** (level → unlocks; ← marks `unlock_requires`; starting
|
||||||
set at −1: drone, frigate, railgun_s, salvager, building_block recipe):
|
set at −1: drone, frigate, railgun_s, salvager, block_pressing recipe):
|
||||||
|
|
||||||
| level | ships | modules | recipes |
|
| level | ships | modules | recipes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 0 | destroyer | repair_tool, armor_plates | |
|
| 0 | destroyer | repair_tool, armor_plates | |
|
||||||
| 1 | | maneuvering_thrusters, sensor_booster | shortcut_steel_plate |
|
| 1 | | maneuvering_thrusters, sensor_booster | direct_rolling |
|
||||||
| 2 | cruiser | railgun_m, afterburner | shortcut_control_chip, shortcut_hardened_steel |
|
| 2 | cruiser | railgun_m, afterburner | direct_etching, direct_hardening |
|
||||||
| 3 | | weapon_stabilizer | |
|
| 3 | | weapon_stabilizer | |
|
||||||
| 4 | battlecruiser ← cruiser | weapon_primer, weapon_upgrade | |
|
| 4 | battlecruiser ← cruiser | weapon_primer, weapon_upgrade | |
|
||||||
| 5 | | drone_bay | |
|
| 5 | | drone_bay | |
|
||||||
|
|||||||
@@ -390,11 +390,11 @@ Reshape mutations to flow through one path; behaviour unchanged.
|
|||||||
before the tick batch (runs even at 0× → build-while-paused preserved). A drained `Reset`
|
before the tick batch (runs even at 0× → build-while-paused preserved). A drained `Reset`
|
||||||
triggers the view reset.
|
triggers the view reset.
|
||||||
- Refactored every UI mutation site: `GameWorldView` owns the `CommandManager` and enqueues
|
- Refactored every UI mutation site: `GameWorldView` owns the `CommandManager` and enqueues
|
||||||
directly; `MainWindow` and `SelectedBuildingPanel` emit `CommandRequestedEvent` (carrying a
|
directly; `MainWindow` and `SelectionPanel` emit `CommandRequestedEvent` (carrying a
|
||||||
`shared_ptr<const Command>`) which `GameWorldView` subscribes to and enqueues.
|
`shared_ptr<const Command>`) which `GameWorldView` subscribes to and enqueues.
|
||||||
- **Files:** new `lib/sim/Command.h`, `CommandManager.{h,cpp}`; `CommandRequestedEvent.h`;
|
- **Files:** new `lib/sim/Command.h`, `CommandManager.{h,cpp}`; `CommandRequestedEvent.h`;
|
||||||
`Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`,
|
`Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`,
|
||||||
`SelectedBuildingPanel.cpp`; new `CommandTest.cpp`.
|
`SelectionPanel.cpp`; new `CommandTest.cpp`.
|
||||||
- **Exit criteria:** game plays identically (including build-while-paused); determinism test
|
- **Exit criteria:** game plays identically (including build-while-paused); determinism test
|
||||||
still passes; `[command]` equivalence tests pass; no production call site can mutate the sim
|
still passes; `[command]` equivalence tests pass; no production call site can mutate the sim
|
||||||
directly (compile-enforced: the `Simulation` mutators are private, tests excepted via
|
directly (compile-enforced: the `Simulation` mutators are private, tests excepted via
|
||||||
|
|||||||
@@ -6,8 +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). Any recipe entry may optionally define `icon` (string): the id of an item whose icon represents the recipe in the recipe-selection dialog (REQ-UI-RECIPE-ICON); when omitted, the recipe's first output item is used.
|
- **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.
|
||||||
- **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.
|
||||||
@@ -91,7 +90,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
## Game World
|
## Game World
|
||||||
|
|
||||||
- REQ-GW-COORDS: Tile coordinates are integer `(x, y)`. The origin `(0, 0)` is the first column of space — the tile immediately to the right of the asteroid's right edge at game start, at the top of the world. X grows right; Y grows down. All asteroid tiles have `x < 0`; asteroid left-expansions add tiles at increasingly negative X. The origin never shifts.
|
- REQ-GW-COORDS: Tile coordinates are integer `(x, y)`. The origin `(0, 0)` is the first column of space — the tile immediately to the right of the asteroid's right edge at game start, at the top of the world. X grows right; Y grows down. All asteroid tiles have `x < 0`; asteroid left-expansions add tiles at increasingly negative X. The origin never shifts.
|
||||||
- REQ-GW-TILE-SIZE: Tiles are square. The tile size in pixels is derived automatically so that the world height (in tiles) exactly fills the game world view's height in pixels. Items on belts are rendered at half-tile size (drawn as an item icon, or a colored square as the fallback — REQ-UI-ITEM-ICON); when multiple items occupy the same tile they are spaced quarter-tile apart along the direction of travel and overlap, rendered in ascending order of progress — the least-progressed item is drawn first (bottom) and the furthest-progressed item is drawn last (on top). Items emerging from a building's output port are rendered by these same rules on that port's output belt (REQ-MAT-OUTPUT-EMERGE).
|
- REQ-GW-TILE-SIZE: Tiles are square. The tile size in pixels is derived automatically so that the world height (in tiles) exactly fills the game world view's height in pixels. Items on belts are rendered at half-tile size (drawn as a colored square carrying the item's icon, or the square alone when the item has no icon — REQ-UI-ITEM-ICON); when multiple items occupy the same tile they are spaced quarter-tile apart along the direction of travel and overlap, rendered in ascending order of progress — the least-progressed item is drawn first (bottom) and the furthest-progressed item is drawn last (on top). Items emerging from a building's output port are rendered by these same rules on that port's output belt (REQ-MAT-OUTPUT-EMERGE).
|
||||||
- REQ-GW-BELT-CAPACITY: Belt tiles and tunnel entry/exit tiles each hold up to four items simultaneously, queued one behind the other in the direction of travel. Splitter tiles hold up to four items: two unassigned items (progress < 0.5, not yet routed to an output) and one item per output slot (progress ≥ 0.5, committed to a specific output direction). Output-slot items are rendered on top of unassigned items; when both output slots are occupied, their rendering order follows the clockwise port order starting from East.
|
- REQ-GW-BELT-CAPACITY: Belt tiles and tunnel entry/exit tiles each hold up to four items simultaneously, queued one behind the other in the direction of travel. Splitter tiles hold up to four items: two unassigned items (progress < 0.5, not yet routed to an output) and one item per output slot (progress ≥ 0.5, committed to a specific output direction). Output-slot items are rendered on top of unassigned items; when both output slots are occupied, their rendering order follows the clockwise port order starting from East.
|
||||||
- REQ-GW-BELT-SPEED: Items on belts move at `world.toml [world].belt_speed_tiles_per_second` tiles per second (default 2).
|
- REQ-GW-BELT-SPEED: Items on belts move at `world.toml [world].belt_speed_tiles_per_second` tiles per second (default 2).
|
||||||
- REQ-GW-HEIGHT: The world height (in tiles) is read from `world.toml [world].height_tiles`.
|
- REQ-GW-HEIGHT: The world height (in tiles) is read from `world.toml [world].height_tiles`.
|
||||||
@@ -127,15 +126,15 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- 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 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, 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.
|
||||||
- REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. **Exception:** Tunnel Entries and Tunnel Exits are never rotated in place — re-orienting a tunnel requires deconstructing and re-placing it (REQ-BLD-TUNNEL-MODE). A tunnel ghost whose footprint coincides with an existing tunnel is therefore treated as an ordinary occupied-tile placement (invalid in normal builder mode; skipped in blueprint placement mode). This applies in both normal builder mode and blueprint placement mode; in blueprint placement mode it is evaluated per building in the blueprint independently — buildings in the blueprint whose footprint coincides with an existing same-type building or site are rotated in place, while the remaining buildings in the blueprint are placed as normal construction sites (subject to the usual validity checks and total cost).
|
- REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. **Exception:** Tunnel Entries and Tunnel Exits are never rotated in place — re-orienting a tunnel requires deconstructing and re-placing it (REQ-BLD-TUNNEL-MODE). A tunnel ghost whose footprint coincides with an existing tunnel is therefore treated as an ordinary occupied-tile placement, which is invalid. This applies **only in normal builder mode**, including the belt drag of REQ-BLD-BELT-DRAG. Blueprint placement mode never rotates an existing building in place: there, a ghost whose footprint coincides with an existing same-type building or site is a configuration-transfer target (REQ-UI-BLUEPRINT-TRANSFER), or a compatible overlap that is left untouched (REQ-UI-BLUEPRINT-OVERLAP), or else an ordinary occupied-tile overlap, which is invalid.
|
||||||
- REQ-BLD-BELT-DRAG: **Belt drag placement.** For belts, placement is a deferred drag gesture rather than immediate per-tile placement: construction sites are not placed while the cursor hovers new tiles, but only once the player releases the left mouse button. Pressing the left mouse button in the game world while in belt builder mode starts a drag anchored at the tile under the cursor. As the cursor moves, a **rectilinear (L-shaped) path** of belt tiles is computed from the anchor tile to the tile under the cursor: the path first runs along the axis **parallel to the belt's current orientation** (REQ-BLD-ROTATE) — stepping toward the cursor's coordinate on that axis to a corner tile — and then runs along the orthogonal axis to the cursor tile. When the cursor shares the anchor's row or column the path degenerates to a straight line, and when it is on the anchor tile the path is a single tile.
|
- REQ-BLD-BELT-DRAG: **Belt drag placement.** For belts, placement is a deferred drag gesture rather than immediate per-tile placement: construction sites are not placed while the cursor hovers new tiles, but only once the player releases the left mouse button. Pressing the left mouse button in the game world while in belt builder mode starts a drag anchored at the tile under the cursor. As the cursor moves, a **rectilinear (L-shaped) path** of belt tiles is computed from the anchor tile to the tile under the cursor: the path first runs along the axis **parallel to the belt's current orientation** (REQ-BLD-ROTATE) — stepping toward the cursor's coordinate on that axis to a corner tile — and then runs along the orthogonal axis to the cursor tile. When the cursor shares the anchor's row or column the path degenerates to a straight line, and when it is on the anchor tile the path is a single tile.
|
||||||
- **Snapping to a building.** When the tile under the cursor is occupied by a non-belt building or construction site (the **target**), the path does not end on that occupied tile. Instead the end tile is the tile **closest to the cursor** (by distance from the cursor position to the tile) among the tiles orthogonally adjacent to the target across one of its **input-capable edges** — any footprint edge that is not one of the target's output ports, i.e. an edge on which the target can accept an incoming item (REQ-MAT-INPUT-PORTS for buildings, REQ-MAT-ACCEPT-DIR for splitters and tunnels). The geometrically closest such tile is **always** used, even if it turns out not to be a valid belt endpoint — in that case it is previewed and applied by the ordinary rules below (invalid color and skipped if occupied by a non-belt building or invalid terrain; re-oriented if it already holds a belt). The rest of the L-shaped path is computed from the anchor to this end tile exactly as above. The end tile's belt direction points **toward the target** (across the shared input edge), overriding the "final tile keeps its incoming step" rule; this applies whether the end tile is a newly placed belt or an existing belt re-oriented in place, and is reflected both in the ghost preview and in the placement on release.
|
- **Snapping to a building.** When the tile under the cursor is occupied by a non-belt building or construction site (the **target**), the path does not end on that occupied tile. Instead the end tile is the tile **closest to the cursor** (by distance from the cursor position to the tile) among the tiles orthogonally adjacent to the target across one of its **input-capable edges** — any footprint edge that is not one of the target's output ports, i.e. an edge on which the target can accept an incoming item (REQ-MAT-INPUT-PORTS for buildings, REQ-MAT-ACCEPT-DIR for splitters and tunnels). The geometrically closest such tile is **always** used, even if it turns out not to be a valid belt endpoint — in that case it is previewed and applied by the ordinary rules below (invalid color and skipped if occupied by a non-belt building or invalid terrain; re-oriented if it already holds a belt). The rest of the L-shaped path is computed from the anchor to this end tile exactly as above. The end tile's belt direction points **toward the target** (across the shared input edge), overriding the "final tile keeps its incoming step" rule; this applies whether the end tile is a newly placed belt or an existing belt re-oriented in place, and is reflected both in the ghost preview and in the placement on release.
|
||||||
- **Rotating during the drag.** Rotating the belt with R / Shift+R (REQ-BLD-ROTATE) while a drag is in progress re-picks the path's primary axis immediately from the new orientation and re-derives the whole path from the anchor to the current cursor tile, without waiting for the next cursor movement.
|
- **Rotating during the drag.** Rotating the belt with R / Shift+R (REQ-BLD-ROTATE) while a drag is in progress re-picks the path's primary axis immediately from the new orientation and re-derives the whole path from the anchor to the current cursor tile, without waiting for the next cursor movement.
|
||||||
- **Ghosts.** While dragging, a belt ghost (REQ-BLD-GHOST) is rendered on every path tile that would be acted on, instead of a single ghost under the cursor. Each ghost is oriented to point toward the next tile along the path toward the cursor, so the path forms one connected belt run that turns at the corner (curved belts along the path auto-derive per REQ-BLD-BELT); the final tile keeps the direction of its incoming step (unless the end tile is snapped to a building, in which case it points into the target — see **Snapping to a building**), and a single-tile path keeps the belt's current orientation. A tile occupied by only an existing belt or belt construction site is a valid target — its belt is re-oriented to follow the path — and shows a normal belt ghost. A tile occupied by a non-belt building or construction site, or otherwise an invalid belt position (REQ-BLD-PLACE-VALID), is drawn in the distinct invalid color, overriding the belt coloring. A tile whose new belt is unaffordable — the cumulative cost of the belts newly placed up to and including it exceeds the global stock — shows **no ghost at all**.
|
- **Ghosts.** While dragging, a belt ghost (REQ-BLD-GHOST) is rendered on every path tile that would be acted on, instead of a single ghost under the cursor. Each ghost is oriented to point toward the next tile along the path toward the cursor, so the path forms one connected belt run that turns at the corner (curved belts along the path auto-derive per REQ-BLD-BELT); the final tile keeps the direction of its incoming step (unless the end tile is snapped to a building, in which case it points into the target — see **Snapping to a building**), and a single-tile path keeps the belt's current orientation. A tile occupied by only an existing belt or belt construction site is a valid target — its belt is re-oriented to follow the path — and shows a normal belt ghost. A tile occupied by a non-belt building or construction site, or otherwise an invalid belt position (REQ-BLD-PLACE-VALID), is drawn in the distinct invalid color, overriding the belt coloring. A tile whose new belt is unaffordable — the cumulative cost of the belts newly placed up to and including it exceeds the global stock — shows **no ghost at all**.
|
||||||
- **Placement on release.** No construction site is placed while dragging. On releasing the left mouse button, the path is applied in order (anchor to cursor): each cell occupied by only an existing belt or belt construction site has that belt re-oriented in place to its path direction, consuming no building blocks and preserving any construction progress (REQ-BLD-ROTATE-IN-PLACE); each empty, valid cell gets a new belt construction site, consuming building blocks from the global stock (REQ-BLD-COST). Cells occupied by a non-belt building or construction site, cells that are otherwise invalid (REQ-BLD-PLACE-VALID), and cells whose new belt can no longer be afforded once the running total has been spent are skipped. This supersedes the click-to-place of REQ-BLD-PLACE for belts, including both the single-tile case and multi-tile drags that pass over existing belts.
|
- **Placement on release.** No construction site is placed while dragging. On releasing the left mouse button, the path is applied in order (anchor to cursor): each cell occupied by only an existing belt or belt construction site has that belt re-oriented in place to its path direction, consuming no building blocks and preserving any construction progress (REQ-BLD-ROTATE-IN-PLACE); each empty, valid cell gets a new belt construction site, consuming building blocks from the global stock (REQ-BLD-COST). Cells occupied by a non-belt building or construction site, cells that are otherwise invalid (REQ-BLD-PLACE-VALID), and cells whose new belt can no longer be afforded once the running total has been spent are skipped. This supersedes the click-to-place of REQ-BLD-PLACE for belts, including both the single-tile case and multi-tile drags that pass over existing belts.
|
||||||
- **Right-click cancels the drag.** Right-clicking while a belt drag is in progress cancels it: the path is discarded, no construction site is placed, and belt builder mode stays active (the exception to REQ-BLD-BUILDER-MODE). Right-clicking when no drag is in progress exits builder mode as usual (REQ-BLD-BUILDER-MODE).
|
- **Right-click cancels the drag.** Right-clicking while a belt drag is in progress cancels it: the path is discarded, no construction site is placed, and belt builder mode stays active (the exception to REQ-BLD-BUILDER-MODE). Right-clicking when no drag is in progress exits builder mode as usual (REQ-BLD-BUILDER-MODE).
|
||||||
- REQ-BLD-TUNNEL-MODE: **Unified tunnel build mode.** The build button grid contains a single **Tunnel** button rather than separate Tunnel Entry and Tunnel Exit buttons (REQ-UI-BUILD-GRID), activated by that button or by hotkey 3 (REQ-UI-HOTKEYS). This one builder mode places either a Tunnel Entry or a Tunnel Exit construction site depending on the hovered position, so the player never manually chooses between the two ends. Both remain distinct building types (REQ-BLD-TUNNEL-ENTRY, REQ-BLD-TUNNEL-EXIT) with their own costs and construction; only their build-menu entry point is unified.
|
- REQ-BLD-TUNNEL-MODE: **Unified tunnel build mode.** The build button bar contains a single **Tunnel** button rather than separate Tunnel Entry and Tunnel Exit buttons (REQ-UI-BUILD-BAR), activated by that button or by hotkey 3 (REQ-UI-HOTKEYS). This one builder mode places either a Tunnel Entry or a Tunnel Exit construction site depending on the hovered position, so the player never manually chooses between the two ends. Both remain distinct building types (REQ-BLD-TUNNEL-ENTRY, REQ-BLD-TUNNEL-EXIT) with their own costs and construction; only their build-menu entry point is unified.
|
||||||
- **Default type.** The ghost (REQ-BLD-GHOST) is a **Tunnel Entry** by default; clicking places a Tunnel Entry construction site (REQ-BLD-PLACE). Rotation (REQ-BLD-ROTATE) sets the ghost's facing direction as for any building.
|
- **Default type.** The ghost (REQ-BLD-GHOST) is a **Tunnel Entry** by default; clicking places a Tunnel Entry construction site (REQ-BLD-PLACE). Rotation (REQ-BLD-ROTATE) sets the ghost's facing direction as for any building.
|
||||||
- **Exit-completion match.** While the ghost is at a valid position, the game tests whether placing a **Tunnel Exit** at the hovered tile with the current ghost rotation would pair — per the pairing rules of REQ-BLD-TUNNEL-PAIR (same facing direction, within `tunnel_max_distance`, first same-direction building along the search, nearest-claim semantics) — with an existing Tunnel Entry. If so, that Entry is the **exit-completion match** and the ghost turns into a **Tunnel Exit**; clicking then places a Tunnel Exit construction site.
|
- **Exit-completion match.** While the ghost is at a valid position, the game tests whether placing a **Tunnel Exit** at the hovered tile with the current ghost rotation would pair — per the pairing rules of REQ-BLD-TUNNEL-PAIR (same facing direction, within `tunnel_max_distance`, first same-direction building along the search, nearest-claim semantics) — with an existing Tunnel Entry. If so, that Entry is the **exit-completion match** and the ghost turns into a **Tunnel Exit**; clicking then places a Tunnel Exit construction site.
|
||||||
- **Entry-completion match.** The game also tests whether placing a **Tunnel Entry** at the hovered tile with the current ghost rotation would pair — again per REQ-BLD-TUNNEL-PAIR — with an existing Tunnel Exit. If so, that Exit is the **entry-completion match** and the ghost stays a Tunnel Entry.
|
- **Entry-completion match.** The game also tests whether placing a **Tunnel Entry** at the hovered tile with the current ghost rotation would pair — again per REQ-BLD-TUNNEL-PAIR — with an existing Tunnel Exit. If so, that Exit is the **entry-completion match** and the ghost stays a Tunnel Entry.
|
||||||
@@ -145,25 +144,23 @@ 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 or Assembler (REQ-UI-SELECT-BUTTON), 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 Selected Building 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.)
|
||||||
- REQ-BLD-COPY-CONFIG: **Copy building settings (hold Shift).** While the Shift key is held, the player can copy one building's settings onto other buildings of the same type, so several identical machines can be set up without opening each one's panel. This gesture is available only in the default selection mode; while a builder, blueprint placement, or deconstruct mode is active it is disabled, so it never clashes with placement or demolition clicks.
|
|
||||||
- **Shift + right-click** a building copies its current settings into a temporary cache, along with the building's type. The settings copied are whatever that building type supports: the selected recipe (Miner, Assembler), the selected schematic together with its module layout (Shipyard), or the two output filters (Splitter, REQ-BLD-SPLITTER). Copying succeeds only when there is something to copy — a Miner or Assembler with a recipe selected, a Shipyard with a schematic selected, or any Splitter (whose output filters, even when empty/accept-all, always constitute valid settings). Shift + right-clicking a configurable building with nothing yet selected, a building type that has no settings at all (Smelter, Reprocessing Plant, Salvage Bay, belt/tunnel tiles, the HQ), or empty world space, has no effect and leaves any existing cache unchanged.
|
|
||||||
- **Shift + left-click** a building of the **same type** as the cached one applies the cached settings to it, exactly as if the player had made that selection through the selected building panel — with the same effects as a normal selection change (buffer clearing per REQ-MAT-INPUT-BUFFER and REQ-MAT-OUTPUT-BUFFER, and, for a Shipyard, in-progress cycle cancellation per REQ-BLD-SHIPYARD). This can be repeated on any number of same-type buildings while Shift stays held. Shift + left-clicking a building of a different type than the cached one, any building while the cache is empty, or empty world space, has no effect.
|
|
||||||
- Both operational buildings and construction sites take part as source and target (REQ-BLD-SITE-CONFIG); settings applied to a construction site carry over unchanged when it finishes building.
|
|
||||||
- **Releasing Shift clears the temporary cache.** It is never persisted and does not survive Shift being released; the next copy starts fresh.
|
|
||||||
- Because the cached settings were already valid on a same-type source building, they remain valid and available on the target (a selected recipe/schematic stays unlocked per REQ-LOCK-UI-RECIPE and REQ-LOCK-UI-SCHEMATIC; splitter filter item types stay unlocked per REQ-LOCK-UI-SPLITTER).
|
|
||||||
|
|
||||||
## 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. No recipe selection required. 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 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-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one higher-level intermediate product per cycle via weighted random pick. 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). The output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING.
|
- 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-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration.
|
- 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-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. It has an output buffer whose holding capacity is defined by the `output_buffer_capacity` field of the `salvage_bay` entry in `buildings.toml` (rather than by a production cycle, since the Salvage Bay has no recipe). A ship at the bay hands over one unit of scrap per tick while the buffer has free space; a full buffer blocks further drop-off until space frees up (consistent with the buffer-full semantics of REQ-MAT-OUTPUT-BUFFER). Held scrap is pushed onto connected output belts.
|
- **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.
|
||||||
|
- **The player is always in control.** Selecting a different recipe clears the buffers (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER); that is how a building left holding part of a cycle nothing feeds any more is freed, and how a selection the player did not want is corrected. For these buildings the dialog's clearing option is captioned `(Auto)` rather than `(None)` (REQ-UI-SELECT-OPTIONS): it unsets the recipe and hands the building back to automatic selection.
|
||||||
|
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration. Confirming a layout identical to the one already configured is not a change and cancels nothing (REQ-MAT-INPUT-BUFFER).
|
||||||
|
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. It has a single output buffer — scrap is the only thing it ever holds — whose holding capacity is defined by the `output_buffer_capacity` field of the `salvage_bay` entry in `buildings.toml` (rather than by a production cycle, since the Salvage Bay has no recipe). A ship at the bay hands over one unit of scrap per tick while the buffer has free space; a full buffer blocks further drop-off until space frees up (consistent with the buffer-full semantics of REQ-MAT-OUTPUT-BUFFER). Held scrap is pushed onto connected output belts.
|
||||||
- REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED). A belt accepts items only through a non-output edge (REQ-MAT-ACCEPT-DIR).
|
- REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED). A belt accepts items only through a non-output edge (REQ-MAT-ACCEPT-DIR).
|
||||||
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Incoming items are accepted only through the splitter's non-output edges (REQ-MAT-ACCEPT-DIR). Each output can optionally have a filter (a list of item types), configurable via the selected building panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
|
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Incoming items are accepted only through the splitter's non-output edges (REQ-MAT-ACCEPT-DIR). Each output can optionally have a filter (a list of item types), configurable via the selection panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
|
||||||
- An item matching only one output's filter is routed to that output.
|
- An item matching only one output's filter is routed to that output.
|
||||||
- An item matching both outputs' filters is distributed by strict alternation between those outputs.
|
- An item matching both outputs' filters is distributed by strict alternation between those outputs.
|
||||||
- An item matching neither output's filter is routed to the unfiltered output. If both outputs have a filter and the item matches neither, the splitter stalls and moves no items until the situation is resolved.
|
- An item matching neither output's filter is routed to the unfiltered output. If both outputs have a filter and the item matches neither, the splitter stalls and moves no items until the situation is resolved.
|
||||||
@@ -194,8 +191,8 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- **Intake rendering (no pop-out).** Mirror of the emergence rendering in REQ-MAT-OUTPUT-EMERGE: the building is rendered over the input belt, so an in-transit item is occluded while inside the footprint and is only visible as it crosses the outer edge — appearing to sink into the port. The portion inside the footprint is hidden, and the item disappears at the tile centre (progress 0.5) as it enters the buffer.
|
- **Intake rendering (no pop-out).** Mirror of the emergence rendering in REQ-MAT-OUTPUT-EMERGE: the building is rendered over the input belt, so an in-transit item is occluded while inside the footprint and is only visible as it crosses the outer edge — appearing to sink into the port. The portion inside the footprint is hidden, and the item disappears at the tile centre (progress 0.5) as it enters the buffer.
|
||||||
- REQ-MAT-OUTPUT-PORT: Each building has one or more fixed output port(s) defined by its surface_mask (direction determined by rotation). Produced items do not appear on the outgoing belt instantly; each item leaves the building by first emerging across the output port tile on that port's own output belt and then transferring onto the adjacent real belt tile (REQ-MAT-OUTPUT-EMERGE). The adjacent belt's direction is otherwise unconstrained (it may flow away from the building or perpendicular to it), except that a belt oriented with its own output edge facing back into the building refuses the transfer and the item stays stuck at the port (REQ-MAT-ACCEPT-DIR, REQ-MAT-OUTPUT-EMERGE).
|
- REQ-MAT-OUTPUT-PORT: Each building has one or more fixed output port(s) defined by its surface_mask (direction determined by rotation). Produced items do not appear on the outgoing belt instantly; each item leaves the building by first emerging across the output port tile on that port's own output belt and then transferring onto the adjacent real belt tile (REQ-MAT-OUTPUT-EMERGE). The adjacent belt's direction is otherwise unconstrained (it may flow away from the building or perpendicular to it), except that a belt oriented with its own output edge facing back into the building refuses the transfer and the item stays stuck at the port (REQ-MAT-ACCEPT-DIR, REQ-MAT-OUTPUT-EMERGE).
|
||||||
- REQ-MAT-OUTPUT-EMERGE: Items emerge from a building output port as an animation rather than popping directly onto the outgoing belt. Each output port has its own **output belt** — a virtual belt tile occupying the output port tile, oriented in the port's facing direction, with progress 0.0 at the tile's inner edge and 1.0 at the outer (port) edge adjacent to the next real belt tile. It reuses the belt subsystem: movement at belt speed (REQ-GW-BELT-SPEED), item rendering and spacing (REQ-GW-TILE-SIZE), and capacity/packing (REQ-GW-BELT-CAPACITY), but restricted to the 0.5→1.0 half of the tile. This applies to every building that outputs items onto belts (Miner, Smelter, Assembler, Reprocessing Plant, Salvage Bay); it does not apply to the Shipyard, which spawns a ship rather than a belt item (REQ-SHP-SPAWN-PLAYER).
|
- REQ-MAT-OUTPUT-EMERGE: Items emerge from a building output port as an animation rather than popping directly onto the outgoing belt. Each output port has its own **output belt** — a virtual belt tile occupying the output port tile, oriented in the port's facing direction, with progress 0.0 at the tile's inner edge and 1.0 at the outer (port) edge adjacent to the next real belt tile. It reuses the belt subsystem: movement at belt speed (REQ-GW-BELT-SPEED), item rendering and spacing (REQ-GW-TILE-SIZE), and capacity/packing (REQ-GW-BELT-CAPACITY), but restricted to the 0.5→1.0 half of the tile. This applies to every building that outputs items onto belts (Miner, Smelter, Assembler, Reprocessing Plant, Salvage Bay); it does not apply to the Shipyard, which spawns a ship rather than a belt item (REQ-SHP-SPAWN-PLAYER).
|
||||||
- **Feeding.** While the output buffer (REQ-MAT-OUTPUT-BUFFER) holds an item that has not yet begun emerging and the output belt's entry slot at progress 0.5 is free (per REQ-GW-BELT-CAPACITY spacing — no emerging item within a quarter tile of progress 0.5), the next buffered item is placed on the output belt at progress 0.5. Because only the 0.5→1.0 span is used, the output belt holds at most three emerging items (progress 0.5, 0.75, 1.0); once that span is full the building places no further items on it even if the output buffer still holds more.
|
- **Feeding.** While the output buffers (REQ-MAT-OUTPUT-BUFFER) hold an item that has not yet begun emerging and the output belt's entry slot at progress 0.5 is free (per REQ-GW-BELT-CAPACITY spacing — no emerging item within a quarter tile of progress 0.5), the longest-waiting buffered item is placed on the output belt at progress 0.5. Items leave in the order they were produced whatever their type, so per-item-type buffers do not reorder what comes out of the port. Because only the 0.5→1.0 span is used, the output belt holds at most three emerging items (progress 0.5, 0.75, 1.0); once that span is full the building places no further items on it even if the output buffers still hold more.
|
||||||
- **Cosmetic hold.** An emerging item still counts as residing in the output buffer (REQ-MAT-GLOBAL-STOCK) for the whole animation; it only leaves the building when it transfers onto a real belt tile at progress 1.0. The output belt therefore adds no inventory capacity beyond the output buffer, and clearing the output buffer on a recipe or schematic change (REQ-MAT-OUTPUT-BUFFER) also removes any items currently emerging.
|
- **Cosmetic hold.** An emerging item still counts as residing in its output buffer (REQ-MAT-GLOBAL-STOCK) for the whole animation; it only leaves the building when it transfers onto a real belt tile at progress 1.0. The output belt therefore adds no inventory capacity beyond the output buffers, and clearing them on a recipe or schematic change (REQ-MAT-OUTPUT-BUFFER) also removes any items currently emerging.
|
||||||
- **Travel & handoff.** An emerging item advances from progress 0.5 to 1.0 at belt speed. At progress 1.0 it attempts to transfer onto the adjacent real belt tile using the normal belt hand-off and accept-direction rules (REQ-MAT-OUTPUT-PORT, REQ-MAT-ACCEPT-DIR): the transfer succeeds only if a transport tile exists there, is not oriented with its output edge facing back into the building, and has free space. On success the item leaves the output buffer and becomes an ordinary item on that belt tile. If instead the output port tile is a directly adjacent building's input edge, the item transfers straight into that building (REQ-MAT-DIRECT-COUPLE).
|
- **Travel & handoff.** An emerging item advances from progress 0.5 to 1.0 at belt speed. At progress 1.0 it attempts to transfer onto the adjacent real belt tile using the normal belt hand-off and accept-direction rules (REQ-MAT-OUTPUT-PORT, REQ-MAT-ACCEPT-DIR): the transfer succeeds only if a transport tile exists there, is not oriented with its output edge facing back into the building, and has free space. On success the item leaves the output buffer and becomes an ordinary item on that belt tile. If instead the output port tile is a directly adjacent building's input edge, the item transfers straight into that building (REQ-MAT-DIRECT-COUPLE).
|
||||||
- **Stuck items.** If there is no next real belt tile and no directly-coupled building (REQ-MAT-DIRECT-COUPLE), or the transfer is refused or blocked, the emerging item stops at progress 1.0 and is rendered there (still counted in the output buffer). Following items pile up behind it at progress 0.75 and 0.5 per the packing above, and once the 0.5→1.0 span is full no further items emerge until the front item transfers.
|
- **Stuck items.** If there is no next real belt tile and no directly-coupled building (REQ-MAT-DIRECT-COUPLE), or the transfer is refused or blocked, the emerging item stops at progress 1.0 and is rendered there (still counted in the output buffer). Following items pile up behind it at progress 0.75 and 0.5 per the packing above, and once the 0.5→1.0 span is full no further items emerge until the front item transfers.
|
||||||
- **Emergence rendering (no pop-in).** An emerging item must not simply appear at progress 0.5. The output port tile's building is rendered over the output belt, so an emerging item is occluded while inside the footprint and is revealed progressively as it slides past the port edge — appearing to physically emerge from the building. The portion of the item still within the output port tile is hidden; the portion past the outer edge is drawn.
|
- **Emergence rendering (no pop-in).** An emerging item must not simply appear at progress 0.5. The output port tile's building is rendered over the output belt, so an emerging item is occluded while inside the footprint and is revealed progressively as it slides past the port edge — appearing to physically emerge from the building. The portion of the item still within the output port tile is hidden; the portion past the outer edge is drawn.
|
||||||
@@ -204,9 +201,9 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- **Scope.** Direct coupling is the only case in which materials move between buildings without a belt, splitter, or tunnel (REQ-MAT-BELT-ONLY); it bridges only two buildings that are directly adjacent with meeting output/input ports. Transport tiles feeding a building (belt, splitter, or tunnel exit) continue to work through the normal pull, and a producer still hands off to a transport tile placed in the gap as before; a single such tile between two buildings is unaffected by this requirement.
|
- **Scope.** Direct coupling is the only case in which materials move between buildings without a belt, splitter, or tunnel (REQ-MAT-BELT-ONLY); it bridges only two buildings that are directly adjacent with meeting output/input ports. Transport tiles feeding a building (belt, splitter, or tunnel exit) continue to work through the normal pull, and a producer still hands off to a transport tile placed in the gap as before; a single such tile between two buildings is unaffected by this requirement.
|
||||||
- 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.
|
||||||
- REQ-MAT-OUTPUT-BUFFER: Each building has an output buffer that holds up to twice the quantity produced by one production cycle. If the output buffer is full, production stops until space is available. When the player selects a new recipe or schematic, all items in the output buffer are cleared (relevant when the adjacent belt is jammed and items have accumulated).
|
- **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-REPROCESSING: Exception to REQ-MAT-OUTPUT-BUFFER — the Reprocessing Plant's output buffer holds at most one cycle's output. This prevents exploits where the player stalls the output belt to force the plant to reroll.
|
- 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-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) the cycle's output must fit in the output buffer. For the Reprocessing Plant, the output is picked at cycle start (weighted pick); the cycle only starts if that chosen output fits. On cycle start, inputs are consumed immediately and the production timer begins. On cycle completion, the (already-decided) output is deposited into the output buffer and the building returns to idle.
|
- 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.
|
||||||
- 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
|
||||||
@@ -311,19 +308,19 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
|
|
||||||
### Module UI
|
### Module UI
|
||||||
|
|
||||||
- REQ-MOD-UI-PREVIEW: For a selected shipyard (operational building or construction site), the selected building panel always shows a small non-interactive **ship layout preview** widget below the schematic selection button (REQ-UI-SELECT-BUTTON) and a "Configure" button below the preview. Both are **disabled while no schematic is selected**, and enabled once one is; the preview then shows an empty placeholder in place of a layout grid. When a schematic is selected, the preview renders the ship's layout grid at a reduced scale: buildable cells without a module are shown as white, non-buildable cells are shown as black, and cells occupied by a module are shown in that module's `fill_color` with the module's `glyph` character. For non-shipyard buildings, neither the preview nor the "Configure" button is shown.
|
- REQ-MOD-UI-PREVIEW: For a selected shipyard (operational building or construction site), the selection panel always shows a small non-interactive **ship layout preview** widget below the schematic selection button (REQ-UI-SELECT-BUTTON) and a "Configure" button below the preview. Both are **disabled while no schematic is selected**, and enabled once one is; the preview then shows an empty placeholder in place of a layout grid. When a schematic is selected, the preview renders the ship's layout grid at a reduced scale: buildable cells without a module are shown as white, non-buildable cells are shown as black, and cells occupied by a module are shown in that module's `fill_color` alone — no glyph, which at this scale would not be legible anyway; the layout configuration dialog's full-scale grid is where a module is identified by its glyph (REQ-MOD-UI-DIALOG). For non-shipyard buildings, neither the preview nor the "Configure" button is shown.
|
||||||
- REQ-MOD-UI-DIALOG: Clicking the "Configure" button opens the **layout configuration dialog** as a modal. While the dialog is open, the game is paused (speed set to 0×). On close, the game speed is restored to what it was before the dialog was opened.
|
- REQ-MOD-UI-DIALOG: Clicking the "Configure" button opens the **layout configuration dialog** as a modal, centered on the selection panel it was opened from (REQ-UI-PANEL-MODAL). While the dialog is open, the game is paused (speed set to 0×). On close, the game speed is restored to what it was before the dialog was opened.
|
||||||
|
|
||||||
The dialog contains:
|
The dialog contains:
|
||||||
- **Top**: The ship's layout grid rendered at full scale. Buildable cells are white; non-buildable cells are black. Placed modules are rendered with their `fill_color` and `glyph`. The ghost of the currently selected module is shown at the cursor position when in placement mode.
|
- **Top**: The ship's layout grid rendered at full scale. Buildable cells are white; non-buildable cells are black. Placed modules are rendered with their `fill_color` and `glyph`. The ghost of the currently selected module is shown at the cursor position when in placement mode.
|
||||||
- **Left** (below the grid): The ship stats panel (see REQ-MOD-UI-STATS-PANEL).
|
- **Left** (below the grid): The ship stats panel (see REQ-MOD-UI-STATS-PANEL), and beneath it the layout's **build cost**, so the price of a configuration is visible while it is being assembled rather than only once it has been confirmed: the total materials required for the ship — the union of the schematic's base materials and those of every placed module, summed per item type (REQ-MOD-MATERIALS) — drawn as item icons on their colored squares with their amounts (REQ-UI-ITEM-ICON), and beside them the total production time (REQ-MOD-PRODUCTION-TIME). Both update in real time as modules are placed and removed. The cost sits beside the stats panel rather than within it because a build cost belongs to a ship being configured: the same panel serves an existing ship (REQ-UI-SHIP-STATS-PANEL) and the balancing tool, neither of which costs anything.
|
||||||
- **Center** (below the grid): A grid of module selection buttons (one per **unlocked** module type; see REQ-DEF-SCHEMATIC-DROP) plus a "Remove" button. Each module button shows the module id and its glyph.
|
- **Center** (below the grid): A grid of module selection buttons (one per **unlocked** module type; see REQ-DEF-SCHEMATIC-DROP) plus a "Remove" button. A module button shows the module's name — its `id` under the usual display convention — on its first line, and beneath it what the module costs: the icons of its `materials` items on their colored squares with their amounts (REQ-UI-ITEM-ICON) and the production time it adds, as `+<n> s` (REQ-MOD-MATERIALS, REQ-MOD-PRODUCTION-TIME). This is the same form a recipe option button uses to state what it makes (REQ-UI-SELECT-OPTIONS), and it puts the price of a module in front of the player before it is placed. The "Remove" button costs nothing and shows its caption alone.
|
||||||
- **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD).
|
- **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD).
|
||||||
- **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selected building panel are recalculated, and the ship layout preview is refreshed.
|
- **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selection panel are recalculated, and the ship layout preview is refreshed.
|
||||||
|
|
||||||
- REQ-MOD-UI-EMPTY-PULSE: While a module is selected for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG), the empty buildable cells of the layout grid pulse smoothly around their normal fill shade, oscillating between a slightly darker and a slightly brighter shade at approximately 1 Hz (one full cycle per second), to draw the player's attention to where the module can be placed. All empty buildable cells pulse in phase. When no module is selected for placement (including remove mode), empty buildable cells render at their normal static shade. Non-buildable cells and cells occupied by a placed module do not pulse.
|
- REQ-MOD-UI-EMPTY-PULSE: While a module is selected for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG), the empty buildable cells of the layout grid pulse smoothly around their normal fill shade, oscillating between a slightly darker and a slightly brighter shade at approximately 1 Hz (one full cycle per second), to draw the player's attention to where the module can be placed. All empty buildable cells pulse in phase. When no module is selected for placement (including remove mode), empty buildable cells render at their normal static shade. Non-buildable cells and cells occupied by a placed module do not pulse.
|
||||||
|
|
||||||
- REQ-MOD-UI-AUTO-DIALOG: When the player selects a schematic for a shipyard (operational building or construction site) through the schematic selection dialog (REQ-UI-SELECT-BUTTON), and the chosen schematic **differs** from the shipyard's current schematic, the layout configuration dialog (REQ-MOD-UI-DIALOG) opens automatically and immediately once the selection dialog closes — exactly as if the player had then clicked "Configure". Re-selecting the schematic already set does not reopen the dialog. This auto-open applies only to the manual schematic selection dialog; schematic changes applied via the copy-settings gesture (REQ-BLD-COPY-CONFIG) or blueprint placement (REQ-UI-BLUEPRINT-PLACE) do **not** auto-open the dialog. The player may still cancel the auto-opened dialog (REQ-MOD-UI-DIALOG), which leaves the newly selected schematic in place with its default empty layout; the "Configure" button (REQ-MOD-UI-PREVIEW) remains available to open the dialog again later.
|
- REQ-MOD-UI-AUTO-DIALOG: When the player selects a schematic for a shipyard (operational building or construction site) through the schematic selection dialog (REQ-UI-SELECT-BUTTON), and the chosen schematic **differs** from the shipyard's current schematic, the layout configuration dialog (REQ-MOD-UI-DIALOG) opens automatically and immediately once the selection dialog closes — exactly as if the player had then clicked "Configure". Re-selecting the schematic already set does not reopen the dialog. This auto-open applies only to the manual schematic selection dialog; a schematic applied by blueprint placement (REQ-UI-BLUEPRINT-PLACE) does **not** auto-open the dialog. The player may still cancel the auto-opened dialog (REQ-MOD-UI-DIALOG), which leaves the newly selected schematic in place with its default empty layout; the "Configure" button (REQ-MOD-UI-PREVIEW) remains available to open the dialog again later.
|
||||||
|
|
||||||
- REQ-MOD-UI-MODULE-TOOLTIP: Each module selection button in the layout configuration dialog (REQ-MOD-UI-DIALOG) shows a hover tooltip with the descriptive text defined for that module type in `modules.toml` (the optional per-module tooltip field). If a module type defines no tooltip text, its button shows no tooltip. The "Remove" button is not a module type and has no config-defined tooltip.
|
- REQ-MOD-UI-MODULE-TOOLTIP: Each module selection button in the layout configuration dialog (REQ-MOD-UI-DIALOG) shows a hover tooltip with the descriptive text defined for that module type in `modules.toml` (the optional per-module tooltip field). If a module type defines no tooltip text, its button shows no tooltip. The "Remove" button is not a module type and has no config-defined tooltip.
|
||||||
|
|
||||||
@@ -377,9 +374,9 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
|
|
||||||
Each option in the dialog displays the unlock group's display name — derived from its `id` (same display convention as building, module, and recipe ids) — and the list of items it would grant: its ship, module, building, and assembler-recipe ids (each shown with the same display convention as its respective selection dialog). The artifact option (if present) is displayed as a distinct entry with the name "Artifact".
|
Each option in the dialog displays the unlock group's display name — derived from its `id` (same display convention as building, module, and recipe ids) — and the list of items it would grant: its ship, module, building, and assembler-recipe ids (each shown with the same display convention as its respective selection dialog). The artifact option (if present) is displayed as a distinct entry with the name "Artifact".
|
||||||
|
|
||||||
Each option additionally displays a vertical list of recipe names labeled "Unlocks recipes:", showing which miner and assembler recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the miner recipes and implicitly-gated assembler recipes that are not currently implicitly unlocked but would become so after applying this option's effect. To compute this, all `materials` of the group's granted ship and module schematics are added to the base set per REQ-LOCK-IMPLICIT step 1a, and the output items of the group's granted assembler recipes are added per step 1b, before recomputation.
|
Each option additionally displays a vertical list of recipe lines labeled "Unlocks recipes:", showing which miner and assembler recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the miner recipes and implicitly-gated assembler recipes that are not currently implicitly unlocked but would become so after applying this option's effect. To compute this, all `materials` of the group's granted ship and module schematics are added to the base set per REQ-LOCK-IMPLICIT step 1a, and the output items of the group's granted assembler recipes are added per step 1b, before recomputation.
|
||||||
|
|
||||||
Each recipe is listed by its `id` (using the same display convention as the assembler recipe-selection dialog), sorted alphabetically. Hovering a recipe in this list displays the recipe info tooltip described for a recipe in REQ-UI-SELECT-TOOLTIP (the recipe name; the name and quantity of each input item; the completion time; and the name and quantity of the produced output item). If no recipes would be newly unlocked, the list shows "None".
|
Each recipe is shown as a **recipe line** of the same two-row card the item production tooltip uses (REQ-UI-ITEM-TOOLTIP): the icon of the building that runs it (REQ-UI-BUILD-ICON) and the recipe's name — by its `id`, using the same display convention as the assembler recipe-selection dialog — on the first row, and the recipe drawn as the recipe summary draws it (REQ-UI-RECIPE-SUMMARY) on the second. The lines are sorted alphabetically by recipe name. The line says everything there is to say about the recipe, so nothing in this list carries a tooltip, as in the selection dialog (REQ-UI-SELECT-OPTIONS). If no recipes would be newly unlocked, the list shows "None".
|
||||||
|
|
||||||
The player selects one option by clicking it. If the player selects the artifact option, the player's artifact count is incremented by 1 (REQ-WIN-ARTIFACT-COUNT) and the dialog closes; no unlock is applied. Otherwise the selected unlock group is awarded and the dialog closes: every ship, module, building, and assembler recipe the group grants becomes unlocked at once — ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG); building types become available in the build menu (REQ-LOCK-BUILDING); assembler recipes become available in the assembler recipe-selection dialog (subject to REQ-LOCK-UI-RECIPE). The unlock group is removed from the pool permanently (REQ-LOCK-EXPLICIT), and the implicit unlock set is recomputed (REQ-LOCK-IMPLICIT).
|
The player selects one option by clicking it. If the player selects the artifact option, the player's artifact count is incremented by 1 (REQ-WIN-ARTIFACT-COUNT) and the dialog closes; no unlock is applied. Otherwise the selected unlock group is awarded and the dialog closes: every ship, module, building, and assembler recipe the group grants becomes unlocked at once — ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG); building types become available in the build menu (REQ-LOCK-BUILDING); assembler recipes become available in the assembler recipe-selection dialog (subject to REQ-LOCK-UI-RECIPE). The unlock group is removed from the pool permanently (REQ-LOCK-EXPLICIT), and the implicit unlock set is recomputed (REQ-LOCK-IMPLICIT).
|
||||||
|
|
||||||
@@ -397,11 +394,11 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
|
|
||||||
- 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-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-UI-RECIPE: Locked miner ore-type recipes and assembler recipes are not shown in their respective recipe-selection dialogs (REQ-UI-SELECT-BUTTON).
|
- 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-SCHEMATIC: Locked ship schematics are not shown in the shipyard's schematic-selection dialog (REQ-UI-SELECT-BUTTON).
|
- REQ-LOCK-UI-SCHEMATIC: Locked ship schematics are not shown in the shipyard's schematic-selection dialog (REQ-UI-SELECT-BUTTON).
|
||||||
|
|
||||||
- REQ-LOCK-BUILDING: A building type granted by an unlock group (REQ-LOCK-EXPLICIT) is **locked** until that group is awarded. A locked building type has no button in the build button grid (REQ-UI-BUILD-GRID) and cannot be placed, selected as a build tool, or triggered by its build hotkey (REQ-UI-HOTKEYS); its button appears in the grid only once the building type is unlocked. Building types not granted by any unlock group are available from game start. Lock state resets on Restart (REQ-CFG-RELOAD).
|
- REQ-LOCK-BUILDING: A building type granted by an unlock group (REQ-LOCK-EXPLICIT) is **locked** until that group is awarded. A locked building type has no button in the build button bar (REQ-UI-BUILD-BAR) and cannot be placed, selected as a build tool, or triggered by its build hotkey (REQ-UI-HOTKEYS); its button appears in the bar only once the building type is unlocked, at which point the bar re-centers. Building types not granted by any unlock group are available from game start. Lock state resets on Restart (REQ-CFG-RELOAD).
|
||||||
|
|
||||||
- REQ-LOCK-UI-SPLITTER: Item types that are not implicitly unlocked are excluded from splitter filter dropdowns (REQ-BLD-SPLITTER).
|
- REQ-LOCK-UI-SPLITTER: Item types that are not implicitly unlocked are excluded from splitter filter dropdowns (REQ-BLD-SPLITTER).
|
||||||
|
|
||||||
@@ -433,26 +430,25 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
The screen is divided into two columns: a main column (75% width) containing the header bar and game world, and a side panel column (25% width) containing the three UI panels stacked vertically:
|
The screen is a single column: a header bar across the top and the game world view filling the whole area below it. There is no side panel. All three permanent UI widgets float over the game world — the build button bar (REQ-UI-BUILD-BAR) at its bottom center, the selection panel (REQ-UI-SELECTION-PANEL) beside whatever is currently selected — or wherever the player has dragged it by its header (REQ-UI-SELECTION-PANEL-DRAG) — shown only while something is selected and holding its place on the screen until the next selection, and the controls panel (REQ-UI-CONTROLS-PANEL) in its bottom-left corner, beside the build button bar and rising above it only when the two would overlap. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG):
|
||||||
|
|
||||||
```
|
```
|
||||||
+--------------------------------------+--------------+
|
+-----------------------------------------------------------+
|
||||||
| Header Bar | |
|
| Header Bar |
|
||||||
+--------------------------------------+ Selected |
|
+-----------------------------------------------------------+
|
||||||
| | Building |
|
| +-----------+ |
|
||||||
| | Panel |
|
| | Selection | |
|
||||||
| +--------------+
|
| Game World | Panel | |
|
||||||
| Game World | Build |
|
| +-----------+ |
|
||||||
| | Button |
|
| |
|
||||||
| | Grid |
|
| +----------+ |
|
||||||
| +--------------+
|
| | Controls | +------------------+ |
|
||||||
| | Blueprint |
|
| | Panel | | Build Button Bar | |
|
||||||
| | Panel |
|
+-+----------+-----+------------------+---------------------+
|
||||||
+--------------------------------------+--------------+
|
(full window width)
|
||||||
(75% width) (25% width)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- REQ-UI-HEADER: The header bar spans the width of the game world column (75% of the screen width) and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) and an asteroid expansion button (REQ-UI-EXPAND-BUTTON) to the left of the speed buttons, and game speed controls on the right.
|
- REQ-UI-HEADER: The header bar spans the full width of the game window and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) and an asteroid expansion button (REQ-UI-EXPAND-BUTTON) to the left of the speed buttons, and game speed controls on the right.
|
||||||
- REQ-UI-BLOCKS-ICON: In the header bar (REQ-UI-HEADER), the global building blocks stock is displayed as `Stock: <n>` followed by the `building_block` item icon (REQ-UI-ITEM-ICON) — e.g. `Stock: 200` then a small block icon — replacing the `Building Blocks: <n>` text label. The icon is sized to the header text height. When no icon file exists for `building_block` (a missing icon is not an error, REQ-UI-ITEM-ICON), the display falls back to the `Stock: <n> Blocks` text. The hover tooltip (REQ-UI-BLOCKS-TOOLTIP) applies in either form.
|
- REQ-UI-BLOCKS-ICON: In the header bar (REQ-UI-HEADER), the global building blocks stock is displayed as `Stock: <n>` followed by the `building_block` item icon (REQ-UI-ITEM-ICON) — e.g. `Stock: 200` then a small block icon — replacing the `Building Blocks: <n>` text label. The icon is sized to the header text height. When no icon file exists for `building_block` (a missing icon is not an error, REQ-UI-ITEM-ICON), the display falls back to the `Stock: <n> Blocks` text. The hover tooltip (REQ-UI-BLOCKS-TOOLTIP) applies in either form.
|
||||||
- REQ-UI-BLOCKS-TOOLTIP: The header bar's building blocks stock display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].building_blocks_tooltip` — intended to tell the player what building blocks are used for and how to obtain them. If the field is unset, the stock display shows no tooltip. This tooltip is distinct from the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
|
- REQ-UI-BLOCKS-TOOLTIP: The header bar's building blocks stock display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].building_blocks_tooltip` — intended to tell the player what building blocks are used for and how to obtain them. If the field is unset, the stock display shows no tooltip. This tooltip is distinct from the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
|
||||||
- REQ-UI-ARTIFACTS-TOOLTIP: The header bar's artifact count display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].artifact_tooltip` — intended to tell the player what artifacts are, how they are obtained (REQ-DEF-SCHEMATIC-DROP), and that collecting `world.toml [world].artifact_win_count` of them wins the game (REQ-WIN-ARTIFACT-COUNT). If the field is unset, the artifact count display shows no tooltip. This tooltip is distinct from the building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP) and the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
|
- REQ-UI-ARTIFACTS-TOOLTIP: The header bar's artifact count display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].artifact_tooltip` — intended to tell the player what artifacts are, how they are obtained (REQ-DEF-SCHEMATIC-DROP), and that collecting `world.toml [world].artifact_win_count` of them wins the game (REQ-WIN-ARTIFACT-COUNT). If the field is unset, the artifact count display shows no tooltip. This tooltip is distinct from the building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP) and the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
|
||||||
@@ -461,9 +457,30 @@ The screen is divided into two columns: a main column (75% width) containing the
|
|||||||
- REQ-UI-PAUSE-BORDER: While the game is paused (speed 0×, whether set via the speed controls (REQ-UI-SPEED), the Space toggle (REQ-UI-HOTKEYS), or an auto-pausing modal), a vignette border is drawn around the edges of the game world view to make the paused state hard to miss. The border is black and fades in the alpha channel from fully transparent at its inner (center-facing) edge to 50% opacity at the viewport edge, over a thickness of 100 pixels (capped at half the smaller viewport dimension on very small views).
|
- REQ-UI-PAUSE-BORDER: While the game is paused (speed 0×, whether set via the speed controls (REQ-UI-SPEED), the Space toggle (REQ-UI-HOTKEYS), or an auto-pausing modal), a vignette border is drawn around the edges of the game world view to make the paused state hard to miss. The border is black and fades in the alpha channel from fully transparent at its inner (center-facing) edge to 50% opacity at the viewport edge, over a thickness of 100 pixels (capped at half the smaller viewport dimension on very small views).
|
||||||
- REQ-UI-DECONSTRUCT-BORDER: While deconstruct mode is active (REQ-UI-DECONSTRUCT-BUTTON, REQ-UI-HOTKEYS), a vignette border is drawn around the edges of the game world view to signal the mode, matching the geometry of the paused-state vignette (REQ-UI-PAUSE-BORDER): a 100-pixel thickness (capped at half the smaller viewport dimension on very small views) with the four sides meeting along mitred corner diagonals. It fades in the alpha channel from fully transparent at its inner (center-facing) edge to the deconstruct tint color at the viewport edge. The color — including its alpha, which sets the peak opacity at the viewport edge — is read from `visuals.toml [overlays].deconstruct_tint`, the same deconstruct-mode color used for the hover tint. The border is presentation-only and has no effect on the simulation. If the game is both paused and in deconstruct mode, both vignettes are drawn and compose over each other.
|
- REQ-UI-DECONSTRUCT-BORDER: While deconstruct mode is active (REQ-UI-DECONSTRUCT-BUTTON, REQ-UI-HOTKEYS), a vignette border is drawn around the edges of the game world view to signal the mode, matching the geometry of the paused-state vignette (REQ-UI-PAUSE-BORDER): a 100-pixel thickness (capped at half the smaller viewport dimension on very small views) with the four sides meeting along mitred corner diagonals. It fades in the alpha channel from fully transparent at its inner (center-facing) edge to the deconstruct tint color at the viewport edge. The color — including its alpha, which sets the peak opacity at the viewport edge — is read from `visuals.toml [overlays].deconstruct_tint`, the same deconstruct-mode color used for the hover tint. The border is presentation-only and has no effect on the simulation. If the game is both paused and in deconstruct mode, both vignettes are drawn and compose over each other.
|
||||||
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x>` followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). When no icon file exists for `building_block`, the caption falls back to the `Expand: <x> Blocks` text. Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
|
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x>` followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). When no icon file exists for `building_block`, the caption falls back to the `Expand: <x> Blocks` text. Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
|
||||||
- REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width).
|
- 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-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom).
|
- REQ-UI-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-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the side panel column — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **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).
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **Presentation only.** Moving the panel is not a player command: it never enters the replay stream and has no effect on the simulation, consistent with the controls panel's collapsed state (REQ-UI-CONTROLS-PANEL). The desired position is not saved to disk.
|
||||||
|
- REQ-UI-PANEL-MODAL: **A modal opened from the selection panel opens on the panel.** A modal the player opens from a control inside the selection panel is placed **centered on the panel's current rectangle** rather than centered on the game window, so it appears where the player is already looking and under the cursor that just clicked the control. This is the same reason the panel itself is placed beside the selection instead of at a fixed corner (REQ-UI-SELECTION-PANEL): a modal centered on the window sends the cursor back across the view and then back again. Two modals are opened from the panel — the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), from the selection button; and the layout configuration dialog (REQ-MOD-UI-DIALOG), both from the "Configure" button (REQ-MOD-UI-PREVIEW) and when it opens automatically after a schematic change (REQ-MOD-UI-AUTO-DIALOG).
|
||||||
|
- **The panel's rectangle as it currently stands.** The modal is centered on where the panel actually is when the modal opens: the position resolved from the anchor rectangle (REQ-UI-SELECTION-PANEL) or, once the player has dragged the panel, the position they dragged it to (REQ-UI-SELECTION-PANEL-DRAG). The panel is always shown when one of these modals opens, since the modal is opened from a control within it (REQ-UI-EMPTY-SELECTION).
|
||||||
|
- **Kept inside the game window.** Should the modal, centered that way, extend past an edge of the game window, it is pushed back inside; its size is never changed to make it fit. A modal larger than the window in a dimension is instead aligned with the window's top or left edge in that dimension, so the part read first stays visible.
|
||||||
|
- **Placement is all that changes.** The modal is modal as before, pauses the game and restores the speed on close as before (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-DIALOG), shows the dim over the entire window including the panel it sits on (REQ-UI-MODAL-DIM), and is dismissed the same way. That it covers the panel costs nothing: while it is open the panel is dimmed and takes no input anyway.
|
||||||
|
- **Only these modals.** Every other modal is placed as before, centered on the game window: the escape menu (REQ-UI-GAME-MENU), the blueprint save and selection dialogs (REQ-UI-BLUEPRINT-CREATE, REQ-UI-BLUEPRINT-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), none of which is opened from the panel. So is a modal opened from another modal rather than from the panel — the Create Blueprint name dialog within the layout configuration dialog (REQ-MOD-UI-BLUEPRINT-CREATE) — which is placed against the modal that opened it.
|
||||||
|
- **Presentation only.** The placement is computed once, when the modal opens, and is not revisited while it is open; the panel cannot move meanwhile, being behind the modal and receiving no input. It is not a player command, never enters the replay stream, and has no effect on the simulation.
|
||||||
|
- REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the widgets floating over it (the build button bar, REQ-UI-BUILD-BAR, the selection panel, REQ-UI-SELECTION-PANEL, and the controls panel, REQ-UI-CONTROLS-PANEL) — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE), and the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The same applies when one modal hands directly off to another — the blueprint save dialog opening the blueprint selection dialog on confirm (REQ-UI-BLUEPRINT-CREATE): the dim persists across the handoff rather than flickering off and back on, and the simulation is not resumed in between. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
|
||||||
|
|
||||||
### Game World
|
### Game World
|
||||||
|
|
||||||
@@ -475,17 +492,17 @@ The screen is divided into two columns: a main column (75% width) containing the
|
|||||||
|
|
||||||
Because the contest-zone boundaries shift as the scrollable area grows with each push (REQ-GW-PUSH-EXPAND, REQ-GW-SCROLL-LIMIT), the ramp bands are recomputed from the current contest-zone boundaries. This is a presentation-only concern and does not affect the simulation, consistent with REQ-UI-NO-ZOOM.
|
Because the contest-zone boundaries shift as the scrollable area grows with each push (REQ-GW-PUSH-EXPAND, REQ-GW-SCROLL-LIMIT), the ramp bands are recomputed from the current contest-zone boundaries. This is a presentation-only concern and does not affect the simulation, consistent with REQ-UI-NO-ZOOM.
|
||||||
- REQ-UI-WORLD-ICON: In the game world, a building is drawn with an icon's glyph symbol centered on its footprint, in place of the letter identity glyph. The icon is an SVG loaded from `data/icons/buildings/`; only the icon's glyph is drawn in the world — in a contrasting ink (white over dark fills, dark over light fills) so it stays legible — and its colored chip background is omitted, because the footprint is already filled with the building's `visuals.toml` fill color. This applies to the production buildings (Miner, Smelter, Assembler, Reprocessing Plant, Shipyard, Salvage Bay), the HQ, and the player and enemy defence stations, wherever the identity label appears: operational buildings, construction sites (REQ-UI-CONSTRUCTION-PROGRESS), and the builder-mode and blueprint-placement ghosts. **Belts, splitters, and tunnels are excluded** — they keep their existing tile rendering so their orientation and flow stay readable (a centered icon would obscure direction). Their build-menu buttons still use icons (REQ-UI-BUILD-ICON); in particular the shared Tunnel button's `tunnel_entry.svg` is a build-button icon only, not a world icon. The directional output-port glyphs (REQ-UI-PORT-GLYPH, REQ-UI-PORT-TARGET-GLYPH) are a separate indicator and are unaffected. A building or station with no icon file falls back to its `visuals.toml` text glyph; a type with neither icon nor glyph shows no identity label. A missing icon is not an error, consistent with REQ-UI-BUILD-ICON.
|
- REQ-UI-WORLD-ICON: In the game world, a building is drawn with an icon's glyph symbol centered on its footprint, in place of the letter identity glyph. The icon is an SVG loaded from `data/icons/buildings/`; only the icon's glyph is drawn in the world — in a contrasting ink (white over dark fills, dark over light fills) so it stays legible — and its colored chip background is omitted, because the footprint is already filled with the building's `visuals.toml` fill color. This applies to the production buildings (Miner, Smelter, Assembler, Reprocessing Plant, Shipyard, Salvage Bay), the HQ, and the player and enemy defence stations, wherever the identity label appears: operational buildings, construction sites (REQ-UI-CONSTRUCTION-PROGRESS), and the builder-mode and blueprint-placement ghosts. **Belts, splitters, and tunnels are excluded** — they keep their existing tile rendering so their orientation and flow stay readable (a centered icon would obscure direction). Their build-menu buttons still use icons (REQ-UI-BUILD-ICON); in particular the shared Tunnel button's `tunnel_entry.svg` is a build-button icon only, not a world icon. The directional output-port glyphs (REQ-UI-PORT-GLYPH, REQ-UI-PORT-TARGET-GLYPH) are a separate indicator and are unaffected. A building or station with no icon file falls back to its `visuals.toml` text glyph; a type with neither icon nor glyph shows no identity label. A missing icon is not an error, consistent with REQ-UI-BUILD-ICON.
|
||||||
- REQ-UI-ITEM-ICON: In the game world, an item is drawn with its **item icon** in place of the colored square of REQ-GW-TILE-SIZE. The icon is a self-contained, full-color SVG (rendered as-is, unlike the glyph-only building icons of REQ-UI-WORLD-ICON), loaded at runtime from `data/icons/items/` — a sibling of the config directory, read the same way as the building icons (REQ-UI-BUILD-ICON) — one file per item type named after the item's id (e.g. `iron_ore.svg`). It fills the item's half-tile rect, keeping the size, spacing, and draw-order rules of REQ-GW-TILE-SIZE, and applies wherever an item is drawn: on belts, splitters, and tunnel ends, and while emerging from or sinking into a building port (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE). An item type with no icon file falls back to its `visuals.toml` colored square (`fill` + `outline`); a missing icon is not an error, consistent with REQ-UI-BUILD-ICON. For performance, each item icon is rasterized to a pixmap cached per target pixel size — re-rasterized only when the tile pixel size changes (e.g. on view resize) — rather than re-rendered from vector every frame.
|
- REQ-UI-ITEM-ICON: In the game world, an item is drawn as its `visuals.toml` colored square (`fill` + `outline`, the square of REQ-GW-TILE-SIZE) carrying its **item icon** on top. The square is drawn for every item, with or without an icon: it is what gives the item contrast against the tile beneath it, and its outline is what separates neighbouring items where they overlap on a belt. The icon is a self-contained, full-color SVG (rendered as-is, unlike the glyph-only building icons of REQ-UI-WORLD-ICON), loaded at runtime from `data/icons/items/` — a sibling of the config directory, read the same way as the building icons (REQ-UI-BUILD-ICON) — one file per item type named after the item's id (e.g. `iron_ore.svg`). The square fills the item's half-tile rect, keeping the size, spacing, and draw-order rules of REQ-GW-TILE-SIZE; the icon is drawn **inset** within that rect so a frame of the square's color stays visible all around it — required because each icon's viewBox is cropped tight to its artwork, so an icon drawn at the full rect would cover the square entirely. This applies wherever an item is drawn: on belts, splitters, and tunnel ends, and while emerging from or sinking into a building port (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE). An item type with no icon file shows the colored square alone; a missing icon is not an error, consistent with REQ-UI-BUILD-ICON. **The colored square travels with the icon into the UI.** Wherever the UI displays an item *as an item* — the selection panel's item chips (REQ-UI-SINGLE-SELECTION, REQ-UI-HQ-PANEL), the recipe summary's input and output icons (REQ-UI-RECIPE-SUMMARY), and those same icons wherever a recipe is drawn in that form: on the selection dialog's option buttons (REQ-UI-SELECT-OPTIONS), in the item production tooltip (REQ-UI-ITEM-TOOLTIP), and in the unlock-choice dialog's recipe lines (REQ-DEF-SCHEMATIC-DROP) — the icon is drawn on that same square, by the same rules: the item's `fill` and `outline` from `visuals.toml`, the icon inset within the square so a frame of the square's color stays visible all around it, and the square alone when the item has no icon file. The square fills the rect the display allots to the icon, as it fills the half-tile rect in the world. One item therefore reads the same in a panel as it does on a belt. The one exception is an **inline icon standing in for the item's name beside a number** on a line of text: the `building_block` icon in place of the word `Blocks` (REQ-UI-BLOCKS-ICON, REQ-UI-EXPAND-BUTTON, REQ-UI-BUILD-COST, REQ-UI-MULTI-SELECTION, REQ-UI-BLUEPRINT-CARD), and the `scrap` icon in place of the word `Scrap` in the debris panel's remaining-scrap value (REQ-UI-DEBRIS-PANEL, REQ-UI-FIELD-MULTI-SELECTION). There the icon is a decoration on a line of text rather than an item display: it is drawn bare, without the square, and sized to the height of the text it sits in. Where the same value is instead shown *as an item* it keeps the square, as the HQ's block stock chip does beside the header bar's bare block icon (REQ-UI-HQ-PANEL). For performance, each item icon is rasterized to a pixmap cached per target pixel size — re-rasterized only when the tile pixel size changes (e.g. on view resize) — rather than re-rendered from vector every frame.
|
||||||
- REQ-UI-CONSTRUCTION-PROGRESS: Construction sites display the building's identity symbol centered on the footprint (same as an operational building) — its icon glyph, or the text glyph as a fallback (REQ-UI-WORLD-ICON). Below the symbol — or centered on the footprint if the building has neither an icon nor a glyph — a construction progress percentage is shown (integer, e.g. `42%`), increasing from 0% to 100% as construction completes.
|
- REQ-UI-CONSTRUCTION-PROGRESS: Construction sites display the building's identity symbol centered on the footprint (same as an operational building) — its icon glyph, or the text glyph as a fallback (REQ-UI-WORLD-ICON). Below the symbol — or centered on the footprint if the building has neither an icon nor a glyph — a construction progress percentage is shown (integer, e.g. `42%`), increasing from 0% to 100% as construction completes.
|
||||||
- REQ-UI-PORT-GLYPH: Every output port of every building is indicated by a directional glyph drawn on the port's tile. The glyph is a `>` rotated to face the port's exit direction (`>` for East, `^` for North, `<` for West, `v` for South). It is drawn at the midpoint between the tile center and the tile edge that the port exits through (i.e. halfway from center toward the exit edge). The indicator is rendered for all building states: operational buildings, construction sites, and the builder-mode ghost. Buildings with multiple output ports (e.g. splitters) show one indicator per port.
|
- REQ-UI-PORT-GLYPH: Every output port of every building is indicated by a directional glyph drawn on the port's tile. The glyph is a `>` rotated to face the port's exit direction (`>` for East, `^` for North, `<` for West, `v` for South). It is drawn at the midpoint between the tile center and the tile edge that the port exits through (i.e. halfway from center toward the exit edge). The indicator is rendered for all building states: operational buildings, construction sites, and the builder-mode ghost. Buildings with multiple output ports (e.g. splitters) show one indicator per port.
|
||||||
- REQ-UI-PORT-TARGET-GLYPH: While in builder mode (REQ-BLD-BUILDER-MODE), the builder-mode ghost additionally shows, for each of the building's output ports, a directional glyph drawn centered in the port's **target cell** — the cell immediately outside the footprint that the port pushes into, i.e. the cell the surface-mask output-port indicator occupies (see Surface Mask Format). As in REQ-UI-PORT-GLYPH the glyph is a `>` rotated to face the port's exit direction (`>` East, `^` North, `<` West, `v` South), previewing where the port's output will go before placement. This is in addition to the on-tile port glyph of REQ-UI-PORT-GLYPH, and — unlike that indicator — is shown only for the builder-mode ghost, not for operational buildings, construction sites, or the blueprint-placement ghost (REQ-UI-BLUEPRINT-PLACE). A building with multiple output ports (e.g. a splitter) shows one target-cell glyph per port. The target-cell glyph is drawn larger than the on-tile port glyph so it stands out as the flow-direction preview. Exceptions: the Tunnel Entry shows no target-cell glyph, because it receives items (which may arrive from any of its non-mouth edges, REQ-BLD-TUNNEL-ENTRY) rather than emitting into a single adjacent cell; the Shipyard shows none either, because its output port is a ship-spawn point (REQ-SHP-SPAWN-PLAYER) rather than a belt-item output (REQ-MAT-OUTPUT-EMERGE).
|
- REQ-UI-PORT-TARGET-GLYPH: While in builder mode (REQ-BLD-BUILDER-MODE), the builder-mode ghost additionally shows, for each of the building's output ports, a directional glyph drawn centered in the port's **target cell** — the cell immediately outside the footprint that the port pushes into, i.e. the cell the surface-mask output-port indicator occupies (see Surface Mask Format). As in REQ-UI-PORT-GLYPH the glyph is a `>` rotated to face the port's exit direction (`>` East, `^` North, `<` West, `v` South), previewing where the port's output will go before placement. This is in addition to the on-tile port glyph of REQ-UI-PORT-GLYPH, and — unlike that indicator — is shown only for the builder-mode ghost, not for operational buildings, construction sites, or the blueprint-placement ghost (REQ-UI-BLUEPRINT-PLACE). A building with multiple output ports (e.g. a splitter) shows one target-cell glyph per port. The target-cell glyph is drawn larger than the on-tile port glyph so it stands out as the flow-direction preview. Exceptions: the Tunnel Entry shows no target-cell glyph, because it receives items (which may arrive from any of its non-mouth edges, REQ-BLD-TUNNEL-ENTRY) rather than emitting into a single adjacent cell; the Shipyard shows none either, because its output port is a ship-spawn point (REQ-SHP-SPAWN-PLAYER) rather than a belt-item output (REQ-MAT-OUTPUT-EMERGE).
|
||||||
- REQ-UI-STATUS-LIGHT: Every operational production building — Miner, Smelter, Assembler, Reprocessing Plant, Shipyard, and Salvage Bay — renders a small **status light**: a filled circle with a black outline drawn in the building's upper-right corner, letting the player read a building's production state without selecting it. The light is anchored to the footprint corner that is the upper-right corner in the building's default orientation and rotates with the building — like the output-port glyph (REQ-UI-PORT-GLYPH) — so it stays on the same physical corner of the building as it is rotated. The status light is rendered only for operational buildings; construction sites (which instead show construction progress, REQ-UI-CONSTRUCTION-PROGRESS) and the builder-mode ghost do not render it. Buildings that are not production buildings — belts, splitters, tunnel entries/exits, and the HQ — have no status light. The black outline is constant; the fill color reflects the building's current production state.
|
- REQ-UI-STATUS-LIGHT: Every operational production building — Miner, Smelter, Assembler, Reprocessing Plant, Shipyard, and Salvage Bay — renders a small **status light**: a filled circle with a black outline drawn in the building's upper-right corner, letting the player read a building's production state without selecting it. The light is anchored to the footprint corner that is the upper-right corner in the building's default orientation and rotates with the building — like the output-port glyph (REQ-UI-PORT-GLYPH) — so it stays on the same physical corner of the building as it is rotated. The status light is rendered only for operational buildings; construction sites (which instead show construction progress, REQ-UI-CONSTRUCTION-PROGRESS) and the builder-mode ghost do not render it. Buildings that are not production buildings — belts, splitters, tunnel entries/exits, and the HQ — have no status light. The black outline is constant; the fill color reflects the building's current production state.
|
||||||
- For the five production buildings (Miner, Smelter, Assembler, Reprocessing Plant, Shipyard), the fill color is determined by evaluating, in order:
|
- For the five production buildings (Miner, Smelter, Assembler, Reprocessing Plant, Shipyard), the fill color is determined by evaluating, in order:
|
||||||
- **Grey** — no recipe or schematic is selected. This applies only to buildings with a player-facing selection (Miner, Assembler, Shipyard); the Smelter and Reprocessing Plant always run an implicit recipe (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) and are never grey.
|
- **Grey** — no recipe or schematic is selected. Every production building can show it, an auto-recipe building included: it is grey until something is offered at an input port for it to select a recipe from, and again whenever the player hands it back to automatic selection (REQ-BLD-AUTO-RECIPE).
|
||||||
- **Green** — the building is currently producing: a production cycle is active (REQ-MAT-CYCLE; for the Shipyard, an in-progress production cycle per REQ-BLD-SHIPYARD).
|
- **Green** — the building is currently producing: a production cycle is active (REQ-MAT-CYCLE; for the Shipyard, an in-progress production cycle per REQ-BLD-SHIPYARD).
|
||||||
- **Red** — the building is idle because a required input is missing from its input buffers, so it cannot start a cycle. Missing input takes precedence over a full output buffer: if any required input is missing the light is red even when the output buffer is also full.
|
- **Red** — the building is idle because a required input is missing from its input buffers, so it cannot start a cycle. Missing input takes precedence over a blocked output side: if any required input is missing the light is red even when an output buffer is also out of room.
|
||||||
- **Yellow** — the building is idle with all required inputs present but its output buffer full, so no new cycle can start (REQ-MAT-OUTPUT-BUFFER, REQ-MAT-CYCLE).
|
- **Yellow** — the building is idle with all required inputs present but the output side cannot take the cycle: at least one item the cycle could produce has no room in its own buffer, so no new cycle can start (REQ-MAT-OUTPUT-BUFFER, REQ-MAT-CYCLE). A buffer short of a full cycle's worth of free space blocks just as a completely full one does.
|
||||||
- A configured building that is momentarily idle yet blocked by neither condition (all inputs present and the output buffer has room — a transient state that resolves into a started cycle on the same or the next tick per REQ-MAT-CYCLE) shows green.
|
- A configured building that is momentarily idle yet blocked by neither condition (all inputs present and room for every output the cycle could produce — a transient state that resolves into a started cycle on the same or the next tick per REQ-MAT-CYCLE) shows green.
|
||||||
- The Salvage Bay has no recipe and no production cycle (REQ-BLD-SALVAGE-BAY); its status light uses only two states: **green** while its output buffer holds at least one unit of scrap, and **red** while its output buffer is empty. The Salvage Bay's status light is never grey or yellow.
|
- The Salvage Bay has no recipe and no production cycle (REQ-BLD-SALVAGE-BAY); its status light uses only two states: **green** while its output buffer holds at least one unit of scrap, and **red** while its output buffer is empty. The Salvage Bay's status light is never grey or yellow.
|
||||||
- The four fill colors (grey, green, red, yellow) and the outline color are read from `visuals.toml [status_light]`, consistent with the other rendering-only colors. The status light is presentation-only and has no effect on the simulation.
|
- The four fill colors (grey, green, red, yellow) and the outline color are read from `visuals.toml [status_light]`, consistent with the other rendering-only colors. The status light is presentation-only and has no effect on the simulation.
|
||||||
- REQ-UI-HP-BARS: All entities with HP — the HQ, player and enemy defence stations, and player and enemy ships — render an HP bar below them. The bar is always visible regardless of current HP. The bar's filled portion represents the fraction of current HP to maximum HP.
|
- REQ-UI-HP-BARS: All entities with HP — the HQ, player and enemy defence stations, and player and enemy ships — render an HP bar below them. The bar is always visible regardless of current HP. The bar's filled portion represents the fraction of current HP to maximum HP.
|
||||||
@@ -497,12 +514,17 @@ The screen is divided into two columns: a main column (75% width) containing the
|
|||||||
- **A / D** — scroll the view left / right (REQ-UI-SCROLL).
|
- **A / D** — scroll the view left / right (REQ-UI-SCROLL).
|
||||||
- **Q** — context-sensitive. If a build mode is active (builder mode or blueprint placement mode), pressing Q exits it. Otherwise, pressing Q toggles deconstruct mode: it enters deconstruct mode if inactive, or exits deconstruct mode if already active. (See also REQ-UI-DECONSTRUCT-BUTTON for the equivalent button.)
|
- **Q** — context-sensitive. If a build mode is active (builder mode or blueprint placement mode), pressing Q exits it. Otherwise, pressing Q toggles deconstruct mode: it enters deconstruct mode if inactive, or exits deconstruct mode if already active. (See also REQ-UI-DECONSTRUCT-BUTTON for the equivalent button.)
|
||||||
- **R / Shift+R** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE).
|
- **R / Shift+R** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE).
|
||||||
- **T** — create a temporary blueprint from the current selection and enter its placement mode (REQ-UI-BLUEPRINT-TEMP).
|
- **C** — create a temporary blueprint from the current selection and enter its placement mode (REQ-UI-BLUEPRINT-TEMP). Has effect only when at least one player-placeable building is selected; otherwise it does nothing.
|
||||||
- **Escape** — opens the escape menu (REQ-UI-GAME-MENU).
|
- **V** — re-enter placement mode for the last temporary blueprint created with C (REQ-UI-BLUEPRINT-TEMP). Does nothing when no temporary blueprint exists.
|
||||||
|
- **Ctrl+C** — save the current selection as a named blueprint: opens the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE). Has effect only when at least one player-placeable building is selected; otherwise it does nothing.
|
||||||
|
- **Ctrl+V** — opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG), from which a saved blueprint is picked for placement. It is available whenever the game is being played, regardless of the current selection or of which build mode is active, and opens the dialog even when no blueprints are saved yet.
|
||||||
|
- **Escape** — opens the escape menu (REQ-UI-GAME-MENU). While a blueprint dialog is open, Escape closes that dialog instead (REQ-UI-BLUEPRINT-DIALOG).
|
||||||
- **Build mode selection** — pressing a build hotkey activates builder mode for the corresponding building type, equivalent to clicking its build button (REQ-BLD-BUILDER-MODE):
|
- **Build mode selection** — pressing a build hotkey activates builder mode for the corresponding building type, equivalent to clicking its build button (REQ-BLD-BUILDER-MODE):
|
||||||
- **1** — Belt, **2** — Splitter, **3** — Tunnel (the unified tunnel build mode, REQ-BLD-TUNNEL-MODE). Hotkey 4 is unused.
|
- **1** — Belt, **2** — Splitter, **3** — Tunnel (the unified tunnel build mode, REQ-BLD-TUNNEL-MODE). Hotkey 4 is unused.
|
||||||
- **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant.
|
- **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant.
|
||||||
|
|
||||||
|
These shortcuts are the definition; the controls panel (REQ-UI-CONTROLS-PANEL) displays the subset of them that applies to the player's current situation, and the build buttons carry the build hotkeys on their badges (REQ-UI-BUILD-COST). Neither display defines a binding of its own.
|
||||||
|
|
||||||
### Debug Draw
|
### Debug Draw
|
||||||
|
|
||||||
- REQ-UI-DEBUG-DRAW: A debug draw mode can be toggled on and off with the **F3** key. It is inactive by default. While active, the sensor range of every ship — both player and enemy — is drawn as a circle centered on the ship, using that ship schematic's outline color from `visuals.toml`.
|
- REQ-UI-DEBUG-DRAW: A debug draw mode can be toggled on and off with the **F3** key. It is inactive by default. While active, the sensor range of every ship — both player and enemy — is drawn as a circle centered on the ship, using that ship schematic's outline color from `visuals.toml`.
|
||||||
@@ -522,24 +544,78 @@ The screen is divided into two columns: a main column (75% width) containing the
|
|||||||
- **Quit** — closes the application.
|
- **Quit** — closes the application.
|
||||||
Pressing Escape while the escape menu is open is equivalent to clicking Continue.
|
Pressing Escape while the escape menu is open is equivalent to clicking Continue.
|
||||||
|
|
||||||
### Selected Building Panel
|
### Selection Panel
|
||||||
|
|
||||||
- REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or piece of debris), the panel is empty.
|
The selection panel shows the details of the current selection, whatever its category (REQ-UI-SELECTION-CATEGORIES): buildings and construction sites, ships, defence stations, and debris. Its position, size, and overlay behavior are defined in REQ-UI-SELECTION-PANEL; the requirements below define its content.
|
||||||
|
|
||||||
|
The panel shows exactly one **content** at a time, picked from the catalog in REQ-UI-SELECTION-CONTENT by what is selected. Every content is assembled from the same small set of parts and follows the same card structure (REQ-UI-SELECTION-CARD), so different selections read alike and a part means the same thing wherever it appears.
|
||||||
|
|
||||||
|
- REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or piece of debris), the selection panel is not shown at all — it is hidden rather than shown empty, so the full game world view is visible (REQ-UI-SELECTION-PANEL). It reappears as soon as an object is selected.
|
||||||
- REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with debris). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus debris, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident piece of debris (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT).
|
- REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with debris). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus debris, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident piece of debris (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT).
|
||||||
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs). For a selected construction site, the recipe/schematic selection (and, for a shipyard, the layout preview and "Configure" button) are shown but the buffer rows are omitted (REQ-BLD-SITE-CONFIG).
|
- REQ-UI-SELECTION-CARD: **Card structure.** Every panel content is a card with the same three parts, top to bottom:
|
||||||
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown.
|
- **Header** — always shown. It holds the selection's identity symbol on the left — the building's icon glyph (REQ-UI-WORLD-ICON), a ship's schematic color swatch, or the kind symbol of a defence station or piece of debris — the selection's name beside it, and one optional **right slot**. The right slot holds a status indicator (REQ-UI-SELECTION-STATUS), a ship's current behavior (REQ-UI-SHIP-BEHAVIOR), or an object count — never more than one of them; which one applies is stated per content in REQ-UI-SELECTION-CONTENT. The header carries no control of its own, and doubles as the panel's drag handle (REQ-UI-SELECTION-PANEL-DRAG).
|
||||||
|
- **Configuration group** — the controls that change how the selected object is set up: the recipe/schematic selection control (REQ-UI-SELECT-BUTTON), a shipyard's layout preview and Configure button (REQ-MOD-UI-PREVIEW), and a splitter's output filters (REQ-BLD-SPLITTER). It is shown identically for an operational building and for a construction site of the same type (REQ-BLD-SITE-CONFIG).
|
||||||
|
- **Runtime group** — what the object is currently doing: buffer contents, production progress, HP, remaining scrap, and the belt clear action (REQ-UI-BELT-CLEAR). Where the object has **HP**, its bar is the first thing in this group, above everything else the card shows (REQ-UI-HQ-PANEL, REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL) — how close the thing is to dying outranks what it is holding. For a **construction site** the entire runtime group is replaced by a captioned `Construction` section: a progress bar filled to the site's construction completion with that completion as an integer percentage beside the caption — the same value the world draws on the footprint (REQ-UI-CONSTRUCTION-PROGRESS) — followed by a note that buffers appear once the building is built, because a site has neither buffers nor a production cycle (REQ-BLD-SITE-CONFIG). That section sits **directly below the header, above the configuration group**, so how far along the site is reads first; the configuration group is otherwise unaffected and stays visible on a site.
|
||||||
|
|
||||||
|
A group with nothing to show takes no space, so a content may consist of a header alone. Within a group, related parts form **sections** carrying a short caption above them (e.g. `Layout`, `Input buffers`, `Production`, `Output buffers`); a section and its caption are shown only while that section has content, so e.g. a Miner (which consumes nothing) shows no input buffer section.
|
||||||
|
- REQ-UI-SELECTION-CONTENT: **Content catalog.** Which content the panel shows follows from the selection alone:
|
||||||
|
|
||||||
|
| Selection | Header right slot | Configuration group | Runtime group |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Miner, Assembler, Smelter, Reprocessing Plant | status | recipe control + recipe summary | buffers + production |
|
||||||
|
| Shipyard | status | schematic control + layout preview + Configure | buffers + production |
|
||||||
|
| Salvage Bay | status | — | buffers |
|
||||||
|
| HQ | — | — | block stock + HP |
|
||||||
|
| Belt, Tunnel Entry, Tunnel Exit | count | — | clear action |
|
||||||
|
| Splitter | — | output filters | clear action |
|
||||||
|
| Several buildings | — | — | type counts + total cost |
|
||||||
|
| One ship | behavior | — | HP + hull stats + module summaries |
|
||||||
|
| One defence station | — | — | HP + stats |
|
||||||
|
| Debris (one or several) | count | — | remaining scrap |
|
||||||
|
| Several mixed field objects | count | — | type counts + scrap total |
|
||||||
|
|
||||||
|
Selections sharing a row of this table get the same content and differ only in the name and symbol in the header. A count in the right slot appears only for an aggregated multi-selection (REQ-UI-SELECTION-AGGREGATE); a single selection of those types shows an empty slot.
|
||||||
|
- REQ-UI-SELECTION-STATUS: **Status indicator.** For a building whose production state is already rendered in the world as a status light (REQ-UI-STATUS-LIGHT) — Miner, Smelter, Assembler, Reprocessing Plant, Shipyard, Salvage Bay — the header's right slot repeats that same state as a colored dot with a short caption beside it, so the panel and the world never disagree. The state is derived from the evaluation defined in REQ-UI-STATUS-LIGHT rather than from a second definition, and the dot uses that state's fill color from `visuals.toml [status_light]`. The captions name the state: `no recipe` (grey), `producing` (green), `missing input` (red), `output full` (yellow); for the Salvage Bay, `holding scrap` (green) and `empty` (red). A selected **construction site** shows the caption `constructing` with no dot, whatever its type. Buildings with no status light — belts, splitters, tunnel ends, the HQ — show nothing in the slot.
|
||||||
|
- REQ-UI-SELECTION-AGGREGATE: **Aggregating a homogeneous multi-selection.** When several objects are selected and their content can be shown as one — the same content, with its values aggregated over the whole selection — the panel shows that single content with the number of selected objects in the header's right slot (`x<count>`), instead of the count summary of REQ-UI-MULTI-SELECTION / REQ-UI-FIELD-MULTI-SELECTION. This applies exactly where every part of the content aggregates:
|
||||||
|
- **Belt-subsystem tiles** — any mix of belts, tunnel entries, and tunnel exits. Their content is the clear action alone, which already acts on the whole selection (REQ-UI-BELT-CLEAR).
|
||||||
|
- **Debris** — several pieces of debris and nothing else. Their remaining scrap sums into one value (REQ-UI-DEBRIS-PANEL).
|
||||||
|
|
||||||
|
Every other multi-selection falls back to the count summary. In particular a selection mixing a splitter with belts does not aggregate (a splitter carries per-object output filters, which have no aggregate), and neither do several production buildings of one type (per-building buffers and cycle progress have no aggregate).
|
||||||
|
- 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 **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.
|
||||||
|
|
||||||
|
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).
|
||||||
|
- 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-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).
|
||||||
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. The panel additionally shows the **total building block cost** of the selection — the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button grid); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-BUTTON). Construction sites count at their building type's full placement cost regardless of construction progress.
|
- **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).
|
||||||
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel. Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
|
- **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.
|
||||||
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selected building panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selected building panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
|
- **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.
|
||||||
- REQ-UI-SELECT-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selected building panel when a selection is set, displays an info tooltip:
|
- **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.
|
||||||
- For a **recipe** (Miner or Assembler): the recipe name; the name and quantity of each input item (no inputs are listed for miner recipes, which consume nothing); the completion time (`duration_seconds`); and the name and quantity of the produced output item.
|
- 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.
|
||||||
- For a **ship schematic** (Shipyard): the ship's `display_name`; the name and quantity of each base required material (`[ship.schematic].materials`, excluding any module contributions); the base production time (`[ship.schematic].production_time_seconds`); and "Produces: 1 <ship display name>".
|
- 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-RECIPE-ICON: In the recipe-selection dialog (REQ-UI-SELECT-BUTTON) for a Miner or Assembler, each recipe option button shows the icon of the recipe's produced item **instead of** its name caption (icon-only). The item shown is the recipe's `icon` field if set, otherwise its first output item; the icon is that item's icon per REQ-UI-ITEM-ICON. When the item has no icon file, the button falls back to the recipe/item name caption. The recipe name and details remain available on hover via the selection info tooltip (REQ-UI-SELECT-TOOLTIP). The `(None)` option keeps its text caption. This applies only to recipe options; the Shipyard schematic-selection dialog is unaffected and continues to show ship name captions.
|
- 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-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels.
|
- 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 **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).
|
||||||
|
- 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 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.
|
||||||
|
|
||||||
|
**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 whose every producing recipe is still locked.** The caption stays `Produced by`, and the single line beneath it reads `Undiscovered`: the player is told the item is made somehow, without being shown a path they have not unlocked yet.
|
||||||
|
|
||||||
|
The tooltip belongs to item chips only. The selection dialog shows no tooltips at all, its buttons describing themselves (REQ-UI-SELECT-OPTIONS), and the icons of the recipe summary line (REQ-UI-RECIPE-SUMMARY) show none either: they are parts of one line that already describes one recipe, rather than standalone item displays.
|
||||||
|
- REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel's runtime group shows a **"Clear stuck items"** button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels. The button acts on every selected tile, which is why a selection of belts and tunnel ends aggregates into one content rather than a count summary (REQ-UI-SELECTION-AGGREGATE).
|
||||||
|
- REQ-UI-HQ-PANEL: When the HQ is selected, the panel shows the HQ's **HP** as a bar labelled `current / maximum` (REQ-HQ-STATS, REQ-UI-HP-BARS) and, beneath it, the **global building blocks stock** — the same value as the header bar's stock display (REQ-UI-BLOCKS-ICON), rendered as an item chip (REQ-UI-SINGLE-SELECTION) carrying the `building_block` icon on its colored square — a chip is an item display, so it takes the square even though the header bar's inline block icon does not (REQ-UI-ITEM-ICON, REQ-UI-BLOCKS-ICON), and hovering it shows the item production tooltip as any other chip does (REQ-UI-ITEM-TOOLTIP). The HP comes first, as it does on every card that has it (REQ-UI-SELECTION-CARD). The HQ has no input or output buffers of its own: building blocks delivered by belt go straight into the global stock (REQ-HQ-BELT-INPUT), and showing that stock on the HQ is what tells the player to route blocks there. The HQ has no configuration group and no status indicator (REQ-UI-SELECTION-STATUS), and it is never a construction site.
|
||||||
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with debris and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a piece of debris adds to or establishes a field selection (REQ-UI-DEBRIS-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or piece of debris) clears the selection.
|
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with debris and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a piece of debris adds to or establishes a field selection (REQ-UI-DEBRIS-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or piece of debris) clears the selection.
|
||||||
- REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selected building panel shows a **ship stats panel**. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
|
- REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **ship stats panel**. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. Its header (REQ-UI-SELECTION-CARD) carries the schematic's color swatch and display name, with the ship's current behavior in the right slot (REQ-UI-SHIP-BEHAVIOR). The panel always shows all hull stats: HP (current / maximum) as a **bar** with the two values beside its caption, then max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed as label/value rows. In addition, capability module summaries are shown below the hull stats, each as its own outlined row, conditioned on which module types are installed and using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
|
||||||
- REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are:
|
- REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** in its header's right slot (REQ-UI-SELECTION-CARD) — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are:
|
||||||
- **Retreating** — the ship is retreating (REQ-SHP-RETREAT).
|
- **Retreating** — the ship is retreating (REQ-SHP-RETREAT).
|
||||||
- **Engaging** — the ship is engaging a combat target (player: REQ-SHP-COMBAT; enemy: REQ-SHP-ENEMY-AI).
|
- **Engaging** — the ship is engaging a combat target (player: REQ-SHP-COMBAT; enemy: REQ-SHP-ENEMY-AI).
|
||||||
- **Salvaging** — the ship is executing salvage navigation: seeking debris, collecting, or delivering to a Salvage Bay (REQ-SHP-SALVAGE).
|
- **Salvaging** — the ship is executing salvage navigation: seeking debris, collecting, or delivering to a Salvage Bay (REQ-SHP-SALVAGE).
|
||||||
@@ -547,38 +623,178 @@ The screen is divided into two columns: a main column (75% width) containing the
|
|||||||
- **Rallying** — the ship is moving to or orbiting the rally point (REQ-SHP-RALLY).
|
- **Rallying** — the ship is moving to or orbiting the rally point (REQ-SHP-RALLY).
|
||||||
- **Standby** — the ship is holding with its fleet (REQ-SHP-STANDBY).
|
- **Standby** — the ship is holding with its fleet (REQ-SHP-STANDBY).
|
||||||
- **Advancing** — the ship is executing the baseline forward advance with no higher-priority behavior active (player: REQ-SHP-COMBAT advance toward the enemy; enemy: REQ-SHP-ENEMY-AI advance toward the asteroid).
|
- **Advancing** — the ship is executing the baseline forward advance with no higher-priority behavior active (player: REQ-SHP-COMBAT advance toward the enemy; enemy: REQ-SHP-ENEMY-AI advance toward the asteroid).
|
||||||
- REQ-UI-STATION-STATS-PANEL: When exactly one defence station is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selected building panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.)
|
- REQ-UI-STATION-STATS-PANEL: When exactly one defence station is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum) as a **bar** with the two values beside its caption, then damage, range, and fire rate as label/value rows, matching the ship stats panel's rendering (REQ-UI-SHIP-STATS-PANEL). Its header carries no right slot: a station has no behavior label and no status light. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.)
|
||||||
- REQ-UI-FIELD-MULTI-SELECTION: A full single-object stats panel (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL, REQ-UI-DEBRIS-PANEL) is shown only when the field selection holds exactly one object — one ship, one defence station, or one piece of debris. Whenever the selection holds more than one field object — multiple actors, multiple pieces of debris, or any mix of actors and debris — the panel shows a **compact summary** instead: a count per type, one line per type rendered as "<type> x <count>" (the same `x`-count notation as the recipe tooltip and the building multi-selection, REQ-UI-MULTI-SELECTION). Ships are grouped by schematic display name and defence stations as a group, distinguishing player from enemy; all selected pieces of debris are grouped into a single "Debris x <count>" line whose count is the number of selected debris pieces. No per-object detail and no total-object-count header are shown (consistent with the building panel). If debris is part of the selection, a final "Scrap x <total>" line is appended after the "Debris" line, summing the remaining scrap across all selected debris (REQ-UI-DEBRIS-PANEL), so all lines share uniform spacing. Building selections use REQ-UI-SINGLE-SELECTION / REQ-UI-MULTI-SELECTION instead.
|
- REQ-UI-FIELD-MULTI-SELECTION: A full single-object stats panel (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL, REQ-UI-DEBRIS-PANEL) is shown when the field selection holds exactly one object — one ship, one defence station, or one piece of debris — and, for debris only, when it holds several pieces of debris and nothing else, which aggregate into that same content (REQ-UI-SELECTION-AGGREGATE). Every other field selection of more than one object — multiple actors, or any mix of actors and debris — shows a **count summary** instead. Its header reads `Mixed selection` with the total number of selected objects in the right slot (REQ-UI-SELECTION-CARD). Below it is one row per type — the type's symbol, its name, and the number selected as `x<count>`, the same `x`-count notation as the recipe tooltip and the building multi-selection (REQ-UI-MULTI-SELECTION). Ships are grouped by schematic display name and defence stations as a group, distinguishing player from enemy; all selected pieces of debris are grouped into a single `Debris` row whose count is the number of selected pieces. No per-object detail is shown. If debris is part of the selection, its row is followed by an indented sub-row giving the summed remaining scrap across all selected debris as the amount followed by the bare `scrap` icon, exactly as the debris panel states it (REQ-UI-DEBRIS-PANEL, REQ-UI-ITEM-ICON). Building selections use REQ-UI-SINGLE-SELECTION / REQ-UI-MULTI-SELECTION instead.
|
||||||
- REQ-UI-DEBRIS-CLICK-SELECT: The player can click any piece of debris (REQ-RES-DEBRIS-DROP) in the game world to select it. Debris are field objects (REQ-UI-SELECTION-CATEGORIES) and can be selected together with ships and defence stations, but not with buildings. A plain click on a piece of debris makes it the sole selection, clearing any previous selection; selecting a building clears any debris (buildings win), and selecting a piece of debris clears any building selection. Hit-testing prefers a building over a coincident actor or piece of debris, and an actor (ship or defence station) over a coincident piece of debris: a piece of debris is selected only when no building or actor is under the cursor. A selected piece of debris that despawns or is fully collected (REQ-RES-DEBRIS-DROP) is removed from the selection; if no selected object remains, the panel becomes empty (REQ-UI-EMPTY-SELECTION).
|
- REQ-UI-DEBRIS-CLICK-SELECT: The player can click any piece of debris (REQ-RES-DEBRIS-DROP) in the game world to select it. Debris are field objects (REQ-UI-SELECTION-CATEGORIES) and can be selected together with ships and defence stations, but not with buildings. A plain click on a piece of debris makes it the sole selection, clearing any previous selection; selecting a building clears any debris (buildings win), and selecting a piece of debris clears any building selection. Hit-testing prefers a building over a coincident actor or piece of debris, and an actor (ship or defence station) over a coincident piece of debris: a piece of debris is selected only when no building or actor is under the cursor. A selected piece of debris that despawns or is fully collected (REQ-RES-DEBRIS-DROP) is removed from the selection; if no selected object remains, the panel becomes empty (REQ-UI-EMPTY-SELECTION).
|
||||||
- REQ-UI-DEBRIS-MULTI-SELECT: Multiple pieces of debris can be selected by box-drag or by Ctrl+clicking individual pieces to add or remove them, mirroring building multi-select (REQ-UI-MULTI-SELECT). Debris shares the field-object category with ships and defence stations (REQ-UI-SELECTION-CATEGORIES), so a field selection may hold debris and actors together. Ctrl+clicking a piece of debris while a field selection is active adds or removes that piece within the same selection; Ctrl+clicking a piece of debris while a building selection is active first clears the buildings and begins a field selection (buildings win). Conversely, selecting a building while a field selection is active clears it. Box-drag disambiguation follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering no building selects the ships, defence stations, and debris within it).
|
- REQ-UI-DEBRIS-MULTI-SELECT: Multiple pieces of debris can be selected by box-drag or by Ctrl+clicking individual pieces to add or remove them, mirroring building multi-select (REQ-UI-MULTI-SELECT). Debris shares the field-object category with ships and defence stations (REQ-UI-SELECTION-CATEGORIES), so a field selection may hold debris and actors together. Ctrl+clicking a piece of debris while a field selection is active adds or removes that piece within the same selection; Ctrl+clicking a piece of debris while a building selection is active first clears the buildings and begins a field selection (buildings win). Conversely, selecting a building while a field selection is active clears it. Box-drag disambiguation follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering no building selects the ships, defence stations, and debris within it).
|
||||||
- REQ-UI-DEBRIS-PANEL: When exactly one piece of debris is selected (and no actors, REQ-UI-FIELD-MULTI-SELECTION), the selected building panel shows a **debris stats panel** structured like the ship and station stats panels (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL): a **"Debris"** heading followed by a single stat row, **"Scrap"**, showing that piece's current remaining scrap amount (REQ-RES-DEBRIS-DROP), rendered in the same label/value style as a ship hull stat row. When more than one field object is selected — multiple pieces of debris, or debris together with actors — the debris are instead summarized within the compact count summary (REQ-UI-FIELD-MULTI-SELECTION): a "Debris x <count>" line giving the number of selected debris pieces, followed by a "Scrap x <total>" line summing the remaining scrap across all selected debris. The displayed scrap value(s) update as selected debris are partially collected or despawn (REQ-UI-DEBRIS-CLICK-SELECT).
|
- REQ-UI-DEBRIS-PANEL: When debris is selected and no actors are (REQ-UI-FIELD-MULTI-SELECTION), the selection panel shows a **debris stats panel** structured like the ship and station stats panels (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL): a header reading **"Debris"**, followed by a single stat row captioned **"Remaining"**, in the same label/value style as a ship hull stat row. Its value is the scrap amount followed by the `scrap` item icon drawn **bare, without its colored square** and sized to the row's text height — the same inline form the header bar uses for building blocks (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON). The icon stands in for the word `Scrap`, which is why the caption names neither the item nor its unit; when no icon file exists for `scrap` (a missing icon is not an error, REQ-UI-ITEM-ICON), the row falls back to the caption `Scrap remaining` with the amount alone as its value. With one piece selected the row shows that piece's remaining scrap amount (REQ-RES-DEBRIS-DROP) and the header's right slot is empty. With several pieces selected the same content is shown aggregated (REQ-UI-SELECTION-AGGREGATE): the number of selected pieces appears in the header's right slot as `x<count>` and the row shows the summed remaining scrap across them. When debris is selected together with actors, the debris are instead summarized within the count summary (REQ-UI-FIELD-MULTI-SELECTION): a `Debris` row giving the number of selected pieces, followed by an indented sub-row with their summed remaining scrap, stated in the same amount-plus-bare-icon form. The displayed scrap value updates as selected debris are partially collected or despawn (REQ-UI-DEBRIS-CLICK-SELECT).
|
||||||
|
|
||||||
### Build Button Grid
|
### Build Button Bar
|
||||||
|
|
||||||
- REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each.
|
- REQ-UI-BUILD-BAR: All placeable building types are shown as a **single horizontal row** of buttons with no grouping and no wrapping, inside a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), horizontally centered and anchored at the bottom edge with a small margin. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each. The bar is sized to its buttons and re-centers whenever the set of shown buttons changes (REQ-LOCK-BUILDING) or the view is resized.
|
||||||
- REQ-UI-BUILD-COST: Each button caption shows the building name and its building block cost with the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, e.g. `Belt: 2` then a small block icon. When no icon file exists for `building_block`, the caption falls back to the text form, e.g. "Belt: 2 Blocks".
|
- **Overlay behavior.** The bar occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected (the world is not inset for the bar). The bar is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full 100-pixel bottom band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the bar.
|
||||||
- REQ-UI-BUILD-ICON: Each build button shows an icon alongside its caption. Icons are SVG files loaded at runtime from `data/icons/buildings/` (a sibling of the config directory, read the same way as `visuals.toml`), one file per button named after the building's id (e.g. `belt.svg`, `reprocessing_plant.svg`). The shared Tunnel button (REQ-UI-BUILD-GRID) uses `tunnel_entry.svg`; the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) uses `deconstruct.svg`. Each icon is a rounded colored "chip" bearing a white line glyph, the chip color following the building's fill color in `visuals.toml`. A missing icon file leaves the button with its caption and no icon; it is not an error.
|
- **No overlap with the selection panel.** The bar and the selection panel (REQ-UI-SELECTION-PANEL) never overlap, and keeping them apart is entirely the panel's job: the bar's position depends only on its own button set and the view size, and it never moves, re-centers, or resizes because the panel appears, disappears, or changes size. The panel instead steps around the bar's current rectangle wherever its own column would meet it (REQ-UI-SELECTION-PANEL).
|
||||||
- REQ-UI-BUILD-TOOLTIP: Each building-type button shows a hover tooltip with the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). This tooltip is distinct from the recipe/schematic selection tooltip (REQ-UI-SELECT-TOOLTIP). If a building type defines no tooltip text, its button shows no tooltip. The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) is not a building type and so has no config-defined tooltip; it instead shows its own refund tooltip defined in REQ-UI-DECONSTRUCT-BUTTON.
|
- **Input.** Mouse events over the bar are consumed by the bar and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the bar does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG).
|
||||||
|
- REQ-UI-BUILD-COST: Each button is **icon-only with a cost**, its face composed of three elements: the button's **hotkey badge** in the top-left corner, the building's icon (REQ-UI-BUILD-ICON) centered below it, and the building block cost centered under the icon, shown with the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) to the right of the number in place of the trailing `Blocks` word, e.g. `2` then a small block icon. The building name is not shown on the button; it is shown in the button's hover tooltip instead (REQ-UI-BUILD-TOOLTIP). When no icon file exists for `building_block`, the cost is shown as the bare number. The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) has no cost and shows its name as a text caption in the cost's place.
|
||||||
|
- **Hotkey badge.** The badge names the build hotkey that activates the button (REQ-UI-HOTKEYS), so the player can learn the shortcuts from the bar itself. It is rendered dimmer than the cost so it reads as secondary, but at the same size and in bold, because a smaller badge is not legible. A plain-digit hotkey is shown as the bare digit (`1`, `2`, `3`); a Shift+digit hotkey is shown with an upwards arrow prefixed and no separator (`↑1` … `↑6`); the Deconstruct button shows `Q`. A button whose building type has no build hotkey shows no badge and keeps the same face size, so the row stays even.
|
||||||
|
- REQ-UI-BUILD-ICON: Each build button shows an icon. Icons are SVG files loaded at runtime from `data/icons/buildings/` (a sibling of the config directory, read the same way as `visuals.toml`), one file per button named after the building's id (e.g. `belt.svg`, `reprocessing_plant.svg`). The shared Tunnel button (REQ-UI-BUILD-BAR) uses `tunnel_entry.svg`; the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) uses `deconstruct.svg`. Each icon is a rounded colored "chip" bearing a white line glyph, the chip color following the building's fill color in `visuals.toml`. A missing icon file leaves the button showing its building name as a text caption in place of the icon, so the button stays identifiable in the icon-only bar (REQ-UI-BUILD-COST); it is not an error.
|
||||||
|
- REQ-UI-BUILD-TOOLTIP: Each building-type button shows a hover tooltip consisting of the building name followed by the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). Because the button caption is icon-only (REQ-UI-BUILD-COST), the name is always part of the tooltip; if a building type defines no tooltip text, the tooltip shows the name alone. This tooltip is distinct from the item production tooltip of the selection panel's item chips (REQ-UI-ITEM-TOOLTIP); the recipe/schematic selection dialog has no tooltip at all (REQ-UI-SELECT-OPTIONS). The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) is not a building type and so has no config-defined tooltip; it instead shows its own refund tooltip defined in REQ-UI-DECONSTRUCT-BUTTON.
|
||||||
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained.
|
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained.
|
||||||
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button grid. Clicking it toggles deconstruct mode on and off, equivalent to the Q deconstruct toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage.
|
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button bar (REQ-UI-BUILD-BAR), as the last entry of the row and **visually separated** from the building-type buttons by a gap (not a divider line), because it toggles a mode rather than selecting a building type. Its face follows REQ-UI-BUILD-COST with two differences: its hotkey badge reads `Q`, and because it has no building block cost it shows its **Deconstruct** name as a text caption where the building-type buttons show their cost — so it is the one labelled button in the bar. It is therefore wider than the building-type buttons, which share a uniform width. Clicking it toggles deconstruct mode on and off, equivalent to the Q deconstruct toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage.
|
||||||
|
|
||||||
### Blueprint Panel
|
### Controls Panel
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-PANEL: The blueprint panel is shown to the right of the build button grid. It contains, from top to bottom: a "Create Blueprint" button, and a list of blueprint entries (one per saved blueprint, in creation order). The panel has no Save or Load buttons; blueprints are persisted automatically (REQ-UI-BLUEPRINT-SAVE) and restored at startup (REQ-UI-BLUEPRINT-LOAD).
|
The controls panel tells the player which controls are available right now. It is context-sensitive: the game is always in exactly one **control context**, derived from the active build mode and the current selection, and the panel shows that context's rows and no others. Its position, size, and overlay behavior are defined in REQ-UI-CONTROLS-PANEL; its structure in REQ-UI-CONTROLS-CARD; and which rows each context shows in REQ-UI-CONTROLS-CONTENT. The panel never defines a binding: every row restates one already defined in REQ-UI-HOTKEYS or in the mouse gestures cited beside it.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-CREATE: The "Create Blueprint" button is enabled only when at least one player-placeable building (i.e. a building with a button in the build button grid) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition. A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). When clicked, a modal dialog appears prompting the player to enter a name. The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, and appends its button to the blueprint list.
|
- REQ-UI-CONTROLS-PANEL: The **controls panel** is a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), anchored to the view's **bottom-left corner** with a small margin on both edges. It is **sized to its content in both width and height**, growing and shrinking upward from that corner as the context changes. Should its content ever be taller than the view, the panel's height is capped at the view height less its margins and the content scrolls vertically within it. The selection panel is placed beside the current selection (REQ-UI-SELECTION-PANEL) and so can reach this corner; the two never overlap, and keeping them apart is entirely the selection panel's job — this panel's position depends only on its own content, the view size, and the build button bar, and it never moves or resizes because the selection panel appears, disappears, or changes size.
|
||||||
|
- **Stepping around the build button bar.** Like the selection panel (REQ-UI-SELECTION-PANEL), the panel does **not** confine itself to the band above the build button bar's strip: the bar is horizontally centered and sized to its buttons (REQ-UI-BUILD-BAR), so it normally leaves this corner free, and the panel shares the view's bottom edge with it. The panel **rises only to avoid an actual overlap**: whenever the panel at the bottom-left corner would intersect the bar's current rectangle, it is moved up so that its bottom edge clears the bar's top by the same margin it keeps from the view's edges, and its height is capped at the space that leaves. Whether it rises therefore depends on how wide the bar and the panel currently are, and it returns to the corner as soon as they no longer meet. The bar never moves on the panel's account (REQ-UI-BUILD-BAR).
|
||||||
|
- **Visibility.** The panel is shown whenever the game is being played. Unlike the selection panel it has no empty state (REQ-UI-EMPTY-SELECTION): every context has rows, so there is never nothing to show.
|
||||||
|
- **Collapsing.** Clicking anywhere on the panel's header (REQ-UI-CONTROLS-CARD) toggles the panel between **expanded** and **collapsed**. Collapsed, it shows its header alone, keeping the context name visible and the header clickable so the panel can be expanded again; expanded, it shows the header followed by every row of the current context. The panel starts **expanded**. Changing context does not change the collapsed state: a panel collapsed in one context stays collapsed in the next, and its header updates in place. The collapsed state is presentation-only — it is not a player command, never enters the replay stream, and has no effect on the simulation. It persists for as long as the application runs, including across a restart from the escape menu (REQ-UI-GAME-MENU), and is not saved to disk.
|
||||||
|
- **Overlay behavior.** As for the build button bar and the selection panel (REQ-UI-BUILD-BAR, REQ-UI-SELECTION-PANEL): 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 left-hand band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the 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). The only control the panel itself offers is the header click that collapses and expands it.
|
||||||
|
- REQ-UI-CONTROLS-CARD: **Card structure.** The panel is a card with two parts, top to bottom:
|
||||||
|
- **Header** — always shown, and the panel's only interactive element (REQ-UI-CONTROLS-PANEL). It holds a colored context dot on the left, the context's name beside it in upper case, and, for contexts that define one, a **detail suffix** separated by a middle dot (`BUILD MODE · Assembler`). The name and detail per context are given in REQ-UI-CONTROLS-CONTENT.
|
||||||
|
- **Rows** — one per available control, shown only while the panel is expanded. Each row is one or more **key badges** on the left — the key or mouse button drawn as a small bordered chip — and a **label** beside them naming what it does. An action reachable two ways carries both badges in the same row (`RMB` `Q` — Exit placement) rather than occupying two rows. A row whose action leaves the current mode is drawn with the destructive badge styling, distinguishing it from the rows that act within the mode. No row is ever drawn greyed or otherwise disabled: a control the player cannot currently use is not shown at all (REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-TEMP: Pressing the **T** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing T with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing. Entering this mode replaces any currently active build, blueprint placement, or deconstruct mode. The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint panel (REQ-UI-BLUEPRINT-PANEL), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode, at which point the temporary blueprint is discarded.
|
The rows that are live in every context (REQ-UI-CONTROLS-CONTENT) are shown last, under a divider and the caption `ALWAYS AVAILABLE`. This holds in every context including the General one, which has context rows of its own above the divider like any other, so the card is read the same way wherever the player is.
|
||||||
|
- REQ-UI-CONTROLS-CONTENT: **Content catalog.** The control context follows from the active build mode and the selection alone. Build modes are mutually exclusive (REQ-BLD-BUILDER-MODE), so exactly one context applies at any moment:
|
||||||
|
|
||||||
|
| Context | When | Header name | Header detail |
|
||||||
|
|---|---|---|---|
|
||||||
|
| General | no build mode active, nothing selected | `GENERAL` | — |
|
||||||
|
| 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 |
|
||||||
|
| 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` | — |
|
||||||
|
|
||||||
|
The Selection context's detail counts the selection and names its category (REQ-UI-SELECTION-CATEGORIES): `<n> buildings` for a building selection, `<n> objects` for a field selection, in the singular at a count of one. The Build and Blueprint contexts show **the same rows** and differ only in their header.
|
||||||
|
|
||||||
|
**Always-available rows**, shown in every context:
|
||||||
|
|
||||||
|
| Badges | Label | Shown |
|
||||||
|
|---|---|---|
|
||||||
|
| `A` `D` | Move | always |
|
||||||
|
| `W` `S` | Game speed | always |
|
||||||
|
| `Space` | Toggle pause | always |
|
||||||
|
| `V` | Paste last | only while a temporary blueprint exists (REQ-UI-BLUEPRINT-TEMP) |
|
||||||
|
| `Ctrl` `V` | Blueprints | always |
|
||||||
|
| `Esc` | Menu | always |
|
||||||
|
|
||||||
|
**Context rows**, shown above the always-available block:
|
||||||
|
|
||||||
|
| Context | Badges | Label |
|
||||||
|
|---|---|---|
|
||||||
|
| General | `LMB` | Select |
|
||||||
|
| | `LMB` drag | Select area |
|
||||||
|
| | `Q` | Deconstruct mode |
|
||||||
|
| Selection | `LMB` | Select / clear selection |
|
||||||
|
| | `LMB` drag | Select area |
|
||||||
|
| | `Ctrl` `LMB` | Add / remove from selection |
|
||||||
|
| | `Ctrl` `LMB` drag | Add area to selection |
|
||||||
|
| | `Q` | Deconstruct mode |
|
||||||
|
| | `C` | Copy to temporary blueprint |
|
||||||
|
| | `Ctrl` `C` | Create blueprint |
|
||||||
|
| Build, Blueprint | `LMB` | Place |
|
||||||
|
| | `LMB` drag | Place belt line |
|
||||||
|
| | `R` / `Shift` `R` | Rotate |
|
||||||
|
| | `RMB` `Q` | Exit placement |
|
||||||
|
| Deconstruct | `LMB` | Toggle deconstruct |
|
||||||
|
| | `LMB` drag | Deconstruct area |
|
||||||
|
| | `RMB` `Q` | Exit deconstruct mode |
|
||||||
|
|
||||||
|
Four rows are conditional on more than the context, because the binding behind them is:
|
||||||
|
- **`C` / `Ctrl` `C`** are shown only while the selection holds at least one player-placeable building, the condition under which those keys do anything (REQ-UI-HOTKEYS). A selection of ships, defence stations, or debris shows neither.
|
||||||
|
- **`LMB` drag — Place belt line** is shown only in builder mode for the Belt type, the only type placed by dragging (REQ-BLD-BELT-DRAG). Every other builder type and blueprint placement omit the row.
|
||||||
|
- **`RMB` `Q` — Exit placement** splits into two rows **while a belt drag is in progress**, because the two bindings then part company (REQ-BLD-BELT-DRAG): `RMB` reads **Cancel belt line** and cancels the drag while leaving builder mode active, and `Q` reads **Exit placement** and leaves the mode outright.
|
||||||
|
- **`LMB` — Place** reads **Apply settings** instead whenever the ghost under the cursor resolves to a configuration transfer (REQ-UI-BLUEPRINT-TRANSFER) — a single-building blueprint hovering a same-type building of a configurable type, which is the case in which clicking hands over settings rather than placing anything. A blueprint holding more than one building keeps the `Place` label, since its click both places and transfers (REQ-UI-BLUEPRINT-PLACE).
|
||||||
|
- REQ-UI-CONTROLS-ACCURACY: **The panel never advertises a binding that would do nothing.** Every row shown must, if triggered in the situation the panel is showing it in, have the effect its label names; a binding that is inert in the current context is omitted rather than shown greyed (REQ-UI-CONTROLS-CARD). The relation holds in one direction only: the panel may omit a binding that is available, and deliberately does so in three cases:
|
||||||
|
- **Build hotkeys** (REQ-UI-HOTKEYS) are live in every context, but are advertised on the build buttons' badges (REQ-UI-BUILD-COST) instead of taking eleven rows in every context of this panel.
|
||||||
|
- **`C` and `Ctrl` `C`** are omitted from the Build, Blueprint, and Deconstruct contexts even though a selection surviving into a build mode keeps them working. They belong to the Selection context, and repeating them in every mode would defeat the panel's purpose of showing what the player's current situation affords.
|
||||||
|
- **`Ctrl` `LMB` and `Ctrl` `LMB` drag** work with nothing selected — they select the object under the cursor much as a plain click would — but are shown only in the Selection context. "Add / remove from selection" names an operation on a selection, and there is none to operate on until something is selected; the plain `LMB` row already covers what the gesture does before then.
|
||||||
|
- **`F3` and `F4`** (REQ-UI-DEBUG-DRAW) are development controls rather than player controls and appear in no context.
|
||||||
|
|
||||||
|
This asymmetry between what is available and what is shown is why the two are separate questions in the implementation, and why the tests assert that a resolvable input is *available* rather than that it is displayed.
|
||||||
|
|
||||||
|
### Blueprints
|
||||||
|
|
||||||
|
Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from the current selection (REQ-UI-BLUEPRINT-CREATE) and picked for placement from the blueprint selection dialog, opened with **Ctrl+V** (REQ-UI-BLUEPRINT-DIALOG). The unmodified **C** and **V** keys are the throwaway counterparts of the same two gestures: they capture and re-place a single unnamed temporary blueprint that is never saved and never listed (REQ-UI-BLUEPRINT-TEMP). Blueprints have no widget on the game screen at all. (The ship layout blueprint panel of the layout configuration dialog, REQ-MOD-UI-BLUEPRINT-PANEL, is a separate feature and is unaffected.)
|
||||||
|
|
||||||
|
- REQ-UI-BLUEPRINT-CREATE: Pressing **Ctrl+C** (REQ-UI-HOTKEYS) opens the modal **blueprint save dialog**, which pauses the simulation and dims the game window (REQ-UI-MODAL-DIM). It has effect only when at least one player-placeable building (i.e. a building with a button in the build button bar) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition, and pressing Ctrl+C with an empty selection or a selection of only non-player-placeable buildings does nothing (no dialog opens). A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). The dialog prompts the player to enter a name and has Confirm and Cancel buttons. Clicking Cancel — or pressing Escape, or closing the dialog — closes it with no effect and does not open the blueprint selection dialog. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, appends it to the blueprint list, closes the save dialog, and immediately opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) showing the new blueprint among the others.
|
||||||
|
|
||||||
|
- REQ-UI-BLUEPRINT-DIALOG: The **blueprint selection dialog** is the only place saved blueprints are shown. It is opened by pressing **Ctrl+V** (REQ-UI-HOTKEYS) and by confirming a save (REQ-UI-BLUEPRINT-CREATE). It is modal, pauses the simulation, and dims the game window (REQ-UI-MODAL-DIM). The dialog has a fixed size and consists of:
|
||||||
|
- A **title bar** reading `Blueprints`, followed by a small dimmed **hotkey badge** reading `Ctrl+V` — the same "learn the shortcut from the widget" device as the build button badges (REQ-UI-BUILD-COST) — and, at the far right, a **close ("×") button**.
|
||||||
|
- Below it, a **scrollable two-column grid of blueprint cards** (REQ-UI-BLUEPRINT-CARD), one per saved blueprint, filling the grid left to right and top to bottom in creation order. The column count is fixed at two; the grid scrolls vertically when the cards do not fit, and does not scroll horizontally.
|
||||||
|
- When no blueprints are saved, the dialog still opens and shows an empty-state message in place of the grid, telling the player that blueprints are created with Ctrl+C from a selection of buildings.
|
||||||
|
|
||||||
|
Clicking the close button, pressing Escape, or closing the dialog through the window manager closes it with no other effect: the current selection, build mode, and blueprint list are unchanged, and the simulation speed is restored to what it was before the dialog was opened. While the dialog is open, Escape closes it rather than opening the escape menu (REQ-UI-GAME-MENU).
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------------------------------------------+
|
||||||
|
| Blueprints [Ctrl+V] [x] |
|
||||||
|
+------------------------------------------------------+
|
||||||
|
| +---------------------+ +---------------------+ |^| |
|
||||||
|
| | Smelter array | | Gear cell | | | |
|
||||||
|
| | 4 Smelters, 1 Miner | | 2 Assemblers, ... | | | |
|
||||||
|
| | 528 [blk] (x) | | 460 [blk] (x) | | | |
|
||||||
|
| +---------------------+ +---------------------+ | | |
|
||||||
|
| +---------------------+ | | |
|
||||||
|
| | Defence line | |_v_|
|
||||||
|
+------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
- REQ-UI-BLUEPRINT-TEMP: Pressing the **C** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing C with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing at all, and in particular leaves any existing temporary blueprint in place. Entering this mode replaces any currently active build, blueprint placement, or deconstruct mode. The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode; unlike the mode, the temporary blueprint itself survives, so it can be entered again with V.
|
||||||
|
|
||||||
|
Pressing the **V** key re-enters blueprint placement mode for the temporary blueprint, capturing nothing new: it is independent of the current selection, can be pressed any number of times, and yields exactly the mode described above. Like C it replaces any currently active build, blueprint placement, or deconstruct mode, and it does not test whether the player can currently afford the blueprint — cost is enforced at placement (REQ-UI-BLUEPRINT-PLACE), consistently with C. Pressing V when no temporary blueprint exists does nothing: no mode is entered and any currently active mode is left untouched.
|
||||||
|
|
||||||
|
There is at most one temporary blueprint at a time; pressing C replaces the previous one. It is held only in memory for the current run: it is discarded when the application closes and when the simulation is restarted from the escape menu (REQ-UI-GAME-MENU), after which V does nothing until C captures a new one.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-STORAGE: A blueprint stores its name and, for each building in the selection, the building type, its rotation, its tile offset (integer dx, dy) from the center of the bounding box of all selected buildings' footprints, and — where applicable — the selected recipe ID (miners and assemblers) or schematic ID (shipyards), and for splitters the two output filters (each a list of item types; an empty list means accept-all), at the time of capture. A source building may be either an operational building or a construction site (REQ-BLD-SITE-CONFIG); a construction site is captured identically, storing whatever configuration it currently holds and never any buffer or construction-progress state. If no recipe or schematic was selected at capture time, none is stored; for a splitter with no filters set, no filter lists are stored. This structure maps directly to a TOML representation (e.g. one `[[building]]` array entry per constituent building, with the splitter filters as `filter_a`/`filter_b` arrays of item-type ids).
|
- REQ-UI-BLUEPRINT-STORAGE: A blueprint stores its name and, for each building in the selection, the building type, its rotation, its tile offset (integer dx, dy) from the center of the bounding box of all selected buildings' footprints, and — where applicable — the selected recipe ID (miners and assemblers) or schematic ID (shipyards), and for splitters the two output filters (each a list of item types; an empty list means accept-all), at the time of capture. A source building may be either an operational building or a construction site (REQ-BLD-SITE-CONFIG); a construction site is captured identically, storing whatever configuration it currently holds and never any buffer or construction-progress state. If no recipe or schematic was selected at capture time, none is stored; for a splitter with no filters set, no filter lists are stored. This structure maps directly to a TOML representation (e.g. one `[[building]]` array entry per constituent building, with the splitter filters as `filter_a`/`filter_b` arrays of item-type ids).
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-BUTTON: Each blueprint entry consists of a blueprint button and a dedicated delete icon ("×") placed to the right of the button. The blueprint button displays the blueprint name and, below it, the total building block cost of the blueprint (sum of the individual costs of all constituent buildings). A blueprint button is disabled when the player cannot afford the total cost. Clicking an enabled blueprint button enters blueprint placement mode for that blueprint. The delete icon is always enabled regardless of whether the player can afford the blueprint.
|
- REQ-UI-BLUEPRINT-CARD: Each blueprint in the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) is shown as a **card**. All cards share a uniform size. A card contains, from top to bottom:
|
||||||
|
- The **blueprint name**.
|
||||||
|
- A **contents line** summarizing what the blueprint holds: one `<building name> x <count>` entry per building type it contains, comma-separated, in descending count order with ties broken by the order the types appear in the build button bar (REQ-UI-BUILD-BAR). This is the same `x`-count notation as the building multi-selection summary (REQ-UI-MULTI-SELECTION). If the entries do not fit on the line, the line is elided at its end rather than wrapped or shrunk, so every card keeps the same height.
|
||||||
|
- The **total building block cost** of the blueprint (sum of the individual costs of all constituent buildings), shown with the `building_block` item icon to the right of the number in place of a trailing `Blocks` word, exactly as elsewhere in the UI (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST). When no icon file exists for `building_block`, the cost is shown as the bare number.
|
||||||
|
- A **delete icon ("×")** in the card's bottom-right corner (REQ-UI-BLUEPRINT-DELETE).
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-MODE: In blueprint placement mode a ghost is rendered for every building in the blueprint (excluding any of a currently locked building type, REQ-LOCK-BUILDING, which is omitted entirely per REQ-LOCK-UI-BLUEPRINT) at the position determined by its stored tile offset from the bounding-box center, which is anchored to the tile under the cursor. Each ghost is rendered individually as valid or invalid, applying REQ-BLD-PLACE-VALID conditions (a) and (b) per building (the other ghosts in the same blueprint do not count as existing buildings for the overlap check). A valid ghost uses its building type's semi-transparent per-building coloring (REQ-BLD-GHOST); an invalid ghost uses the distinct "invalid" color, as in single-building builder mode. Pressing R / Shift+R rotates the entire constellation 90° counter-clockwise / clockwise: each building's tile offset is rotated around the bounding-box center and each building's own rotation is updated, consistent with REQ-BLD-ROTATE. Blueprint placement mode is exited by right-clicking in the game world. Clicking a different blueprint button exits the current mode and enters blueprint placement mode for the newly clicked blueprint.
|
A card shows no preview of the blueprint's layout.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-PLACE: Buildings of a currently locked building type (REQ-LOCK-BUILDING) are first excluded from the blueprint for this placement, per REQ-LOCK-UI-BLUEPRINT — they are not ghosted, not validity-checked, not placed, and their cost is excluded from the total. Left-clicking in blueprint placement mode then places the (remaining) blueprint if (a) every building in the constellation satisfies REQ-BLD-PLACE-VALID conditions (a) and (b) at its resolved tile, and (b) the player has enough building blocks to afford the total cost. If both conditions are met, a construction site is added to the build queue for each building in the blueprint and the full total cost is deducted from the global building blocks stock in one transaction. If a recipe ID is stored for a building, it is applied to the construction site immediately. If a schematic ID is stored, it is applied only if that schematic is currently unlocked; if it is not unlocked, the shipyard's schematic is left unset. If splitter output filters are stored, they are applied to the construction site immediately and carry over when it finishes building (REQ-BLD-SITE-CONFIG). Locked recipe IDs and splitter filter entries for locked item types are handled on placement per REQ-LOCK-UI-BLUEPRINT. After a successful placement the game remains in blueprint placement mode, allowing the player to place the same blueprint again immediately.
|
Clicking anywhere on an enabled card other than its delete icon closes the dialog and enters blueprint placement mode for that blueprint (REQ-UI-BLUEPRINT-MODE). A card is disabled when the player cannot currently afford its total cost; a disabled card is rendered dimmed and clicking it does nothing (consistent with REQ-UI-BUILD-DISABLED), and neither closes the dialog nor enters placement mode. The delete icon is always enabled regardless of whether the player can afford the blueprint.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-DELETE: Clicking the delete icon ("×") on a blueprint entry immediately removes that blueprint from the list. If the deleted blueprint was active in blueprint placement mode, that mode is exited.
|
- REQ-UI-BLUEPRINT-MODE: In blueprint placement mode a ghost is rendered for every building in the blueprint (excluding any of a currently locked building type, REQ-LOCK-BUILDING, which is omitted entirely per REQ-LOCK-UI-BLUEPRINT) at the position determined by its stored tile offset from the bounding-box center, which is anchored to the tile under the cursor. Each ghost is rendered individually as valid or invalid, applying REQ-BLD-PLACE-VALID conditions (a) and (b) per building (the other ghosts in the same blueprint do not count as existing buildings for the overlap check). A valid ghost uses its building type's semi-transparent per-building coloring (REQ-BLD-GHOST); an invalid ghost uses the distinct "invalid" color, as in single-building builder mode; a ghost over a configuration-transfer target uses the distinct "transfer" color instead of either and, for a single-building blueprint, is drawn snapped onto the target rather than at the blueprint's own position (REQ-UI-BLUEPRINT-TRANSFER); a ghost over a compatible overlap counts as valid and keeps the ordinary per-building coloring (REQ-UI-BLUEPRINT-OVERLAP). Pressing R / Shift+R rotates the entire constellation 90° counter-clockwise / clockwise: each building's tile offset is rotated around the bounding-box center and each building's own rotation is updated, consistent with REQ-BLD-ROTATE. Blueprint placement mode is exited by right-clicking in the game world. Opening the blueprint selection dialog while placement mode is active (REQ-UI-BLUEPRINT-DIALOG) leaves the mode active — closing the dialog without picking a card returns to it unchanged — while clicking a card exits the current mode and enters blueprint placement mode for the newly picked blueprint.
|
||||||
|
|
||||||
|
- REQ-UI-BLUEPRINT-PLACE: This describes placing the blueprint's buildings as new construction sites. Ghosts sitting on a building that is already there are handled elsewhere and place nothing: a configuration-transfer target receives the blueprint's stored settings instead (REQ-UI-BLUEPRINT-TRANSFER), and a compatible overlap is left alone (REQ-UI-BLUEPRINT-OVERLAP). Both still take part in the single all-or-nothing click described here. Buildings of a currently locked building type (REQ-LOCK-BUILDING) are first excluded from the blueprint for this placement, per REQ-LOCK-UI-BLUEPRINT — they are not ghosted, not validity-checked, not placed, and their cost is excluded from the total. Left-clicking in blueprint placement mode then places the (remaining) blueprint if (a) every building in the constellation satisfies REQ-BLD-PLACE-VALID conditions (a) and (b) at its resolved tile, and (b) the player has enough building blocks to afford the total cost. Buildings that are compatible overlaps (REQ-UI-BLUEPRINT-OVERLAP) or configuration-transfer targets (REQ-UI-BLUEPRINT-TRANSFER) are excluded from the total cost and are not placed, but do not block the placement; a transfer target additionally receives the blueprint's stored settings. If both conditions are met, a construction site is added to the build queue for each remaining building in the blueprint and the full total cost is deducted from the global building blocks stock in one transaction. If a recipe ID is stored for a building, it is applied to the construction site immediately. If a schematic ID is stored, it is applied only if that schematic is currently unlocked; if it is not unlocked, the shipyard's schematic is left unset. If splitter output filters are stored, they are applied to the construction site immediately and carry over when it finishes building (REQ-BLD-SITE-CONFIG). Locked recipe IDs and splitter filter entries for locked item types are handled on placement per REQ-LOCK-UI-BLUEPRINT. After a successful placement the game remains in blueprint placement mode, allowing the player to place the same blueprint again immediately.
|
||||||
|
|
||||||
|
- REQ-UI-BLUEPRINT-OVERLAP: **Compatible overlap.** In blueprint placement mode, a ghost whose footprint **exactly coincides** with the footprint of an existing placed building or construction site that is of the **same building type** and has the **same rotation** is a *compatible overlap*: the building the blueprint wants is already there. Such a ghost is **valid** despite the occupied tiles (REQ-BLD-PLACE-VALID condition (b)), so it does not block the placement of the rest of the constellation — dropping a blueprint over a partially-built copy of itself fills in what is missing. It is drawn in the ordinary per-building ghost color (REQ-BLD-GHOST), like any other valid ghost.
|
||||||
|
|
||||||
|
On placement the overlapped building is **left completely untouched**: no construction site is placed on it, no building blocks are charged for it (it is excluded from the total cost of REQ-UI-BLUEPRINT-PLACE), its rotation is not changed, and a construction site's progress is preserved. This applies per building in the blueprint, independently, and to blueprints of any size. Unlike REQ-BLD-ROTATE-IN-PLACE, which it replaces in this mode, it applies to Tunnel Entries and Tunnel Exits too — nothing is re-oriented, so the reason for their exception does not arise.
|
||||||
|
|
||||||
|
A coinciding same-type building whose **rotation differs** is not a compatible overlap: the blueprint cannot rotate it (REQ-BLD-ROTATE-IN-PLACE no longer applies here), so the position is an ordinary occupied-tile overlap and therefore invalid.
|
||||||
|
|
||||||
|
**Order of the two rules.** A ghost is tested for a configuration transfer (REQ-UI-BLUEPRINT-TRANSFER) first, and this requirement governs only what that test does not claim. Because a coinciding building of a **configurable** type always transfers, this requirement covers exactly the types that have nothing to configure:
|
||||||
|
- **Configurable type** (Miner, Assembler, Shipyard, Splitter) — a transfer, never a compatible overlap. It is drawn in the transfer color and hands the blueprint's settings over.
|
||||||
|
- **Type with no settings** (Smelter, Reprocessing Plant, Salvage Bay, belt, tunnel end) — a compatible overlap if the rotation matches, invalid otherwise. There is nothing to hand over, so the building is simply left as it is and the ghost keeps its ordinary color. This holds for a single-building blueprint too: the cursor hit-test of REQ-UI-BLUEPRINT-TRANSFER applies only to configurable types, so hovering a belt with a belt blueprint still just overlaps it.
|
||||||
|
|
||||||
|
A single blueprint can hold both kinds at once, and each ghost is judged on its own: dropping a constellation over a partial copy of itself may reconfigure some of the buildings already there (cyan) while leaving others untouched (ordinary color) and placing the rest as new construction sites.
|
||||||
|
|
||||||
|
- REQ-UI-BLUEPRINT-TRANSFER: **Configuration transfer.** A blueprint hands its stored settings to buildings that are already standing where it wants them, instead of only to ones it places. A single-building blueprint therefore doubles as a way to copy one building's settings onto others of the same type: select a configured building, press **C** to capture it as a temporary blueprint and enter placement mode (REQ-UI-BLUEPRINT-TEMP), then click same-type buildings to stamp its settings onto them. **V** re-enters that mode later. A multi-building blueprint does the same for each of its buildings as it is placed.
|
||||||
|
|
||||||
|
A blueprint ghost is a **configuration-transfer target** when all of the following hold:
|
||||||
|
- The building is of a **configurable building type** — one with player-facing settings: Miner and Assembler (recipe), Shipyard (schematic and module layout), Splitter (output filters). Whether anything was actually configured at capture time is irrelevant; an unconfigured source transfers its unconfigured state (see below). Building types with no settings at all (Smelter, Reprocessing Plant, Salvage Bay, belts, tunnel entries and exits, the HQ) never transfer; a coinciding building of those types is a compatible overlap instead (REQ-UI-BLUEPRINT-OVERLAP).
|
||||||
|
- There is a target, found in one of two ways depending on the blueprint's size, because the two are different gestures:
|
||||||
|
- **Single-building blueprint** — the target is the building or construction site **under the cursor**, if it is of the same type. There is no coincidence test at all: the target's rotation, its footprint, and its alignment with the ghost are all irrelevant. The ghost is drawn **snapped onto the target**, at the target's own anchor and rotation, so it shows what the click will act on rather than where a building would go. This is the copying gesture, and a footprint test made it unusable for buildings that are not square: a Shipyard rotated 90° covers different tiles altogether, so no amount of lining up would ever match a differently-facing one.
|
||||||
|
- **Multi-building blueprint** — the ghost's footprint must **exactly coincide** with the footprint of an existing building or site of the same type (the coincidence test of REQ-BLD-ROTATE-IN-PLACE), and that target must have the **same rotation** as the ghost, which is what makes the position valid at all (REQ-UI-BLUEPRINT-OVERLAP). A constellation is placed as a layout, so its ghosts stay where the blueprint puts them: nothing snaps to the cursor and nothing is re-oriented.
|
||||||
|
|
||||||
|
At a transfer target:
|
||||||
|
- The ghost is drawn in a distinct **transfer** color read from `visuals.toml [overlays]`, overriding both the per-building coloring and the "invalid" color (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID). The position counts as valid despite the occupied tiles (REQ-BLD-PLACE-VALID condition (b)).
|
||||||
|
- **Left-clicking transfers the configuration** to the existing building or site, making the target's settings **identical to the source's**: the recipe ID (Miner, Assembler), the schematic ID together with the ship layout (Shipyard), or the two output filters (Splitter). No construction site is placed, no building blocks are consumed (it is excluded from the total cost of REQ-UI-BLUEPRINT-PLACE), and the target's **rotation is not changed** — a transfer never rotates.
|
||||||
|
- The transfer is a **full mirror, including the absence of a setting**: where the blueprint stores no configuration for a field (REQ-UI-BLUEPRINT-STORAGE stores nothing for an unselected recipe or schematic, and no filter lists for a splitter whose filters were empty at capture time), the target's corresponding setting is **cleared** rather than left as it was. So a splitter captured with no filters clears the target splitter's filters back to accept-all, and a miner captured with no recipe selected clears the target miner's recipe. This holds for every blueprint size, so a constellation captured from unconfigured buildings clears the settings of every matching building it is dropped on.
|
||||||
|
- The transfer has the same effects as making that selection through the selection panel, clearing included: buffer clearing per REQ-MAT-INPUT-BUFFER and REQ-MAT-OUTPUT-BUFFER, and, for a Shipyard, in-progress cycle cancellation per REQ-BLD-SHIPYARD. It inherits the no-op rule of REQ-MAT-INPUT-BUFFER with them: a transfer onto a building whose settings already match the source changes nothing at all — no buffers cleared, no production cycle cancelled, no construction progress lost — so repeatedly clicking already-matching buildings is harmless. Each field is judged on its own, so transferring an identical recipe with a differing layout affects only the layout. The layout configuration dialog does not auto-open (REQ-MOD-UI-AUTO-DIALOG).
|
||||||
|
- Unlock gating matches placement (REQ-UI-BLUEPRINT-PLACE): a stored schematic is applied only if it is currently unlocked, and locked recipe IDs and splitter filter entries for locked item types are handled per REQ-LOCK-UI-BLUEPRINT.
|
||||||
|
- Both operational buildings and construction sites are transfer targets (REQ-BLD-SITE-CONFIG); a configuration applied to a site carries over unchanged when it finishes building.
|
||||||
|
- After the transfer the game stays in blueprint placement mode, so further same-type buildings can be clicked in turn.
|
||||||
|
- A blueprint placement applies every transfer among its ghosts in the same click that places its new construction sites (REQ-UI-BLUEPRINT-PLACE); the placement is all-or-nothing, so if any ghost is invalid nothing is placed and nothing is transferred.
|
||||||
|
|
||||||
|
- REQ-UI-BLUEPRINT-DELETE: Clicking the delete icon ("×") on a blueprint card (REQ-UI-BLUEPRINT-CARD) immediately removes that blueprint from the list, without a confirmation prompt. The blueprint selection dialog stays open and its card grid reflows to close the gap. If the deleted blueprint was active in blueprint placement mode, that mode is exited.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-SAVE: On application shutdown, all current blueprints are serialized to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. Write errors are silently ignored on shutdown (no button, no dialog).
|
- REQ-UI-BLUEPRINT-SAVE: On application shutdown, all current blueprints are serialized to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. Write errors are silently ignored on shutdown (no button, no dialog).
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ set(TARGET_LIB_INCLUDE_DIRS
|
|||||||
"${CMAKE_CURRENT_SOURCE_DIR}/lib"
|
"${CMAKE_CURRENT_SOURCE_DIR}/lib"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/external"
|
"${CMAKE_CURRENT_SOURCE_DIR}/external"
|
||||||
)
|
)
|
||||||
set(TARGET_UI_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/ui")
|
set(TARGET_UI_INCLUDE_DIRS
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/ui"
|
||||||
|
# The balancing target compiles a few ui files into itself rather than linking the
|
||||||
|
# ui library, and the ship stats panel is built from the selection card's parts.
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/ui/selection"
|
||||||
|
)
|
||||||
set(TARGET_TEST_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/test")
|
set(TARGET_TEST_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/test")
|
||||||
set(TARGET_BALANCING_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/balancing")
|
set(TARGET_BALANCING_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/balancing")
|
||||||
|
|
||||||
@@ -78,6 +83,7 @@ unset(SRCS)
|
|||||||
|
|
||||||
set(HDRS)
|
set(HDRS)
|
||||||
set(SRCS)
|
set(SRCS)
|
||||||
|
set(UI_INCLUDE_PATH)
|
||||||
|
|
||||||
add_subdirectory(ui)
|
add_subdirectory(ui)
|
||||||
|
|
||||||
@@ -106,6 +112,7 @@ set_target_properties(${TARGET_UI_NAME} PROPERTIES
|
|||||||
)
|
)
|
||||||
target_include_directories(${TARGET_UI_NAME} PUBLIC
|
target_include_directories(${TARGET_UI_NAME} PUBLIC
|
||||||
"${TARGET_UI_INCLUDE_DIRS}"
|
"${TARGET_UI_INCLUDE_DIRS}"
|
||||||
|
"${UI_INCLUDE_PATH}"
|
||||||
"${TARGET_LIB_INCLUDE_DIRS}"
|
"${TARGET_LIB_INCLUDE_DIRS}"
|
||||||
"${LIB_INCLUDE_PATH}"
|
"${LIB_INCLUDE_PATH}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
|
|||||||
, m_finished(false)
|
, m_finished(false)
|
||||||
, m_stopRequested(false)
|
, m_stopRequested(false)
|
||||||
{
|
{
|
||||||
|
m_factoryState = makeFactoryState(m_gameConfig);
|
||||||
|
|
||||||
m_buildingSystem = std::make_unique<BuildingSystem>(
|
m_buildingSystem = std::make_unique<BuildingSystem>(
|
||||||
m_gameConfig,
|
m_gameConfig,
|
||||||
m_beltSystem,
|
m_beltSystem,
|
||||||
@@ -162,7 +164,7 @@ void ArenaSimulation::placeStructures()
|
|||||||
hp, hp, false);
|
hp, hp, false);
|
||||||
// Tag as an HQ so it is excluded from repair targeting (REQ-SHP-REPAIR).
|
// Tag as an HQ so it is excluded from repair targeting (REQ-SHP-REPAIR).
|
||||||
m_admin.addComponent<HqProxyComponent>(m_team1HqEntity);
|
m_admin.addComponent<HqProxyComponent>(m_team1HqEntity);
|
||||||
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
|
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Team 2 HQ — ECS proxy entity, enemy faction (isEnemy=true). No weapon.
|
// Team 2 HQ — ECS proxy entity, enemy faction (isEnemy=true). No weapon.
|
||||||
@@ -183,7 +185,7 @@ void ArenaSimulation::placeStructures()
|
|||||||
hp, hp, true);
|
hp, hp, true);
|
||||||
// Tag as an HQ so it is excluded from repair targeting (REQ-SHP-REPAIR).
|
// Tag as an HQ so it is excluded from repair targeting (REQ-SHP-REPAIR).
|
||||||
m_admin.addComponent<HqProxyComponent>(m_team2HqEntity);
|
m_admin.addComponent<HqProxyComponent>(m_team2HqEntity);
|
||||||
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
|
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
|
||||||
}
|
}
|
||||||
|
|
||||||
auto placeArenaStation = [&](const ArenaStationEntry& entry, bool isEnemy)
|
auto placeArenaStation = [&](const ArenaStationEntry& entry, bool isEnemy)
|
||||||
@@ -237,7 +239,7 @@ void ArenaSimulation::placeStructures()
|
|||||||
m_admin.addComponent<ModuleOwnerComponent>(wChild,
|
m_admin.addComponent<ModuleOwnerComponent>(wChild,
|
||||||
ModuleOwnerComponent{stationEntity});
|
ModuleOwnerComponent{stationEntity});
|
||||||
}
|
}
|
||||||
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
|
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const ArenaStationEntry& entry : m_arenaConfig.teams[0].stations)
|
for (const ArenaStationEntry& entry : m_arenaConfig.teams[0].stations)
|
||||||
@@ -322,13 +324,13 @@ void ArenaSimulation::tick()
|
|||||||
// Ship behavior systems (tick step 7): evaluate, select winner, execute.
|
// Ship behavior systems (tick step 7): evaluate, select winner, execute.
|
||||||
// Module + combat systems emit their tool beams into a shared buffer.
|
// Module + combat systems emit their tool beams into a shared buffer.
|
||||||
m_shipSystem->clearMovementIntents();
|
m_shipSystem->clearMovementIntents();
|
||||||
m_aiSystem->tick(m_admin, *m_buildingSystem, *m_debrisSystem);
|
m_aiSystem->tick(m_admin, m_factoryState);
|
||||||
std::vector<BeamFiredEvent> beamFiredEvents;
|
std::vector<BeamFiredEvent> beamFiredEvents;
|
||||||
m_salvagerSystem->tick(m_currentTick, *m_debrisSystem, *m_buildingSystem, beamFiredEvents);
|
m_salvagerSystem->tick(m_currentTick, m_factoryState, beamFiredEvents);
|
||||||
m_repairSystem->tick(m_currentTick, beamFiredEvents);
|
m_repairSystem->tick(m_currentTick, beamFiredEvents);
|
||||||
|
|
||||||
// Combat resolution (tick step 8).
|
// Combat resolution (tick step 8).
|
||||||
m_combatSystem->tick(m_currentTick, m_admin, *m_buildingSystem, beamFiredEvents);
|
m_combatSystem->tick(m_currentTick, m_admin, beamFiredEvents);
|
||||||
m_beamFiredEvents.insert(m_beamFiredEvents.end(), beamFiredEvents.begin(), beamFiredEvents.end());
|
m_beamFiredEvents.insert(m_beamFiredEvents.end(), beamFiredEvents.begin(), beamFiredEvents.end());
|
||||||
m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
|
m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
|
||||||
|
|
||||||
@@ -392,7 +394,7 @@ void ArenaSimulation::tickDeaths()
|
|||||||
for (entt::entity deadEntity : deadStations)
|
for (entt::entity deadEntity : deadStations)
|
||||||
{
|
{
|
||||||
const StationBodyComponent& sb = m_admin.get<StationBodyComponent>(deadEntity);
|
const StationBodyComponent& sb = m_admin.get<StationBodyComponent>(deadEntity);
|
||||||
m_buildingSystem->unregisterTileOccupancy(sb.bodyCells);
|
m_buildingSystem->unregisterTileOccupancy(m_factoryState, sb.bodyCells);
|
||||||
{
|
{
|
||||||
std::vector<entt::entity> stationChildren;
|
std::vector<entt::entity> stationChildren;
|
||||||
m_admin.forEach<ModuleOwnerComponent>(
|
m_admin.forEach<ModuleOwnerComponent>(
|
||||||
@@ -487,6 +489,11 @@ const ArenaConfig& ArenaSimulation::getArenaConfig() const
|
|||||||
return m_arenaConfig;
|
return m_arenaConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FactoryState& ArenaSimulation::getFactoryState() const
|
||||||
|
{
|
||||||
|
return m_factoryState;
|
||||||
|
}
|
||||||
|
|
||||||
const BuildingSystem& ArenaSimulation::getBuildings() const
|
const BuildingSystem& ArenaSimulation::getBuildings() const
|
||||||
{
|
{
|
||||||
return *m_buildingSystem;
|
return *m_buildingSystem;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
#include "BalancingConfig.h"
|
#include "BalancingConfig.h"
|
||||||
#include "BeltSystem.h"
|
#include "BeltSystem.h"
|
||||||
|
#include "FactoryState.h"
|
||||||
#include "EntityAdmin.h"
|
#include "EntityAdmin.h"
|
||||||
#include "BuildingId.h"
|
#include "BuildingId.h"
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ public:
|
|||||||
|
|
||||||
const ArenaConfig& getArenaConfig() const;
|
const ArenaConfig& getArenaConfig() const;
|
||||||
const BuildingSystem& getBuildings() const;
|
const BuildingSystem& getBuildings() const;
|
||||||
|
const FactoryState& getFactoryState() const;
|
||||||
const ShipSystem& getShips() const;
|
const ShipSystem& getShips() const;
|
||||||
const DebrisSystem& getDebrisSystem() const;
|
const DebrisSystem& getDebrisSystem() const;
|
||||||
EntityAdmin& getAdmin();
|
EntityAdmin& getAdmin();
|
||||||
@@ -107,6 +109,7 @@ private:
|
|||||||
BuildingId m_nextBuildingId;
|
BuildingId m_nextBuildingId;
|
||||||
|
|
||||||
EntityAdmin m_admin;
|
EntityAdmin m_admin;
|
||||||
|
FactoryState m_factoryState;
|
||||||
BeltSystem m_beltSystem;
|
BeltSystem m_beltSystem;
|
||||||
std::unique_ptr<BuildingSystem> m_buildingSystem;
|
std::unique_ptr<BuildingSystem> m_buildingSystem;
|
||||||
std::unique_ptr<ShipSystem> m_shipSystem;
|
std::unique_ptr<ShipSystem> m_shipSystem;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "ArenaView.h"
|
#include "ArenaView.h"
|
||||||
|
#include "FactoryQueries.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
#include "ShipIdentityComponent.h"
|
#include "ShipIdentityComponent.h"
|
||||||
#include "StationBodyComponent.h"
|
#include "StationBodyComponent.h"
|
||||||
#include "DebrisComponent.h"
|
#include "DebrisComponent.h"
|
||||||
|
#include "WorldPrimitives.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -175,55 +177,36 @@ void ArenaView::paintGL()
|
|||||||
QPainter painter(this);
|
QPainter painter(this);
|
||||||
painter.setRenderHint(QPainter::Antialiasing, false);
|
painter.setRenderHint(QPainter::Antialiasing, false);
|
||||||
|
|
||||||
drawTiles(painter);
|
// One transform snapshot for the whole frame; every draw below reads the
|
||||||
drawBuildings(painter);
|
// viewport through it.
|
||||||
drawStations(painter);
|
const WorldCoordinates coordinates = getCoordinates();
|
||||||
drawDebris(painter);
|
|
||||||
|
drawTiles(painter, coordinates);
|
||||||
|
drawBuildings(painter, coordinates);
|
||||||
|
drawStations(painter, coordinates);
|
||||||
|
drawDebris(painter, coordinates);
|
||||||
if (m_debugDraw)
|
if (m_debugDraw)
|
||||||
{
|
{
|
||||||
drawDebugSensorRanges(painter);
|
drawDebugSensorRanges(painter, coordinates);
|
||||||
drawDebugTargetLines(painter);
|
drawDebugTargetLines(painter, coordinates);
|
||||||
}
|
}
|
||||||
drawShips(painter);
|
drawShips(painter, coordinates);
|
||||||
drawBeams(painter);
|
drawBeams(painter, coordinates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Coordinate helpers
|
// Coordinate helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
float ArenaView::getTilePx() const
|
WorldCoordinates ArenaView::getCoordinates() const
|
||||||
{
|
{
|
||||||
|
// The arena is a fixed, fully visible world — unlike the game view it has no
|
||||||
|
// scrolling, so the tile size comes from fitting the whole arena in the widget.
|
||||||
const ArenaConfig& ac = m_sim->getArenaConfig();
|
const ArenaConfig& ac = m_sim->getArenaConfig();
|
||||||
const int totalWidth = ac.playerBufferWidth_tiles
|
const int totalWidth = ac.playerBufferWidth_tiles
|
||||||
+ ac.contestZoneWidth_tiles
|
+ ac.contestZoneWidth_tiles
|
||||||
+ ac.enemyBufferWidth_tiles;
|
+ ac.enemyBufferWidth_tiles;
|
||||||
const int totalHeight = ac.heightTiles;
|
return WorldCoordinates::fitToWorld(size(), totalWidth, ac.heightTiles);
|
||||||
if (totalWidth <= 0 || totalHeight <= 0) { return 1.0f; }
|
|
||||||
|
|
||||||
const float pxPerTileH = static_cast<float>(height()) / static_cast<float>(totalHeight);
|
|
||||||
const float pxPerTileW = static_cast<float>(width()) / static_cast<float>(totalWidth);
|
|
||||||
return std::min(pxPerTileH, pxPerTileW);
|
|
||||||
}
|
|
||||||
|
|
||||||
QPointF ArenaView::worldToWidget(QVector2D worldPos) const
|
|
||||||
{
|
|
||||||
return QPointF(
|
|
||||||
static_cast<qreal>(worldPos.x() * getTilePx()),
|
|
||||||
static_cast<qreal>(worldPos.y() * getTilePx()));
|
|
||||||
}
|
|
||||||
|
|
||||||
QPointF ArenaView::tileToWidget(QPoint tile) const
|
|
||||||
{
|
|
||||||
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
|
|
||||||
static_cast<float>(tile.y())));
|
|
||||||
}
|
|
||||||
|
|
||||||
QRectF ArenaView::tileRect(QPoint tile) const
|
|
||||||
{
|
|
||||||
const QPointF tl = tileToWidget(tile);
|
|
||||||
return QRectF(tl.x(), tl.y(),
|
|
||||||
static_cast<qreal>(getTilePx()), static_cast<qreal>(getTilePx()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
|
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
|
||||||
@@ -235,19 +218,11 @@ std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
|
|||||||
return m_sim->getAdmin().get<PositionComponent>(entity).value;
|
return m_sim->getAdmin().get<PositionComponent>(entity).value;
|
||||||
}
|
}
|
||||||
|
|
||||||
QVector2D ArenaView::widgetToWorld(QPoint widgetPt) const
|
|
||||||
{
|
|
||||||
const float px = getTilePx();
|
|
||||||
if (px < 0.001f) { return QVector2D(0.0f, 0.0f); }
|
|
||||||
return QVector2D(static_cast<float>(widgetPt.x()) / px,
|
|
||||||
static_cast<float>(widgetPt.y()) / px);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ArenaView::mousePressEvent(QMouseEvent* event)
|
void ArenaView::mousePressEvent(QMouseEvent* event)
|
||||||
{
|
{
|
||||||
if (event->button() == Qt::LeftButton)
|
if (event->button() == Qt::LeftButton)
|
||||||
{
|
{
|
||||||
const QVector2D worldPos = widgetToWorld(event->pos());
|
const QVector2D worldPos = getCoordinates().widgetToWorld(event->pos());
|
||||||
entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
|
entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
|
||||||
|
|
||||||
if (hit != entt::null)
|
if (hit != entt::null)
|
||||||
@@ -286,7 +261,7 @@ void ArenaView::keyPressEvent(QKeyEvent* event)
|
|||||||
// Rendering
|
// Rendering
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
void ArenaView::drawTiles(QPainter& painter)
|
void ArenaView::drawTiles(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
const ArenaConfig& ac = m_sim->getArenaConfig();
|
const ArenaConfig& ac = m_sim->getArenaConfig();
|
||||||
const int totalWidth = ac.playerBufferWidth_tiles
|
const int totalWidth = ac.playerBufferWidth_tiles
|
||||||
@@ -299,14 +274,14 @@ void ArenaView::drawTiles(QPainter& painter)
|
|||||||
{
|
{
|
||||||
for (int y = 0; y < totalHeight; ++y)
|
for (int y = 0; y < totalHeight; ++y)
|
||||||
{
|
{
|
||||||
painter.fillRect(tileRect(QPoint(x, y)), m_visuals->space.fill);
|
painter.fillRect(coordinates.tileRect(QPoint(x, y)), m_visuals->space.fill);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawBuildings(QPainter& painter)
|
void ArenaView::drawBuildings(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||||
{
|
{
|
||||||
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
||||||
m_visuals->buildings.find(b.type);
|
m_visuals->buildings.find(b.type);
|
||||||
@@ -316,13 +291,13 @@ void ArenaView::drawBuildings(QPainter& painter)
|
|||||||
painter.setPen(Qt::NoPen);
|
painter.setPen(Qt::NoPen);
|
||||||
for (const QPoint& cell : b.bodyCells)
|
for (const QPoint& cell : b.bodyCells)
|
||||||
{
|
{
|
||||||
painter.fillRect(tileRect(cell), bv.fill);
|
painter.fillRect(coordinates.tileRect(cell), bv.fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QPointF tl = tileToWidget(b.anchor);
|
const QPointF tl = coordinates.tileToWidget(b.anchor);
|
||||||
const QRectF bboxRect(tl.x(), tl.y(),
|
const QRectF bboxRect(tl.x(), tl.y(),
|
||||||
b.footprint.width() * static_cast<qreal>(getTilePx()),
|
b.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
|
||||||
b.footprint.height() * static_cast<qreal>(getTilePx()));
|
b.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
|
||||||
|
|
||||||
painter.setPen(QPen(bv.outline, 1));
|
painter.setPen(QPen(bv.outline, 1));
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
@@ -336,20 +311,16 @@ void ArenaView::drawBuildings(QPainter& painter)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawDebris(QPainter& painter)
|
void ArenaView::drawDebris(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
const float r = getTilePx() * 0.2f;
|
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
|
||||||
for (const DebrisInfo& debris : m_sim->getDebrisSystem().getAllDebrisInfo())
|
|
||||||
{
|
{
|
||||||
const QPointF center = worldToWidget(debris.position);
|
drawDebrisMarker(painter, coordinates,
|
||||||
painter.setBrush(QColor(128, 110, 90));
|
coordinates.worldToWidget(debris.position));
|
||||||
painter.setPen(QPen(QColor(50, 40, 30), 1));
|
|
||||||
painter.drawEllipse(center,
|
|
||||||
static_cast<qreal>(r), static_cast<qreal>(r));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawStations(QPainter& painter)
|
void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||||
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h)
|
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h)
|
||||||
@@ -365,13 +336,13 @@ void ArenaView::drawStations(QPainter& painter)
|
|||||||
painter.setPen(Qt::NoPen);
|
painter.setPen(Qt::NoPen);
|
||||||
for (const QPoint& cell : sb.bodyCells)
|
for (const QPoint& cell : sb.bodyCells)
|
||||||
{
|
{
|
||||||
painter.fillRect(tileRect(cell), bv.fill);
|
painter.fillRect(coordinates.tileRect(cell), bv.fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QPointF tl = tileToWidget(sb.anchor);
|
const QPointF tl = coordinates.tileToWidget(sb.anchor);
|
||||||
const QRectF bboxRect(tl.x(), tl.y(),
|
const QRectF bboxRect(tl.x(), tl.y(),
|
||||||
sb.footprint.width() * static_cast<qreal>(getTilePx()),
|
sb.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
|
||||||
sb.footprint.height() * static_cast<qreal>(getTilePx()));
|
sb.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
|
||||||
|
|
||||||
painter.setPen(QPen(bv.outline, 1));
|
painter.setPen(QPen(bv.outline, 1));
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
@@ -379,14 +350,9 @@ void ArenaView::drawStations(QPainter& painter)
|
|||||||
|
|
||||||
if (h.maxHp > 0.0f)
|
if (h.maxHp > 0.0f)
|
||||||
{
|
{
|
||||||
const float fraction = std::max(0.0f, h.hp / h.maxHp);
|
drawHealthBar(painter, coordinates, bboxRect.left(),
|
||||||
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
|
bboxRect.bottom() + 1.0, bboxRect.width(),
|
||||||
const qreal barY = bboxRect.bottom() + 1.0;
|
h.hp / h.maxHp, f.isEnemy);
|
||||||
const qreal barW = bboxRect.width();
|
|
||||||
painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH),
|
|
||||||
QColor(60, 60, 60));
|
|
||||||
painter.fillRect(QRectF(bboxRect.left(), barY, barW * static_cast<qreal>(fraction), barH),
|
|
||||||
f.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
||||||
@@ -398,8 +364,10 @@ void ArenaView::drawStations(QPainter& painter)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawShips(QPainter& painter)
|
void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
|
const float forward = getShipForwardExtentPx(coordinates);
|
||||||
|
|
||||||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
|
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
|
||||||
FactionComponent, HealthComponent>(
|
FactionComponent, HealthComponent>(
|
||||||
[&](entt::entity e, const ShipIdentityComponent& si,
|
[&](entt::entity e, const ShipIdentityComponent& si,
|
||||||
@@ -410,40 +378,22 @@ void ArenaView::drawShips(QPainter& painter)
|
|||||||
m_visuals->ships.find(si.schematicId);
|
m_visuals->ships.find(si.schematicId);
|
||||||
if (it == m_visuals->ships.end()) { return; }
|
if (it == m_visuals->ships.end()) { return; }
|
||||||
|
|
||||||
const QPointF center = worldToWidget(pos.value);
|
const QPointF center = coordinates.worldToWidget(pos.value);
|
||||||
const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians));
|
drawShipBody(painter, coordinates, center, facing.radians,
|
||||||
const QVector2D perp(-dir.y(), dir.x());
|
it->second.fill, it->second.outline);
|
||||||
|
|
||||||
const float fwd = getTilePx() * 0.45f;
|
|
||||||
const float side = getTilePx() * 0.25f;
|
|
||||||
|
|
||||||
QPolygonF tri;
|
|
||||||
tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd),
|
|
||||||
center.y() + static_cast<qreal>(dir.y() * fwd))
|
|
||||||
<< QPointF(center.x() + static_cast<qreal>(perp.x() * side - dir.x() * side),
|
|
||||||
center.y() + static_cast<qreal>(perp.y() * side - dir.y() * side))
|
|
||||||
<< QPointF(center.x() + static_cast<qreal>(-perp.x() * side - dir.x() * side),
|
|
||||||
center.y() + static_cast<qreal>(-perp.y() * side - dir.y() * side));
|
|
||||||
|
|
||||||
painter.setPen(QPen(it->second.outline, 1));
|
|
||||||
painter.setBrush(it->second.fill);
|
|
||||||
painter.drawPolygon(tri);
|
|
||||||
|
|
||||||
if (h.maxHp > 0.0f)
|
if (h.maxHp > 0.0f)
|
||||||
{
|
{
|
||||||
const float fraction = std::max(0.0f, h.hp / h.maxHp);
|
const qreal barW = static_cast<qreal>(forward) * 2.0;
|
||||||
const qreal barW = static_cast<qreal>(fwd) * 2.0;
|
const qreal barX = center.x() - static_cast<qreal>(forward);
|
||||||
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
|
const qreal barY = center.y() + static_cast<qreal>(forward) + 1.0;
|
||||||
const qreal barX = center.x() - static_cast<qreal>(fwd);
|
drawHealthBar(painter, coordinates, barX, barY, barW,
|
||||||
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
|
h.hp / h.maxHp, fac.isEnemy);
|
||||||
painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60));
|
|
||||||
painter.fillRect(QRectF(barX, barY, barW * static_cast<qreal>(fraction), barH),
|
|
||||||
fac.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
||||||
{
|
{
|
||||||
const qreal radius = static_cast<qreal>(getTilePx()) * 0.55;
|
const qreal radius = static_cast<qreal>(coordinates.getTilePx()) * 0.55;
|
||||||
painter.setPen(QPen(QColor(255, 255, 0), 2));
|
painter.setPen(QPen(QColor(255, 255, 0), 2));
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
painter.drawEllipse(center, radius, radius);
|
painter.drawEllipse(center, radius, radius);
|
||||||
@@ -451,9 +401,9 @@ void ArenaView::drawShips(QPainter& painter)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawDebugSensorRanges(QPainter& painter)
|
void ArenaView::drawDebugSensorRanges(QPainter& painter,
|
||||||
|
const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
painter.setBrush(Qt::NoBrush);
|
|
||||||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
|
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
|
||||||
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
|
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
|
||||||
const PositionComponent& pos, const SensorRangeComponent& sensor)
|
const PositionComponent& pos, const SensorRangeComponent& sensor)
|
||||||
@@ -462,17 +412,14 @@ void ArenaView::drawDebugSensorRanges(QPainter& painter)
|
|||||||
m_visuals->ships.find(si.schematicId);
|
m_visuals->ships.find(si.schematicId);
|
||||||
if (it == m_visuals->ships.end()) { return; }
|
if (it == m_visuals->ships.end()) { return; }
|
||||||
|
|
||||||
const QPointF center = worldToWidget(pos.value);
|
drawSensorRange(painter, coordinates,
|
||||||
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
|
coordinates.worldToWidget(pos.value),
|
||||||
* static_cast<qreal>(getTilePx());
|
sensor.value_tiles, it->second.outline);
|
||||||
QColor circleColor = it->second.outline;
|
|
||||||
circleColor.setAlpha(77);
|
|
||||||
painter.setPen(QPen(circleColor, 1));
|
|
||||||
painter.drawEllipse(center, radiusPx, radiusPx);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawDebugTargetLines(QPainter& painter)
|
void ArenaView::drawDebugTargetLines(QPainter& painter,
|
||||||
|
const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
// Draw a thin translucent line from a ship to a target, colored by the ship's
|
// Draw a thin translucent line from a ship to a target, colored by the ship's
|
||||||
// team to match the per-side HQ/station colors used elsewhere in the arena
|
// team to match the per-side HQ/station colors used elsewhere in the arena
|
||||||
@@ -490,7 +437,8 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
|
|||||||
QColor lineColor = it->second.fill;
|
QColor lineColor = it->second.fill;
|
||||||
lineColor.setAlpha(128);
|
lineColor.setAlpha(128);
|
||||||
painter.setPen(QPen(lineColor, 1));
|
painter.setPen(QPen(lineColor, 1));
|
||||||
painter.drawLine(worldToWidget(from), worldToWidget(to));
|
painter.drawLine(coordinates.worldToWidget(from),
|
||||||
|
coordinates.worldToWidget(to));
|
||||||
};
|
};
|
||||||
|
|
||||||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
|
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
|
||||||
@@ -535,7 +483,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawBeams(QPainter& painter)
|
void ArenaView::drawBeams(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
for (const ActiveBeam& beam : m_activeBeams)
|
for (const ActiveBeam& beam : m_activeBeams)
|
||||||
{
|
{
|
||||||
@@ -551,7 +499,7 @@ void ArenaView::drawBeams(QPainter& painter)
|
|||||||
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
|
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
|
||||||
}
|
}
|
||||||
painter.setPen(QPen(color, m_visuals->beams.widthPx));
|
painter.setPen(QPen(color, m_visuals->beams.widthPx));
|
||||||
painter.drawLine(worldToWidget(*shooterPos),
|
painter.drawLine(coordinates.worldToWidget(*shooterPos),
|
||||||
worldToWidget(*targetPos + beam.targetOffset));
|
coordinates.worldToWidget(*targetPos + beam.targetOffset));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "TickDriver.h"
|
#include "TickDriver.h"
|
||||||
#include "VisualsConfig.h"
|
#include "VisualsConfig.h"
|
||||||
|
#include "WorldCoordinates.h"
|
||||||
|
|
||||||
class ArenaSimulation;
|
class ArenaSimulation;
|
||||||
class QPainter;
|
class QPainter;
|
||||||
@@ -47,22 +48,22 @@ private slots:
|
|||||||
private:
|
private:
|
||||||
void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
|
void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
|
||||||
|
|
||||||
void drawTiles(QPainter& painter);
|
void drawTiles(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawBuildings(QPainter& painter);
|
void drawBuildings(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawStations(QPainter& painter);
|
void drawStations(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawDebris(QPainter& painter);
|
void drawDebris(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawShips(QPainter& painter);
|
void drawShips(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawDebugSensorRanges(QPainter& painter);
|
void drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawDebugTargetLines(QPainter& painter);
|
void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawBeams(QPainter& painter);
|
void drawBeams(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
|
|
||||||
float getTilePx() const;
|
// The world <-> widget transform for the current viewport size. The arena
|
||||||
QPointF worldToWidget(QVector2D worldPos) const;
|
// shows the whole world at once and never scrolls, so this fits the arena's
|
||||||
QPointF tileToWidget(QPoint tile) const;
|
// full extent into the widget; like GameWorldView's, it is a per-frame
|
||||||
QRectF tileRect(QPoint tile) const;
|
// snapshot rather than cached state.
|
||||||
|
WorldCoordinates getCoordinates() const;
|
||||||
|
|
||||||
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
||||||
QVector2D widgetToWorld(QPoint widgetPt) const;
|
|
||||||
|
|
||||||
struct ActiveBeam
|
struct ActiveBeam
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,8 +7,21 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.h
|
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h
|
||||||
|
# The card parts the ship stats panel is built from. They are deliberately free of
|
||||||
|
# Simulation and GameConfig, which is what lets them come along here.
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/StatRow.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SelectionNames.h
|
||||||
|
# A stat row states a value with an inline item icon (REQ-UI-ITEM-ICON), which this
|
||||||
|
# composes; it needs nothing but Qt's painting.
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/IconCaption.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h
|
||||||
|
# Shared world-space shapes so the arena keeps looking like the game
|
||||||
|
# (see WorldPrimitives.h). The balancing target does not link the ui library,
|
||||||
|
# so the few ui files it needs are compiled into it, as above.
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.h
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +35,12 @@ SET(SRCS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/StatRow.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SelectionNames.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/IconCaption.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.cpp
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprintSerializer.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprintSerializer.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/TomlHelpers.h
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,9 +21,17 @@ SET(SRCS
|
|||||||
${SRCS}
|
${SRCS}
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/Formula.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/Formula.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderWorld.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderBuildings.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderRecipes.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderShips.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderStations.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderModules.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoaderUnlocks.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprintSerializer.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprintSerializer.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/TomlHelpers.cpp
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,779 +1,10 @@
|
|||||||
#include "ConfigLoader.h"
|
#include "ConfigLoader.h"
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <sstream>
|
|
||||||
#include <stdexcept>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <utility>
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <QPoint>
|
#include "TomlHelpers.h"
|
||||||
|
|
||||||
#include "toml.hpp"
|
|
||||||
|
|
||||||
#include "Rotation.h"
|
|
||||||
#include "ShipLayout.h"
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
|
|
||||||
// --- Error helpers --------------------------------------------------------
|
|
||||||
|
|
||||||
std::runtime_error makeError(const std::string& file,
|
|
||||||
const std::string& path,
|
|
||||||
const std::string& why)
|
|
||||||
{
|
|
||||||
return std::runtime_error("Config: " + file + ": '" + path + "' " + why);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Typed accessors (throw on missing or wrong type) ---------------------
|
|
||||||
|
|
||||||
int64_t requireInt(const toml::node_view<toml::node>& node,
|
|
||||||
const std::string& file,
|
|
||||||
const std::string& path)
|
|
||||||
{
|
|
||||||
const std::optional<int64_t> value = node.value<int64_t>();
|
|
||||||
if (!value)
|
|
||||||
{
|
|
||||||
throw makeError(file, path, "missing or not an integer");
|
|
||||||
}
|
|
||||||
return *value;
|
|
||||||
}
|
|
||||||
|
|
||||||
double requireDouble(const toml::node_view<toml::node>& node,
|
|
||||||
const std::string& file,
|
|
||||||
const std::string& path)
|
|
||||||
{
|
|
||||||
if (const std::optional<double> v = node.value<double>())
|
|
||||||
{
|
|
||||||
return *v;
|
|
||||||
}
|
|
||||||
if (const std::optional<int64_t> v = node.value<int64_t>())
|
|
||||||
{
|
|
||||||
return static_cast<double>(*v);
|
|
||||||
}
|
|
||||||
throw makeError(file, path, "missing or not a number");
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string requireString(const toml::node_view<toml::node>& node,
|
|
||||||
const std::string& file,
|
|
||||||
const std::string& path)
|
|
||||||
{
|
|
||||||
const std::optional<std::string> value = node.value<std::string>();
|
|
||||||
if (!value)
|
|
||||||
{
|
|
||||||
throw makeError(file, path, "missing or not a string");
|
|
||||||
}
|
|
||||||
return *value;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool requireBool(const toml::node_view<toml::node>& node,
|
|
||||||
const std::string& file,
|
|
||||||
const std::string& path)
|
|
||||||
{
|
|
||||||
const std::optional<bool> value = node.value<bool>();
|
|
||||||
if (!value)
|
|
||||||
{
|
|
||||||
throw makeError(file, path, "missing or not a boolean");
|
|
||||||
}
|
|
||||||
return *value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toml::array& requireArray(const toml::node_view<toml::node>& node,
|
|
||||||
const std::string& file,
|
|
||||||
const std::string& path)
|
|
||||||
{
|
|
||||||
const toml::array* arr = node.as_array();
|
|
||||||
if (arr == nullptr)
|
|
||||||
{
|
|
||||||
throw makeError(file, path, "missing or not an array");
|
|
||||||
}
|
|
||||||
return *arr;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toml::table& requireTable(const toml::node_view<toml::node>& node,
|
|
||||||
const std::string& file,
|
|
||||||
const std::string& path)
|
|
||||||
{
|
|
||||||
const toml::table* tbl = node.as_table();
|
|
||||||
if (tbl == nullptr)
|
|
||||||
{
|
|
||||||
throw makeError(file, path, "missing or not a table");
|
|
||||||
}
|
|
||||||
return *tbl;
|
|
||||||
}
|
|
||||||
|
|
||||||
Formula requireFormula(const toml::node_view<toml::node>& node,
|
|
||||||
const std::string& file,
|
|
||||||
const std::string& path)
|
|
||||||
{
|
|
||||||
const std::string source = requireString(node, file, path);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return Formula::compile(source);
|
|
||||||
}
|
|
||||||
catch (const std::exception& e)
|
|
||||||
{
|
|
||||||
throw makeError(file, path, std::string("formula error: ") + e.what());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<std::string> requireStringArray(const toml::node_view<toml::node>& node,
|
|
||||||
const std::string& file,
|
|
||||||
const std::string& path)
|
|
||||||
{
|
|
||||||
const toml::array& arr = requireArray(node, file, path);
|
|
||||||
std::vector<std::string> 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 std::optional<std::string> s = arr[i].value<std::string>();
|
|
||||||
if (!s)
|
|
||||||
{
|
|
||||||
throw makeError(file, elemPath, "not a string");
|
|
||||||
}
|
|
||||||
result.push_back(*s);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<RecipeIngredient> parseIngredients(const toml::array& arr,
|
|
||||||
const std::string& file,
|
|
||||||
const std::string& path)
|
|
||||||
{
|
|
||||||
std::vector<RecipeIngredient> 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 makeError(file, elemPath, "not a table");
|
|
||||||
}
|
|
||||||
|
|
||||||
// We need a mutable node_view to reuse our helpers, which is fine
|
|
||||||
// because the helpers never mutate.
|
|
||||||
toml::table& mt = const_cast<toml::table&>(*t);
|
|
||||||
|
|
||||||
RecipeIngredient ing;
|
|
||||||
ing.item = requireString(mt["item"], file, elemPath + ".item");
|
|
||||||
ing.amount = static_cast<int>(requireInt(mt["amount"], file, elemPath + ".amount"));
|
|
||||||
result.push_back(std::move(ing));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<RecipeOutput> parseRecipeOutputs(const toml::array& arr,
|
|
||||||
const std::string& file,
|
|
||||||
const std::string& path)
|
|
||||||
{
|
|
||||||
std::vector<RecipeOutput> 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 makeError(file, elemPath, "not a table");
|
|
||||||
}
|
|
||||||
toml::table& mt = const_cast<toml::table&>(*t);
|
|
||||||
|
|
||||||
RecipeOutput out;
|
|
||||||
out.item = requireString(mt["item"], file, elemPath + ".item");
|
|
||||||
out.amount = static_cast<int>(requireInt(mt["amount"], file, elemPath + ".amount"));
|
|
||||||
if (const std::optional<double> p = mt["probability"].value<double>())
|
|
||||||
{
|
|
||||||
out.probability = *p;
|
|
||||||
}
|
|
||||||
else if (const std::optional<int64_t> p = mt["probability"].value<int64_t>())
|
|
||||||
{
|
|
||||||
out.probability = static_cast<double>(*p);
|
|
||||||
}
|
|
||||||
result.push_back(std::move(out));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
toml::table parseFile(const std::string& path, const std::string& file)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return toml::parse_file(path);
|
|
||||||
}
|
|
||||||
catch (const toml::parse_error& e)
|
|
||||||
{
|
|
||||||
std::ostringstream oss;
|
|
||||||
oss << "Config: " << file << ": TOML parse error: " << e.description()
|
|
||||||
<< " at " << e.source().begin;
|
|
||||||
throw std::runtime_error(oss.str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rotation parseRotationString(const std::string& s)
|
|
||||||
{
|
|
||||||
if (s == "east") { return Rotation::East; }
|
|
||||||
if (s == "south") { return Rotation::South; }
|
|
||||||
if (s == "west") { return Rotation::West; }
|
|
||||||
return Rotation::North;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<PlacedModule> parsePlacedModules(const toml::array& arr,
|
|
||||||
const std::string& file,
|
|
||||||
const std::string& path)
|
|
||||||
{
|
|
||||||
std::vector<PlacedModule> 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) { continue; }
|
|
||||||
toml::table& mt = const_cast<toml::table&>(*t);
|
|
||||||
|
|
||||||
const std::optional<std::string> type = mt["type"].value<std::string>();
|
|
||||||
const std::optional<int64_t> x = mt["x"].value<int64_t>();
|
|
||||||
const std::optional<int64_t> y = mt["y"].value<int64_t>();
|
|
||||||
const std::optional<std::string> rot = mt["rotation"].value<std::string>();
|
|
||||||
if (!type || !x || !y || !rot) { continue; }
|
|
||||||
|
|
||||||
PlacedModule pm;
|
|
||||||
pm.moduleId = *type;
|
|
||||||
pm.position = QPoint(static_cast<int>(*x), static_cast<int>(*y));
|
|
||||||
pm.rotation = parseRotationString(*rot);
|
|
||||||
result.push_back(std::move(pm));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
|
|
||||||
// --- Per-file loaders -----------------------------------------------------
|
|
||||||
|
|
||||||
WorldConfig ConfigLoader::loadWorld(const std::string& path)
|
|
||||||
{
|
|
||||||
const std::string file = "world.toml";
|
|
||||||
toml::table tbl = parseFile(path, file);
|
|
||||||
|
|
||||||
WorldConfig cfg;
|
|
||||||
|
|
||||||
cfg.heightTiles = static_cast<int>(requireInt(tbl["world"]["height_tiles"], file, "world.height_tiles"));
|
|
||||||
cfg.refundPercentage = static_cast<int>(requireInt(tbl["world"]["refund_percentage"], file, "world.refund_percentage"));
|
|
||||||
cfg.deconstructionTimeSeconds = requireDouble(tbl["world"]["deconstruction_time_seconds"], file, "world.deconstruction_time_seconds");
|
|
||||||
cfg.startingBuildingBlocks = static_cast<int>(requireInt(tbl["world"]["starting_building_blocks"], file, "world.starting_building_blocks"));
|
|
||||||
cfg.debrisDespawnSeconds = requireDouble(tbl["world"]["debris_despawn_seconds"], file, "world.debris_despawn_seconds");
|
|
||||||
cfg.scrapPerThreat = requireDouble(tbl["world"]["scrap_per_threat"], file, "world.scrap_per_threat");
|
|
||||||
cfg.tileSize_m = requireDouble(tbl["world"]["tile_size_m"], file, "world.tile_size_m");
|
|
||||||
cfg.beltSpeed_tps = requireDouble(tbl["world"]["belt_speed_mps"], file, "world.belt_speed_mps") / cfg.tileSize_m;
|
|
||||||
cfg.tunnelMaxDistance_tiles = static_cast<int>(requireInt(tbl["world"]["tunnel_max_distance_tiles"], file, "world.tunnel_max_distance_tiles"));
|
|
||||||
cfg.departureIntervalSeconds = requireDouble(tbl["world"]["departure_interval_seconds"], file, "world.departure_interval_seconds");
|
|
||||||
cfg.orbitFactor = requireDouble(tbl["world"]["orbit_factor"], file, "world.orbit_factor");
|
|
||||||
cfg.rallyOrbitRadius_tiles = requireDouble(tbl["world"]["rally_orbit_radius_tiles"], file, "world.rally_orbit_radius_tiles");
|
|
||||||
|
|
||||||
if (const std::optional<std::string> tip =
|
|
||||||
tbl["world"]["building_blocks_tooltip"].value<std::string>())
|
|
||||||
{
|
|
||||||
cfg.buildingBlocksTooltip = *tip;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (const std::optional<std::string> tip =
|
|
||||||
tbl["world"]["artifact_tooltip"].value<std::string>())
|
|
||||||
{
|
|
||||||
cfg.artifactTooltip = *tip;
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.regions.asteroidWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["asteroid_width_tiles"], file, "regions.asteroid_width_tiles"));
|
|
||||||
cfg.regions.playerBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["player_buffer_width_tiles"], file, "regions.player_buffer_width_tiles"));
|
|
||||||
cfg.regions.contestZoneWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["contest_zone_width_tiles"], file, "regions.contest_zone_width_tiles"));
|
|
||||||
cfg.regions.enemyBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["enemy_buffer_width_tiles"], file, "regions.enemy_buffer_width_tiles"));
|
|
||||||
|
|
||||||
cfg.expansion.columnsPerExpansion_tiles = static_cast<int>(requireInt(tbl["expansion"]["columns_per_expansion_tiles"], file, "expansion.columns_per_expansion_tiles"));
|
|
||||||
cfg.expansion.costBuildingBlocksFormula = requireFormula(tbl["expansion"]["cost_building_blocks_formula"], file, "expansion.cost_building_blocks_formula");
|
|
||||||
|
|
||||||
cfg.push.pushExpandColumns_tiles = static_cast<int>(requireInt(tbl["push"]["push_expand_columns_tiles"], file, "push.push_expand_columns_tiles"));
|
|
||||||
cfg.push.bossAdvanceSeconds = requireDouble(tbl["push"]["boss_advance_seconds"], file, "push.boss_advance_seconds");
|
|
||||||
|
|
||||||
cfg.waves.threatRateFormula = requireFormula(tbl["waves"]["threat_rate_formula"], file, "waves.threat_rate_formula");
|
|
||||||
cfg.waves.gapMinSeconds = requireDouble(tbl["waves"]["gap_min_seconds"], file, "waves.gap_min_seconds");
|
|
||||||
cfg.waves.gapMaxSeconds = requireDouble(tbl["waves"]["gap_max_seconds"], file, "waves.gap_max_seconds");
|
|
||||||
cfg.waves.spawnDurationSeconds = requireDouble(tbl["waves"]["spawn_duration_seconds"], file, "waves.spawn_duration_seconds");
|
|
||||||
cfg.waves.bossCountdownSeconds = requireDouble(tbl["waves"]["boss_countdown_seconds"], file, "waves.boss_countdown_seconds");
|
|
||||||
cfg.waves.bossThreatDurationSeconds = requireDouble(tbl["waves"]["boss_threat_duration_seconds"], file, "waves.boss_threat_duration_seconds");
|
|
||||||
cfg.waves.bossQuietBeforeSeconds = requireDouble(tbl["waves"]["boss_quiet_before_seconds"], file, "waves.boss_quiet_before_seconds");
|
|
||||||
cfg.waves.bossQuietAfterSeconds = requireDouble(tbl["waves"]["boss_quiet_after_seconds"], file, "waves.boss_quiet_after_seconds");
|
|
||||||
|
|
||||||
if (cfg.waves.gapMinSeconds > cfg.waves.gapMaxSeconds)
|
|
||||||
{
|
|
||||||
throw makeError(file, "waves", "gap_min_seconds > gap_max_seconds");
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.targeting.targetScoreFormula = requireFormula(tbl["targeting"]["target_score_formula"], file, "targeting.target_score_formula");
|
|
||||||
cfg.targeting.overclaimPenaltyFormula = requireFormula(tbl["targeting"]["overclaim_penalty_formula"], file, "targeting.overclaim_penalty_formula");
|
|
||||||
cfg.targeting.hysteresis = requireDouble(tbl["targeting"]["target_hysteresis"], file, "targeting.target_hysteresis");
|
|
||||||
|
|
||||||
cfg.artifacts.artifactChanceFormula = requireFormula(tbl["artifacts"]["artifact_chance_formula"], file, "artifacts.artifact_chance_formula");
|
|
||||||
cfg.artifacts.artifactWinCount = static_cast<int>(requireInt(tbl["artifacts"]["artifact_win_count"], file, "artifacts.artifact_win_count"));
|
|
||||||
|
|
||||||
cfg.scroll.panSpeedSlow_tps = requireDouble(tbl["scroll"]["pan_speed_slow_tiles_per_second"], file, "scroll.pan_speed_slow_tiles_per_second");
|
|
||||||
cfg.scroll.panSpeedFast_tps = requireDouble(tbl["scroll"]["pan_speed_fast_tiles_per_second"], file, "scroll.pan_speed_fast_tiles_per_second");
|
|
||||||
cfg.scroll.panRampBandWidth_tiles = static_cast<int>(requireInt(tbl["scroll"]["pan_ramp_band_width_tiles"], file, "scroll.pan_ramp_band_width_tiles"));
|
|
||||||
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
BuildingsConfig ConfigLoader::loadBuildings(const std::string& path)
|
|
||||||
{
|
|
||||||
const std::string file = "buildings.toml";
|
|
||||||
toml::table tbl = parseFile(path, file);
|
|
||||||
|
|
||||||
BuildingsConfig cfg;
|
|
||||||
const toml::array& arr = requireArray(tbl["building"], file, "building");
|
|
||||||
|
|
||||||
for (std::size_t i = 0; i < arr.size(); ++i)
|
|
||||||
{
|
|
||||||
const std::string elemPath = "building[" + std::to_string(i) + "]";
|
|
||||||
const toml::table* bt = arr[i].as_table();
|
|
||||||
if (bt == nullptr)
|
|
||||||
{
|
|
||||||
throw makeError(file, elemPath, "not a table");
|
|
||||||
}
|
|
||||||
toml::table& mt = const_cast<toml::table&>(*bt);
|
|
||||||
|
|
||||||
BuildingDef def;
|
|
||||||
def.id = requireString(mt["id"], file, elemPath + ".id");
|
|
||||||
def.cost = static_cast<int>(requireInt(mt["cost"], file, elemPath + ".cost"));
|
|
||||||
def.playerPlaceable = requireBool(mt["player_placeable"], file, elemPath + ".player_placeable");
|
|
||||||
def.constructionTimeSeconds = requireDouble(mt["construction_time_seconds"], file, elemPath + ".construction_time_seconds");
|
|
||||||
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
|
|
||||||
|
|
||||||
if (mt.contains("output_buffer_capacity"))
|
|
||||||
{
|
|
||||||
def.outputBufferCapacity = static_cast<int>(
|
|
||||||
requireInt(mt["output_buffer_capacity"], file, elemPath + ".output_buffer_capacity"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mt.contains("tooltip"))
|
|
||||||
{
|
|
||||||
def.tooltip = requireString(mt["tooltip"], file, elemPath + ".tooltip");
|
|
||||||
}
|
|
||||||
|
|
||||||
const std::optional<BuildingType> parsedType = parseBuildingType(def.id);
|
|
||||||
if (!parsedType)
|
|
||||||
{
|
|
||||||
throw makeError(file, elemPath + ".id", "unknown building id '" + def.id + "'");
|
|
||||||
}
|
|
||||||
def.type = *parsedType;
|
|
||||||
|
|
||||||
cfg.buildings.push_back(std::move(def));
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
|
|
||||||
{
|
|
||||||
const std::string file = "recipes.toml";
|
|
||||||
toml::table tbl = parseFile(path, file);
|
|
||||||
|
|
||||||
RecipesConfig cfg;
|
|
||||||
const toml::array& arr = requireArray(tbl["recipe"], file, "recipe");
|
|
||||||
|
|
||||||
for (std::size_t i = 0; i < arr.size(); ++i)
|
|
||||||
{
|
|
||||||
const std::string elemPath = "recipe[" + std::to_string(i) + "]";
|
|
||||||
const toml::table* rt = arr[i].as_table();
|
|
||||||
if (rt == nullptr)
|
|
||||||
{
|
|
||||||
throw makeError(file, elemPath, "not a table");
|
|
||||||
}
|
|
||||||
toml::table& mt = const_cast<toml::table&>(*rt);
|
|
||||||
|
|
||||||
RecipeDef def;
|
|
||||||
def.id = requireString(mt["id"], file, elemPath + ".id");
|
|
||||||
def.durationSeconds = requireDouble(mt["duration_seconds"], file, elemPath + ".duration_seconds");
|
|
||||||
|
|
||||||
const std::string buildingId = requireString(mt["building"], file, elemPath + ".building");
|
|
||||||
const std::optional<BuildingType> parsedType = parseBuildingType(buildingId);
|
|
||||||
if (!parsedType)
|
|
||||||
{
|
|
||||||
throw makeError(file, elemPath + ".building",
|
|
||||||
"unknown building id '" + buildingId + "'");
|
|
||||||
}
|
|
||||||
def.building = *parsedType;
|
|
||||||
|
|
||||||
if (def.building == BuildingType::Assembler && mt.contains("unlocked_at_start"))
|
|
||||||
{
|
|
||||||
def.unlockedAtStart = requireBool(mt["unlocked_at_start"], file,
|
|
||||||
elemPath + ".unlocked_at_start");
|
|
||||||
}
|
|
||||||
|
|
||||||
// inputs may be omitted (e.g. miner recipes). An empty array is fine.
|
|
||||||
if (mt.contains("inputs"))
|
|
||||||
{
|
|
||||||
const toml::array& inputs = requireArray(mt["inputs"], file, elemPath + ".inputs");
|
|
||||||
def.inputs = parseIngredients(inputs, file, elemPath + ".inputs");
|
|
||||||
}
|
|
||||||
|
|
||||||
const toml::array& outputs = requireArray(mt["outputs"], file, elemPath + ".outputs");
|
|
||||||
def.outputs = parseRecipeOutputs(outputs, file, elemPath + ".outputs");
|
|
||||||
|
|
||||||
// Optional icon item id (REQ-UI-RECIPE-ICON); defaults to the first output
|
|
||||||
// in the UI when unset. Not validated against known items here — a missing
|
|
||||||
// icon is not an error (REQ-UI-ITEM-ICON).
|
|
||||||
if (mt.contains("icon"))
|
|
||||||
{
|
|
||||||
def.icon = requireString(mt["icon"], file, elemPath + ".icon");
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.recipes.push_back(std::move(def));
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
ShipsConfig ConfigLoader::loadShips(const std::string& path)
|
|
||||||
{
|
|
||||||
const std::string file = "ships.toml";
|
|
||||||
toml::table tbl = parseFile(path, file);
|
|
||||||
|
|
||||||
ShipsConfig cfg;
|
|
||||||
const toml::array& arr = requireArray(tbl["ship"], file, "ship");
|
|
||||||
|
|
||||||
for (std::size_t i = 0; i < arr.size(); ++i)
|
|
||||||
{
|
|
||||||
const std::string elemPath = "ship[" + std::to_string(i) + "]";
|
|
||||||
const toml::table* st = arr[i].as_table();
|
|
||||||
if (st == nullptr)
|
|
||||||
{
|
|
||||||
throw makeError(file, elemPath, "not a table");
|
|
||||||
}
|
|
||||||
toml::table& mt = const_cast<toml::table&>(*st);
|
|
||||||
|
|
||||||
ShipDef def;
|
|
||||||
def.id = requireString(mt["id"], file, elemPath + ".id");
|
|
||||||
def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout");
|
|
||||||
|
|
||||||
// Schematic
|
|
||||||
{
|
|
||||||
const std::string bpPath = elemPath + ".schematic";
|
|
||||||
const toml::table& bpTable = requireTable(mt["schematic"], file, bpPath);
|
|
||||||
toml::table& bpMt = const_cast<toml::table&>(bpTable);
|
|
||||||
|
|
||||||
const toml::array& materials = requireArray(bpMt["materials"], file, bpPath + ".materials");
|
|
||||||
def.schematic.materials = parseIngredients(materials, file, bpPath + ".materials");
|
|
||||||
def.schematic.productionTimeSeconds = requireDouble(
|
|
||||||
bpMt["production_time_seconds"], file, bpPath + ".production_time_seconds");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Health
|
|
||||||
{
|
|
||||||
const std::string hPath = elemPath + ".health";
|
|
||||||
const toml::table& hTable = requireTable(mt["health"], file, hPath);
|
|
||||||
toml::table& hMt = const_cast<toml::table&>(hTable);
|
|
||||||
def.health.hp = static_cast<float>(requireDouble(hMt["hp"], file, hPath + ".hp"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Movement
|
|
||||||
{
|
|
||||||
const std::string mPath = elemPath + ".movement";
|
|
||||||
const toml::table& mTable = requireTable(mt["movement"], file, mPath);
|
|
||||||
toml::table& mMt = const_cast<toml::table&>(mTable);
|
|
||||||
def.movement.speed_mps = static_cast<float>(requireDouble(mMt["speed_mps"], file, mPath + ".speed_mps"));
|
|
||||||
def.movement.mainAcceleration_mpss = static_cast<float>(requireDouble(mMt["main_acceleration_mpss"], file, mPath + ".main_acceleration_mpss"));
|
|
||||||
def.movement.maneuveringAcceleration_mpss = static_cast<float>(requireDouble(mMt["maneuvering_acceleration_mpss"], file, mPath + ".maneuvering_acceleration_mpss"));
|
|
||||||
def.movement.angularAcceleration_radpss = static_cast<float>(requireDouble(mMt["angular_acceleration_radpss"], file, mPath + ".angular_acceleration_radpss"));
|
|
||||||
def.movement.maxRotationSpeed_radps = static_cast<float>(requireDouble(mMt["max_rotation_speed_radps"], file, mPath + ".max_rotation_speed_radps"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sensor
|
|
||||||
{
|
|
||||||
const std::string snsPath = elemPath + ".sensor";
|
|
||||||
const toml::table& snsTable = requireTable(mt["sensor"], file, snsPath);
|
|
||||||
toml::table& snsMt = const_cast<toml::table&>(snsTable);
|
|
||||||
def.sensor.sensorRange_m = static_cast<float>(requireDouble(snsMt["sensor_range_m"], file, snsPath + ".sensor_range_m"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: default_modules (REQ-WAV-DEFAULT-MODULES)
|
|
||||||
if (mt.contains("default_modules"))
|
|
||||||
{
|
|
||||||
const toml::array& modArr = requireArray(mt["default_modules"], file,
|
|
||||||
elemPath + ".default_modules");
|
|
||||||
def.defaultModules = parsePlacedModules(modArr, file,
|
|
||||||
elemPath + ".default_modules");
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.ships.push_back(std::move(def));
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
StationsConfig ConfigLoader::loadStations(const std::string& path)
|
|
||||||
{
|
|
||||||
const std::string file = "stations.toml";
|
|
||||||
toml::table tbl = parseFile(path, file);
|
|
||||||
|
|
||||||
StationsConfig cfg;
|
|
||||||
|
|
||||||
// HQ
|
|
||||||
{
|
|
||||||
const std::string p = "hq";
|
|
||||||
cfg.hq.surfaceMask = requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask");
|
|
||||||
cfg.hq.hpFormula = requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Player station
|
|
||||||
{
|
|
||||||
const std::string p = "player_station";
|
|
||||||
cfg.playerStation.surfaceMask = requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask");
|
|
||||||
cfg.playerStation.level = static_cast<int>(requireInt(tbl[p]["level"], file, p + ".level"));
|
|
||||||
cfg.playerStation.hpFormula = requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula");
|
|
||||||
cfg.playerStation.damageFormula = requireFormula(tbl[p]["damage_formula"], file, p + ".damage_formula");
|
|
||||||
cfg.playerStation.rangeFormula = requireFormula(tbl[p]["range_m_formula"], file, p + ".range_m_formula");
|
|
||||||
cfg.playerStation.fireRateFormula = requireFormula(tbl[p]["fire_rate_hz_formula"], file, p + ".fire_rate_hz_formula");
|
|
||||||
cfg.playerStation.scrapDropFormula = requireFormula(tbl[p]["scrap_drop_formula"], file, p + ".scrap_drop_formula");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enemy station
|
|
||||||
{
|
|
||||||
const std::string p = "enemy_station";
|
|
||||||
cfg.enemyStation.surfaceMask = requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask");
|
|
||||||
cfg.enemyStation.hpFormula = requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula");
|
|
||||||
cfg.enemyStation.damageFormula = requireFormula(tbl[p]["damage_formula"], file, p + ".damage_formula");
|
|
||||||
cfg.enemyStation.rangeFormula = requireFormula(tbl[p]["range_m_formula"], file, p + ".range_m_formula");
|
|
||||||
cfg.enemyStation.fireRateFormula = requireFormula(tbl[p]["fire_rate_hz_formula"], file, p + ".fire_rate_hz_formula");
|
|
||||||
cfg.enemyStation.scrapDropFormula = requireFormula(tbl[p]["scrap_drop_formula"], file, p + ".scrap_drop_formula");
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Known category→stat mappings for module stat modifier discovery.
|
|
||||||
// addedKeySuffix: unit suffix appended before "_formula" for additive modifier keys only.
|
|
||||||
// Multiplicative modifier keys are always dimensionless and carry no suffix.
|
|
||||||
struct StatEntry
|
|
||||||
{
|
|
||||||
const char* category;
|
|
||||||
const char* stat;
|
|
||||||
const char* addedKeySuffix;
|
|
||||||
};
|
|
||||||
|
|
||||||
static const StatEntry kKnownStats[] = {
|
|
||||||
{"health", "hp", ""},
|
|
||||||
{"movement", "speed", "_mps"},
|
|
||||||
{"movement", "main_acceleration", "_mpss"},
|
|
||||||
{"movement", "maneuvering_acceleration", "_mpss"},
|
|
||||||
{"sensor", "sensor_range", "_m"},
|
|
||||||
{"weapon", "damage", ""},
|
|
||||||
{"weapon", "attack_range", "_m"},
|
|
||||||
{"weapon", "attack_rate", "_hz"},
|
|
||||||
{"salvage", "collection_range", "_m"},
|
|
||||||
{"salvage", "collection_rate", "_hz"},
|
|
||||||
{"cargo", "cargo_capacity", ""},
|
|
||||||
{"repair", "repair_rate", "_hz"},
|
|
||||||
{"repair", "repair_range", "_m"},
|
|
||||||
};
|
|
||||||
|
|
||||||
ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
|
||||||
{
|
|
||||||
const std::string file = "modules.toml";
|
|
||||||
toml::table tbl = parseFile(path, file);
|
|
||||||
|
|
||||||
ModulesConfig cfg;
|
|
||||||
|
|
||||||
if (!tbl.contains("module"))
|
|
||||||
{
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toml::array& arr = requireArray(tbl["module"], file, "module");
|
|
||||||
|
|
||||||
for (std::size_t i = 0; i < arr.size(); ++i)
|
|
||||||
{
|
|
||||||
const std::string elemPath = "module[" + std::to_string(i) + "]";
|
|
||||||
const toml::table* st = arr[i].as_table();
|
|
||||||
if (st == nullptr)
|
|
||||||
{
|
|
||||||
throw makeError(file, elemPath, "not a table");
|
|
||||||
}
|
|
||||||
toml::table& mt = const_cast<toml::table&>(*st);
|
|
||||||
|
|
||||||
ModuleDef def;
|
|
||||||
def.id = requireString(mt["id"], file, elemPath + ".id");
|
|
||||||
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
|
|
||||||
def.productionTimeSeconds = requireDouble(
|
|
||||||
mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
|
|
||||||
def.fillColor = requireString(mt["fill_color"], file, elemPath + ".fill_color");
|
|
||||||
def.glyph = requireString(mt["glyph"], file, elemPath + ".glyph");
|
|
||||||
|
|
||||||
if (mt.contains("tooltip"))
|
|
||||||
{
|
|
||||||
def.tooltip = requireString(mt["tooltip"], file, elemPath + ".tooltip");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Materials
|
|
||||||
{
|
|
||||||
const toml::array& materials = requireArray(mt["materials"], file, elemPath + ".materials");
|
|
||||||
def.materials = parseIngredients(materials, file, elemPath + ".materials");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stat modifiers from [module.<category>] sub-tables
|
|
||||||
for (const StatEntry& se : kKnownStats)
|
|
||||||
{
|
|
||||||
if (!mt.contains(se.category))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const toml::table& catTable = requireTable(mt[se.category], file,
|
|
||||||
elemPath + "." + se.category);
|
|
||||||
toml::table& catMt = const_cast<toml::table&>(catTable);
|
|
||||||
|
|
||||||
const std::string addedKey = std::string("added_") + se.stat + se.addedKeySuffix;
|
|
||||||
const std::string multipliedKey = std::string("multiplied_") + se.stat + se.addedKeySuffix;
|
|
||||||
|
|
||||||
if (catMt.contains(addedKey))
|
|
||||||
{
|
|
||||||
ModuleStatModifier mod;
|
|
||||||
mod.stat = se.stat;
|
|
||||||
mod.modifierType = "additive";
|
|
||||||
mod.value = requireDouble(catMt[addedKey], file,
|
|
||||||
elemPath + "." + se.category + "." + addedKey);
|
|
||||||
def.statModifiers.push_back(std::move(mod));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (catMt.contains(multipliedKey))
|
|
||||||
{
|
|
||||||
ModuleStatModifier mod;
|
|
||||||
mod.stat = se.stat;
|
|
||||||
mod.modifierType = "multiplicative";
|
|
||||||
mod.value = requireDouble(catMt[multipliedKey], file,
|
|
||||||
elemPath + "." + se.category + "." + multipliedKey);
|
|
||||||
def.statModifiers.push_back(std::move(mod));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Weapon capability section: [module.weapon] with base stat formulas
|
|
||||||
if (mt.contains("weapon"))
|
|
||||||
{
|
|
||||||
const std::string wPath = elemPath + ".weapon";
|
|
||||||
const toml::table& wTable = requireTable(mt["weapon"], file, wPath);
|
|
||||||
toml::table& wMt = const_cast<toml::table&>(wTable);
|
|
||||||
if (wMt.contains("damage") || wMt.contains("attack_range_m")
|
|
||||||
|| wMt.contains("attack_rate_hz"))
|
|
||||||
{
|
|
||||||
ModuleWeaponCapability cap;
|
|
||||||
cap.damage = static_cast<float>(requireDouble(wMt["damage"],
|
|
||||||
file, wPath + ".damage"));
|
|
||||||
cap.attackRange_m = static_cast<float>(requireDouble(wMt["attack_range_m"],
|
|
||||||
file, wPath + ".attack_range_m"));
|
|
||||||
cap.attackRate_hz = static_cast<float>(requireDouble(wMt["attack_rate_hz"],
|
|
||||||
file, wPath + ".attack_rate_hz"));
|
|
||||||
def.weaponCapability = std::move(cap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Salvage capability section: [module.salvage] with base stat formulas
|
|
||||||
if (mt.contains("salvage"))
|
|
||||||
{
|
|
||||||
const std::string sPath = elemPath + ".salvage";
|
|
||||||
const toml::table& sTable = requireTable(mt["salvage"], file, sPath);
|
|
||||||
toml::table& sMt = const_cast<toml::table&>(sTable);
|
|
||||||
if (sMt.contains("collection_range_m") || sMt.contains("cargo_capacity")
|
|
||||||
|| sMt.contains("collection_rate_hz"))
|
|
||||||
{
|
|
||||||
ModuleSalvageCapability cap;
|
|
||||||
cap.collectionRange_m = static_cast<float>(requireDouble(sMt["collection_range_m"],
|
|
||||||
file, sPath + ".collection_range_m"));
|
|
||||||
cap.cargoCapacity = static_cast<float>(requireDouble(sMt["cargo_capacity"],
|
|
||||||
file, sPath + ".cargo_capacity"));
|
|
||||||
cap.collectionRate_hz = static_cast<float>(requireDouble(sMt["collection_rate_hz"],
|
|
||||||
file, sPath + ".collection_rate_hz"));
|
|
||||||
def.salvageCapability = std::move(cap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Repair capability section: [module.repair] with base stat formulas
|
|
||||||
if (mt.contains("repair"))
|
|
||||||
{
|
|
||||||
const std::string rPath = elemPath + ".repair";
|
|
||||||
const toml::table& rTable = requireTable(mt["repair"], file, rPath);
|
|
||||||
toml::table& rMt = const_cast<toml::table&>(rTable);
|
|
||||||
if (rMt.contains("repair_rate_hz") || rMt.contains("repair_range_m"))
|
|
||||||
{
|
|
||||||
ModuleRepairCapability cap;
|
|
||||||
cap.repairRate_hz = static_cast<float>(requireDouble(rMt["repair_rate_hz"],
|
|
||||||
file, rPath + ".repair_rate_hz"));
|
|
||||||
cap.repairAmountHp = static_cast<float>(requireDouble(rMt["repair_amount_hp"],
|
|
||||||
file, rPath + ".repair_amount_hp"));
|
|
||||||
cap.repairRange_m = static_cast<float>(requireDouble(rMt["repair_range_m"],
|
|
||||||
file, rPath + ".repair_range_m"));
|
|
||||||
def.repairCapability = std::move(cap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.modules.push_back(std::move(def));
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
UnlocksConfig ConfigLoader::loadUnlocks(const std::string& path)
|
|
||||||
{
|
|
||||||
const std::string file = "unlocks.toml";
|
|
||||||
toml::table tbl = parseFile(path, file);
|
|
||||||
|
|
||||||
UnlocksConfig cfg;
|
|
||||||
if (!tbl.contains("unlock"))
|
|
||||||
{
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toml::array& arr = requireArray(tbl["unlock"], file, "unlock");
|
|
||||||
|
|
||||||
for (std::size_t i = 0; i < arr.size(); ++i)
|
|
||||||
{
|
|
||||||
const std::string elemPath = "unlock[" + std::to_string(i) + "]";
|
|
||||||
const toml::table* ut = arr[i].as_table();
|
|
||||||
if (ut == nullptr)
|
|
||||||
{
|
|
||||||
throw makeError(file, elemPath, "not a table");
|
|
||||||
}
|
|
||||||
toml::table& mt = const_cast<toml::table&>(*ut);
|
|
||||||
|
|
||||||
UnlockGroupDef def;
|
|
||||||
def.id = requireString(mt["id"], file, elemPath + ".id");
|
|
||||||
def.stationLevel = static_cast<int>(
|
|
||||||
requireInt(mt["station_level"], file, elemPath + ".station_level"));
|
|
||||||
if (mt.contains("requires"))
|
|
||||||
{
|
|
||||||
def.requiredGroupIds = requireStringArray(mt["requires"], file, elemPath + ".requires");
|
|
||||||
}
|
|
||||||
if (mt.contains("ships"))
|
|
||||||
{
|
|
||||||
def.ships = requireStringArray(mt["ships"], file, elemPath + ".ships");
|
|
||||||
}
|
|
||||||
if (mt.contains("modules"))
|
|
||||||
{
|
|
||||||
def.modules = requireStringArray(mt["modules"], file, elemPath + ".modules");
|
|
||||||
}
|
|
||||||
if (mt.contains("buildings"))
|
|
||||||
{
|
|
||||||
def.buildings = requireStringArray(mt["buildings"], file, elemPath + ".buildings");
|
|
||||||
}
|
|
||||||
if (mt.contains("recipes"))
|
|
||||||
{
|
|
||||||
def.recipes = requireStringArray(mt["recipes"], file, elemPath + ".recipes");
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.groups.push_back(std::move(def));
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -815,11 +46,11 @@ void validateUnlocks(const GameConfig& cfg)
|
|||||||
{
|
{
|
||||||
if (valid.count(id) == 0)
|
if (valid.count(id) == 0)
|
||||||
{
|
{
|
||||||
throw makeError(file, gPath, "grants unknown " + kind + " '" + id + "'");
|
throw utility::makeError(file, gPath, "grants unknown " + kind + " '" + id + "'");
|
||||||
}
|
}
|
||||||
if (!granted.insert(id).second)
|
if (!granted.insert(id).second)
|
||||||
{
|
{
|
||||||
throw makeError(file, gPath,
|
throw utility::makeError(file, gPath,
|
||||||
"grants " + kind + " '" + id + "' which is already granted by another unlock group");
|
"grants " + kind + " '" + id + "' which is already granted by another unlock group");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -830,13 +61,13 @@ void validateUnlocks(const GameConfig& cfg)
|
|||||||
const std::string gPath = "unlock '" + group.id + "'";
|
const std::string gPath = "unlock '" + group.id + "'";
|
||||||
if (!groupIds.insert(group.id).second)
|
if (!groupIds.insert(group.id).second)
|
||||||
{
|
{
|
||||||
throw makeError(file, gPath, "duplicate unlock group id");
|
throw utility::makeError(file, gPath, "duplicate unlock group id");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (group.ships.empty() && group.modules.empty()
|
if (group.ships.empty() && group.modules.empty()
|
||||||
&& group.buildings.empty() && group.recipes.empty())
|
&& group.buildings.empty() && group.recipes.empty())
|
||||||
{
|
{
|
||||||
throw makeError(file, gPath, "grants no items (must grant at least one)");
|
throw utility::makeError(file, gPath, "grants no items (must grant at least one)");
|
||||||
}
|
}
|
||||||
|
|
||||||
checkGrants(group.ships, shipIds, grantedShipIds, "ship", gPath);
|
checkGrants(group.ships, shipIds, grantedShipIds, "ship", gPath);
|
||||||
@@ -852,7 +83,7 @@ void validateUnlocks(const GameConfig& cfg)
|
|||||||
{
|
{
|
||||||
if (groupIds.count(req) == 0)
|
if (groupIds.count(req) == 0)
|
||||||
{
|
{
|
||||||
throw makeError(file, "unlock '" + group.id + "'.requires",
|
throw utility::makeError(file, "unlock '" + group.id + "'.requires",
|
||||||
"references unknown unlock group '" + req + "'");
|
"references unknown unlock group '" + req + "'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/lib/config/ConfigLoaderBuildings.cpp
Normal file
58
src/lib/config/ConfigLoaderBuildings.cpp
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#include "ConfigLoader.h"
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "toml.hpp"
|
||||||
|
|
||||||
|
#include "TomlHelpers.h"
|
||||||
|
|
||||||
|
BuildingsConfig ConfigLoader::loadBuildings(const std::string& path)
|
||||||
|
{
|
||||||
|
const std::string file = "buildings.toml";
|
||||||
|
toml::table tbl = utility::parseFile(path, file);
|
||||||
|
|
||||||
|
BuildingsConfig cfg;
|
||||||
|
const toml::array& arr = utility::requireArray(tbl["building"], file, "building");
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < arr.size(); ++i)
|
||||||
|
{
|
||||||
|
const std::string elemPath = "building[" + std::to_string(i) + "]";
|
||||||
|
const toml::table* bt = arr[i].as_table();
|
||||||
|
if (bt == nullptr)
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath, "not a table");
|
||||||
|
}
|
||||||
|
toml::table& mt = const_cast<toml::table&>(*bt);
|
||||||
|
|
||||||
|
BuildingDef def;
|
||||||
|
def.id = utility::requireString(mt["id"], file, elemPath + ".id");
|
||||||
|
def.cost = static_cast<int>(utility::requireInt(mt["cost"], file, elemPath + ".cost"));
|
||||||
|
def.playerPlaceable = utility::requireBool(mt["player_placeable"], file, elemPath + ".player_placeable");
|
||||||
|
def.constructionTimeSeconds = utility::requireDouble(mt["construction_time_seconds"], file, elemPath + ".construction_time_seconds");
|
||||||
|
def.surfaceMask = utility::requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
|
||||||
|
|
||||||
|
if (mt.contains("output_buffer_capacity"))
|
||||||
|
{
|
||||||
|
def.outputBufferCapacity = static_cast<int>(
|
||||||
|
utility::requireInt(mt["output_buffer_capacity"], file, elemPath + ".output_buffer_capacity"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mt.contains("tooltip"))
|
||||||
|
{
|
||||||
|
def.tooltip = utility::requireString(mt["tooltip"], file, elemPath + ".tooltip");
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::optional<BuildingType> parsedType = parseBuildingType(def.id);
|
||||||
|
if (!parsedType)
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath + ".id", "unknown building id '" + def.id + "'");
|
||||||
|
}
|
||||||
|
def.type = *parsedType;
|
||||||
|
|
||||||
|
cfg.buildings.push_back(std::move(def));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
182
src/lib/config/ConfigLoaderModules.cpp
Normal file
182
src/lib/config/ConfigLoaderModules.cpp
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
#include "ConfigLoader.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "toml.hpp"
|
||||||
|
|
||||||
|
#include "TomlHelpers.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// Known category→stat mappings for module stat modifier discovery.
|
||||||
|
// addedKeySuffix: unit suffix appended before "_formula" for additive modifier keys only.
|
||||||
|
// Multiplicative modifier keys are always dimensionless and carry no suffix.
|
||||||
|
struct StatEntry
|
||||||
|
{
|
||||||
|
const char* category;
|
||||||
|
const char* stat;
|
||||||
|
const char* addedKeySuffix;
|
||||||
|
};
|
||||||
|
|
||||||
|
static const StatEntry kKnownStats[] = {
|
||||||
|
{"health", "hp", ""},
|
||||||
|
{"movement", "speed", "_mps"},
|
||||||
|
{"movement", "main_acceleration", "_mpss"},
|
||||||
|
{"movement", "maneuvering_acceleration", "_mpss"},
|
||||||
|
{"sensor", "sensor_range", "_m"},
|
||||||
|
{"weapon", "damage", ""},
|
||||||
|
{"weapon", "attack_range", "_m"},
|
||||||
|
{"weapon", "attack_rate", "_hz"},
|
||||||
|
{"salvage", "collection_range", "_m"},
|
||||||
|
{"salvage", "collection_rate", "_hz"},
|
||||||
|
{"cargo", "cargo_capacity", ""},
|
||||||
|
{"repair", "repair_rate", "_hz"},
|
||||||
|
{"repair", "repair_range", "_m"},
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
||||||
|
{
|
||||||
|
const std::string file = "modules.toml";
|
||||||
|
toml::table tbl = utility::parseFile(path, file);
|
||||||
|
|
||||||
|
ModulesConfig cfg;
|
||||||
|
|
||||||
|
if (!tbl.contains("module"))
|
||||||
|
{
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toml::array& arr = utility::requireArray(tbl["module"], file, "module");
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < arr.size(); ++i)
|
||||||
|
{
|
||||||
|
const std::string elemPath = "module[" + std::to_string(i) + "]";
|
||||||
|
const toml::table* st = arr[i].as_table();
|
||||||
|
if (st == nullptr)
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath, "not a table");
|
||||||
|
}
|
||||||
|
toml::table& mt = const_cast<toml::table&>(*st);
|
||||||
|
|
||||||
|
ModuleDef def;
|
||||||
|
def.id = utility::requireString(mt["id"], file, elemPath + ".id");
|
||||||
|
def.surfaceMask = utility::requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
|
||||||
|
def.productionTimeSeconds = utility::requireDouble(
|
||||||
|
mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
|
||||||
|
def.fillColor = utility::requireString(mt["fill_color"], file, elemPath + ".fill_color");
|
||||||
|
def.glyph = utility::requireString(mt["glyph"], file, elemPath + ".glyph");
|
||||||
|
|
||||||
|
if (mt.contains("tooltip"))
|
||||||
|
{
|
||||||
|
def.tooltip = utility::requireString(mt["tooltip"], file, elemPath + ".tooltip");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Materials
|
||||||
|
{
|
||||||
|
const toml::array& materials = utility::requireArray(mt["materials"], file, elemPath + ".materials");
|
||||||
|
def.materials = utility::parseIngredients(materials, file, elemPath + ".materials");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat modifiers from [module.<category>] sub-tables
|
||||||
|
for (const StatEntry& se : kKnownStats)
|
||||||
|
{
|
||||||
|
if (!mt.contains(se.category))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const toml::table& catTable = utility::requireTable(mt[se.category], file,
|
||||||
|
elemPath + "." + se.category);
|
||||||
|
toml::table& catMt = const_cast<toml::table&>(catTable);
|
||||||
|
|
||||||
|
const std::string addedKey = std::string("added_") + se.stat + se.addedKeySuffix;
|
||||||
|
const std::string multipliedKey = std::string("multiplied_") + se.stat + se.addedKeySuffix;
|
||||||
|
|
||||||
|
if (catMt.contains(addedKey))
|
||||||
|
{
|
||||||
|
ModuleStatModifier mod;
|
||||||
|
mod.stat = se.stat;
|
||||||
|
mod.modifierType = "additive";
|
||||||
|
mod.value = utility::requireDouble(catMt[addedKey], file,
|
||||||
|
elemPath + "." + se.category + "." + addedKey);
|
||||||
|
def.statModifiers.push_back(std::move(mod));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (catMt.contains(multipliedKey))
|
||||||
|
{
|
||||||
|
ModuleStatModifier mod;
|
||||||
|
mod.stat = se.stat;
|
||||||
|
mod.modifierType = "multiplicative";
|
||||||
|
mod.value = utility::requireDouble(catMt[multipliedKey], file,
|
||||||
|
elemPath + "." + se.category + "." + multipliedKey);
|
||||||
|
def.statModifiers.push_back(std::move(mod));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weapon capability section: [module.weapon] with base stat formulas
|
||||||
|
if (mt.contains("weapon"))
|
||||||
|
{
|
||||||
|
const std::string wPath = elemPath + ".weapon";
|
||||||
|
const toml::table& wTable = utility::requireTable(mt["weapon"], file, wPath);
|
||||||
|
toml::table& wMt = const_cast<toml::table&>(wTable);
|
||||||
|
if (wMt.contains("damage") || wMt.contains("attack_range_m")
|
||||||
|
|| wMt.contains("attack_rate_hz"))
|
||||||
|
{
|
||||||
|
ModuleWeaponCapability cap;
|
||||||
|
cap.damage = static_cast<float>(utility::requireDouble(wMt["damage"],
|
||||||
|
file, wPath + ".damage"));
|
||||||
|
cap.attackRange_m = static_cast<float>(utility::requireDouble(wMt["attack_range_m"],
|
||||||
|
file, wPath + ".attack_range_m"));
|
||||||
|
cap.attackRate_hz = static_cast<float>(utility::requireDouble(wMt["attack_rate_hz"],
|
||||||
|
file, wPath + ".attack_rate_hz"));
|
||||||
|
def.weaponCapability = std::move(cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salvage capability section: [module.salvage] with base stat formulas
|
||||||
|
if (mt.contains("salvage"))
|
||||||
|
{
|
||||||
|
const std::string sPath = elemPath + ".salvage";
|
||||||
|
const toml::table& sTable = utility::requireTable(mt["salvage"], file, sPath);
|
||||||
|
toml::table& sMt = const_cast<toml::table&>(sTable);
|
||||||
|
if (sMt.contains("collection_range_m") || sMt.contains("cargo_capacity")
|
||||||
|
|| sMt.contains("collection_rate_hz"))
|
||||||
|
{
|
||||||
|
ModuleSalvageCapability cap;
|
||||||
|
cap.collectionRange_m = static_cast<float>(utility::requireDouble(sMt["collection_range_m"],
|
||||||
|
file, sPath + ".collection_range_m"));
|
||||||
|
cap.cargoCapacity = static_cast<float>(utility::requireDouble(sMt["cargo_capacity"],
|
||||||
|
file, sPath + ".cargo_capacity"));
|
||||||
|
cap.collectionRate_hz = static_cast<float>(utility::requireDouble(sMt["collection_rate_hz"],
|
||||||
|
file, sPath + ".collection_rate_hz"));
|
||||||
|
def.salvageCapability = std::move(cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repair capability section: [module.repair] with base stat formulas
|
||||||
|
if (mt.contains("repair"))
|
||||||
|
{
|
||||||
|
const std::string rPath = elemPath + ".repair";
|
||||||
|
const toml::table& rTable = utility::requireTable(mt["repair"], file, rPath);
|
||||||
|
toml::table& rMt = const_cast<toml::table&>(rTable);
|
||||||
|
if (rMt.contains("repair_rate_hz") || rMt.contains("repair_range_m"))
|
||||||
|
{
|
||||||
|
ModuleRepairCapability cap;
|
||||||
|
cap.repairRate_hz = static_cast<float>(utility::requireDouble(rMt["repair_rate_hz"],
|
||||||
|
file, rPath + ".repair_rate_hz"));
|
||||||
|
cap.repairAmountHp = static_cast<float>(utility::requireDouble(rMt["repair_amount_hp"],
|
||||||
|
file, rPath + ".repair_amount_hp"));
|
||||||
|
cap.repairRange_m = static_cast<float>(utility::requireDouble(rMt["repair_range_m"],
|
||||||
|
file, rPath + ".repair_range_m"));
|
||||||
|
def.repairCapability = std::move(cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.modules.push_back(std::move(def));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
101
src/lib/config/ConfigLoaderRecipes.cpp
Normal file
101
src/lib/config/ConfigLoaderRecipes.cpp
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#include "ConfigLoader.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "toml.hpp"
|
||||||
|
|
||||||
|
#include "TomlHelpers.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
std::vector<RecipeOutput> parseRecipeOutputs(const toml::array& arr,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
std::vector<RecipeOutput> 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);
|
||||||
|
|
||||||
|
RecipeOutput out;
|
||||||
|
out.item = utility::requireString(mt["item"], file, elemPath + ".item");
|
||||||
|
out.amount = static_cast<int>(utility::requireInt(mt["amount"], file, elemPath + ".amount"));
|
||||||
|
if (const std::optional<double> p = mt["probability"].value<double>())
|
||||||
|
{
|
||||||
|
out.probability = *p;
|
||||||
|
}
|
||||||
|
else if (const std::optional<int64_t> p = mt["probability"].value<int64_t>())
|
||||||
|
{
|
||||||
|
out.probability = static_cast<double>(*p);
|
||||||
|
}
|
||||||
|
result.push_back(std::move(out));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
|
||||||
|
{
|
||||||
|
const std::string file = "recipes.toml";
|
||||||
|
toml::table tbl = utility::parseFile(path, file);
|
||||||
|
|
||||||
|
RecipesConfig cfg;
|
||||||
|
const toml::array& arr = utility::requireArray(tbl["recipe"], file, "recipe");
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < arr.size(); ++i)
|
||||||
|
{
|
||||||
|
const std::string elemPath = "recipe[" + std::to_string(i) + "]";
|
||||||
|
const toml::table* rt = arr[i].as_table();
|
||||||
|
if (rt == nullptr)
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath, "not a table");
|
||||||
|
}
|
||||||
|
toml::table& mt = const_cast<toml::table&>(*rt);
|
||||||
|
|
||||||
|
RecipeDef def;
|
||||||
|
def.id = utility::requireString(mt["id"], file, elemPath + ".id");
|
||||||
|
def.durationSeconds = utility::requireDouble(mt["duration_seconds"], file, elemPath + ".duration_seconds");
|
||||||
|
|
||||||
|
const std::string buildingId = utility::requireString(mt["building"], file, elemPath + ".building");
|
||||||
|
const std::optional<BuildingType> parsedType = parseBuildingType(buildingId);
|
||||||
|
if (!parsedType)
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath + ".building",
|
||||||
|
"unknown building id '" + buildingId + "'");
|
||||||
|
}
|
||||||
|
def.building = *parsedType;
|
||||||
|
|
||||||
|
if (def.building == BuildingType::Assembler && mt.contains("unlocked_at_start"))
|
||||||
|
{
|
||||||
|
def.unlockedAtStart = utility::requireBool(mt["unlocked_at_start"], file,
|
||||||
|
elemPath + ".unlocked_at_start");
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputs may be omitted (e.g. miner recipes). An empty array is fine.
|
||||||
|
if (mt.contains("inputs"))
|
||||||
|
{
|
||||||
|
const toml::array& inputs = utility::requireArray(mt["inputs"], file, elemPath + ".inputs");
|
||||||
|
def.inputs = utility::parseIngredients(inputs, file, elemPath + ".inputs");
|
||||||
|
}
|
||||||
|
|
||||||
|
const toml::array& outputs = utility::requireArray(mt["outputs"], file, elemPath + ".outputs");
|
||||||
|
def.outputs = parseRecipeOutputs(outputs, file, elemPath + ".outputs");
|
||||||
|
|
||||||
|
cfg.recipes.push_back(std::move(def));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
133
src/lib/config/ConfigLoaderShips.cpp
Normal file
133
src/lib/config/ConfigLoaderShips.cpp
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#include "ConfigLoader.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
|
|
||||||
|
#include "toml.hpp"
|
||||||
|
|
||||||
|
#include "Rotation.h"
|
||||||
|
#include "ShipLayout.h"
|
||||||
|
#include "TomlHelpers.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
Rotation parseRotationString(const std::string& s)
|
||||||
|
{
|
||||||
|
if (s == "east") { return Rotation::East; }
|
||||||
|
if (s == "south") { return Rotation::South; }
|
||||||
|
if (s == "west") { return Rotation::West; }
|
||||||
|
return Rotation::North;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<PlacedModule> parsePlacedModules(const toml::array& arr,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
std::vector<PlacedModule> 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) { continue; }
|
||||||
|
toml::table& mt = const_cast<toml::table&>(*t);
|
||||||
|
|
||||||
|
const std::optional<std::string> type = mt["type"].value<std::string>();
|
||||||
|
const std::optional<int64_t> x = mt["x"].value<int64_t>();
|
||||||
|
const std::optional<int64_t> y = mt["y"].value<int64_t>();
|
||||||
|
const std::optional<std::string> rot = mt["rotation"].value<std::string>();
|
||||||
|
if (!type || !x || !y || !rot) { continue; }
|
||||||
|
|
||||||
|
PlacedModule pm;
|
||||||
|
pm.moduleId = *type;
|
||||||
|
pm.position = QPoint(static_cast<int>(*x), static_cast<int>(*y));
|
||||||
|
pm.rotation = parseRotationString(*rot);
|
||||||
|
result.push_back(std::move(pm));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ShipsConfig ConfigLoader::loadShips(const std::string& path)
|
||||||
|
{
|
||||||
|
const std::string file = "ships.toml";
|
||||||
|
toml::table tbl = utility::parseFile(path, file);
|
||||||
|
|
||||||
|
ShipsConfig cfg;
|
||||||
|
const toml::array& arr = utility::requireArray(tbl["ship"], file, "ship");
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < arr.size(); ++i)
|
||||||
|
{
|
||||||
|
const std::string elemPath = "ship[" + std::to_string(i) + "]";
|
||||||
|
const toml::table* st = arr[i].as_table();
|
||||||
|
if (st == nullptr)
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath, "not a table");
|
||||||
|
}
|
||||||
|
toml::table& mt = const_cast<toml::table&>(*st);
|
||||||
|
|
||||||
|
ShipDef def;
|
||||||
|
def.id = utility::requireString(mt["id"], file, elemPath + ".id");
|
||||||
|
def.layout = utility::requireStringArray(mt["layout"], file, elemPath + ".layout");
|
||||||
|
|
||||||
|
// Schematic
|
||||||
|
{
|
||||||
|
const std::string bpPath = elemPath + ".schematic";
|
||||||
|
const toml::table& bpTable = utility::requireTable(mt["schematic"], file, bpPath);
|
||||||
|
toml::table& bpMt = const_cast<toml::table&>(bpTable);
|
||||||
|
|
||||||
|
const toml::array& materials = utility::requireArray(bpMt["materials"], file, bpPath + ".materials");
|
||||||
|
def.schematic.materials = utility::parseIngredients(materials, file, bpPath + ".materials");
|
||||||
|
def.schematic.productionTimeSeconds = utility::requireDouble(
|
||||||
|
bpMt["production_time_seconds"], file, bpPath + ".production_time_seconds");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health
|
||||||
|
{
|
||||||
|
const std::string hPath = elemPath + ".health";
|
||||||
|
const toml::table& hTable = utility::requireTable(mt["health"], file, hPath);
|
||||||
|
toml::table& hMt = const_cast<toml::table&>(hTable);
|
||||||
|
def.health.hp = static_cast<float>(utility::requireDouble(hMt["hp"], file, hPath + ".hp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movement
|
||||||
|
{
|
||||||
|
const std::string mPath = elemPath + ".movement";
|
||||||
|
const toml::table& mTable = utility::requireTable(mt["movement"], file, mPath);
|
||||||
|
toml::table& mMt = const_cast<toml::table&>(mTable);
|
||||||
|
def.movement.speed_mps = static_cast<float>(utility::requireDouble(mMt["speed_mps"], file, mPath + ".speed_mps"));
|
||||||
|
def.movement.mainAcceleration_mpss = static_cast<float>(utility::requireDouble(mMt["main_acceleration_mpss"], file, mPath + ".main_acceleration_mpss"));
|
||||||
|
def.movement.maneuveringAcceleration_mpss = static_cast<float>(utility::requireDouble(mMt["maneuvering_acceleration_mpss"], file, mPath + ".maneuvering_acceleration_mpss"));
|
||||||
|
def.movement.angularAcceleration_radpss = static_cast<float>(utility::requireDouble(mMt["angular_acceleration_radpss"], file, mPath + ".angular_acceleration_radpss"));
|
||||||
|
def.movement.maxRotationSpeed_radps = static_cast<float>(utility::requireDouble(mMt["max_rotation_speed_radps"], file, mPath + ".max_rotation_speed_radps"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sensor
|
||||||
|
{
|
||||||
|
const std::string snsPath = elemPath + ".sensor";
|
||||||
|
const toml::table& snsTable = utility::requireTable(mt["sensor"], file, snsPath);
|
||||||
|
toml::table& snsMt = const_cast<toml::table&>(snsTable);
|
||||||
|
def.sensor.sensorRange_m = static_cast<float>(utility::requireDouble(snsMt["sensor_range_m"], file, snsPath + ".sensor_range_m"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: default_modules (REQ-WAV-DEFAULT-MODULES)
|
||||||
|
if (mt.contains("default_modules"))
|
||||||
|
{
|
||||||
|
const toml::array& modArr = utility::requireArray(mt["default_modules"], file,
|
||||||
|
elemPath + ".default_modules");
|
||||||
|
def.defaultModules = parsePlacedModules(modArr, file,
|
||||||
|
elemPath + ".default_modules");
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.ships.push_back(std::move(def));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
47
src/lib/config/ConfigLoaderStations.cpp
Normal file
47
src/lib/config/ConfigLoaderStations.cpp
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#include "ConfigLoader.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "toml.hpp"
|
||||||
|
|
||||||
|
#include "TomlHelpers.h"
|
||||||
|
|
||||||
|
StationsConfig ConfigLoader::loadStations(const std::string& path)
|
||||||
|
{
|
||||||
|
const std::string file = "stations.toml";
|
||||||
|
toml::table tbl = utility::parseFile(path, file);
|
||||||
|
|
||||||
|
StationsConfig cfg;
|
||||||
|
|
||||||
|
// HQ
|
||||||
|
{
|
||||||
|
const std::string p = "hq";
|
||||||
|
cfg.hq.surfaceMask = utility::requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask");
|
||||||
|
cfg.hq.hpFormula = utility::requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player station
|
||||||
|
{
|
||||||
|
const std::string p = "player_station";
|
||||||
|
cfg.playerStation.surfaceMask = utility::requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask");
|
||||||
|
cfg.playerStation.level = static_cast<int>(utility::requireInt(tbl[p]["level"], file, p + ".level"));
|
||||||
|
cfg.playerStation.hpFormula = utility::requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula");
|
||||||
|
cfg.playerStation.damageFormula = utility::requireFormula(tbl[p]["damage_formula"], file, p + ".damage_formula");
|
||||||
|
cfg.playerStation.rangeFormula = utility::requireFormula(tbl[p]["range_m_formula"], file, p + ".range_m_formula");
|
||||||
|
cfg.playerStation.fireRateFormula = utility::requireFormula(tbl[p]["fire_rate_hz_formula"], file, p + ".fire_rate_hz_formula");
|
||||||
|
cfg.playerStation.scrapDropFormula = utility::requireFormula(tbl[p]["scrap_drop_formula"], file, p + ".scrap_drop_formula");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enemy station
|
||||||
|
{
|
||||||
|
const std::string p = "enemy_station";
|
||||||
|
cfg.enemyStation.surfaceMask = utility::requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask");
|
||||||
|
cfg.enemyStation.hpFormula = utility::requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula");
|
||||||
|
cfg.enemyStation.damageFormula = utility::requireFormula(tbl[p]["damage_formula"], file, p + ".damage_formula");
|
||||||
|
cfg.enemyStation.rangeFormula = utility::requireFormula(tbl[p]["range_m_formula"], file, p + ".range_m_formula");
|
||||||
|
cfg.enemyStation.fireRateFormula = utility::requireFormula(tbl[p]["fire_rate_hz_formula"], file, p + ".fire_rate_hz_formula");
|
||||||
|
cfg.enemyStation.scrapDropFormula = utility::requireFormula(tbl[p]["scrap_drop_formula"], file, p + ".scrap_drop_formula");
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
62
src/lib/config/ConfigLoaderUnlocks.cpp
Normal file
62
src/lib/config/ConfigLoaderUnlocks.cpp
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#include "ConfigLoader.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "toml.hpp"
|
||||||
|
|
||||||
|
#include "TomlHelpers.h"
|
||||||
|
|
||||||
|
UnlocksConfig ConfigLoader::loadUnlocks(const std::string& path)
|
||||||
|
{
|
||||||
|
const std::string file = "unlocks.toml";
|
||||||
|
toml::table tbl = utility::parseFile(path, file);
|
||||||
|
|
||||||
|
UnlocksConfig cfg;
|
||||||
|
if (!tbl.contains("unlock"))
|
||||||
|
{
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toml::array& arr = utility::requireArray(tbl["unlock"], file, "unlock");
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < arr.size(); ++i)
|
||||||
|
{
|
||||||
|
const std::string elemPath = "unlock[" + std::to_string(i) + "]";
|
||||||
|
const toml::table* ut = arr[i].as_table();
|
||||||
|
if (ut == nullptr)
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath, "not a table");
|
||||||
|
}
|
||||||
|
toml::table& mt = const_cast<toml::table&>(*ut);
|
||||||
|
|
||||||
|
UnlockGroupDef def;
|
||||||
|
def.id = utility::requireString(mt["id"], file, elemPath + ".id");
|
||||||
|
def.stationLevel = static_cast<int>(
|
||||||
|
utility::requireInt(mt["station_level"], file, elemPath + ".station_level"));
|
||||||
|
if (mt.contains("requires"))
|
||||||
|
{
|
||||||
|
def.requiredGroupIds = utility::requireStringArray(mt["requires"], file, elemPath + ".requires");
|
||||||
|
}
|
||||||
|
if (mt.contains("ships"))
|
||||||
|
{
|
||||||
|
def.ships = utility::requireStringArray(mt["ships"], file, elemPath + ".ships");
|
||||||
|
}
|
||||||
|
if (mt.contains("modules"))
|
||||||
|
{
|
||||||
|
def.modules = utility::requireStringArray(mt["modules"], file, elemPath + ".modules");
|
||||||
|
}
|
||||||
|
if (mt.contains("buildings"))
|
||||||
|
{
|
||||||
|
def.buildings = utility::requireStringArray(mt["buildings"], file, elemPath + ".buildings");
|
||||||
|
}
|
||||||
|
if (mt.contains("recipes"))
|
||||||
|
{
|
||||||
|
def.recipes = utility::requireStringArray(mt["recipes"], file, elemPath + ".recipes");
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.groups.push_back(std::move(def));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
79
src/lib/config/ConfigLoaderWorld.cpp
Normal file
79
src/lib/config/ConfigLoaderWorld.cpp
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#include "ConfigLoader.h"
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "toml.hpp"
|
||||||
|
|
||||||
|
#include "TomlHelpers.h"
|
||||||
|
|
||||||
|
WorldConfig ConfigLoader::loadWorld(const std::string& path)
|
||||||
|
{
|
||||||
|
const std::string file = "world.toml";
|
||||||
|
toml::table tbl = utility::parseFile(path, file);
|
||||||
|
|
||||||
|
WorldConfig cfg;
|
||||||
|
|
||||||
|
cfg.heightTiles = static_cast<int>(utility::requireInt(tbl["world"]["height_tiles"], file, "world.height_tiles"));
|
||||||
|
cfg.refundPercentage = static_cast<int>(utility::requireInt(tbl["world"]["refund_percentage"], file, "world.refund_percentage"));
|
||||||
|
cfg.deconstructionTimeSeconds = utility::requireDouble(tbl["world"]["deconstruction_time_seconds"], file, "world.deconstruction_time_seconds");
|
||||||
|
cfg.startingBuildingBlocks = static_cast<int>(utility::requireInt(tbl["world"]["starting_building_blocks"], file, "world.starting_building_blocks"));
|
||||||
|
cfg.debrisDespawnSeconds = utility::requireDouble(tbl["world"]["debris_despawn_seconds"], file, "world.debris_despawn_seconds");
|
||||||
|
cfg.scrapPerThreat = utility::requireDouble(tbl["world"]["scrap_per_threat"], file, "world.scrap_per_threat");
|
||||||
|
cfg.tileSize_m = utility::requireDouble(tbl["world"]["tile_size_m"], file, "world.tile_size_m");
|
||||||
|
cfg.beltSpeed_tps = utility::requireDouble(tbl["world"]["belt_speed_mps"], file, "world.belt_speed_mps") / cfg.tileSize_m;
|
||||||
|
cfg.tunnelMaxDistance_tiles = static_cast<int>(utility::requireInt(tbl["world"]["tunnel_max_distance_tiles"], file, "world.tunnel_max_distance_tiles"));
|
||||||
|
cfg.departureIntervalSeconds = utility::requireDouble(tbl["world"]["departure_interval_seconds"], file, "world.departure_interval_seconds");
|
||||||
|
cfg.orbitFactor = utility::requireDouble(tbl["world"]["orbit_factor"], file, "world.orbit_factor");
|
||||||
|
cfg.rallyOrbitRadius_tiles = utility::requireDouble(tbl["world"]["rally_orbit_radius_tiles"], file, "world.rally_orbit_radius_tiles");
|
||||||
|
|
||||||
|
if (const std::optional<std::string> tip =
|
||||||
|
tbl["world"]["building_blocks_tooltip"].value<std::string>())
|
||||||
|
{
|
||||||
|
cfg.buildingBlocksTooltip = *tip;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (const std::optional<std::string> tip =
|
||||||
|
tbl["world"]["artifact_tooltip"].value<std::string>())
|
||||||
|
{
|
||||||
|
cfg.artifactTooltip = *tip;
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.regions.asteroidWidth_tiles = static_cast<int>(utility::requireInt(tbl["regions"]["asteroid_width_tiles"], file, "regions.asteroid_width_tiles"));
|
||||||
|
cfg.regions.playerBufferWidth_tiles = static_cast<int>(utility::requireInt(tbl["regions"]["player_buffer_width_tiles"], file, "regions.player_buffer_width_tiles"));
|
||||||
|
cfg.regions.contestZoneWidth_tiles = static_cast<int>(utility::requireInt(tbl["regions"]["contest_zone_width_tiles"], file, "regions.contest_zone_width_tiles"));
|
||||||
|
cfg.regions.enemyBufferWidth_tiles = static_cast<int>(utility::requireInt(tbl["regions"]["enemy_buffer_width_tiles"], file, "regions.enemy_buffer_width_tiles"));
|
||||||
|
|
||||||
|
cfg.expansion.columnsPerExpansion_tiles = static_cast<int>(utility::requireInt(tbl["expansion"]["columns_per_expansion_tiles"], file, "expansion.columns_per_expansion_tiles"));
|
||||||
|
cfg.expansion.costBuildingBlocksFormula = utility::requireFormula(tbl["expansion"]["cost_building_blocks_formula"], file, "expansion.cost_building_blocks_formula");
|
||||||
|
|
||||||
|
cfg.push.pushExpandColumns_tiles = static_cast<int>(utility::requireInt(tbl["push"]["push_expand_columns_tiles"], file, "push.push_expand_columns_tiles"));
|
||||||
|
cfg.push.bossAdvanceSeconds = utility::requireDouble(tbl["push"]["boss_advance_seconds"], file, "push.boss_advance_seconds");
|
||||||
|
|
||||||
|
cfg.waves.threatRateFormula = utility::requireFormula(tbl["waves"]["threat_rate_formula"], file, "waves.threat_rate_formula");
|
||||||
|
cfg.waves.gapMinSeconds = utility::requireDouble(tbl["waves"]["gap_min_seconds"], file, "waves.gap_min_seconds");
|
||||||
|
cfg.waves.gapMaxSeconds = utility::requireDouble(tbl["waves"]["gap_max_seconds"], file, "waves.gap_max_seconds");
|
||||||
|
cfg.waves.spawnDurationSeconds = utility::requireDouble(tbl["waves"]["spawn_duration_seconds"], file, "waves.spawn_duration_seconds");
|
||||||
|
cfg.waves.bossCountdownSeconds = utility::requireDouble(tbl["waves"]["boss_countdown_seconds"], file, "waves.boss_countdown_seconds");
|
||||||
|
cfg.waves.bossThreatDurationSeconds = utility::requireDouble(tbl["waves"]["boss_threat_duration_seconds"], file, "waves.boss_threat_duration_seconds");
|
||||||
|
cfg.waves.bossQuietBeforeSeconds = utility::requireDouble(tbl["waves"]["boss_quiet_before_seconds"], file, "waves.boss_quiet_before_seconds");
|
||||||
|
cfg.waves.bossQuietAfterSeconds = utility::requireDouble(tbl["waves"]["boss_quiet_after_seconds"], file, "waves.boss_quiet_after_seconds");
|
||||||
|
|
||||||
|
if (cfg.waves.gapMinSeconds > cfg.waves.gapMaxSeconds)
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, "waves", "gap_min_seconds > gap_max_seconds");
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.targeting.targetScoreFormula = utility::requireFormula(tbl["targeting"]["target_score_formula"], file, "targeting.target_score_formula");
|
||||||
|
cfg.targeting.overclaimPenaltyFormula = utility::requireFormula(tbl["targeting"]["overclaim_penalty_formula"], file, "targeting.overclaim_penalty_formula");
|
||||||
|
cfg.targeting.hysteresis = utility::requireDouble(tbl["targeting"]["target_hysteresis"], file, "targeting.target_hysteresis");
|
||||||
|
|
||||||
|
cfg.artifacts.artifactChanceFormula = utility::requireFormula(tbl["artifacts"]["artifact_chance_formula"], file, "artifacts.artifact_chance_formula");
|
||||||
|
cfg.artifacts.artifactWinCount = static_cast<int>(utility::requireInt(tbl["artifacts"]["artifact_win_count"], file, "artifacts.artifact_win_count"));
|
||||||
|
|
||||||
|
cfg.scroll.panSpeedSlow_tps = utility::requireDouble(tbl["scroll"]["pan_speed_slow_tiles_per_second"], file, "scroll.pan_speed_slow_tiles_per_second");
|
||||||
|
cfg.scroll.panSpeedFast_tps = utility::requireDouble(tbl["scroll"]["pan_speed_fast_tiles_per_second"], file, "scroll.pan_speed_fast_tiles_per_second");
|
||||||
|
cfg.scroll.panRampBandWidth_tiles = static_cast<int>(utility::requireInt(tbl["scroll"]["pan_ramp_band_width_tiles"], file, "scroll.pan_ramp_band_width_tiles"));
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
@@ -59,4 +59,18 @@ struct ModuleDef
|
|||||||
struct ModulesConfig
|
struct ModulesConfig
|
||||||
{
|
{
|
||||||
std::vector<ModuleDef> modules;
|
std::vector<ModuleDef> modules;
|
||||||
|
|
||||||
|
// Returns the definition for the given module id, or nullptr if the id has
|
||||||
|
// no entry in modules.toml.
|
||||||
|
const ModuleDef* findModuleDef(const std::string& id) const
|
||||||
|
{
|
||||||
|
for (const ModuleDef& def : modules)
|
||||||
|
{
|
||||||
|
if (def.id == id)
|
||||||
|
{
|
||||||
|
return &def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,10 +32,6 @@ struct RecipeDef
|
|||||||
std::vector<RecipeIngredient> inputs;
|
std::vector<RecipeIngredient> inputs;
|
||||||
std::vector<RecipeOutput> outputs;
|
std::vector<RecipeOutput> outputs;
|
||||||
double durationSeconds;
|
double durationSeconds;
|
||||||
// Optional id of the item whose icon represents this recipe in the recipe-
|
|
||||||
// selection dialog (REQ-UI-RECIPE-ICON). When unset, the first output item is
|
|
||||||
// used. A missing icon file for that item is not an error (REQ-UI-ITEM-ICON).
|
|
||||||
std::optional<std::string> icon;
|
|
||||||
// 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
|
||||||
// schematic's materials reach (e.g. building blocks). See REQ-LOCK-IMPLICIT.
|
// schematic's materials reach (e.g. building blocks). See REQ-LOCK-IMPLICIT.
|
||||||
@@ -47,4 +43,32 @@ struct RecipeDef
|
|||||||
struct RecipesConfig
|
struct RecipesConfig
|
||||||
{
|
{
|
||||||
std::vector<RecipeDef> recipes;
|
std::vector<RecipeDef> recipes;
|
||||||
|
|
||||||
|
// Returns the definition for the given recipe id, or nullptr if the id has
|
||||||
|
// no entry in recipes.toml.
|
||||||
|
const RecipeDef* findRecipeDef(const std::string& id) const
|
||||||
|
{
|
||||||
|
for (const RecipeDef& recipe : recipes)
|
||||||
|
{
|
||||||
|
if (recipe.id == id)
|
||||||
|
{
|
||||||
|
return &recipe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same, but additionally requires the recipe to belong to the given building
|
||||||
|
// type — recipe ids are only unique per building type.
|
||||||
|
const RecipeDef* findRecipeDef(const std::string& id, BuildingType building) const
|
||||||
|
{
|
||||||
|
for (const RecipeDef& recipe : recipes)
|
||||||
|
{
|
||||||
|
if (recipe.id == id && recipe.building == building)
|
||||||
|
{
|
||||||
|
return &recipe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,4 +49,18 @@ struct ShipDef
|
|||||||
struct ShipsConfig
|
struct ShipsConfig
|
||||||
{
|
{
|
||||||
std::vector<ShipDef> ships;
|
std::vector<ShipDef> ships;
|
||||||
|
|
||||||
|
// Returns the definition for the given ship schematic id, or nullptr if the
|
||||||
|
// id has no entry in ships.toml.
|
||||||
|
const ShipDef* findShipDef(const std::string& id) const
|
||||||
|
{
|
||||||
|
for (const ShipDef& def : ships)
|
||||||
|
{
|
||||||
|
if (def.id == id)
|
||||||
|
{
|
||||||
|
return &def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
172
src/lib/config/TomlHelpers.cpp
Normal file
172
src/lib/config/TomlHelpers.cpp
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
#include "TomlHelpers.h"
|
||||||
|
|
||||||
|
#include <sstream>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace utility
|
||||||
|
{
|
||||||
|
|
||||||
|
// --- Error helpers --------------------------------------------------------
|
||||||
|
|
||||||
|
std::runtime_error makeError(const std::string& file,
|
||||||
|
const std::string& path,
|
||||||
|
const std::string& why)
|
||||||
|
{
|
||||||
|
return std::runtime_error("Config: " + file + ": '" + path + "' " + why);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Typed accessors (throw on missing or wrong type) ---------------------
|
||||||
|
|
||||||
|
int64_t requireInt(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
const std::optional<int64_t> value = node.value<int64_t>();
|
||||||
|
if (!value)
|
||||||
|
{
|
||||||
|
throw makeError(file, path, "missing or not an integer");
|
||||||
|
}
|
||||||
|
return *value;
|
||||||
|
}
|
||||||
|
|
||||||
|
double requireDouble(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
if (const std::optional<double> v = node.value<double>())
|
||||||
|
{
|
||||||
|
return *v;
|
||||||
|
}
|
||||||
|
if (const std::optional<int64_t> v = node.value<int64_t>())
|
||||||
|
{
|
||||||
|
return static_cast<double>(*v);
|
||||||
|
}
|
||||||
|
throw makeError(file, path, "missing or not a number");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string requireString(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
const std::optional<std::string> value = node.value<std::string>();
|
||||||
|
if (!value)
|
||||||
|
{
|
||||||
|
throw makeError(file, path, "missing or not a string");
|
||||||
|
}
|
||||||
|
return *value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool requireBool(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
const std::optional<bool> value = node.value<bool>();
|
||||||
|
if (!value)
|
||||||
|
{
|
||||||
|
throw makeError(file, path, "missing or not a boolean");
|
||||||
|
}
|
||||||
|
return *value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toml::array& requireArray(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
const toml::array* arr = node.as_array();
|
||||||
|
if (arr == nullptr)
|
||||||
|
{
|
||||||
|
throw makeError(file, path, "missing or not an array");
|
||||||
|
}
|
||||||
|
return *arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toml::table& requireTable(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
const toml::table* tbl = node.as_table();
|
||||||
|
if (tbl == nullptr)
|
||||||
|
{
|
||||||
|
throw makeError(file, path, "missing or not a table");
|
||||||
|
}
|
||||||
|
return *tbl;
|
||||||
|
}
|
||||||
|
|
||||||
|
Formula requireFormula(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
const std::string source = requireString(node, file, path);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Formula::compile(source);
|
||||||
|
}
|
||||||
|
catch (const std::exception& e)
|
||||||
|
{
|
||||||
|
throw makeError(file, path, std::string("formula error: ") + e.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> requireStringArray(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
const toml::array& arr = requireArray(node, file, path);
|
||||||
|
std::vector<std::string> 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 std::optional<std::string> s = arr[i].value<std::string>();
|
||||||
|
if (!s)
|
||||||
|
{
|
||||||
|
throw makeError(file, elemPath, "not a string");
|
||||||
|
}
|
||||||
|
result.push_back(*s);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<RecipeIngredient> parseIngredients(const toml::array& arr,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
std::vector<RecipeIngredient> 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 makeError(file, elemPath, "not a table");
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need a mutable node_view to reuse our helpers, which is fine
|
||||||
|
// because the helpers never mutate.
|
||||||
|
toml::table& mt = const_cast<toml::table&>(*t);
|
||||||
|
|
||||||
|
RecipeIngredient ing;
|
||||||
|
ing.item = requireString(mt["item"], file, elemPath + ".item");
|
||||||
|
ing.amount = static_cast<int>(requireInt(mt["amount"], file, elemPath + ".amount"));
|
||||||
|
result.push_back(std::move(ing));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
toml::table parseFile(const std::string& path, const std::string& file)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return toml::parse_file(path);
|
||||||
|
}
|
||||||
|
catch (const toml::parse_error& e)
|
||||||
|
{
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << "Config: " << file << ": TOML parse error: " << e.description()
|
||||||
|
<< " at " << e.source().begin;
|
||||||
|
throw std::runtime_error(oss.str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace utility
|
||||||
70
src/lib/config/TomlHelpers.h
Normal file
70
src/lib/config/TomlHelpers.h
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "toml.hpp"
|
||||||
|
|
||||||
|
#include "Formula.h"
|
||||||
|
#include "RecipesConfig.h" // for RecipeIngredient
|
||||||
|
|
||||||
|
// Shared TOML-parsing helpers used by two or more ConfigLoader per-domain
|
||||||
|
// loaders. Helpers used by exactly one domain stay local to that domain's
|
||||||
|
// .cpp file instead.
|
||||||
|
//
|
||||||
|
// Namespaced because the names are generic: VisualsLoader.cpp and
|
||||||
|
// BalancingConfig.cpp each have their own same-named helpers in anonymous
|
||||||
|
// namespaces, and unqualified globals here would form an overload set with
|
||||||
|
// them the moment either file includes this header.
|
||||||
|
namespace utility
|
||||||
|
{
|
||||||
|
|
||||||
|
// --- Error helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
std::runtime_error makeError(const std::string& file,
|
||||||
|
const std::string& path,
|
||||||
|
const std::string& why);
|
||||||
|
|
||||||
|
// --- Typed accessors (throw on missing or wrong type) -----------------------
|
||||||
|
|
||||||
|
int64_t requireInt(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path);
|
||||||
|
|
||||||
|
double requireDouble(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path);
|
||||||
|
|
||||||
|
std::string requireString(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path);
|
||||||
|
|
||||||
|
bool requireBool(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path);
|
||||||
|
|
||||||
|
const toml::array& requireArray(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path);
|
||||||
|
|
||||||
|
const toml::table& requireTable(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path);
|
||||||
|
|
||||||
|
Formula requireFormula(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path);
|
||||||
|
|
||||||
|
std::vector<std::string> requireStringArray(const toml::node_view<toml::node>& node,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path);
|
||||||
|
|
||||||
|
std::vector<RecipeIngredient> parseIngredients(const toml::array& arr,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path);
|
||||||
|
|
||||||
|
toml::table parseFile(const std::string& path, const std::string& file);
|
||||||
|
|
||||||
|
} // namespace utility
|
||||||
272
src/lib/core/BuildModeController.cpp
Normal file
272
src/lib/core/BuildModeController.cpp
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
#include "BuildModeController.h"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "BlueprintModeExitedEvent.h"
|
||||||
|
#include "BuilderModeExitedEvent.h"
|
||||||
|
#include "DeconstructModeChangedEvent.h"
|
||||||
|
#include "EventManager.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
Rotation rotateClockwise(Rotation rotation)
|
||||||
|
{
|
||||||
|
switch (rotation)
|
||||||
|
{
|
||||||
|
case Rotation::North: return Rotation::East;
|
||||||
|
case Rotation::East: return Rotation::South;
|
||||||
|
case Rotation::South: return Rotation::West;
|
||||||
|
case Rotation::West: return Rotation::North;
|
||||||
|
}
|
||||||
|
return Rotation::East;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rotation rotateCounterClockwise(Rotation rotation)
|
||||||
|
{
|
||||||
|
switch (rotation)
|
||||||
|
{
|
||||||
|
case Rotation::North: return Rotation::West;
|
||||||
|
case Rotation::East: return Rotation::North;
|
||||||
|
case Rotation::South: return Rotation::East;
|
||||||
|
case Rotation::West: return Rotation::South;
|
||||||
|
}
|
||||||
|
return Rotation::East;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
BuildMode BuildModeController::getMode() const
|
||||||
|
{
|
||||||
|
return m_mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isBuilderMode() const
|
||||||
|
{
|
||||||
|
return m_mode == BuildMode::Builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isBlueprintMode() const
|
||||||
|
{
|
||||||
|
return m_mode == BuildMode::Blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isDeconstructMode() const
|
||||||
|
{
|
||||||
|
return m_mode == BuildMode::Deconstruct;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::enterMode(BuildMode mode)
|
||||||
|
{
|
||||||
|
if (m_mode == mode) { return; }
|
||||||
|
|
||||||
|
// Leave the current mode properly, so its widget hears about it however the
|
||||||
|
// player left. Each exit clears only its own state.
|
||||||
|
switch (m_mode)
|
||||||
|
{
|
||||||
|
case BuildMode::Builder:
|
||||||
|
m_draggingBelt = false;
|
||||||
|
m_beltDragPath.clear();
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<BuilderModeExitedEvent>());
|
||||||
|
break;
|
||||||
|
case BuildMode::Blueprint:
|
||||||
|
m_hoveredGhostIsTransfer = false;
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<BlueprintModeExitedEvent>());
|
||||||
|
break;
|
||||||
|
case BuildMode::Deconstruct:
|
||||||
|
m_deconstructHoverBuildingId.reset();
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<DeconstructModeChangedEvent>(false));
|
||||||
|
break;
|
||||||
|
case BuildMode::None:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_mode = mode;
|
||||||
|
|
||||||
|
if (mode == BuildMode::Deconstruct)
|
||||||
|
{
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<DeconstructModeChangedEvent>(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::enterBuilderMode(BuildingType type)
|
||||||
|
{
|
||||||
|
enterMode(BuildMode::Builder);
|
||||||
|
m_builderType = type;
|
||||||
|
m_ghostRotation = Rotation::East;
|
||||||
|
m_ghostValid = false;
|
||||||
|
m_tunnelGhostType = BuildingType::TunnelEntry;
|
||||||
|
m_tunnelPartnerTile.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::enterBlueprintMode(Blueprint blueprint)
|
||||||
|
{
|
||||||
|
enterMode(BuildMode::Blueprint);
|
||||||
|
// The layout starts where the builder ghost last was, so switching from a
|
||||||
|
// building to a blueprint does not jump the preview across the world.
|
||||||
|
m_blueprintGhostTile = m_ghostTile;
|
||||||
|
m_blueprint = std::move(blueprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::toggleDeconstructMode()
|
||||||
|
{
|
||||||
|
enterMode(isDeconstructMode() ? BuildMode::None : BuildMode::Deconstruct);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::exitBuilderMode()
|
||||||
|
{
|
||||||
|
if (!isBuilderMode()) { return; }
|
||||||
|
enterMode(BuildMode::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::exitBlueprintMode()
|
||||||
|
{
|
||||||
|
if (!isBlueprintMode()) { return; }
|
||||||
|
enterMode(BuildMode::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::exitCurrentMode()
|
||||||
|
{
|
||||||
|
enterMode(BuildMode::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildingType BuildModeController::getBuilderType() const
|
||||||
|
{
|
||||||
|
return m_builderType;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isTunnelMode() const
|
||||||
|
{
|
||||||
|
return isBuilderMode() && m_builderType == BuildingType::TunnelEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildingType BuildModeController::getEffectiveBuilderType() const
|
||||||
|
{
|
||||||
|
return isTunnelMode() ? m_tunnelGhostType : m_builderType;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPoint BuildModeController::getGhostTile() const
|
||||||
|
{
|
||||||
|
return m_ghostTile;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rotation BuildModeController::getGhostRotation() const
|
||||||
|
{
|
||||||
|
return m_ghostRotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isGhostValid() const
|
||||||
|
{
|
||||||
|
return m_ghostValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setGhostTile(QPoint tile)
|
||||||
|
{
|
||||||
|
m_ghostTile = tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setGhostValidity(bool valid)
|
||||||
|
{
|
||||||
|
m_ghostValid = valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::rotateGhost(bool clockwise)
|
||||||
|
{
|
||||||
|
m_ghostRotation = clockwise ? rotateClockwise(m_ghostRotation)
|
||||||
|
: rotateCounterClockwise(m_ghostRotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildingType BuildModeController::getTunnelGhostType() const
|
||||||
|
{
|
||||||
|
return m_tunnelGhostType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::optional<QPoint>& BuildModeController::getTunnelPartnerTile() const
|
||||||
|
{
|
||||||
|
return m_tunnelPartnerTile;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setTunnelGhost(BuildingType resolvedType,
|
||||||
|
std::optional<QPoint> partnerTile)
|
||||||
|
{
|
||||||
|
m_tunnelGhostType = resolvedType;
|
||||||
|
m_tunnelPartnerTile = std::move(partnerTile);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isDraggingBelt() const
|
||||||
|
{
|
||||||
|
return m_draggingBelt;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPoint BuildModeController::getBeltDragAnchor() const
|
||||||
|
{
|
||||||
|
return m_beltDragAnchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<BeltPathTile>& BuildModeController::getBeltDragPath() const
|
||||||
|
{
|
||||||
|
return m_beltDragPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::beginBeltDrag(QPoint anchorTile)
|
||||||
|
{
|
||||||
|
m_draggingBelt = true;
|
||||||
|
m_beltDragAnchor = anchorTile;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setBeltDragPath(std::vector<BeltPathTile> path)
|
||||||
|
{
|
||||||
|
m_beltDragPath = std::move(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::cancelBeltDrag()
|
||||||
|
{
|
||||||
|
m_draggingBelt = false;
|
||||||
|
m_beltDragPath.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
const Blueprint& BuildModeController::getBlueprint() const
|
||||||
|
{
|
||||||
|
return m_blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
Blueprint& BuildModeController::getMutableBlueprint()
|
||||||
|
{
|
||||||
|
return m_blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPoint BuildModeController::getBlueprintGhostTile() const
|
||||||
|
{
|
||||||
|
return m_blueprintGhostTile;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setBlueprintGhostTile(QPoint tile)
|
||||||
|
{
|
||||||
|
m_blueprintGhostTile = tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isHoveredGhostTransfer() const
|
||||||
|
{
|
||||||
|
return m_hoveredGhostIsTransfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setHoveredGhostTransfer(bool transfer)
|
||||||
|
{
|
||||||
|
m_hoveredGhostIsTransfer = transfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::optional<BuildingId>&
|
||||||
|
BuildModeController::getDeconstructHoverBuildingId() const
|
||||||
|
{
|
||||||
|
return m_deconstructHoverBuildingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setDeconstructHoverBuildingId(std::optional<BuildingId> id)
|
||||||
|
{
|
||||||
|
m_deconstructHoverBuildingId = std::move(id);
|
||||||
|
}
|
||||||
133
src/lib/core/BuildModeController.h
Normal file
133
src/lib/core/BuildModeController.h
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
|
|
||||||
|
#include "BeltDragPath.h"
|
||||||
|
#include "Blueprint.h"
|
||||||
|
#include "BuildingId.h"
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "Rotation.h"
|
||||||
|
|
||||||
|
// Which of the mutually exclusive world-interaction modes is active
|
||||||
|
// (REQ-UI-HOTKEYS, REQ-BLD-GHOST, REQ-UI-BLUEPRINT-PLACE, REQ-BLD-DECONSTRUCT).
|
||||||
|
enum class BuildMode
|
||||||
|
{
|
||||||
|
None, // plain selection
|
||||||
|
Builder, // placing one building type, ghost following the cursor
|
||||||
|
Blueprint, // placing a saved multi-building layout
|
||||||
|
Deconstruct // marking buildings for demolition
|
||||||
|
};
|
||||||
|
|
||||||
|
// The active build mode and the transient state that belongs to it.
|
||||||
|
//
|
||||||
|
// These modes were previously three independent flags, and every entry point
|
||||||
|
// cleared the other two by hand — inconsistently, which is how entering builder
|
||||||
|
// mode came to drop a blueprint without announcing it. Here exclusivity is
|
||||||
|
// structural: one mode is active, and every transition runs through enterMode(),
|
||||||
|
// which exits whatever was active first and publishes the same events regardless
|
||||||
|
// of which way the player got there.
|
||||||
|
//
|
||||||
|
// Everything needing the simulation — placement validity, tunnel matching, belt
|
||||||
|
// path building — stays with the caller, which computes and hands back the result
|
||||||
|
// (setGhostValidity, setTunnelGhost, setBeltDragPath). That keeps this a plain
|
||||||
|
// value that can be tested without a world.
|
||||||
|
class BuildModeController
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
BuildMode getMode() const;
|
||||||
|
bool isBuilderMode() const;
|
||||||
|
bool isBlueprintMode() const;
|
||||||
|
bool isDeconstructMode() const;
|
||||||
|
|
||||||
|
// --- transitions ----------------------------------------------------------
|
||||||
|
// Each leaves the previously active mode with its proper exit event.
|
||||||
|
void enterBuilderMode(BuildingType type);
|
||||||
|
void enterBlueprintMode(Blueprint blueprint);
|
||||||
|
// Leaves deconstruct mode if it is active, enters it otherwise
|
||||||
|
// (REQ-BLD-DECONSTRUCT-CLICK).
|
||||||
|
void toggleDeconstructMode();
|
||||||
|
void exitBuilderMode();
|
||||||
|
void exitBlueprintMode();
|
||||||
|
// Backs out of whichever mode is active, if any (the Q key and right-click).
|
||||||
|
void exitCurrentMode();
|
||||||
|
|
||||||
|
// --- builder mode ---------------------------------------------------------
|
||||||
|
// Only meaningful while isBuilderMode().
|
||||||
|
BuildingType getBuilderType() const;
|
||||||
|
// True while the builder type is TunnelEntry, where the ghost resolves to an
|
||||||
|
// entry or an exit by hovered position (REQ-BLD-TUNNEL-MODE).
|
||||||
|
bool isTunnelMode() const;
|
||||||
|
// The type the ghost currently represents: the position-resolved tunnel type in
|
||||||
|
// tunnel mode, the plain builder type otherwise.
|
||||||
|
BuildingType getEffectiveBuilderType() const;
|
||||||
|
|
||||||
|
QPoint getGhostTile() const;
|
||||||
|
Rotation getGhostRotation() const;
|
||||||
|
bool isGhostValid() const;
|
||||||
|
void setGhostTile(QPoint tile);
|
||||||
|
void setGhostValidity(bool valid);
|
||||||
|
// Turns the ghost one quarter turn. Validity is not rechecked here; the caller
|
||||||
|
// does that and calls setGhostValidity, because only it can see the world.
|
||||||
|
void rotateGhost(bool clockwise);
|
||||||
|
|
||||||
|
BuildingType getTunnelGhostType() const;
|
||||||
|
const std::optional<QPoint>& getTunnelPartnerTile() const;
|
||||||
|
void setTunnelGhost(BuildingType resolvedType, std::optional<QPoint> partnerTile);
|
||||||
|
|
||||||
|
// --- belt drag placement (REQ-BLD-BELT-DRAG) ------------------------------
|
||||||
|
bool isDraggingBelt() const;
|
||||||
|
QPoint getBeltDragAnchor() const;
|
||||||
|
const std::vector<BeltPathTile>& getBeltDragPath() const;
|
||||||
|
void beginBeltDrag(QPoint anchorTile);
|
||||||
|
void setBeltDragPath(std::vector<BeltPathTile> path);
|
||||||
|
// Drops the drag without placing anything, staying in builder mode.
|
||||||
|
void cancelBeltDrag();
|
||||||
|
|
||||||
|
// --- blueprint mode -------------------------------------------------------
|
||||||
|
// Only meaningful while isBlueprintMode().
|
||||||
|
const Blueprint& getBlueprint() const;
|
||||||
|
// Mutable so the caller can rotate the layout in place; rotating a blueprint
|
||||||
|
// needs building footprints from the config, which does not belong here.
|
||||||
|
Blueprint& getMutableBlueprint();
|
||||||
|
QPoint getBlueprintGhostTile() const;
|
||||||
|
void setBlueprintGhostTile(QPoint tile);
|
||||||
|
|
||||||
|
// Whether the ghost under the cursor would hand its settings to the building
|
||||||
|
// already there rather than place anything (REQ-UI-BLUEPRINT-TRANSFER). Classifying
|
||||||
|
// it needs the factory state, so the caller resolves it and stores the answer here,
|
||||||
|
// as with setGhostValidity. Kept here rather than recomputed per reader so the
|
||||||
|
// click, the ghost's colour, and the controls panel cannot disagree about what the
|
||||||
|
// cursor is over.
|
||||||
|
bool isHoveredGhostTransfer() const;
|
||||||
|
void setHoveredGhostTransfer(bool transfer);
|
||||||
|
|
||||||
|
// --- deconstruct mode -----------------------------------------------------
|
||||||
|
const std::optional<BuildingId>& getDeconstructHoverBuildingId() const;
|
||||||
|
void setDeconstructHoverBuildingId(std::optional<BuildingId> id);
|
||||||
|
|
||||||
|
private:
|
||||||
|
// The single transition point: leaves the active mode, then enters `mode`.
|
||||||
|
void enterMode(BuildMode mode);
|
||||||
|
|
||||||
|
BuildMode m_mode = BuildMode::None;
|
||||||
|
|
||||||
|
BuildingType m_builderType = BuildingType::Belt;
|
||||||
|
QPoint m_ghostTile;
|
||||||
|
Rotation m_ghostRotation = Rotation::East;
|
||||||
|
bool m_ghostValid = false;
|
||||||
|
BuildingType m_tunnelGhostType = BuildingType::TunnelEntry;
|
||||||
|
std::optional<QPoint> m_tunnelPartnerTile;
|
||||||
|
|
||||||
|
bool m_draggingBelt = false;
|
||||||
|
QPoint m_beltDragAnchor;
|
||||||
|
std::vector<BeltPathTile> m_beltDragPath;
|
||||||
|
|
||||||
|
Blueprint m_blueprint;
|
||||||
|
QPoint m_blueprintGhostTile;
|
||||||
|
bool m_hoveredGhostIsTransfer = false;
|
||||||
|
|
||||||
|
std::optional<BuildingId> m_deconstructHoverBuildingId;
|
||||||
|
};
|
||||||
@@ -38,3 +38,48 @@ std::string buildingTypeId(BuildingType type)
|
|||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isAutoRecipeBuildingType(BuildingType type)
|
||||||
|
{
|
||||||
|
return type == BuildingType::Smelter
|
||||||
|
|| type == BuildingType::ReprocessingPlant;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isBeltSubsystemType(BuildingType type)
|
||||||
|
{
|
||||||
|
return type == BuildingType::Belt
|
||||||
|
|| type == BuildingType::Splitter
|
||||||
|
|| type == BuildingType::TunnelEntry
|
||||||
|
|| type == BuildingType::TunnelExit;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isConfigurableBuildingType(BuildingType type)
|
||||||
|
{
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case BuildingType::Miner: // recipe (REQ-BLD-MINER)
|
||||||
|
case BuildingType::Assembler: // recipe (REQ-BLD-ASSEMBLER)
|
||||||
|
case BuildingType::Smelter: // recipe (REQ-BLD-AUTO-RECIPE)
|
||||||
|
case BuildingType::ReprocessingPlant: // recipe (REQ-BLD-AUTO-RECIPE)
|
||||||
|
case BuildingType::Shipyard: // schematic and layout (REQ-BLD-SHIPYARD, REQ-MOD-LAYOUT)
|
||||||
|
case BuildingType::Splitter: // output filters (REQ-BLD-SPLITTER)
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isProductionBuildingType(BuildingType type)
|
||||||
|
{
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case BuildingType::Miner:
|
||||||
|
case BuildingType::Smelter:
|
||||||
|
case BuildingType::Assembler:
|
||||||
|
case BuildingType::ReprocessingPlant:
|
||||||
|
case BuildingType::Shipyard:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,3 +29,25 @@ std::optional<BuildingType> parseBuildingType(const std::string& id);
|
|||||||
|
|
||||||
// Canonical id string for a BuildingType. The inverse of parseBuildingType.
|
// Canonical id string for a BuildingType. The inverse of parseBuildingType.
|
||||||
std::string buildingTypeId(BuildingType type);
|
std::string buildingTypeId(BuildingType type);
|
||||||
|
|
||||||
|
// Smelter and Reprocessing Plant pick a recipe for themselves from the first material
|
||||||
|
// offered to them while they have none (REQ-BLD-AUTO-RECIPE). In every other respect
|
||||||
|
// their recipe is selected and held exactly as any other building's.
|
||||||
|
bool isAutoRecipeBuildingType(BuildingType type);
|
||||||
|
|
||||||
|
// Buildings that run a production cycle: Miner, Smelter, Assembler, Reprocessing
|
||||||
|
// Plant and Shipyard (REQ-UI-DEBUG-OVERLAY counts these).
|
||||||
|
bool isProductionBuildingType(BuildingType type);
|
||||||
|
|
||||||
|
// Belts, splitters, and tunnel ends keep their runtime data in the belt subsystem
|
||||||
|
// rather than in the Building instance, so placing/removing them must register or
|
||||||
|
// unregister a tile with BeltSystem.
|
||||||
|
bool isBeltSubsystemType(BuildingType type);
|
||||||
|
|
||||||
|
// Building types with player-facing settings that a blueprint can carry and hand to an
|
||||||
|
// existing building (REQ-UI-BLUEPRINT-TRANSFER): Miner, Assembler, Smelter and
|
||||||
|
// Reprocessing Plant (recipe -- the last two select their own when they have none,
|
||||||
|
// REQ-BLD-AUTO-RECIPE), Shipyard (schematic and module layout), Splitter (output
|
||||||
|
// filters). Every other type has nothing to configure, so a blueprint of one has
|
||||||
|
// nothing to transfer.
|
||||||
|
bool isConfigurableBuildingType(BuildingType type);
|
||||||
|
|||||||
@@ -9,20 +9,35 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/Item.h
|
${CMAKE_CURRENT_SOURCE_DIR}/Item.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/Port.h
|
${CMAKE_CURRENT_SOURCE_DIR}/Port.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/PortGeometry.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceOption.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceOption.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
|
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanelPlacement.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionBox.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.h
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
SET(SRCS
|
SET(SRCS
|
||||||
${SRCS}
|
${SRCS}
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/PortGeometry.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanelPlacement.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.cpp
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
263
src/lib/core/ControlAction.cpp
Normal file
263
src/lib/core/ControlAction.cpp
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
#include "ControlAction.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// A binding that a belt drag can take over. Availability is a property of the action,
|
||||||
|
// but a binding can be claimed by a different action while a gesture is in progress:
|
||||||
|
// right-click cancels the drag instead of leaving builder mode (REQ-BLD-BELT-DRAG), so
|
||||||
|
// ExitMode's right-click drops out and it is left with Q alone.
|
||||||
|
enum class BindingCondition
|
||||||
|
{
|
||||||
|
Always,
|
||||||
|
NotDraggingBelt
|
||||||
|
};
|
||||||
|
|
||||||
|
struct KeyBindingEntry
|
||||||
|
{
|
||||||
|
ControlAction action;
|
||||||
|
int key;
|
||||||
|
// Shown on the badge, and Ctrl additionally participates in matching. Every other
|
||||||
|
// modifier is display only -- see resolveKeyAction.
|
||||||
|
Qt::KeyboardModifiers modifiers;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct MouseBindingEntry
|
||||||
|
{
|
||||||
|
ControlAction action;
|
||||||
|
MouseBinding binding;
|
||||||
|
BindingCondition condition;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolution is first-match-wins over these tables, so an entry that must beat another
|
||||||
|
// on the same input is listed above it -- CancelBeltLine before ExitMode on the right
|
||||||
|
// mouse button. Everything else is disjoint by availability.
|
||||||
|
const KeyBindingEntry KEY_BINDINGS[] = {
|
||||||
|
{ControlAction::Move, Qt::Key_A, Qt::NoModifier},
|
||||||
|
{ControlAction::Move, Qt::Key_D, Qt::NoModifier},
|
||||||
|
{ControlAction::GameSpeed, Qt::Key_W, Qt::NoModifier},
|
||||||
|
{ControlAction::GameSpeed, Qt::Key_S, Qt::NoModifier},
|
||||||
|
{ControlAction::TogglePause, Qt::Key_Space, Qt::NoModifier},
|
||||||
|
{ControlAction::CopyTemporary, Qt::Key_C, Qt::NoModifier},
|
||||||
|
{ControlAction::CreateBlueprint, Qt::Key_C, Qt::ControlModifier},
|
||||||
|
{ControlAction::PasteTemporary, Qt::Key_V, Qt::NoModifier},
|
||||||
|
{ControlAction::OpenBlueprints, Qt::Key_V, Qt::ControlModifier},
|
||||||
|
// One binding, two badges: Shift picks the rotation direction and is read by the
|
||||||
|
// handler, the way a build hotkey's digit is (REQ-BLD-ROTATE). Both entries match
|
||||||
|
// the same press, and both resolve to the same action, so listing them twice costs
|
||||||
|
// nothing and is what puts "R" and "Shift+R" on the row.
|
||||||
|
{ControlAction::Rotate, Qt::Key_R, Qt::NoModifier},
|
||||||
|
{ControlAction::Rotate, Qt::Key_R, Qt::ShiftModifier},
|
||||||
|
{ControlAction::EnterDeconstruct, Qt::Key_Q, Qt::NoModifier},
|
||||||
|
{ControlAction::ExitMode, Qt::Key_Q, Qt::NoModifier},
|
||||||
|
{ControlAction::OpenMenu, Qt::Key_Escape, Qt::NoModifier},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MouseBindingEntry MOUSE_BINDINGS[] = {
|
||||||
|
{ControlAction::Select, MouseBinding::LeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::Place, MouseBinding::LeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::ApplySettings, MouseBinding::LeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::ToggleDeconstruct, MouseBinding::LeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::SelectArea, MouseBinding::LeftDrag, BindingCondition::Always},
|
||||||
|
{ControlAction::PlaceBeltLine, MouseBinding::LeftDrag, BindingCondition::Always},
|
||||||
|
{ControlAction::DeconstructArea, MouseBinding::LeftDrag, BindingCondition::Always},
|
||||||
|
{ControlAction::AddToSelection, MouseBinding::CtrlLeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::AddAreaToSelection, MouseBinding::CtrlLeftDrag, BindingCondition::Always},
|
||||||
|
{ControlAction::CancelBeltLine, MouseBinding::RightClick, BindingCondition::Always},
|
||||||
|
{ControlAction::ExitMode, MouseBinding::RightClick, BindingCondition::NotDraggingBelt},
|
||||||
|
};
|
||||||
|
|
||||||
|
bool isConditionMet(BindingCondition condition, const ControlContext& context)
|
||||||
|
{
|
||||||
|
if (condition == BindingCondition::NotDraggingBelt) { return !context.draggingBelt; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPlacementMode(const ControlContext& context)
|
||||||
|
{
|
||||||
|
return context.mode == BuildMode::Builder || context.mode == BuildMode::Blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlAction> filterAvailable(const std::vector<ControlAction>& actions,
|
||||||
|
const ControlContext& context)
|
||||||
|
{
|
||||||
|
std::vector<ControlAction> available;
|
||||||
|
for (ControlAction action : actions)
|
||||||
|
{
|
||||||
|
if (isControlActionAvailable(action, context)) { available.push_back(action); }
|
||||||
|
}
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool isControlActionAvailable(ControlAction action, const ControlContext& context)
|
||||||
|
{
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case ControlAction::None:
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case ControlAction::Move:
|
||||||
|
case ControlAction::GameSpeed:
|
||||||
|
case ControlAction::TogglePause:
|
||||||
|
case ControlAction::OpenBlueprints:
|
||||||
|
case ControlAction::OpenMenu:
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Does nothing until something has been captured with C, so it is not offered
|
||||||
|
// before then (REQ-UI-BLUEPRINT-TEMP, REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
case ControlAction::PasteTemporary:
|
||||||
|
return context.temporaryBlueprintExists;
|
||||||
|
|
||||||
|
case ControlAction::Select:
|
||||||
|
case ControlAction::SelectArea:
|
||||||
|
case ControlAction::AddToSelection:
|
||||||
|
case ControlAction::AddAreaToSelection:
|
||||||
|
case ControlAction::EnterDeconstruct:
|
||||||
|
return context.mode == BuildMode::None;
|
||||||
|
|
||||||
|
// Both need something a blueprint can be made of; a selection of ships or debris
|
||||||
|
// leaves them inert (REQ-UI-HOTKEYS).
|
||||||
|
case ControlAction::CopyTemporary:
|
||||||
|
case ControlAction::CreateBlueprint:
|
||||||
|
return context.mode == BuildMode::None && context.placeableBuildingSelected;
|
||||||
|
|
||||||
|
// Place and ApplySettings are the same click; which one it is depends on whether
|
||||||
|
// the ghost under the cursor is a transfer target (REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
case ControlAction::Place:
|
||||||
|
return isPlacementMode(context) && !context.hoveredGhostIsTransfer;
|
||||||
|
case ControlAction::ApplySettings:
|
||||||
|
return context.mode == BuildMode::Blueprint && context.hoveredGhostIsTransfer;
|
||||||
|
|
||||||
|
// The only building type placed by dragging (REQ-BLD-BELT-DRAG).
|
||||||
|
case ControlAction::PlaceBeltLine:
|
||||||
|
return context.mode == BuildMode::Builder
|
||||||
|
&& context.builderType == BuildingType::Belt;
|
||||||
|
|
||||||
|
case ControlAction::Rotate:
|
||||||
|
return isPlacementMode(context);
|
||||||
|
case ControlAction::CancelBeltLine:
|
||||||
|
return context.draggingBelt;
|
||||||
|
case ControlAction::ExitMode:
|
||||||
|
return context.mode != BuildMode::None;
|
||||||
|
|
||||||
|
case ControlAction::ToggleDeconstruct:
|
||||||
|
case ControlAction::DeconstructArea:
|
||||||
|
return context.mode == BuildMode::Deconstruct;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlBinding> getControlActionBindings(ControlAction action,
|
||||||
|
const ControlContext& context)
|
||||||
|
{
|
||||||
|
std::vector<ControlBinding> bindings;
|
||||||
|
for (const MouseBindingEntry& entry : MOUSE_BINDINGS)
|
||||||
|
{
|
||||||
|
if (entry.action != action) { continue; }
|
||||||
|
if (!isConditionMet(entry.condition, context)) { continue; }
|
||||||
|
ControlBinding binding;
|
||||||
|
binding.isMouse = true;
|
||||||
|
binding.mouse = entry.binding;
|
||||||
|
bindings.push_back(binding);
|
||||||
|
}
|
||||||
|
for (const KeyBindingEntry& entry : KEY_BINDINGS)
|
||||||
|
{
|
||||||
|
if (entry.action != action) { continue; }
|
||||||
|
ControlBinding binding;
|
||||||
|
binding.key = entry.key;
|
||||||
|
binding.modifiers = entry.modifiers;
|
||||||
|
bindings.push_back(binding);
|
||||||
|
}
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlContextKind getControlContextKind(const ControlContext& context)
|
||||||
|
{
|
||||||
|
switch (context.mode)
|
||||||
|
{
|
||||||
|
case BuildMode::Builder: return ControlContextKind::Build;
|
||||||
|
case BuildMode::Blueprint: return ControlContextKind::Blueprint;
|
||||||
|
case BuildMode::Deconstruct: return ControlContextKind::Deconstruct;
|
||||||
|
case BuildMode::None: break;
|
||||||
|
}
|
||||||
|
return context.selection == ControlSelection::None ? ControlContextKind::General
|
||||||
|
: ControlContextKind::Selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlAction> getContextActions(const ControlContext& context)
|
||||||
|
{
|
||||||
|
// The candidates of each context, in the order REQ-UI-CONTROLS-CONTENT lists them,
|
||||||
|
// then filtered by availability.
|
||||||
|
//
|
||||||
|
// The General and Selection lists differ rather than one filtered list serving
|
||||||
|
// both, because the additive-selection rows are an omission and not an
|
||||||
|
// unavailability: Ctrl+click does work with nothing selected, it just picks the
|
||||||
|
// object like a plain click would. "Add / remove from selection" is a row that
|
||||||
|
// means nothing until there is a selection to add to, so it waits for one
|
||||||
|
// (REQ-UI-CONTROLS-ACCURACY permits omitting an available binding).
|
||||||
|
switch (getControlContextKind(context))
|
||||||
|
{
|
||||||
|
case ControlContextKind::Build:
|
||||||
|
case ControlContextKind::Blueprint:
|
||||||
|
return filterAvailable({ControlAction::Place, ControlAction::ApplySettings,
|
||||||
|
ControlAction::PlaceBeltLine, ControlAction::Rotate,
|
||||||
|
ControlAction::CancelBeltLine, ControlAction::ExitMode},
|
||||||
|
context);
|
||||||
|
case ControlContextKind::Deconstruct:
|
||||||
|
return filterAvailable({ControlAction::ToggleDeconstruct,
|
||||||
|
ControlAction::DeconstructArea, ControlAction::ExitMode},
|
||||||
|
context);
|
||||||
|
case ControlContextKind::Selection:
|
||||||
|
return filterAvailable({ControlAction::Select, ControlAction::SelectArea,
|
||||||
|
ControlAction::AddToSelection,
|
||||||
|
ControlAction::AddAreaToSelection,
|
||||||
|
ControlAction::EnterDeconstruct,
|
||||||
|
ControlAction::CopyTemporary,
|
||||||
|
ControlAction::CreateBlueprint},
|
||||||
|
context);
|
||||||
|
case ControlContextKind::General:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return filterAvailable({ControlAction::Select, ControlAction::SelectArea,
|
||||||
|
ControlAction::EnterDeconstruct},
|
||||||
|
context);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlAction> getAlwaysAvailableActions(const ControlContext& context)
|
||||||
|
{
|
||||||
|
return filterAvailable({ControlAction::Move, ControlAction::GameSpeed,
|
||||||
|
ControlAction::TogglePause, ControlAction::PasteTemporary,
|
||||||
|
ControlAction::OpenBlueprints, ControlAction::OpenMenu},
|
||||||
|
context);
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlAction resolveKeyAction(int key, Qt::KeyboardModifiers modifiers,
|
||||||
|
const ControlContext& context)
|
||||||
|
{
|
||||||
|
// Ctrl distinguishes a chord from the bare key (Ctrl+C is not C); every other
|
||||||
|
// modifier is ignored, so Shift+A still pans and Shift+R still rotates. This is
|
||||||
|
// what the key handler has always done, and matching modifiers exactly instead
|
||||||
|
// would silently drop those presses.
|
||||||
|
const bool controlHeld = (modifiers & Qt::ControlModifier) != 0;
|
||||||
|
for (const KeyBindingEntry& entry : KEY_BINDINGS)
|
||||||
|
{
|
||||||
|
if (entry.key != key) { continue; }
|
||||||
|
const bool entryNeedsControl = (entry.modifiers & Qt::ControlModifier) != 0;
|
||||||
|
if (entryNeedsControl != controlHeld) { continue; }
|
||||||
|
if (isControlActionAvailable(entry.action, context)) { return entry.action; }
|
||||||
|
}
|
||||||
|
return ControlAction::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlAction resolveMouseAction(MouseBinding binding, const ControlContext& context)
|
||||||
|
{
|
||||||
|
for (const MouseBindingEntry& entry : MOUSE_BINDINGS)
|
||||||
|
{
|
||||||
|
if (entry.binding != binding) { continue; }
|
||||||
|
if (!isConditionMet(entry.condition, context)) { continue; }
|
||||||
|
if (isControlActionAvailable(entry.action, context)) { return entry.action; }
|
||||||
|
}
|
||||||
|
return ControlAction::None;
|
||||||
|
}
|
||||||
164
src/lib/core/ControlAction.h
Normal file
164
src/lib/core/ControlAction.h
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <Qt>
|
||||||
|
|
||||||
|
#include "BuildModeController.h"
|
||||||
|
#include "BuildingType.h"
|
||||||
|
|
||||||
|
// The single statement of what the player can do right now, and which input does it
|
||||||
|
// (REQ-UI-CONTROLS-CONTENT, REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
//
|
||||||
|
// This file declares actions; it never performs them and it never names them. It knows
|
||||||
|
// an action's bindings and the situations in which it does something -- nothing about
|
||||||
|
// the simulation, the widgets, the events an action ends up firing, or the words shown
|
||||||
|
// to the player. Display text lives in ui/ControlActionText.h, which formats the
|
||||||
|
// bindings this hands it, so a badge is derived from the real binding rather than
|
||||||
|
// typed beside it.
|
||||||
|
//
|
||||||
|
// Three readers, all of them consuming this and none of them extending it:
|
||||||
|
//
|
||||||
|
// * ControlsPanel calls getContextActions()/getAlwaysAvailableActions() and draws them.
|
||||||
|
// * InputMapper calls resolveKeyAction() and fires the event the action stands for.
|
||||||
|
// * GameWorldView calls resolveMouseAction() and runs the branch it already ran.
|
||||||
|
//
|
||||||
|
// Two bindings are deliberately absent. Build hotkeys (REQ-UI-HOTKEYS) are advertised
|
||||||
|
// on the build buttons instead of in the panel, and InputMapper::getBuildHotkeyLabel
|
||||||
|
// already derives their badges from the same table the handler switches on, so they
|
||||||
|
// have no drift to fix. F3/F4 are development controls and appear nowhere
|
||||||
|
// (REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
//
|
||||||
|
// When bindings become player-configurable, only the binding tables in the .cpp turn
|
||||||
|
// from hard-coded data into loaded data. The actions, the availability rules, the
|
||||||
|
// panel, and every handler are unaffected.
|
||||||
|
enum class ControlAction
|
||||||
|
{
|
||||||
|
None, // no action is bound to the queried input in the queried context
|
||||||
|
|
||||||
|
// Always available (REQ-UI-CONTROLS-CONTENT).
|
||||||
|
Move,
|
||||||
|
GameSpeed,
|
||||||
|
TogglePause,
|
||||||
|
PasteTemporary,
|
||||||
|
OpenBlueprints,
|
||||||
|
OpenMenu,
|
||||||
|
|
||||||
|
// No build mode active.
|
||||||
|
Select,
|
||||||
|
SelectArea,
|
||||||
|
AddToSelection,
|
||||||
|
AddAreaToSelection,
|
||||||
|
EnterDeconstruct,
|
||||||
|
|
||||||
|
// No build mode active, with something selected.
|
||||||
|
CopyTemporary,
|
||||||
|
CreateBlueprint,
|
||||||
|
|
||||||
|
// Builder and blueprint placement mode.
|
||||||
|
Place,
|
||||||
|
ApplySettings,
|
||||||
|
PlaceBeltLine,
|
||||||
|
Rotate,
|
||||||
|
CancelBeltLine,
|
||||||
|
ExitMode,
|
||||||
|
|
||||||
|
// Deconstruct mode.
|
||||||
|
ToggleDeconstruct,
|
||||||
|
DeconstructArea
|
||||||
|
};
|
||||||
|
|
||||||
|
// The mouse gestures that carry a binding. Each is a whole gesture rather than a raw
|
||||||
|
// event: a drag is one binding, not a press plus a release, because that is the unit
|
||||||
|
// the player and the panel both think in. Which events make up the gesture, and the
|
||||||
|
// state it runs on, stay with the widget that owns them.
|
||||||
|
enum class MouseBinding
|
||||||
|
{
|
||||||
|
LeftClick,
|
||||||
|
LeftDrag,
|
||||||
|
CtrlLeftClick,
|
||||||
|
CtrlLeftDrag,
|
||||||
|
RightClick
|
||||||
|
};
|
||||||
|
|
||||||
|
// Which selection category is held, mirroring REQ-UI-SELECTION-CATEGORIES without
|
||||||
|
// depending on SelectionController.
|
||||||
|
enum class ControlSelection
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Buildings,
|
||||||
|
FieldObjects
|
||||||
|
};
|
||||||
|
|
||||||
|
// Which card the panel is showing (REQ-UI-CONTROLS-CONTENT). Named rather than
|
||||||
|
// spelled, so the heading text stays a presentation concern.
|
||||||
|
enum class ControlContextKind
|
||||||
|
{
|
||||||
|
General,
|
||||||
|
Selection,
|
||||||
|
Build,
|
||||||
|
Blueprint,
|
||||||
|
Deconstruct
|
||||||
|
};
|
||||||
|
|
||||||
|
// Everything the availability rules are allowed to depend on, as a plain snapshot.
|
||||||
|
//
|
||||||
|
// Taking a snapshot rather than references to the live controllers is what keeps this
|
||||||
|
// testable without a world, and it is what stops an action from reaching into the
|
||||||
|
// simulation: if a rule needs a fact, the fact is named here and the caller supplies it.
|
||||||
|
struct ControlContext
|
||||||
|
{
|
||||||
|
BuildMode mode = BuildMode::None;
|
||||||
|
BuildingType builderType = BuildingType::Belt; // while mode == Builder
|
||||||
|
bool draggingBelt = false;
|
||||||
|
// A single-building blueprint whose ghost is over a configuration-transfer target,
|
||||||
|
// so clicking hands over settings rather than placing (REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
bool hoveredGhostIsTransfer = false;
|
||||||
|
ControlSelection selection = ControlSelection::None;
|
||||||
|
int selectionCount = 0;
|
||||||
|
// At least one selected building is player-placeable, the condition under which
|
||||||
|
// C and Ctrl+C do anything (REQ-UI-HOTKEYS).
|
||||||
|
bool placeableBuildingSelected = false;
|
||||||
|
bool temporaryBlueprintExists = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One input an action answers to, in structured form so the badge can be rendered from
|
||||||
|
// it. `modifiers` is what the badge shows; matching is looser than equality, see
|
||||||
|
// resolveKeyAction.
|
||||||
|
struct ControlBinding
|
||||||
|
{
|
||||||
|
bool isMouse = false;
|
||||||
|
MouseBinding mouse = MouseBinding::LeftClick;
|
||||||
|
int key = 0; // Qt::Key_*, when !isMouse
|
||||||
|
Qt::KeyboardModifiers modifiers = Qt::NoModifier;
|
||||||
|
};
|
||||||
|
|
||||||
|
// True when triggering the action in this context would do what its label says. The
|
||||||
|
// panel shows exactly the available actions, and the resolvers return only available
|
||||||
|
// ones, which is REQ-UI-CONTROLS-ACCURACY expressed as one function.
|
||||||
|
bool isControlActionAvailable(ControlAction action, const ControlContext& context);
|
||||||
|
|
||||||
|
// The inputs an action answers to, in the order the panel should badge them.
|
||||||
|
// Context-dependent because a binding can be taken over: while a belt drag is in
|
||||||
|
// progress the right mouse button cancels the drag, so ExitMode is left with its key
|
||||||
|
// binding alone (REQ-BLD-BELT-DRAG).
|
||||||
|
std::vector<ControlBinding> getControlActionBindings(ControlAction action,
|
||||||
|
const ControlContext& context);
|
||||||
|
|
||||||
|
// Which card is showing, and the rows it holds -- the context's own, then the block
|
||||||
|
// available everywhere (REQ-UI-CONTROLS-CARD). Both lists are already filtered to the
|
||||||
|
// available actions and ordered as REQ-UI-CONTROLS-CONTENT lists them.
|
||||||
|
ControlContextKind getControlContextKind(const ControlContext& context);
|
||||||
|
std::vector<ControlAction> getContextActions(const ControlContext& context);
|
||||||
|
std::vector<ControlAction> getAlwaysAvailableActions(const ControlContext& context);
|
||||||
|
|
||||||
|
// The action a key press or a mouse gesture triggers here, or None when the input is
|
||||||
|
// unbound in this context. Both return only actions that are available, so a caller can
|
||||||
|
// act on the result without re-checking the situation.
|
||||||
|
//
|
||||||
|
// Rotation direction is not part of the action: R and Shift+R are one Rotate, and the
|
||||||
|
// caller reads the modifier for the direction, exactly as the digit of a build hotkey
|
||||||
|
// is read from the key. An action with a parameter keeps the parameter at the handler.
|
||||||
|
ControlAction resolveKeyAction(int key, Qt::KeyboardModifiers modifiers,
|
||||||
|
const ControlContext& context);
|
||||||
|
ControlAction resolveMouseAction(MouseBinding binding, const ControlContext& context);
|
||||||
@@ -45,11 +45,11 @@ entt::entity EntityAdmin::spawnShip(QVector2D position, float hp, float maxHp,
|
|||||||
const std::string& schematicId, bool isEnemy)
|
const std::string& schematicId, bool isEnemy)
|
||||||
{
|
{
|
||||||
entt::entity entity = createEntity();
|
entt::entity entity = createEntity();
|
||||||
add<PositionComponent>(entity, PositionComponent{position});
|
addComponent<PositionComponent>(entity, PositionComponent{position});
|
||||||
add<HealthComponent>(entity, HealthComponent{hp, maxHp});
|
addComponent<HealthComponent>(entity, HealthComponent{hp, maxHp});
|
||||||
add<FactionComponent>(entity, FactionComponent{isEnemy});
|
addComponent<FactionComponent>(entity, FactionComponent{isEnemy});
|
||||||
add<FacingComponent>(entity, FacingComponent{0.0f});
|
addComponent<FacingComponent>(entity, FacingComponent{0.0f});
|
||||||
add<DynamicBodyComponent>(entity, DynamicBodyComponent{
|
addComponent<DynamicBodyComponent>(entity, DynamicBodyComponent{
|
||||||
maxSpeed_tpt,
|
maxSpeed_tpt,
|
||||||
mainAcceleration_tptt,
|
mainAcceleration_tptt,
|
||||||
maneuveringAcceleration_tptt,
|
maneuveringAcceleration_tptt,
|
||||||
@@ -60,9 +60,9 @@ entt::entity EntityAdmin::spawnShip(QVector2D position, float hp, float maxHp,
|
|||||||
QVector2D(0.0f, 0.0f), // linearAcceleration_tptt
|
QVector2D(0.0f, 0.0f), // linearAcceleration_tptt
|
||||||
0.0f // angularAcceleration_rptt
|
0.0f // angularAcceleration_rptt
|
||||||
});
|
});
|
||||||
add<SensorRangeComponent>(entity, SensorRangeComponent{sensorRange_tiles});
|
addComponent<SensorRangeComponent>(entity, SensorRangeComponent{sensorRange_tiles});
|
||||||
add<ShipIdentityComponent>(entity, ShipIdentityComponent{schematicId});
|
addComponent<ShipIdentityComponent>(entity, ShipIdentityComponent{schematicId});
|
||||||
add<MovementIntentComponent>(entity, MovementIntentComponent{0, QVector2D(0.0f, 0.0f)});
|
addComponent<MovementIntentComponent>(entity, MovementIntentComponent{0, QVector2D(0.0f, 0.0f)});
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,28 +73,28 @@ entt::entity EntityAdmin::spawnStation(QPoint anchor, QSize footprint,
|
|||||||
entt::entity entity = createEntity();
|
entt::entity entity = createEntity();
|
||||||
QVector2D center(anchor.x() + footprint.width() / 2.0f,
|
QVector2D center(anchor.x() + footprint.width() / 2.0f,
|
||||||
anchor.y() + footprint.height() / 2.0f);
|
anchor.y() + footprint.height() / 2.0f);
|
||||||
add<PositionComponent>(entity, PositionComponent{center});
|
addComponent<PositionComponent>(entity, PositionComponent{center});
|
||||||
add<HealthComponent>(entity, HealthComponent{hp, maxHp});
|
addComponent<HealthComponent>(entity, HealthComponent{hp, maxHp});
|
||||||
add<FactionComponent>(entity, FactionComponent{isEnemy});
|
addComponent<FactionComponent>(entity, FactionComponent{isEnemy});
|
||||||
add<StationBodyComponent>(entity, StationBodyComponent{anchor, footprint, bodyCells});
|
addComponent<StationBodyComponent>(entity, StationBodyComponent{anchor, footprint, bodyCells});
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
entt::entity EntityAdmin::spawnDebris(QVector2D position, int amount, Tick despawnAt)
|
entt::entity EntityAdmin::spawnDebris(QVector2D position, int amount, Tick despawnAt)
|
||||||
{
|
{
|
||||||
entt::entity entity = createEntity();
|
entt::entity entity = createEntity();
|
||||||
add<PositionComponent>(entity, PositionComponent{position});
|
addComponent<PositionComponent>(entity, PositionComponent{position});
|
||||||
add<DebrisComponent>(entity, DebrisComponent{amount});
|
addComponent<DebrisComponent>(entity, DebrisComponent{amount});
|
||||||
add<DespawnAtComponent>(entity, DespawnAtComponent{despawnAt});
|
addComponent<DespawnAtComponent>(entity, DespawnAtComponent{despawnAt});
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
entt::entity EntityAdmin::spawnHqProxy(QVector2D position, float hp, float maxHp)
|
entt::entity EntityAdmin::spawnHqProxy(QVector2D position, float hp, float maxHp)
|
||||||
{
|
{
|
||||||
entt::entity entity = createEntity();
|
entt::entity entity = createEntity();
|
||||||
add<PositionComponent>(entity, PositionComponent{position});
|
addComponent<PositionComponent>(entity, PositionComponent{position});
|
||||||
add<HealthComponent>(entity, HealthComponent{hp, maxHp});
|
addComponent<HealthComponent>(entity, HealthComponent{hp, maxHp});
|
||||||
add<FactionComponent>(entity, FactionComponent{false});
|
addComponent<FactionComponent>(entity, FactionComponent{false});
|
||||||
add<HqProxyComponent>(entity);
|
addComponent<HqProxyComponent>(entity);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public:
|
|||||||
void forEach(Func&& f) const;
|
void forEach(Func&& f) const;
|
||||||
|
|
||||||
template <typename... Ts>
|
template <typename... Ts>
|
||||||
bool hasAll(entt::entity entity);
|
bool hasAll(entt::entity entity) const;
|
||||||
|
|
||||||
template <typename T>
|
template <typename T>
|
||||||
T& get(entt::entity entity);
|
T& get(entt::entity entity);
|
||||||
@@ -73,9 +73,6 @@ public:
|
|||||||
private:
|
private:
|
||||||
entt::entity createEntity();
|
entt::entity createEntity();
|
||||||
|
|
||||||
template <typename T, typename... Args>
|
|
||||||
void add(entt::entity entity, Args&&... args);
|
|
||||||
|
|
||||||
entt::registry m_registry;
|
entt::registry m_registry;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,7 +101,7 @@ void EntityAdmin::forEach(Func&& f) const
|
|||||||
}
|
}
|
||||||
|
|
||||||
template <typename... Ts>
|
template <typename... Ts>
|
||||||
bool EntityAdmin::hasAll(entt::entity entity)
|
bool EntityAdmin::hasAll(entt::entity entity) const
|
||||||
{
|
{
|
||||||
return m_registry.all_of<Ts...>(entity);
|
return m_registry.all_of<Ts...>(entity);
|
||||||
}
|
}
|
||||||
@@ -133,10 +130,4 @@ void EntityAdmin::removeComponent(entt::entity entity)
|
|||||||
m_registry.remove<T>(entity);
|
m_registry.remove<T>(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
template <typename T, typename... Args>
|
|
||||||
void EntityAdmin::add(entt::entity entity, Args&&... args)
|
|
||||||
{
|
|
||||||
m_registry.emplace<T>(entity, std::forward<Args>(args)...);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif // ENTITY_ADMIN_H
|
#endif // ENTITY_ADMIN_H
|
||||||
|
|||||||
110
src/lib/core/FloatingPanelPlacement.cpp
Normal file
110
src/lib/core/FloatingPanelPlacement.cpp
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#include "FloatingPanelPlacement.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// The part every placement shares, whatever put the panel where it wants to be: it is
|
||||||
|
// pushed inside band rather than hanging off it, shortened to what its own column has
|
||||||
|
// free, and lifted by however much of it hangs below that. wantedLeftPx and wantedTopPx
|
||||||
|
// are where the panel would stand if nothing were in the way.
|
||||||
|
QRect fitInBand(const QRect& band, int wantedLeftPx, int wantedTopPx, QSize wantedSize,
|
||||||
|
const std::vector<QRect>& occupiedRects, int marginPx)
|
||||||
|
{
|
||||||
|
const int widthPx = std::min(wantedSize.width(), band.width());
|
||||||
|
|
||||||
|
int leftPx = std::min(wantedLeftPx, band.right() - widthPx + 1);
|
||||||
|
leftPx = std::max(leftPx, band.left());
|
||||||
|
|
||||||
|
// Only the widgets its own column meets can shorten it.
|
||||||
|
const int bottomPx = getAvailableBottomPx(band, occupiedRects, leftPx,
|
||||||
|
leftPx + widthPx - 1, marginPx);
|
||||||
|
const int heightPx =
|
||||||
|
std::min(wantedSize.height(), std::max(0, bottomPx - band.top() + 1));
|
||||||
|
|
||||||
|
// Never above the band: a panel taller than the space left is capped instead, and
|
||||||
|
// scrolls.
|
||||||
|
int topPx = std::max(wantedTopPx, band.top());
|
||||||
|
topPx = std::min(topPx, bottomPx - heightPx + 1);
|
||||||
|
topPx = std::max(topPx, band.top());
|
||||||
|
|
||||||
|
return QRect(leftPx, topPx, widthPx, heightPx);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
int getAvailableBottomPx(const QRect& band, const std::vector<QRect>& occupiedRects,
|
||||||
|
int leftPx, int rightPx, int marginPx)
|
||||||
|
{
|
||||||
|
int bottomPx = band.bottom();
|
||||||
|
for (const QRect& occupied : occupiedRects)
|
||||||
|
{
|
||||||
|
if (occupied.isEmpty())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Only what is actually in the way counts: a widget entirely to one side of this
|
||||||
|
// span is not below it, however tall it is.
|
||||||
|
if (occupied.right() < leftPx || occupied.left() > rightPx)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bottomPx = std::min(bottomPx, occupied.top() - marginPx - 1);
|
||||||
|
}
|
||||||
|
return bottomPx;
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx,
|
||||||
|
int marginPx)
|
||||||
|
{
|
||||||
|
// What each side offers: the gap between the anchor and that edge of the band, less
|
||||||
|
// the margin the panel keeps from the anchor.
|
||||||
|
const int roomRightPx = band.right() - anchorRect.right() - marginPx;
|
||||||
|
const int roomLeftPx = anchorRect.left() - band.left() - marginPx;
|
||||||
|
|
||||||
|
if (roomRightPx >= widthPx)
|
||||||
|
{
|
||||||
|
return PanelSide::Right;
|
||||||
|
}
|
||||||
|
if (roomLeftPx >= widthPx)
|
||||||
|
{
|
||||||
|
return PanelSide::Left;
|
||||||
|
}
|
||||||
|
// Neither side can hold it without covering the selection, so it goes where it
|
||||||
|
// covers the least of it.
|
||||||
|
return (roomRightPx >= roomLeftPx) ? PanelSide::Right : PanelSide::Left;
|
||||||
|
}
|
||||||
|
|
||||||
|
QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side,
|
||||||
|
QSize wantedSize, const std::vector<QRect>& occupiedRects,
|
||||||
|
int marginPx)
|
||||||
|
{
|
||||||
|
const int widthPx = std::min(wantedSize.width(), band.width());
|
||||||
|
|
||||||
|
// Against 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
|
||||||
|
// 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.
|
||||||
|
const int wantedLeftPx = (side == PanelSide::Right)
|
||||||
|
? anchorRect.right() + marginPx + 1
|
||||||
|
: anchorRect.left() - marginPx - widthPx;
|
||||||
|
|
||||||
|
// Top-aligned with the anchor, then lifted by however much of it hangs below what is
|
||||||
|
// free.
|
||||||
|
return fitInBand(band, wantedLeftPx, anchorRect.top(), wantedSize, occupiedRects,
|
||||||
|
marginPx);
|
||||||
|
}
|
||||||
|
|
||||||
|
QRect placeAtDesiredTopLeft(const QRect& band, const QPoint& desiredTopLeftPx,
|
||||||
|
QSize wantedSize, const std::vector<QRect>& occupiedRects,
|
||||||
|
int marginPx)
|
||||||
|
{
|
||||||
|
// Where the player dropped it, resolved by the same rules as any other placement
|
||||||
|
// (REQ-UI-SELECTION-PANEL-DRAG). Nothing here is written back to the desired point:
|
||||||
|
// a panel lifted above the build button bar returns to where it was dropped as soon
|
||||||
|
// as the bar stops meeting its column.
|
||||||
|
return fitInBand(band, desiredTopLeftPx.x(), desiredTopLeftPx.y(), wantedSize,
|
||||||
|
occupiedRects, marginPx);
|
||||||
|
}
|
||||||
54
src/lib/core/FloatingPanelPlacement.h
Normal file
54
src/lib/core/FloatingPanelPlacement.h
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QRect>
|
||||||
|
#include <QSize>
|
||||||
|
|
||||||
|
// Geometry for the widgets floating over the game world view (REQ-UI-WORLD-SIZE). Their
|
||||||
|
// 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
|
||||||
|
// involved, which is what lets the rules be tested without a display.
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// occupied rectangle whose horizontal extent meets that span. A rectangle beside the
|
||||||
|
// span is not in the way and does not shorten it (REQ-UI-SELECTION-PANEL,
|
||||||
|
// REQ-UI-CONTROLS-PANEL). The result is inclusive, as QRect::bottom() is.
|
||||||
|
int getAvailableBottomPx(const QRect& band, const std::vector<QRect>& occupiedRects,
|
||||||
|
int leftPx, int rightPx, int marginPx);
|
||||||
|
|
||||||
|
// Which side of the selection the panel stands on (REQ-UI-SELECTION-PANEL).
|
||||||
|
enum class PanelSide
|
||||||
|
{
|
||||||
|
Right,
|
||||||
|
Left
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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
|
||||||
|
// 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,
|
||||||
|
int marginPx);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// inside band and above whatever occupies it. The returned height is 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,
|
||||||
|
QSize wantedSize, const std::vector<QRect>& occupiedRects,
|
||||||
|
int marginPx);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
// lifted above whatever occupies its column, and capped in height where that leaves too
|
||||||
|
// little room (REQ-UI-SELECTION-PANEL-DRAG). The desired point is an input only: it is
|
||||||
|
// never corrected and handed back, which is what lets the caller keep it exactly as
|
||||||
|
// dropped and return to it once the room is there again.
|
||||||
|
QRect placeAtDesiredTopLeft(const QRect& band, const QPoint& desiredTopLeftPx,
|
||||||
|
QSize wantedSize, const std::vector<QRect>& occupiedRects,
|
||||||
|
int marginPx);
|
||||||
57
src/lib/core/PortGeometry.cpp
Normal file
57
src/lib/core/PortGeometry.cpp
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#include "PortGeometry.h"
|
||||||
|
|
||||||
|
#include <set>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
std::vector<Port> computeInputPorts(
|
||||||
|
const std::vector<QPoint>& bodyCells,
|
||||||
|
const std::vector<Port>& outputPorts)
|
||||||
|
{
|
||||||
|
// Build lookup sets for quick membership checks.
|
||||||
|
std::set<std::pair<int, int>> bodySet;
|
||||||
|
for (const QPoint& cell : bodyCells)
|
||||||
|
{
|
||||||
|
bodySet.insert({cell.x(), cell.y()});
|
||||||
|
}
|
||||||
|
|
||||||
|
std::set<std::pair<int, int>> outputPortTiles;
|
||||||
|
for (const Port& port : outputPorts)
|
||||||
|
{
|
||||||
|
outputPortTiles.insert({port.tile.x(), port.tile.y()});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neighbour deltas and the corresponding "inward" belt direction.
|
||||||
|
const int dx[4] = {-1, 1, 0, 0};
|
||||||
|
const int dy[4] = { 0, 0, -1, 1};
|
||||||
|
const Rotation inward[4] = {
|
||||||
|
Rotation::East, // neighbour is to the West; belt flows East toward building
|
||||||
|
Rotation::West, // neighbour is to the East; belt flows West toward building
|
||||||
|
Rotation::South, // neighbour is above (row-1); belt flows South toward building
|
||||||
|
Rotation::North // neighbour is below (row+1); belt flows North toward building
|
||||||
|
};
|
||||||
|
|
||||||
|
std::set<std::pair<int, int>> seen;
|
||||||
|
std::vector<Port> inputPorts;
|
||||||
|
|
||||||
|
for (const QPoint& cell : bodyCells)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 4; ++i)
|
||||||
|
{
|
||||||
|
const int nx = cell.x() + dx[i];
|
||||||
|
const int ny = cell.y() + dy[i];
|
||||||
|
const std::pair<int, int> neighbor = {nx, ny};
|
||||||
|
|
||||||
|
if (bodySet.count(neighbor)) { continue; }
|
||||||
|
if (outputPortTiles.count(neighbor)){ continue; }
|
||||||
|
if (seen.count(neighbor)) { continue; }
|
||||||
|
|
||||||
|
seen.insert(neighbor);
|
||||||
|
Port port;
|
||||||
|
port.tile = QPoint(nx, ny);
|
||||||
|
port.direction = inward[i];
|
||||||
|
inputPorts.push_back(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputPorts;
|
||||||
|
}
|
||||||
55
src/lib/core/PortGeometry.h
Normal file
55
src/lib/core/PortGeometry.h
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
|
|
||||||
|
#include "Port.h"
|
||||||
|
#include "Rotation.h"
|
||||||
|
|
||||||
|
// Geometry of a building's input/output ports. A Port names the tile *outside* the
|
||||||
|
// building together with the direction items flow across it; these helpers give the
|
||||||
|
// building body tile on the other side of that edge, which is where the virtual
|
||||||
|
// input/output belt lives.
|
||||||
|
//
|
||||||
|
// Shared by the simulation (which moves items across the edge) and the renderer
|
||||||
|
// (which draws the virtual belt), so the two cannot disagree about which tile a
|
||||||
|
// port belongs to.
|
||||||
|
|
||||||
|
// The building body tile that owns an output port, given the port's outside tile
|
||||||
|
// (port.tile) and its facing direction. The virtual output belt occupies this tile
|
||||||
|
// and flows toward port.tile (REQ-MAT-OUTPUT-EMERGE).
|
||||||
|
inline QPoint outputBodyTile(QPoint portTile, Rotation direction)
|
||||||
|
{
|
||||||
|
switch (direction)
|
||||||
|
{
|
||||||
|
case Rotation::East: return portTile + QPoint(-1, 0);
|
||||||
|
case Rotation::West: return portTile + QPoint( 1, 0);
|
||||||
|
case Rotation::North: return portTile + QPoint( 0, 1);
|
||||||
|
case Rotation::South: return portTile + QPoint( 0, -1);
|
||||||
|
}
|
||||||
|
return portTile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The building body tile an input port feeds into, given the port's outside belt
|
||||||
|
// tile (port.tile) and its inward flow direction. The virtual input belt occupies
|
||||||
|
// this tile and flows from the outer edge (progress 0.0) to the centre (0.5)
|
||||||
|
// (REQ-MAT-INPUT-INTAKE).
|
||||||
|
inline QPoint inputBodyTile(QPoint portTile, Rotation inwardDirection)
|
||||||
|
{
|
||||||
|
switch (inwardDirection)
|
||||||
|
{
|
||||||
|
case Rotation::East: return portTile + QPoint( 1, 0);
|
||||||
|
case Rotation::West: return portTile + QPoint(-1, 0);
|
||||||
|
case Rotation::North: return portTile + QPoint( 0, -1);
|
||||||
|
case Rotation::South: return portTile + QPoint( 0, 1);
|
||||||
|
}
|
||||||
|
return portTile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every belt-facing edge of a footprint that is not already an output port — the
|
||||||
|
// tiles a belt can feed the building from, with the direction items must flow to
|
||||||
|
// enter (REQ-MAT-INPUT-PORTS, REQ-BLD-BELT-DRAG). bodyCells and outputPorts are
|
||||||
|
// in absolute tile coordinates, and so is the result.
|
||||||
|
std::vector<Port> computeInputPorts(const std::vector<QPoint>& bodyCells,
|
||||||
|
const std::vector<Port>& outputPorts);
|
||||||
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()));
|
||||||
|
}
|
||||||
165
src/lib/core/SelectionController.cpp
Normal file
165
src/lib/core/SelectionController.cpp
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#include "SelectionController.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "DebrisSelectionChangedEvent.h"
|
||||||
|
#include "EntitySelectionChangedEvent.h"
|
||||||
|
#include "EventManager.h"
|
||||||
|
#include "SelectionChangedEvent.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
bool contains(const std::vector<T>& items, const T& item)
|
||||||
|
{
|
||||||
|
return std::find(items.begin(), items.end(), item) != items.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies `hits` to `selection` per `mode`. Replace is handled by the caller so
|
||||||
|
// that an empty hit list can mean "clear this category" there but "leave this
|
||||||
|
// category alone" here.
|
||||||
|
template <typename T>
|
||||||
|
void combine(std::vector<T>& selection, const std::vector<T>& hits, SelectionMode mode)
|
||||||
|
{
|
||||||
|
for (const T& hit : hits)
|
||||||
|
{
|
||||||
|
const typename std::vector<T>::iterator it =
|
||||||
|
std::find(selection.begin(), selection.end(), hit);
|
||||||
|
if (it == selection.end())
|
||||||
|
{
|
||||||
|
selection.push_back(hit);
|
||||||
|
}
|
||||||
|
else if (mode == SelectionMode::Toggle)
|
||||||
|
{
|
||||||
|
// Only a toggle removes; an additive box drag never deselects.
|
||||||
|
selection.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
const std::vector<BuildingId>& SelectionController::getSelectedBuildings() const
|
||||||
|
{
|
||||||
|
return m_buildings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<entt::entity>& SelectionController::getSelectedActors() const
|
||||||
|
{
|
||||||
|
return m_actors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<entt::entity>& SelectionController::getSelectedDebris() const
|
||||||
|
{
|
||||||
|
return m_debris;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SelectionController::isActorSelected(entt::entity actor) const
|
||||||
|
{
|
||||||
|
return contains(m_actors, actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SelectionController::isDebrisSelected(entt::entity debris) const
|
||||||
|
{
|
||||||
|
return contains(m_debris, debris);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::selectBuildings(const std::vector<BuildingId>& ids,
|
||||||
|
SelectionMode mode)
|
||||||
|
{
|
||||||
|
// Buildings win over field objects (REQ-UI-SELECTION-CATEGORIES).
|
||||||
|
if (clearActorsQuietly()) { publishActors(); }
|
||||||
|
if (clearDebrisQuietly()) { publishDebris(); }
|
||||||
|
|
||||||
|
if (mode == SelectionMode::Replace)
|
||||||
|
{
|
||||||
|
m_buildings = ids;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
combine(m_buildings, ids, mode);
|
||||||
|
}
|
||||||
|
publishBuildings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::selectFieldObjects(const std::vector<entt::entity>& actors,
|
||||||
|
const std::vector<entt::entity>& debris,
|
||||||
|
SelectionMode mode)
|
||||||
|
{
|
||||||
|
if (clearBuildingsQuietly()) { publishBuildings(); }
|
||||||
|
|
||||||
|
if (mode == SelectionMode::Replace)
|
||||||
|
{
|
||||||
|
m_actors = actors;
|
||||||
|
m_debris = debris;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
combine(m_actors, actors, mode);
|
||||||
|
combine(m_debris, debris, mode);
|
||||||
|
}
|
||||||
|
publishActors();
|
||||||
|
publishDebris();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::clearAll()
|
||||||
|
{
|
||||||
|
if (clearBuildingsQuietly()) { publishBuildings(); }
|
||||||
|
if (clearActorsQuietly()) { publishActors(); }
|
||||||
|
if (clearDebrisQuietly()) { publishDebris(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::setSelectedActors(std::vector<entt::entity> actors)
|
||||||
|
{
|
||||||
|
if (actors == m_actors) { return; }
|
||||||
|
m_actors = std::move(actors);
|
||||||
|
publishActors();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::setSelectedDebris(std::vector<entt::entity> debris)
|
||||||
|
{
|
||||||
|
if (debris == m_debris) { return; }
|
||||||
|
m_debris = std::move(debris);
|
||||||
|
publishDebris();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SelectionController::clearBuildingsQuietly()
|
||||||
|
{
|
||||||
|
if (m_buildings.empty()) { return false; }
|
||||||
|
m_buildings.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SelectionController::clearActorsQuietly()
|
||||||
|
{
|
||||||
|
if (m_actors.empty()) { return false; }
|
||||||
|
m_actors.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SelectionController::clearDebrisQuietly()
|
||||||
|
{
|
||||||
|
if (m_debris.empty()) { return false; }
|
||||||
|
m_debris.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::publishBuildings() const
|
||||||
|
{
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<SelectionChangedEvent>(m_buildings));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::publishActors() const
|
||||||
|
{
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<EntitySelectionChangedEvent>(m_actors));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::publishDebris() const
|
||||||
|
{
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<DebrisSelectionChangedEvent>(m_debris));
|
||||||
|
}
|
||||||
75
src/lib/core/SelectionController.h
Normal file
75
src/lib/core/SelectionController.h
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "BuildingId.h"
|
||||||
|
#include "entt/entity/entity.hpp"
|
||||||
|
|
||||||
|
// How a new hit combines with what is already selected.
|
||||||
|
enum class SelectionMode
|
||||||
|
{
|
||||||
|
Replace, // plain click or drag: the hit becomes the whole selection
|
||||||
|
Toggle, // Ctrl + click: the hit joins the selection, or leaves it if present
|
||||||
|
Add // Ctrl + box drag: the hits join the selection, never leave it
|
||||||
|
};
|
||||||
|
|
||||||
|
// The player's current selection across the three categories, and the rules that
|
||||||
|
// govern moving between them (REQ-UI-SELECTION-CATEGORIES).
|
||||||
|
//
|
||||||
|
// The rules were previously written out once for point-clicks and once for box
|
||||||
|
// drags, which is why they live here now: buildings win over field objects, so
|
||||||
|
// selecting a building clears actors and debris, and selecting either of those
|
||||||
|
// clears buildings — but actors and debris coexist with each other
|
||||||
|
// (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT, REQ-UI-MULTI-SELECT,
|
||||||
|
// REQ-UI-DEBRIS-MULTI-SELECT). A point-click and a box drag then differ only in
|
||||||
|
// the SelectionMode they pass and in how many hits they pass.
|
||||||
|
//
|
||||||
|
// Every mutator publishes the change events, so callers never emit them by hand.
|
||||||
|
// Hit-testing is not done here: callers resolve what was hit and pass the result
|
||||||
|
// in, which keeps this free of any simulation dependency.
|
||||||
|
class SelectionController
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
const std::vector<BuildingId>& getSelectedBuildings() const;
|
||||||
|
const std::vector<entt::entity>& getSelectedActors() const;
|
||||||
|
const std::vector<entt::entity>& getSelectedDebris() const;
|
||||||
|
|
||||||
|
bool isActorSelected(entt::entity actor) const;
|
||||||
|
bool isDebrisSelected(entt::entity debris) const;
|
||||||
|
|
||||||
|
// Selects buildings and/or construction sites, clearing any field selection.
|
||||||
|
// Always publishes SelectionChangedEvent, even when the result is unchanged,
|
||||||
|
// so a click on an already-selected building still refreshes its panel.
|
||||||
|
void selectBuildings(const std::vector<BuildingId>& ids, SelectionMode mode);
|
||||||
|
|
||||||
|
// Selects field objects, clearing any building selection. Actors and debris are
|
||||||
|
// set together so a Replace can express "these actors and no debris" — which is
|
||||||
|
// what a plain click on an actor means — while a Toggle or Add leaves the
|
||||||
|
// category whose vector is empty untouched. Always publishes both field events.
|
||||||
|
void selectFieldObjects(const std::vector<entt::entity>& actors,
|
||||||
|
const std::vector<entt::entity>& debris,
|
||||||
|
SelectionMode mode);
|
||||||
|
|
||||||
|
// Empties all three categories, publishing only for those that were non-empty.
|
||||||
|
void clearAll();
|
||||||
|
|
||||||
|
// Replace one category outright, publishing only if it actually changed. For
|
||||||
|
// the per-frame prune of entities that despawned or died: the liveness query
|
||||||
|
// belongs to the caller, which is the one that can see the simulation.
|
||||||
|
void setSelectedActors(std::vector<entt::entity> actors);
|
||||||
|
void setSelectedDebris(std::vector<entt::entity> debris);
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Each returns whether anything changed, without publishing.
|
||||||
|
bool clearBuildingsQuietly();
|
||||||
|
bool clearActorsQuietly();
|
||||||
|
bool clearDebrisQuietly();
|
||||||
|
|
||||||
|
void publishBuildings() const;
|
||||||
|
void publishActors() const;
|
||||||
|
void publishDebris() const;
|
||||||
|
|
||||||
|
std::vector<BuildingId> m_buildings;
|
||||||
|
std::vector<entt::entity> m_actors;
|
||||||
|
std::vector<entt::entity> m_debris;
|
||||||
|
};
|
||||||
@@ -153,3 +153,13 @@ std::optional<QPoint> findTunnelPartner(const TunnelLookup& lookup, QPoint tile,
|
|||||||
|
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels)
|
||||||
|
{
|
||||||
|
return [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
|
||||||
|
{
|
||||||
|
const TunnelTileMap::const_iterator it = tunnels.find(tile);
|
||||||
|
if (it == tunnels.end()) { return std::nullopt; }
|
||||||
|
return it->second;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <map>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
#include <QPoint>
|
#include <QPoint>
|
||||||
@@ -9,6 +10,17 @@
|
|||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
#include "Rotation.h"
|
#include "Rotation.h"
|
||||||
|
|
||||||
|
// QPoint has no operator<, so an explicit ordering is needed to key a map or set
|
||||||
|
// by tile.
|
||||||
|
struct QPointCompare
|
||||||
|
{
|
||||||
|
bool operator()(const QPoint& a, const QPoint& b) const
|
||||||
|
{
|
||||||
|
if (a.x() != b.x()) { return a.x() < b.x(); }
|
||||||
|
return a.y() < b.y();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// A tunnel building occupying a single tile: whether it is an entry or an exit and
|
// A tunnel building occupying a single tile: whether it is an entry or an exit and
|
||||||
// the direction it faces. Used by the tunnel pairing scan (REQ-BLD-TUNNEL-PAIR) and
|
// the direction it faces. Used by the tunnel pairing scan (REQ-BLD-TUNNEL-PAIR) and
|
||||||
// the unified tunnel build mode (REQ-BLD-TUNNEL-MODE).
|
// the unified tunnel build mode (REQ-BLD-TUNNEL-MODE).
|
||||||
@@ -22,6 +34,13 @@ struct TunnelTileInfo
|
|||||||
// direction, or std::nullopt when the tile holds no tunnel building.
|
// direction, or std::nullopt when the tile holds no tunnel building.
|
||||||
using TunnelLookup = std::function<std::optional<TunnelTileInfo>(QPoint)>;
|
using TunnelLookup = std::function<std::optional<TunnelTileInfo>(QPoint)>;
|
||||||
|
|
||||||
|
// Tunnel entries/exits indexed by their single-cell tile (REQ-BLD-TUNNEL-MODE).
|
||||||
|
using TunnelTileMap = std::map<QPoint, TunnelTileInfo, QPointCompare>;
|
||||||
|
|
||||||
|
// Wraps a tunnel tile index in the lookup functor the helpers below take. The
|
||||||
|
// returned functor references `tunnels`, which must outlive it.
|
||||||
|
TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels);
|
||||||
|
|
||||||
// Steps from `start` in `stepDir` over the tiles at distance 1..maxDistance and
|
// Steps from `start` in `stepDir` over the tiles at distance 1..maxDistance and
|
||||||
// returns the first tile whose tunnel faces `targetFacing`. Tunnel buildings facing
|
// returns the first tile whose tunnel faces `targetFacing`. Tunnel buildings facing
|
||||||
// any other direction are skipped, mirroring the "stop at the first same-direction
|
// any other direction are skipped, mirroring the "stop at the first same-direction
|
||||||
|
|||||||
72
src/lib/core/WorldCamera.cpp
Normal file
72
src/lib/core/WorldCamera.cpp
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#include "WorldCamera.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// Linearly blend from valueAt0 (for x <= x0) to valueAt1 (for x >= x1), clamped
|
||||||
|
// outside [x0, x1]. A zero- or negative-width band collapses to a hard step at x1.
|
||||||
|
float lerpClamped(float valueAt0, float valueAt1, float x0, float x1, float x)
|
||||||
|
{
|
||||||
|
if (x1 <= x0) { return x < x1 ? valueAt0 : valueAt1; }
|
||||||
|
const float t = std::max(0.0f, std::min(1.0f, (x - x0) / (x1 - x0)));
|
||||||
|
return valueAt0 + (valueAt1 - valueAt0) * t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldCamera::WorldCamera(const WorldScroll& scroll, const WorldRegions& regions)
|
||||||
|
: m_scroll(&scroll)
|
||||||
|
, m_regions(®ions)
|
||||||
|
, m_viewCenterXTiles(0.0f)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WorldCamera::advance(PanDirection direction, qint64 elapsedMs,
|
||||||
|
ScrollBounds bounds)
|
||||||
|
{
|
||||||
|
const float before = m_viewCenterXTiles;
|
||||||
|
|
||||||
|
if (direction != PanDirection::None)
|
||||||
|
{
|
||||||
|
const float distance =
|
||||||
|
getPanSpeedTilesPerSecondAt(m_viewCenterXTiles, bounds.rightTiles)
|
||||||
|
* static_cast<float>(elapsedMs) / 1000.0f;
|
||||||
|
m_viewCenterXTiles += (direction == PanDirection::Left) ? -distance : distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_viewCenterXTiles = std::max(bounds.leftTiles,
|
||||||
|
std::min(m_viewCenterXTiles, bounds.rightTiles));
|
||||||
|
return m_viewCenterXTiles != before;
|
||||||
|
}
|
||||||
|
|
||||||
|
float WorldCamera::getViewCenterXTiles() const
|
||||||
|
{
|
||||||
|
return m_viewCenterXTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WorldCamera::reset()
|
||||||
|
{
|
||||||
|
m_viewCenterXTiles = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
float WorldCamera::getPanSpeedTilesPerSecondAt(float viewCenterXTiles,
|
||||||
|
float contestZoneRightEdgeTiles) const
|
||||||
|
{
|
||||||
|
// Slow near the asteroid/player buffer, fast across the contest zone, with a
|
||||||
|
// linear ramp straddling each contest-zone boundary (REQ-UI-SCROLL-SPEED). The
|
||||||
|
// contest zone spans from the player buffer's right edge to the enemy stations,
|
||||||
|
// the latter tracked live so the ramp follows the front line as it is pushed.
|
||||||
|
const float slow = static_cast<float>(m_scroll->panSpeedSlow_tps);
|
||||||
|
const float fast = static_cast<float>(m_scroll->panSpeedFast_tps);
|
||||||
|
const float half = static_cast<float>(m_scroll->panRampBandWidth_tiles) / 2.0f;
|
||||||
|
const float leftEdge = static_cast<float>(m_regions->playerBufferWidth_tiles);
|
||||||
|
const float rightEdge = contestZoneRightEdgeTiles;
|
||||||
|
|
||||||
|
// Rising ramp at the left boundary (slow -> fast) and falling ramp at the right
|
||||||
|
// boundary (fast -> slow); their minimum yields flat-slow outside, flat-fast in
|
||||||
|
// the middle, and — if the bands overlap in a narrow contest zone — a single peak
|
||||||
|
// below the fast speed where the two ramps cross.
|
||||||
|
const float leftRamp = lerpClamped(slow, fast, leftEdge - half, leftEdge + half, viewCenterXTiles);
|
||||||
|
const float rightRamp = lerpClamped(fast, slow, rightEdge - half, rightEdge + half, viewCenterXTiles);
|
||||||
|
return std::min(leftRamp, rightRamp);
|
||||||
|
}
|
||||||
72
src/lib/core/WorldCamera.h
Normal file
72
src/lib/core/WorldCamera.h
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
|
#include "WorldConfig.h"
|
||||||
|
|
||||||
|
// Which way the player is currently panning the view (REQ-UI-SCROLL). A plain
|
||||||
|
// direction rather than key state: the camera is deliberately agnostic about how
|
||||||
|
// the intent was expressed, so rebindable controls would change nothing here.
|
||||||
|
enum class PanDirection
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Left,
|
||||||
|
Right
|
||||||
|
};
|
||||||
|
|
||||||
|
// The horizontal limits of the view center, in world tiles (REQ-GW-SCROLL-LIMIT):
|
||||||
|
// the view can pan left until the asteroid's left edge is centered and right until
|
||||||
|
// the enemy stations are. Both move as the game progresses — the left edge with
|
||||||
|
// asteroid expansion (REQ-GW-ASTEROID-EXPAND), the right edge as stations are
|
||||||
|
// pushed back (REQ-GW-PUSH-EXPAND) — so they are supplied per frame by the caller
|
||||||
|
// that can see the simulation, rather than queried here. That keeps the camera a
|
||||||
|
// value with no simulation dependency.
|
||||||
|
struct ScrollBounds
|
||||||
|
{
|
||||||
|
float leftTiles;
|
||||||
|
float rightTiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Horizontal view position for the game world (REQ-UI-SCROLL, REQ-UI-SCROLL-SPEED).
|
||||||
|
// Works purely in world units — tiles and tiles per second, never pixels. Turning
|
||||||
|
// the resulting position into a widget transform is WorldCoordinates' job; the two
|
||||||
|
// meet only where the view feeds getViewCenterXTiles() into that transform.
|
||||||
|
class WorldCamera
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// Both config structs are referenced rather than copied: they live inside the
|
||||||
|
// Simulation's GameConfig, which is assigned in place on restart
|
||||||
|
// (REQ-CFG-RELOAD), so a camera built once still picks up reloaded tuning.
|
||||||
|
WorldCamera(const WorldScroll& scroll, const WorldRegions& regions);
|
||||||
|
|
||||||
|
// Pans by `direction` for `elapsedMs` of wall-clock time, then clamps into
|
||||||
|
// `bounds`. Wall clock rather than ticks because panning is presentation only
|
||||||
|
// (REQ-UI-NO-ZOOM's sibling concern) and keeps working while the simulation is
|
||||||
|
// paused. Clamping happens on every call, not just when panning, so the view
|
||||||
|
// follows the bounds inward when they shrink.
|
||||||
|
//
|
||||||
|
// Returns true when the view center actually moved — including when it moved
|
||||||
|
// only because the bounds did. Callers use that to refresh anything anchored to
|
||||||
|
// the world under a stationary cursor, such as the box-select rectangle.
|
||||||
|
bool advance(PanDirection direction, qint64 elapsedMs, ScrollBounds bounds);
|
||||||
|
|
||||||
|
// World X (tiles) at the center of the viewport.
|
||||||
|
float getViewCenterXTiles() const;
|
||||||
|
|
||||||
|
// Returns the view to the start-of-run position. Deliberately does not clamp:
|
||||||
|
// a new run's bounds are not known here, and the next advance() clamps anyway.
|
||||||
|
void reset();
|
||||||
|
|
||||||
|
// Pan speed at a given view center, in tiles/s (REQ-UI-SCROLL-SPEED). Public
|
||||||
|
// because the ramp shape is the subtle part of this class and is worth testing
|
||||||
|
// directly; advance() uses it internally. `contestZoneRightEdgeTiles` is the
|
||||||
|
// live right-hand boundary — the same value as ScrollBounds::rightTiles — so
|
||||||
|
// the ramp follows the front line as it is pushed.
|
||||||
|
float getPanSpeedTilesPerSecondAt(float viewCenterXTiles,
|
||||||
|
float contestZoneRightEdgeTiles) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const WorldScroll* m_scroll;
|
||||||
|
const WorldRegions* m_regions;
|
||||||
|
float m_viewCenterXTiles;
|
||||||
|
};
|
||||||
118
src/lib/core/WorldCoordinates.cpp
Normal file
118
src/lib/core/WorldCoordinates.cpp
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
#include "WorldCoordinates.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// A zero-height widget, a not-yet-shown widget, or a degenerate world size
|
||||||
|
// would otherwise make every conversion divide by zero.
|
||||||
|
float sanitizeTilePx(float tilePx)
|
||||||
|
{
|
||||||
|
return tilePx > 0.0f ? tilePx : 1.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldCoordinates WorldCoordinates::scrolling(QSize widgetSize_px,
|
||||||
|
int worldHeight_tiles,
|
||||||
|
float viewCenterX_tiles)
|
||||||
|
{
|
||||||
|
float tilePx = 1.0f;
|
||||||
|
if (worldHeight_tiles > 0)
|
||||||
|
{
|
||||||
|
tilePx = static_cast<float>(widgetSize_px.height())
|
||||||
|
/ static_cast<float>(worldHeight_tiles);
|
||||||
|
}
|
||||||
|
tilePx = sanitizeTilePx(tilePx);
|
||||||
|
|
||||||
|
const float viewportWidthTiles = static_cast<float>(widgetSize_px.width()) / tilePx;
|
||||||
|
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
|
||||||
|
viewCenterX_tiles - viewportWidthTiles / 2.0f,
|
||||||
|
worldHeight_tiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldCoordinates WorldCoordinates::fitToWorld(QSize widgetSize_px,
|
||||||
|
int worldWidth_tiles,
|
||||||
|
int worldHeight_tiles)
|
||||||
|
{
|
||||||
|
float tilePx = 1.0f;
|
||||||
|
if (worldWidth_tiles > 0 && worldHeight_tiles > 0)
|
||||||
|
{
|
||||||
|
// The tighter of the two fits, so the whole world stays on screen.
|
||||||
|
tilePx = std::min(
|
||||||
|
static_cast<float>(widgetSize_px.height()) / static_cast<float>(worldHeight_tiles),
|
||||||
|
static_cast<float>(widgetSize_px.width()) / static_cast<float>(worldWidth_tiles));
|
||||||
|
}
|
||||||
|
tilePx = sanitizeTilePx(tilePx);
|
||||||
|
|
||||||
|
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
|
||||||
|
0.0f, worldHeight_tiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldCoordinates::WorldCoordinates(float tilePx, float viewportWidth_px,
|
||||||
|
float viewLeft_tiles, int worldHeight_tiles)
|
||||||
|
: m_tilePx(tilePx)
|
||||||
|
, m_viewportWidthTiles(viewportWidth_px / tilePx)
|
||||||
|
, m_viewLeftTiles(viewLeft_tiles)
|
||||||
|
, m_worldHeightTiles(worldHeight_tiles)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
float WorldCoordinates::getTilePx() const
|
||||||
|
{
|
||||||
|
return m_tilePx;
|
||||||
|
}
|
||||||
|
|
||||||
|
float WorldCoordinates::getViewportWidthTiles() const
|
||||||
|
{
|
||||||
|
return m_viewportWidthTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
float WorldCoordinates::getViewLeftTiles() const
|
||||||
|
{
|
||||||
|
return m_viewLeftTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPointF WorldCoordinates::worldToWidget(QVector2D worldPos) const
|
||||||
|
{
|
||||||
|
return QPointF(
|
||||||
|
static_cast<qreal>((worldPos.x() - m_viewLeftTiles) * m_tilePx),
|
||||||
|
static_cast<qreal>(worldPos.y() * m_tilePx));
|
||||||
|
}
|
||||||
|
|
||||||
|
QPointF WorldCoordinates::tileToWidget(QPoint tile) const
|
||||||
|
{
|
||||||
|
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
|
||||||
|
static_cast<float>(tile.y())));
|
||||||
|
}
|
||||||
|
|
||||||
|
QPoint WorldCoordinates::widgetToTile(QPoint widgetPoint) const
|
||||||
|
{
|
||||||
|
const QVector2D world = widgetToWorld(widgetPoint);
|
||||||
|
return QPoint(static_cast<int>(std::floor(world.x())),
|
||||||
|
static_cast<int>(std::floor(world.y())));
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector2D WorldCoordinates::widgetToWorld(QPoint widgetPoint) const
|
||||||
|
{
|
||||||
|
return QVector2D(
|
||||||
|
static_cast<float>(widgetPoint.x()) / m_tilePx + m_viewLeftTiles,
|
||||||
|
static_cast<float>(widgetPoint.y()) / m_tilePx);
|
||||||
|
}
|
||||||
|
|
||||||
|
QRectF WorldCoordinates::tileRect(QPoint tile) const
|
||||||
|
{
|
||||||
|
const QPointF topLeft = tileToWidget(tile);
|
||||||
|
return QRectF(topLeft.x(), topLeft.y(),
|
||||||
|
static_cast<qreal>(m_tilePx), static_cast<qreal>(m_tilePx));
|
||||||
|
}
|
||||||
|
|
||||||
|
QRect WorldCoordinates::getViewportRect() const
|
||||||
|
{
|
||||||
|
const int left = static_cast<int>(std::floor(m_viewLeftTiles)) - 1;
|
||||||
|
const int top = 0;
|
||||||
|
const int right = static_cast<int>(
|
||||||
|
std::ceil(m_viewLeftTiles + m_viewportWidthTiles)) + 1;
|
||||||
|
const int bottom = m_worldHeightTiles;
|
||||||
|
return QRect(left, top, right - left, bottom - top);
|
||||||
|
}
|
||||||
63
src/lib/core/WorldCoordinates.h
Normal file
63
src/lib/core/WorldCoordinates.h
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QPointF>
|
||||||
|
#include <QRect>
|
||||||
|
#include <QRectF>
|
||||||
|
#include <QSize>
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
// Immutable snapshot of the world <-> widget transform for one viewport state
|
||||||
|
// (REQ-GW-COORDS). Tiles are square; the two factories below differ only in how
|
||||||
|
// the tile size and the left edge are derived, and everything downstream of that
|
||||||
|
// is shared.
|
||||||
|
//
|
||||||
|
// The transform is a value: it is constructed from the viewport size and the view
|
||||||
|
// state, and never observes them again. A caller therefore builds one per frame
|
||||||
|
// (or per event) rather than holding one across a resize or a scroll, which would
|
||||||
|
// silently go stale.
|
||||||
|
class WorldCoordinates
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// The scrolling game world: the tile size is whatever makes the world height
|
||||||
|
// exactly fill the viewport height (REQ-GW-TILE-SIZE, no zoom per
|
||||||
|
// REQ-UI-NO-ZOOM), and the view pans horizontally. `viewCenterX_tiles` is the
|
||||||
|
// world X at the center of the viewport, matching how the scroll position is
|
||||||
|
// stored and clamped (REQ-GW-SCROLL-LIMIT).
|
||||||
|
static WorldCoordinates scrolling(QSize widgetSize_px, int worldHeight_tiles,
|
||||||
|
float viewCenterX_tiles);
|
||||||
|
|
||||||
|
// A whole world shown at once with no scrolling, as the balancing tool's arena
|
||||||
|
// does: the tile size is whichever axis is the tighter fit, so nothing is cut
|
||||||
|
// off, and the world origin sits at the widget's top-left. A viewport wider
|
||||||
|
// than the fitted world leaves empty space to the right rather than centering.
|
||||||
|
static WorldCoordinates fitToWorld(QSize widgetSize_px, int worldWidth_tiles,
|
||||||
|
int worldHeight_tiles);
|
||||||
|
|
||||||
|
// Side length of one tile in pixels. Always positive: a degenerate world or
|
||||||
|
// viewport size falls back to 1.0 so no conversion below divides by zero.
|
||||||
|
float getTilePx() const;
|
||||||
|
float getViewportWidthTiles() const;
|
||||||
|
// World X (tiles) at the left edge of the viewport.
|
||||||
|
float getViewLeftTiles() const;
|
||||||
|
|
||||||
|
QPointF worldToWidget(QVector2D worldPos) const;
|
||||||
|
QPointF tileToWidget(QPoint tile) const;
|
||||||
|
QPoint widgetToTile(QPoint widgetPoint) const;
|
||||||
|
QVector2D widgetToWorld(QPoint widgetPoint) const;
|
||||||
|
// Widget-space rect covering the whole of `tile`.
|
||||||
|
QRectF tileRect(QPoint tile) const;
|
||||||
|
|
||||||
|
// Tile-space rect of everything currently on screen, widened by one column on
|
||||||
|
// each side so items straddling an edge are still drawn.
|
||||||
|
QRect getViewportRect() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
WorldCoordinates(float tilePx, float viewportWidth_px, float viewLeft_tiles,
|
||||||
|
int worldHeight_tiles);
|
||||||
|
|
||||||
|
float m_tilePx;
|
||||||
|
float m_viewportWidthTiles;
|
||||||
|
float m_viewLeftTiles;
|
||||||
|
int m_worldHeightTiles;
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "AiSystem.h"
|
#include "AiSystem.h"
|
||||||
|
#include "FactoryQueries.h"
|
||||||
|
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
|
||||||
@@ -42,8 +43,7 @@ AiSystem::AiSystem(const GameConfig& config)
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
|
void AiSystem::tick(EntityAdmin& admin, const FactoryState& state)
|
||||||
const DebrisSystem& debris)
|
|
||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
|
|
||||||
@@ -54,8 +54,8 @@ void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
|
|||||||
m_retreatEvaluator.evaluate(admin);
|
m_retreatEvaluator.evaluate(admin);
|
||||||
m_attackEvaluator.evaluate(admin);
|
m_attackEvaluator.evaluate(admin);
|
||||||
m_repairEvaluator.evaluate(admin);
|
m_repairEvaluator.evaluate(admin);
|
||||||
m_salvageScrapEvaluator.evaluate(admin, debris);
|
m_salvageScrapEvaluator.evaluate(admin);
|
||||||
m_deliverScrapEvaluator.evaluate(admin, buildings);
|
m_deliverScrapEvaluator.evaluate(admin, state);
|
||||||
|
|
||||||
// Phase 2: pick the highest-scoring behavior per ship.
|
// Phase 2: pick the highest-scoring behavior per ship.
|
||||||
selectWinningBehaviors(admin);
|
selectWinningBehaviors(admin);
|
||||||
@@ -68,7 +68,7 @@ void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
|
|||||||
m_attackExecutor.execute(admin);
|
m_attackExecutor.execute(admin);
|
||||||
m_repairExecutor.execute(admin);
|
m_repairExecutor.execute(admin);
|
||||||
m_salvageScrapExecutor.execute(admin);
|
m_salvageScrapExecutor.execute(admin);
|
||||||
m_deliverScrapExecutor.execute(admin, buildings);
|
m_deliverScrapExecutor.execute(admin, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
void AiSystem::selectWinningBehaviors(EntityAdmin& admin)
|
void AiSystem::selectWinningBehaviors(EntityAdmin& admin)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "FactoryQueries.h"
|
||||||
|
|
||||||
#include "AdvanceEvaluator.h"
|
#include "AdvanceEvaluator.h"
|
||||||
#include "AdvanceExecutor.h"
|
#include "AdvanceExecutor.h"
|
||||||
#include "AttackEvaluator.h"
|
#include "AttackEvaluator.h"
|
||||||
@@ -17,9 +19,7 @@
|
|||||||
#include "StandbyEvaluator.h"
|
#include "StandbyEvaluator.h"
|
||||||
#include "StandbyExecutor.h"
|
#include "StandbyExecutor.h"
|
||||||
|
|
||||||
class BuildingSystem;
|
|
||||||
class EntityAdmin;
|
class EntityAdmin;
|
||||||
class DebrisSystem;
|
|
||||||
struct GameConfig;
|
struct GameConfig;
|
||||||
|
|
||||||
// Orchestrates ship-behavior decision-making in three batched phases:
|
// Orchestrates ship-behavior decision-making in three batched phases:
|
||||||
@@ -34,7 +34,7 @@ class AiSystem
|
|||||||
public:
|
public:
|
||||||
explicit AiSystem(const GameConfig& config);
|
explicit AiSystem(const GameConfig& config);
|
||||||
|
|
||||||
void tick(EntityAdmin& admin, const BuildingSystem& buildings, const DebrisSystem& debris);
|
void tick(EntityAdmin& admin, const FactoryState& state);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void selectWinningBehaviors(EntityAdmin& admin);
|
void selectWinningBehaviors(EntityAdmin& admin);
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackEvaluator.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackEvaluator.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackExecutor.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackExecutor.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/BehaviorTargeting.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/BehaviorTargeting.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/Centroid.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapEvaluator.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapEvaluator.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapExecutor.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapExecutor.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/OrbitAndAssignExecutor.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyEvaluator.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyEvaluator.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyExecutor.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyExecutor.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairEvaluator.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairEvaluator.h
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ CombatSystem::CombatSystem(const GameConfig& config)
|
|||||||
|
|
||||||
void CombatSystem::tick(Tick currentTick,
|
void CombatSystem::tick(Tick currentTick,
|
||||||
EntityAdmin& admin,
|
EntityAdmin& admin,
|
||||||
BuildingSystem& /*buildings*/,
|
|
||||||
std::vector<BeamFiredEvent>& outBeamFiredEvents)
|
std::vector<BeamFiredEvent>& outBeamFiredEvents)
|
||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
|
|
||||||
#include "entt/entity/entity.hpp"
|
#include "entt/entity/entity.hpp"
|
||||||
|
|
||||||
class BuildingSystem;
|
|
||||||
class EntityAdmin;
|
class EntityAdmin;
|
||||||
|
|
||||||
class CombatSystem
|
class CombatSystem
|
||||||
@@ -25,7 +24,6 @@ public:
|
|||||||
|
|
||||||
void tick(Tick currentTick,
|
void tick(Tick currentTick,
|
||||||
EntityAdmin& admin,
|
EntityAdmin& admin,
|
||||||
BuildingSystem& buildings,
|
|
||||||
std::vector<BeamFiredEvent>& outBeamFiredEvents);
|
std::vector<BeamFiredEvent>& outBeamFiredEvents);
|
||||||
|
|
||||||
void applyPendingDamage(Tick currentTick, EntityAdmin& admin);
|
void applyPendingDamage(Tick currentTick, EntityAdmin& admin);
|
||||||
|
|||||||
@@ -46,13 +46,13 @@ std::optional<int> DebrisSystem::consume(entt::entity entity)
|
|||||||
return amount;
|
return amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DebrisSystem::collectOne(entt::entity entity)
|
bool collectOne(EntityAdmin& admin, entt::entity entity)
|
||||||
{
|
{
|
||||||
if (!m_admin.isValid(entity) || !m_admin.hasAll<DebrisComponent>(entity))
|
if (!admin.isValid(entity) || !admin.hasAll<DebrisComponent>(entity))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
DebrisComponent& data = m_admin.get<DebrisComponent>(entity);
|
DebrisComponent& data = admin.get<DebrisComponent>(entity);
|
||||||
if (data.amount <= 0)
|
if (data.amount <= 0)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -60,18 +60,18 @@ bool DebrisSystem::collectOne(entt::entity entity)
|
|||||||
--data.amount;
|
--data.amount;
|
||||||
if (data.amount <= 0)
|
if (data.amount <= 0)
|
||||||
{
|
{
|
||||||
m_admin.destroy(entity);
|
admin.destroy(entity);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<DebrisInfo> DebrisSystem::getAllDebrisInfo() const
|
std::vector<DebrisInfo> getAllDebrisInfo(const EntityAdmin& admin)
|
||||||
{
|
{
|
||||||
std::vector<DebrisInfo> result;
|
std::vector<DebrisInfo> result;
|
||||||
m_admin.forEach<DebrisComponent>(
|
admin.forEach<DebrisComponent>(
|
||||||
[&result, this](entt::entity e, const DebrisComponent& sd)
|
[&result, &admin](entt::entity e, const DebrisComponent& sd)
|
||||||
{
|
{
|
||||||
result.push_back(DebrisInfo{e, m_admin.get<PositionComponent>(e).value, sd.amount});
|
result.push_back(DebrisInfo{e, admin.get<PositionComponent>(e).value, sd.amount});
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,17 @@ public:
|
|||||||
// false if the entity is invalid or already empty (REQ-SHP-SALVAGE).
|
// false if the entity is invalid or already empty (REQ-SHP-SALVAGE).
|
||||||
bool collectOne(entt::entity entity);
|
bool collectOne(entt::entity entity);
|
||||||
|
|
||||||
// Lightweight snapshot for callers that need to iterate all debris.
|
|
||||||
std::vector<DebrisInfo> getAllDebrisInfo() const;
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
EntityAdmin& m_admin;
|
EntityAdmin& m_admin;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Debris state read and changed straight off the registry — no system needed.
|
||||||
|
|
||||||
|
// Lightweight snapshot for callers that need to iterate all debris.
|
||||||
|
std::vector<DebrisInfo> getAllDebrisInfo(const EntityAdmin& admin);
|
||||||
|
|
||||||
|
// Collects a single scrap unit from the debris: decrements its amount by one,
|
||||||
|
// destroying the entity once depleted. Returns true if a scrap was collected,
|
||||||
|
// false if the entity is invalid or already empty (REQ-SHP-SALVAGE).
|
||||||
|
bool collectOne(EntityAdmin& admin, entt::entity entity);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "SalvagerSystem.h"
|
#include "SalvagerSystem.h"
|
||||||
|
#include "FactoryQueries.h"
|
||||||
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -23,14 +24,14 @@ SalvagerSystem::SalvagerSystem(EntityAdmin& admin)
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
void SalvagerSystem::tick(Tick currentTick, DebrisSystem& debris, BuildingSystem& buildings,
|
void SalvagerSystem::tick(Tick currentTick, FactoryState& state,
|
||||||
std::vector<BeamFiredEvent>& outBeamFiredEvents)
|
std::vector<BeamFiredEvent>& outBeamFiredEvents)
|
||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
// Apply collections whose mid-beam delay has elapsed (cycles started earlier).
|
// Apply collections whose mid-beam delay has elapsed (cycles started earlier).
|
||||||
applyPendingCollections(currentTick, debris);
|
applyPendingCollections(currentTick);
|
||||||
|
|
||||||
const std::vector<DebrisInfo> allDebris = debris.getAllDebrisInfo();
|
const std::vector<DebrisInfo> allDebris = getAllDebrisInfo(m_admin);
|
||||||
|
|
||||||
// Tick down per-module collection cooldowns.
|
// Tick down per-module collection cooldowns.
|
||||||
m_admin.forEach<SalvagerComponent>(
|
m_admin.forEach<SalvagerComponent>(
|
||||||
@@ -89,7 +90,7 @@ void SalvagerSystem::tick(Tick currentTick, DebrisSystem& debris, BuildingSystem
|
|||||||
[&](entt::entity ship, const DeliverScrapBehavior& deliver, const PositionComponent& pos)
|
[&](entt::entity ship, const DeliverScrapBehavior& deliver, const PositionComponent& pos)
|
||||||
{
|
{
|
||||||
if (!deliver.deliveryBay.has_value()) { return; }
|
if (!deliver.deliveryBay.has_value()) { return; }
|
||||||
const Building* bay = buildings.findBuilding(*deliver.deliveryBay);
|
const Building* bay = findBuilding(state, *deliver.deliveryBay);
|
||||||
if (!bay) { return; }
|
if (!bay) { return; }
|
||||||
|
|
||||||
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
||||||
@@ -100,14 +101,14 @@ void SalvagerSystem::tick(Tick currentTick, DebrisSystem& debris, BuildingSystem
|
|||||||
if (!m_admin.hasAll<CargoComponent>(ship)) { return; }
|
if (!m_admin.hasAll<CargoComponent>(ship)) { return; }
|
||||||
CargoComponent& cargo = m_admin.get<CargoComponent>(ship);
|
CargoComponent& cargo = m_admin.get<CargoComponent>(ship);
|
||||||
if (cargo.current <= 0) { return; }
|
if (cargo.current <= 0) { return; }
|
||||||
if (buildings.deliverScrapToSalvageBay(*deliver.deliveryBay))
|
if (deliverScrapToSalvageBay(state, *deliver.deliveryBay))
|
||||||
{
|
{
|
||||||
--cargo.current;
|
--cargo.current;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void SalvagerSystem::applyPendingCollections(Tick currentTick, DebrisSystem& debris)
|
void SalvagerSystem::applyPendingCollections(Tick currentTick)
|
||||||
{
|
{
|
||||||
std::vector<PendingCollection>::iterator it = m_pendingCollections.begin();
|
std::vector<PendingCollection>::iterator it = m_pendingCollections.begin();
|
||||||
while (it != m_pendingCollections.end())
|
while (it != m_pendingCollections.end())
|
||||||
@@ -117,7 +118,7 @@ void SalvagerSystem::applyPendingCollections(Tick currentTick, DebrisSystem& deb
|
|||||||
if (m_admin.isValid(it->ship) && m_admin.hasAll<CargoComponent>(it->ship))
|
if (m_admin.isValid(it->ship) && m_admin.hasAll<CargoComponent>(it->ship))
|
||||||
{
|
{
|
||||||
CargoComponent& cargo = m_admin.get<CargoComponent>(it->ship);
|
CargoComponent& cargo = m_admin.get<CargoComponent>(it->ship);
|
||||||
if (cargo.current < cargo.maxCapacity && debris.collectOne(it->debris))
|
if (cargo.current < cargo.maxCapacity && collectOne(m_admin, it->debris))
|
||||||
{
|
{
|
||||||
++cargo.current;
|
++cargo.current;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "FactoryQueries.h"
|
||||||
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "BeamFiredEvent.h"
|
#include "BeamFiredEvent.h"
|
||||||
@@ -7,9 +9,7 @@
|
|||||||
|
|
||||||
#include "entt/entity/entity.hpp"
|
#include "entt/entity/entity.hpp"
|
||||||
|
|
||||||
class BuildingSystem;
|
|
||||||
class EntityAdmin;
|
class EntityAdmin;
|
||||||
class DebrisSystem;
|
|
||||||
|
|
||||||
// World-mutation system for salvage modules: each module runs a collection cycle
|
// World-mutation system for salvage modules: each module runs a collection cycle
|
||||||
// on its own cooldown. When a cycle starts it emits a salvage beam toward an
|
// on its own cooldown. When a cycle starts it emits a salvage beam toward an
|
||||||
@@ -21,7 +21,7 @@ class SalvagerSystem
|
|||||||
public:
|
public:
|
||||||
explicit SalvagerSystem(EntityAdmin& admin);
|
explicit SalvagerSystem(EntityAdmin& admin);
|
||||||
|
|
||||||
void tick(Tick currentTick, DebrisSystem& debris, BuildingSystem& buildings,
|
void tick(Tick currentTick, FactoryState& state,
|
||||||
std::vector<BeamFiredEvent>& outBeamFiredEvents);
|
std::vector<BeamFiredEvent>& outBeamFiredEvents);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -32,7 +32,7 @@ private:
|
|||||||
Tick appliesAt;
|
Tick appliesAt;
|
||||||
};
|
};
|
||||||
|
|
||||||
void applyPendingCollections(Tick currentTick, DebrisSystem& debris);
|
void applyPendingCollections(Tick currentTick);
|
||||||
|
|
||||||
EntityAdmin& m_admin;
|
EntityAdmin& m_admin;
|
||||||
std::vector<PendingCollection> m_pendingCollections;
|
std::vector<PendingCollection> m_pendingCollections;
|
||||||
|
|||||||
@@ -41,35 +41,11 @@ ShipSystem::ShipSystem(const GameConfig& config, EntityAdmin& admin)
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShipDef* ShipSystem::findShipDef(const std::string& schematicId) const
|
|
||||||
{
|
|
||||||
for (const ShipDef& def : m_config.ships.ships)
|
|
||||||
{
|
|
||||||
if (def.id == schematicId)
|
|
||||||
{
|
|
||||||
return &def;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModuleDef* ShipSystem::findModuleDef(const std::string& id) const
|
|
||||||
{
|
|
||||||
for (const ModuleDef& def : m_config.modules.modules)
|
|
||||||
{
|
|
||||||
if (def.id == id)
|
|
||||||
{
|
|
||||||
return &def;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
entt::entity ShipSystem::spawn(const std::string& schematicId,
|
entt::entity ShipSystem::spawn(const std::string& schematicId,
|
||||||
QVector2D position, bool isEnemy,
|
QVector2D position, bool isEnemy,
|
||||||
const std::optional<ShipLayoutConfig>& layout)
|
const std::optional<ShipLayoutConfig>& layout)
|
||||||
{
|
{
|
||||||
const ShipDef* def = findShipDef(schematicId);
|
const ShipDef* def = m_config.ships.findShipDef(schematicId);
|
||||||
assert(def != nullptr);
|
assert(def != nullptr);
|
||||||
|
|
||||||
const float tickRate = static_cast<float>(kTickRateHz);
|
const float tickRate = static_cast<float>(kTickRateHz);
|
||||||
@@ -116,7 +92,7 @@ entt::entity ShipSystem::spawn(const std::string& schematicId,
|
|||||||
|
|
||||||
for (const PlacedModule& pm : modules)
|
for (const PlacedModule& pm : modules)
|
||||||
{
|
{
|
||||||
const ModuleDef* modDef = findModuleDef(pm.moduleId);
|
const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
|
||||||
if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
|
if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
|
||||||
|
|
||||||
if (modDef->weaponCapability)
|
if (modDef->weaponCapability)
|
||||||
@@ -184,7 +160,7 @@ entt::entity ShipSystem::spawn(const std::string& schematicId,
|
|||||||
|
|
||||||
for (const PlacedModule& pm : modules)
|
for (const PlacedModule& pm : modules)
|
||||||
{
|
{
|
||||||
const ModuleDef* modDef = findModuleDef(pm.moduleId);
|
const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
|
||||||
if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
|
if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
|
||||||
|
|
||||||
for (const ModuleStatModifier& sm : modDef->statModifiers)
|
for (const ModuleStatModifier& sm : modDef->statModifiers)
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ public:
|
|||||||
void setRetreatEnabled(bool enabled);
|
void setRetreatEnabled(bool enabled);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
const ShipDef* findShipDef(const std::string& schematicId) const;
|
|
||||||
const ModuleDef* findModuleDef(const std::string& id) const;
|
|
||||||
|
|
||||||
const GameConfig& m_config;
|
const GameConfig& m_config;
|
||||||
EntityAdmin& m_admin;
|
EntityAdmin& m_admin;
|
||||||
QVector2D m_rallyPoint;
|
QVector2D m_rallyPoint;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
#include "AdvanceBehavior.h"
|
#include "AdvanceBehavior.h"
|
||||||
#include "BehaviorKind.h"
|
#include "BehaviorKind.h"
|
||||||
|
#include "Centroid.h"
|
||||||
#include "EntityAdmin.h"
|
#include "EntityAdmin.h"
|
||||||
#include "FactionComponent.h"
|
#include "FactionComponent.h"
|
||||||
#include "HealthComponent.h"
|
#include "HealthComponent.h"
|
||||||
@@ -16,28 +17,6 @@
|
|||||||
#include "StationBodyComponent.h"
|
#include "StationBodyComponent.h"
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
// Accumulates positions to produce their centroid (the center between them).
|
|
||||||
struct Centroid
|
|
||||||
{
|
|
||||||
QVector2D sum;
|
|
||||||
int count = 0;
|
|
||||||
|
|
||||||
void add(const QVector2D& point)
|
|
||||||
{
|
|
||||||
sum += point;
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::optional<QVector2D> value() const
|
|
||||||
{
|
|
||||||
if (count == 0) { return std::nullopt; }
|
|
||||||
return sum / static_cast<float>(count);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
void AdvanceExecutor::execute(EntityAdmin& admin)
|
void AdvanceExecutor::execute(EntityAdmin& admin)
|
||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
#include "AttackBehavior.h"
|
#include "AttackBehavior.h"
|
||||||
#include "BehaviorKind.h"
|
#include "BehaviorKind.h"
|
||||||
#include "DynamicBodyComponent.h"
|
|
||||||
#include "EntityAdmin.h"
|
#include "EntityAdmin.h"
|
||||||
#include "ModuleOwnerComponent.h"
|
#include "OrbitAndAssignExecutor.h"
|
||||||
#include "MovementIntentComponent.h"
|
|
||||||
#include "PositionComponent.h"
|
|
||||||
#include "SelectedBehaviorComponent.h"
|
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
#include "WeaponComponent.h"
|
#include "WeaponComponent.h"
|
||||||
|
|
||||||
@@ -15,55 +11,7 @@ void AttackExecutor::execute(EntityAdmin& admin)
|
|||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
|
|
||||||
// Ships: move toward the behavior target.
|
// Orbit the attack target and hand it to every weapon that can reach it
|
||||||
admin.forEach<AttackBehavior, SelectedBehaviorComponent, PositionComponent,
|
// (REQ-SHP-ORBIT).
|
||||||
MovementIntentComponent>(
|
executeOrbitAndAssign<AttackBehavior, WeaponComponent>(admin, BehaviorKind::Attack);
|
||||||
[&](entt::entity /*e*/, const AttackBehavior& attack,
|
|
||||||
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
|
|
||||||
MovementIntentComponent& intent)
|
|
||||||
{
|
|
||||||
if (selected.winner != BehaviorKind::Attack) { return; }
|
|
||||||
if (!attack.currentTarget) { return; }
|
|
||||||
|
|
||||||
const entt::entity t = *attack.currentTarget;
|
|
||||||
QVector2D center = pos.value;
|
|
||||||
float radius = 0.0f;
|
|
||||||
QVector2D centerVelocity;
|
|
||||||
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
|
||||||
{
|
|
||||||
center = admin.get<PositionComponent>(t).value;
|
|
||||||
radius = attack.orbitRadius_tiles;
|
|
||||||
if (admin.hasAll<DynamicBodyComponent>(t))
|
|
||||||
{
|
|
||||||
centerVelocity = admin.get<DynamicBodyComponent>(t).velocity_tpt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
intent = MovementIntentComponent{true, center, radius, centerVelocity};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Weapons: assign the behavior target only if it is within this weapon's range.
|
|
||||||
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
|
|
||||||
[&](entt::entity /*we*/, WeaponComponent& weapon, const ModuleOwnerComponent& owner)
|
|
||||||
{
|
|
||||||
if (!admin.hasAll<AttackBehavior, SelectedBehaviorComponent>(owner.owner))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const SelectedBehaviorComponent& selected =
|
|
||||||
admin.get<SelectedBehaviorComponent>(owner.owner);
|
|
||||||
if (selected.winner != BehaviorKind::Attack) { return; }
|
|
||||||
|
|
||||||
const AttackBehavior& attack = admin.get<AttackBehavior>(owner.owner);
|
|
||||||
if (!attack.currentTarget) { return; }
|
|
||||||
|
|
||||||
const entt::entity t = *attack.currentTarget;
|
|
||||||
if (!admin.isValid(t) || !admin.hasAll<PositionComponent>(t)) { return; }
|
|
||||||
|
|
||||||
const QVector2D ownerPos = admin.get<PositionComponent>(owner.owner).value;
|
|
||||||
const float dist = (admin.get<PositionComponent>(t).value - ownerPos).length();
|
|
||||||
if (dist <= weapon.range_tiles)
|
|
||||||
{
|
|
||||||
weapon.currentTarget = t;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/lib/ecs/system/ai/Centroid.h
Normal file
26
src/lib/ecs/system/ai/Centroid.h
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
// Accumulates positions to produce their centroid (the center between them).
|
||||||
|
// Shared by the behavior executors that steer toward the middle of a group of
|
||||||
|
// entities (AdvanceExecutor: defence stations; StandbyExecutor: friendly ships).
|
||||||
|
struct Centroid
|
||||||
|
{
|
||||||
|
QVector2D sum;
|
||||||
|
int count = 0;
|
||||||
|
|
||||||
|
void add(const QVector2D& point)
|
||||||
|
{
|
||||||
|
sum += point;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<QVector2D> value() const
|
||||||
|
{
|
||||||
|
if (count == 0) { return std::nullopt; }
|
||||||
|
return sum / static_cast<float>(count);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "DeliverScrapEvaluator.h"
|
#include "DeliverScrapEvaluator.h"
|
||||||
|
#include "FactoryQueries.h"
|
||||||
|
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
#include "PositionComponent.h"
|
#include "PositionComponent.h"
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
|
|
||||||
void DeliverScrapEvaluator::evaluate(EntityAdmin& admin, const BuildingSystem& buildings)
|
void DeliverScrapEvaluator::evaluate(EntityAdmin& admin, const FactoryState& state)
|
||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin);
|
const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin);
|
||||||
@@ -34,7 +35,7 @@ void DeliverScrapEvaluator::evaluate(EntityAdmin& admin, const BuildingSystem& b
|
|||||||
if (!deliver.deliveryBay.has_value())
|
if (!deliver.deliveryBay.has_value())
|
||||||
{
|
{
|
||||||
const Building* bay =
|
const Building* bay =
|
||||||
buildings.findNearestBuilding(pos.value, BuildingType::SalvageBay);
|
findNearestBuilding(state, pos.value, BuildingType::SalvageBay);
|
||||||
if (bay) { deliver.deliveryBay = bay->id; }
|
if (bay) { deliver.deliveryBay = bay->id; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "FactoryQueries.h"
|
||||||
|
|
||||||
class EntityAdmin;
|
class EntityAdmin;
|
||||||
class BuildingSystem;
|
|
||||||
|
|
||||||
// Scores high only when the ship's cargo is full, and assigns the nearest
|
// Scores high only when the ship's cargo is full, and assigns the nearest
|
||||||
// SalvageBay as the delivery destination.
|
// SalvageBay as the delivery destination.
|
||||||
class DeliverScrapEvaluator
|
class DeliverScrapEvaluator
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
void evaluate(EntityAdmin& admin, const BuildingSystem& buildings);
|
void evaluate(EntityAdmin& admin, const FactoryState& state);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "DeliverScrapExecutor.h"
|
#include "DeliverScrapExecutor.h"
|
||||||
|
#include "FactoryQueries.h"
|
||||||
|
|
||||||
#include <QVector2D>
|
#include <QVector2D>
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
#include "SelectedBehaviorComponent.h"
|
#include "SelectedBehaviorComponent.h"
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
|
|
||||||
void DeliverScrapExecutor::execute(EntityAdmin& admin, const BuildingSystem& buildings)
|
void DeliverScrapExecutor::execute(EntityAdmin& admin, const FactoryState& state)
|
||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
admin.forEach<DeliverScrapBehavior, SelectedBehaviorComponent, PositionComponent,
|
admin.forEach<DeliverScrapBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||||
@@ -26,7 +27,7 @@ void DeliverScrapExecutor::execute(EntityAdmin& admin, const BuildingSystem& bui
|
|||||||
QVector2D dest = pos.value;
|
QVector2D dest = pos.value;
|
||||||
if (deliver.deliveryBay.has_value())
|
if (deliver.deliveryBay.has_value())
|
||||||
{
|
{
|
||||||
const Building* bay = buildings.findBuilding(*deliver.deliveryBay);
|
const Building* bay = findBuilding(state, *deliver.deliveryBay);
|
||||||
if (bay)
|
if (bay)
|
||||||
{
|
{
|
||||||
dest = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
dest = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "FactoryQueries.h"
|
||||||
|
|
||||||
class EntityAdmin;
|
class EntityAdmin;
|
||||||
class BuildingSystem;
|
|
||||||
|
|
||||||
// Moves a ship toward its delivery bay when DeliverScrap is the winning
|
// Moves a ship toward its delivery bay when DeliverScrap is the winning
|
||||||
// behavior. Never decrements cargo — SalvagerSystem performs the delivery.
|
// behavior. Never decrements cargo — SalvagerSystem performs the delivery.
|
||||||
class DeliverScrapExecutor
|
class DeliverScrapExecutor
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
void execute(EntityAdmin& admin, const BuildingSystem& buildings);
|
void execute(EntityAdmin& admin, const FactoryState& state);
|
||||||
};
|
};
|
||||||
|
|||||||
89
src/lib/ecs/system/ai/OrbitAndAssignExecutor.h
Normal file
89
src/lib/ecs/system/ai/OrbitAndAssignExecutor.h
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "entt/entity/entity.hpp"
|
||||||
|
|
||||||
|
#include "BehaviorKind.h"
|
||||||
|
#include "DynamicBodyComponent.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "ModuleOwnerComponent.h"
|
||||||
|
#include "MovementIntentComponent.h"
|
||||||
|
#include "PositionComponent.h"
|
||||||
|
#include "SelectedBehaviorComponent.h"
|
||||||
|
|
||||||
|
// Shared executor body for the behaviors that orbit a single target entity and then
|
||||||
|
// hand that target to the ship's in-range modules (REQ-SHP-ORBIT): Attack (with
|
||||||
|
// WeaponComponent) and Repair (with RepairToolComponent).
|
||||||
|
//
|
||||||
|
// Two passes, in this order — the order and the exact sequence of component writes
|
||||||
|
// are load-bearing for determinism (see the Tick Order section of
|
||||||
|
// docs/architecture.md):
|
||||||
|
// 1. Ships that have `Behavior` and won with `kind` write their MovementIntent to
|
||||||
|
// orbit the behavior's target at the behavior's orbit radius. A target that is
|
||||||
|
// gone (or has no position) degenerates to "hold position": the ship's own
|
||||||
|
// position with a zero radius.
|
||||||
|
// 2. Modules of type `ModuleComponent` whose owner won with `kind` adopt the
|
||||||
|
// behavior's target, but only when it lies within that module's own range.
|
||||||
|
// Out-of-range modules keep whatever target they already had, which
|
||||||
|
// CombatSystem/RepairSystem re-validate.
|
||||||
|
//
|
||||||
|
// `Behavior` must expose `std::optional<entt::entity> currentTarget` and
|
||||||
|
// `float orbitRadius_tiles`; `ModuleComponent` must expose `float range_tiles` and
|
||||||
|
// `std::optional<entt::entity> currentTarget`.
|
||||||
|
template <typename Behavior, typename ModuleComponent>
|
||||||
|
void executeOrbitAndAssign(EntityAdmin& admin, BehaviorKind kind)
|
||||||
|
{
|
||||||
|
// Ships: move toward the behavior target.
|
||||||
|
admin.forEach<Behavior, SelectedBehaviorComponent, PositionComponent,
|
||||||
|
MovementIntentComponent>(
|
||||||
|
[&](entt::entity /*e*/, const Behavior& behavior,
|
||||||
|
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
|
||||||
|
MovementIntentComponent& intent)
|
||||||
|
{
|
||||||
|
if (selected.winner != kind) { return; }
|
||||||
|
if (!behavior.currentTarget) { return; }
|
||||||
|
|
||||||
|
const entt::entity t = *behavior.currentTarget;
|
||||||
|
QVector2D center = pos.value;
|
||||||
|
float radius = 0.0f;
|
||||||
|
QVector2D centerVelocity;
|
||||||
|
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
||||||
|
{
|
||||||
|
center = admin.get<PositionComponent>(t).value;
|
||||||
|
radius = behavior.orbitRadius_tiles;
|
||||||
|
if (admin.hasAll<DynamicBodyComponent>(t))
|
||||||
|
{
|
||||||
|
centerVelocity = admin.get<DynamicBodyComponent>(t).velocity_tpt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
intent = MovementIntentComponent{true, center, radius, centerVelocity};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modules: assign the behavior target only if it is within this module's range.
|
||||||
|
admin.forEach<ModuleComponent, ModuleOwnerComponent>(
|
||||||
|
[&](entt::entity /*me*/, ModuleComponent& module,
|
||||||
|
const ModuleOwnerComponent& owner)
|
||||||
|
{
|
||||||
|
if (!admin.hasAll<Behavior, SelectedBehaviorComponent>(owner.owner))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const SelectedBehaviorComponent& selected =
|
||||||
|
admin.get<SelectedBehaviorComponent>(owner.owner);
|
||||||
|
if (selected.winner != kind) { return; }
|
||||||
|
|
||||||
|
const Behavior& behavior = admin.get<Behavior>(owner.owner);
|
||||||
|
if (!behavior.currentTarget) { return; }
|
||||||
|
|
||||||
|
const entt::entity t = *behavior.currentTarget;
|
||||||
|
if (!admin.isValid(t) || !admin.hasAll<PositionComponent>(t)) { return; }
|
||||||
|
|
||||||
|
const QVector2D ownerPos = admin.get<PositionComponent>(owner.owner).value;
|
||||||
|
const float dist = (admin.get<PositionComponent>(t).value - ownerPos).length();
|
||||||
|
if (dist <= module.range_tiles)
|
||||||
|
{
|
||||||
|
module.currentTarget = t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,69 +1,17 @@
|
|||||||
#include "RepairExecutor.h"
|
#include "RepairExecutor.h"
|
||||||
|
|
||||||
#include "BehaviorKind.h"
|
#include "BehaviorKind.h"
|
||||||
#include "DynamicBodyComponent.h"
|
|
||||||
#include "EntityAdmin.h"
|
#include "EntityAdmin.h"
|
||||||
#include "ModuleOwnerComponent.h"
|
#include "OrbitAndAssignExecutor.h"
|
||||||
#include "MovementIntentComponent.h"
|
|
||||||
#include "PositionComponent.h"
|
|
||||||
#include "RepairBehavior.h"
|
#include "RepairBehavior.h"
|
||||||
#include "RepairToolComponent.h"
|
#include "RepairToolComponent.h"
|
||||||
#include "SelectedBehaviorComponent.h"
|
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
|
|
||||||
void RepairExecutor::execute(EntityAdmin& admin)
|
void RepairExecutor::execute(EntityAdmin& admin)
|
||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
|
|
||||||
// Ships: move toward the repair target.
|
// Orbit the repair target and hand it to every repair tool that can reach it
|
||||||
admin.forEach<RepairBehavior, SelectedBehaviorComponent, PositionComponent,
|
// (REQ-SHP-ORBIT).
|
||||||
MovementIntentComponent>(
|
executeOrbitAndAssign<RepairBehavior, RepairToolComponent>(admin, BehaviorKind::Repair);
|
||||||
[&](entt::entity /*e*/, const RepairBehavior& repair,
|
|
||||||
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
|
|
||||||
MovementIntentComponent& intent)
|
|
||||||
{
|
|
||||||
if (selected.winner != BehaviorKind::Repair) { return; }
|
|
||||||
if (!repair.currentTarget) { return; }
|
|
||||||
|
|
||||||
const entt::entity t = *repair.currentTarget;
|
|
||||||
QVector2D center = pos.value;
|
|
||||||
float radius = 0.0f;
|
|
||||||
QVector2D centerVelocity;
|
|
||||||
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
|
||||||
{
|
|
||||||
center = admin.get<PositionComponent>(t).value;
|
|
||||||
radius = repair.orbitRadius_tiles;
|
|
||||||
if (admin.hasAll<DynamicBodyComponent>(t))
|
|
||||||
{
|
|
||||||
centerVelocity = admin.get<DynamicBodyComponent>(t).velocity_tpt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
intent = MovementIntentComponent{true, center, radius, centerVelocity};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Repair tools: prefer the behavior target if it is within tool range.
|
|
||||||
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
|
|
||||||
[&](entt::entity /*re*/, RepairToolComponent& tool, const ModuleOwnerComponent& owner)
|
|
||||||
{
|
|
||||||
if (!admin.hasAll<RepairBehavior, SelectedBehaviorComponent>(owner.owner))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const SelectedBehaviorComponent& selected =
|
|
||||||
admin.get<SelectedBehaviorComponent>(owner.owner);
|
|
||||||
if (selected.winner != BehaviorKind::Repair) { return; }
|
|
||||||
|
|
||||||
const RepairBehavior& repair = admin.get<RepairBehavior>(owner.owner);
|
|
||||||
if (!repair.currentTarget) { return; }
|
|
||||||
|
|
||||||
const entt::entity t = *repair.currentTarget;
|
|
||||||
if (!admin.isValid(t) || !admin.hasAll<PositionComponent>(t)) { return; }
|
|
||||||
|
|
||||||
const QVector2D ownerPos = admin.get<PositionComponent>(owner.owner).value;
|
|
||||||
const float dist = (admin.get<PositionComponent>(t).value - ownerPos).length();
|
|
||||||
if (dist <= tool.range_tiles)
|
|
||||||
{
|
|
||||||
tool.currentTarget = t;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
#include "SensorRangeComponent.h"
|
#include "SensorRangeComponent.h"
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
|
|
||||||
void SalvageScrapEvaluator::evaluate(EntityAdmin& admin, const DebrisSystem& debris)
|
void SalvageScrapEvaluator::evaluate(EntityAdmin& admin)
|
||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin);
|
const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin);
|
||||||
const std::vector<DebrisInfo> allDebris = debris.getAllDebrisInfo();
|
const std::vector<DebrisInfo> allDebris = getAllDebrisInfo(admin);
|
||||||
|
|
||||||
admin.forEach<SalvageScrapBehavior, PositionComponent, SensorRangeComponent>(
|
admin.forEach<SalvageScrapBehavior, PositionComponent, SensorRangeComponent>(
|
||||||
[&](entt::entity e, SalvageScrapBehavior& salvage, const PositionComponent& pos,
|
[&](entt::entity e, SalvageScrapBehavior& salvage, const PositionComponent& pos,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
class EntityAdmin;
|
class EntityAdmin;
|
||||||
class DebrisSystem;
|
|
||||||
|
|
||||||
// When cargo is not full, finds the nearest debris within sensor range and sets
|
// When cargo is not full, finds the nearest debris within sensor range and sets
|
||||||
// it as the target, scoring high. Scores inactive when cargo is full or no debris
|
// it as the target, scoring high. Scores inactive when cargo is full or no debris
|
||||||
@@ -9,5 +8,5 @@ class DebrisSystem;
|
|||||||
class SalvageScrapEvaluator
|
class SalvageScrapEvaluator
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
void evaluate(EntityAdmin& admin, const DebrisSystem& debris);
|
void evaluate(EntityAdmin& admin);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <QVector2D>
|
#include <QVector2D>
|
||||||
|
|
||||||
#include "BehaviorKind.h"
|
#include "BehaviorKind.h"
|
||||||
|
#include "Centroid.h"
|
||||||
#include "EntityAdmin.h"
|
#include "EntityAdmin.h"
|
||||||
#include "FactionComponent.h"
|
#include "FactionComponent.h"
|
||||||
#include "HealthComponent.h"
|
#include "HealthComponent.h"
|
||||||
@@ -16,28 +17,6 @@
|
|||||||
#include "StationBodyComponent.h"
|
#include "StationBodyComponent.h"
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
// Accumulates positions to produce their centroid (the center between them).
|
|
||||||
struct Centroid
|
|
||||||
{
|
|
||||||
QVector2D sum;
|
|
||||||
int count = 0;
|
|
||||||
|
|
||||||
void add(const QVector2D& point)
|
|
||||||
{
|
|
||||||
sum += point;
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::optional<QVector2D> value() const
|
|
||||||
{
|
|
||||||
if (count == 0) { return std::nullopt; }
|
|
||||||
return sum / static_cast<float>(count);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
void StandbyExecutor::execute(EntityAdmin& admin)
|
void StandbyExecutor::execute(EntityAdmin& admin)
|
||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
#include "Event.h"
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Fired when the collected artifact count changes. Carries no payload —
|
||||||
|
// subscribers re-read Simulation::getArtifactCount(), and the win count from
|
||||||
|
// world.artifacts.artifactWinCount.
|
||||||
class ArtifactCountChangedEvent : public Event
|
class ArtifactCountChangedEvent : public Event
|
||||||
{
|
{
|
||||||
public:
|
|
||||||
ArtifactCountChangedEvent(int count, int winCount) : count(count), winCount(winCount) {}
|
|
||||||
const int count;
|
|
||||||
const int winCount;
|
|
||||||
};
|
};
|
||||||
|
|||||||
11
src/lib/eventsystem/event/BlueprintSaveRequestedEvent.h
Normal file
11
src/lib/eventsystem/event/BlueprintSaveRequestedEvent.h
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Ctrl+C: save the current building selection as a named blueprint
|
||||||
|
// (REQ-UI-BLUEPRINT-CREATE, REQ-UI-HOTKEYS). MainWindow owns the modal flow, because
|
||||||
|
// it is the only widget that can pause the game and raise the dim overlay; whether
|
||||||
|
// anything placeable is selected is decided there, not by the key handler.
|
||||||
|
class BlueprintSaveRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
11
src/lib/eventsystem/event/BlueprintSelectionRequestedEvent.h
Normal file
11
src/lib/eventsystem/event/BlueprintSelectionRequestedEvent.h
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Ctrl+V: open the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG,
|
||||||
|
// REQ-UI-HOTKEYS). The Ctrl+C path does not go through this event -- it opens the
|
||||||
|
// dialog directly so one pause scope and one dim scope span both dialogs
|
||||||
|
// (REQ-UI-MODAL-DIM).
|
||||||
|
class BlueprintSelectionRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
@@ -2,16 +2,11 @@
|
|||||||
#define BOSS_WAVE_UPDATED_EVENT_H
|
#define BOSS_WAVE_UPDATED_EVENT_H
|
||||||
|
|
||||||
#include "Event.h"
|
#include "Event.h"
|
||||||
#include "Tick.h"
|
|
||||||
|
|
||||||
|
// Fired when the boss wave counter or its countdown changes. Carries no payload —
|
||||||
|
// subscribers re-read Simulation::getBossWaveCounter() and getBossCountdownTicks().
|
||||||
class BossWaveUpdatedEvent : public Event
|
class BossWaveUpdatedEvent : public Event
|
||||||
{
|
{
|
||||||
public:
|
|
||||||
BossWaveUpdatedEvent(int counter, Tick countdownTicks)
|
|
||||||
: counter(counter), countdownTicks(countdownTicks) {}
|
|
||||||
const int counter;
|
|
||||||
const Tick countdownTicks;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // BOSS_WAVE_UPDATED_EVENT_H
|
#endif // BOSS_WAVE_UPDATED_EVENT_H
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
|
|
||||||
#include "Event.h"
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Fired when the building block stock changes. Carries no payload — subscribers
|
||||||
|
// re-read Simulation::getBuildingBlocksStock().
|
||||||
class BuildingBlocksChangedEvent : public Event
|
class BuildingBlocksChangedEvent : public Event
|
||||||
{
|
{
|
||||||
public:
|
|
||||||
explicit BuildingBlocksChangedEvent(int blocks) : blocks(blocks) {}
|
|
||||||
const int blocks;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // BUILDING_BLOCKS_CHANGED_EVENT_H
|
#endif // BUILDING_BLOCKS_CHANGED_EVENT_H
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,23 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectionChangedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionChangedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionAnchorChangedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/GameResetEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ArtifactCountChangedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ArtifactCountChangedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/UnlockedBuildingsChangedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/UnlockedBuildingsChangedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSaveRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/PanDirectionChangedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/PauseToggleRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SpeedStepRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/GhostRotationRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ModeCancelRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggleRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildHotkeyPressedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildHotkeyPressedEvent.h
|
||||||
@@ -23,7 +33,8 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeToggleRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeToggleRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPlacementRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPlacementRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ExitBlueprintModeRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ExitBlueprintModeRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/TemporaryBlueprintRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/TemporaryBlueprintCaptureRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/TemporaryBlueprintPlaceRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SpeedChangeRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SpeedChangeRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/LayoutDialogRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/LayoutDialogRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionRequestedEvent.h
|
||||||
@@ -33,6 +44,7 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/FloatingLayoutInvalidatedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/PlayerCommandsAppliedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/PlayerCommandsAppliedEvent.h
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|||||||
12
src/lib/eventsystem/event/DebugDrawToggleRequestedEvent.h
Normal file
12
src/lib/eventsystem/event/DebugDrawToggleRequestedEvent.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// The player asked to toggle the debug overlays (REQ-UI-HOTKEYS). The request to
|
||||||
|
// flip the flag; DebugDrawToggledEvent is the announcement that it was flipped and
|
||||||
|
// what it now is. Splitting the two keeps the flag itself in one owner.
|
||||||
|
class DebugDrawToggleRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
DebugDrawToggleRequestedEvent() = default;
|
||||||
|
};
|
||||||
@@ -4,14 +4,11 @@
|
|||||||
#include "Event.h"
|
#include "Event.h"
|
||||||
|
|
||||||
// Fired when the current asteroid-expansion cost changes (REQ-EXP-COST): once at
|
// Fired when the current asteroid-expansion cost changes (REQ-EXP-COST): once at
|
||||||
// startup and again after each expansion is purchased. Carries the cost in
|
// startup and again after each expansion is purchased. Carries no payload — the
|
||||||
// building blocks so the header Expand button can update its caption/enabled
|
// header Expand button re-reads Simulation::getCurrentExpansionCost() to update
|
||||||
// state (REQ-UI-EXPAND-BUTTON).
|
// its caption and enabled state (REQ-UI-EXPAND-BUTTON).
|
||||||
class ExpansionCostChangedEvent : public Event
|
class ExpansionCostChangedEvent : public Event
|
||||||
{
|
{
|
||||||
public:
|
|
||||||
explicit ExpansionCostChangedEvent(int cost) : cost(cost) {}
|
|
||||||
const int cost;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // EXPANSION_COST_CHANGED_EVENT_H
|
#endif // EXPANSION_COST_CHANGED_EVENT_H
|
||||||
|
|||||||
12
src/lib/eventsystem/event/FloatingLayoutInvalidatedEvent.h
Normal file
12
src/lib/eventsystem/event/FloatingLayoutInvalidatedEvent.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Asks the owner of the widgets floating over the game world view to re-run its placement
|
||||||
|
// pass (see ui/FloatingPanel.h). Published by a floating widget whose content or
|
||||||
|
// visibility changed: what space that widget may take depends on the ones placed before
|
||||||
|
// it, so it cannot re-place itself alone (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL,
|
||||||
|
// REQ-UI-SELECTION-PANEL). Carries no payload -- the pass re-reads every widget.
|
||||||
|
class FloatingLayoutInvalidatedEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
13
src/lib/eventsystem/event/GameResetEvent.h
Normal file
13
src/lib/eventsystem/event/GameResetEvent.h
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Emitted once per restart, after a queued ResetCommand has been applied and the view has
|
||||||
|
// been reset for the new run (REQ-UI-GAME-MENU). It lets presentation state that does not
|
||||||
|
// live in the simulation -- e.g. the temporary blueprint, REQ-UI-BLUEPRINT-TEMP -- be
|
||||||
|
// dropped for the fresh run. Sent from GameWorldView::resetForNewGame(), which is where a
|
||||||
|
// restart actually lands: the escape menu, game over, and win dialogs only enqueue the
|
||||||
|
// command.
|
||||||
|
class GameResetEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
13
src/lib/eventsystem/event/GhostRotationRequestedEvent.h
Normal file
13
src/lib/eventsystem/event/GhostRotationRequestedEvent.h
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// The player asked to rotate the placement ghost (REQ-BLD-ROTATE, REQ-UI-HOTKEYS).
|
||||||
|
// Sent whether or not a builder or blueprint mode is actually active; deciding
|
||||||
|
// there is nothing to rotate is the receiver's job.
|
||||||
|
class GhostRotationRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit GhostRotationRequestedEvent(bool clockwise) : clockwise(clockwise) {}
|
||||||
|
const bool clockwise;
|
||||||
|
};
|
||||||
13
src/lib/eventsystem/event/ModeCancelRequestedEvent.h
Normal file
13
src/lib/eventsystem/event/ModeCancelRequestedEvent.h
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// The player pressed the one "get me out of the current mode" key (REQ-UI-HOTKEYS).
|
||||||
|
// Intentionally says only that, not which mode to leave: which of builder,
|
||||||
|
// blueprint placement, or deconstruct is active — and that the key falls through to
|
||||||
|
// entering deconstruct mode when none of them is — is state only the receiver has.
|
||||||
|
class ModeCancelRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
ModeCancelRequestedEvent() = default;
|
||||||
|
};
|
||||||
22
src/lib/eventsystem/event/PanDirectionChangedEvent.h
Normal file
22
src/lib/eventsystem/event/PanDirectionChangedEvent.h
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
#include "WorldCamera.h"
|
||||||
|
|
||||||
|
// The direction the player is currently panning the view has changed
|
||||||
|
// (REQ-UI-SCROLL). Deliberately level-triggered: the payload is the complete
|
||||||
|
// current direction, including PanDirection::None when panning stops, rather than
|
||||||
|
// separate started/stopped events. A receiver that only ever saw edges would have
|
||||||
|
// to reconstruct the state and would be left panning forever if one edge went
|
||||||
|
// missing — which is exactly what happens when the widget loses focus mid-pan.
|
||||||
|
//
|
||||||
|
// Unlike the state-change events elsewhere in the UI, the receiver does cache this
|
||||||
|
// payload instead of re-reading the value from somewhere authoritative. That is
|
||||||
|
// correct here: input has no other source of truth to re-read from, so the
|
||||||
|
// publisher (InputMapper) is the authority and the payload is the value.
|
||||||
|
class PanDirectionChangedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit PanDirectionChangedEvent(PanDirection direction) : direction(direction) {}
|
||||||
|
const PanDirection direction;
|
||||||
|
};
|
||||||
12
src/lib/eventsystem/event/PauseToggleRequestedEvent.h
Normal file
12
src/lib/eventsystem/event/PauseToggleRequestedEvent.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// The player asked to pause or unpause (REQ-UI-HOTKEYS). Carries no speed: which
|
||||||
|
// speed to restore on unpause is the receiver's business, since it is the one that
|
||||||
|
// remembers what was running before the pause.
|
||||||
|
class PauseToggleRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
PauseToggleRequestedEvent() = default;
|
||||||
|
};
|
||||||
25
src/lib/eventsystem/event/SelectionAnchorChangedEvent.h
Normal file
25
src/lib/eventsystem/event/SelectionAnchorChangedEvent.h
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QRect>
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Where on the screen the selection that is about to be made sits: the bounds of the one
|
||||||
|
// object selected, or of all of them when the selection starts as a multi-selection
|
||||||
|
// (REQ-UI-SELECTION-PANEL). The rectangle is in the game world view's own widget
|
||||||
|
// coordinates.
|
||||||
|
//
|
||||||
|
// Published only when a selection *starts* -- a plain click or drag, or an additive one
|
||||||
|
// onto an empty selection -- and always immediately before the selection itself. Adding
|
||||||
|
// to a selection publishes nothing, which is what leaves the selection panel where it
|
||||||
|
// 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
|
||||||
|
// either.
|
||||||
|
class SelectionAnchorChangedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit SelectionAnchorChangedEvent(QRect rectPx)
|
||||||
|
: rectPx(rectPx) {}
|
||||||
|
|
||||||
|
const QRect rectPx;
|
||||||
|
};
|
||||||
14
src/lib/eventsystem/event/SpeedStepRequestedEvent.h
Normal file
14
src/lib/eventsystem/event/SpeedStepRequestedEvent.h
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// The player asked to step the game speed one notch (REQ-UI-HOTKEYS): +1 faster,
|
||||||
|
// -1 slower. A relative step rather than a target speed, because the ladder of
|
||||||
|
// available speeds belongs to the receiver — contrast SpeedChangeRequestedEvent,
|
||||||
|
// which names an absolute multiplier and is what the speed buttons send.
|
||||||
|
class SpeedStepRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit SpeedStepRequestedEvent(int delta) : delta(delta) {}
|
||||||
|
const int delta;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Emitted when the player presses C to capture a temporary blueprint (REQ-UI-BLUEPRINT-TEMP).
|
||||||
|
// Carries no payload: the blueprint is built from the current selection by the receiver.
|
||||||
|
class TemporaryBlueprintCaptureRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Emitted when the player presses V to re-enter placement mode for the temporary blueprint
|
||||||
|
// captured with C (REQ-UI-BLUEPRINT-TEMP). Carries no payload: the receiver holds the
|
||||||
|
// blueprint, and nothing is captured from the current selection.
|
||||||
|
class TemporaryBlueprintPlaceRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "Event.h"
|
|
||||||
|
|
||||||
// Emitted when the player presses the temporary-blueprint hotkey (REQ-UI-BLUEPRINT-TEMP).
|
|
||||||
// Carries no payload: the blueprint is built from the current selection by the receiver.
|
|
||||||
class TemporaryBlueprintRequestedEvent : public Event
|
|
||||||
{
|
|
||||||
};
|
|
||||||
@@ -2,14 +2,11 @@
|
|||||||
#define TICK_ADVANCED_EVENT_H
|
#define TICK_ADVANCED_EVENT_H
|
||||||
|
|
||||||
#include "Event.h"
|
#include "Event.h"
|
||||||
#include "Tick.h"
|
|
||||||
|
|
||||||
|
// Fired when the simulation tick advances. Carries no payload — subscribers
|
||||||
|
// re-read Simulation::getCurrentTick().
|
||||||
class TickAdvancedEvent : public Event
|
class TickAdvancedEvent : public Event
|
||||||
{
|
{
|
||||||
public:
|
|
||||||
explicit TickAdvancedEvent(Tick tick) : tick(tick) {}
|
|
||||||
const Tick tick;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // TICK_ADVANCED_EVENT_H
|
#endif // TICK_ADVANCED_EVENT_H
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
// Emitted when the set of unlocked building types changes (REQ-LOCK-BUILDING),
|
// Emitted when the set of unlocked building types changes (REQ-LOCK-BUILDING),
|
||||||
// i.e. after an unlock group granting a building is awarded (REQ-DEF-SCHEMATIC-DROP)
|
// i.e. after an unlock group granting a building is awarded (REQ-DEF-SCHEMATIC-DROP)
|
||||||
// or on Restart. The build button grid re-evaluates which buttons are shown.
|
// or on Restart. The build button bar re-evaluates which buttons are shown.
|
||||||
class UnlockedBuildingsChangedEvent : public Event
|
class UnlockedBuildingsChangedEvent : public Event
|
||||||
{
|
{
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,11 +27,14 @@ struct InputBuffer
|
|||||||
std::map<ItemType, int> caps; // max items per material (2× per-cycle requirement)
|
std::map<ItemType, int> caps; // max items per material (2× per-cycle requirement)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Output buffer shared by all output materials for a production building.
|
// Per-material output buffer for a production building. The items are held in one
|
||||||
|
// production-ordered queue -- that is the order they leave at the output port
|
||||||
|
// (REQ-MAT-OUTPUT-EMERGE) -- while the capacity is per item type, so one item's backlog
|
||||||
|
// never occupies another's room (REQ-MAT-OUTPUT-BUFFER).
|
||||||
struct OutputBuffer
|
struct OutputBuffer
|
||||||
{
|
{
|
||||||
std::vector<Item> items;
|
std::vector<Item> items; // production order; feeds the output belt
|
||||||
int capacity = 0; // 2× per-cycle output; 1× for ReprocessingPlant
|
std::map<ItemType, int> caps; // max items per material (2x its per-cycle amount)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Active production cycle for a building.
|
// Active production cycle for a building.
|
||||||
@@ -96,6 +99,25 @@ struct Building
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The same over one material, which is what its own capacity is measured against
|
||||||
|
// (REQ-MAT-OUTPUT-BUFFER).
|
||||||
|
int getOutputItemCount(const ItemType& type) const
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
for (const Item& item : outputBuffer.items)
|
||||||
|
{
|
||||||
|
if (item.type == type) { ++count; }
|
||||||
|
}
|
||||||
|
for (const std::vector<BeltItemSlot>& lane : emergingItems)
|
||||||
|
{
|
||||||
|
for (const BeltItemSlot& slot : lane)
|
||||||
|
{
|
||||||
|
if (slot.item.type == type) { ++count; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
// Items currently travelling inward on each input port's virtual input belt
|
// Items currently travelling inward on each input port's virtual input belt
|
||||||
// (REQ-MAT-INPUT-INTAKE); one lane per input port, parallel to inputPorts. Each
|
// (REQ-MAT-INPUT-INTAKE); one lane per input port, parallel to inputPorts. Each
|
||||||
// lane holds slots at progress [0.0, 0.5], front (highest progress) first. An
|
// lane holds slots at progress [0.0, 0.5], front (highest progress) first. An
|
||||||
|
|||||||
142
src/lib/sim/BuildingBuffers.cpp
Normal file
142
src/lib/sim/BuildingBuffers.cpp
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
#include "BuildingBuffers.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cassert>
|
||||||
|
#include <map>
|
||||||
|
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "ItemType.h"
|
||||||
|
#include "ModulesConfig.h"
|
||||||
|
#include "ShipsConfig.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// Folds the output capacities one recipe implies into `caps`: twice each produced
|
||||||
|
// item's per-cycle amount (REQ-MAT-OUTPUT-BUFFER). A Reprocessing Plant rolls exactly
|
||||||
|
// one of its outputs per cycle (REQ-BLD-REPROCESSING), so its per-cycle amount for an
|
||||||
|
// item is that one outcome's amount rather than a sum over the entries.
|
||||||
|
//
|
||||||
|
// Where a cap is already present the larger wins, which is how an auto-recipe building
|
||||||
|
// unions the recipes of its type -- the same rule its input caps follow.
|
||||||
|
void addOutputCaps(std::map<ItemType, int>& caps, BuildingType type,
|
||||||
|
const RecipeDef& recipe)
|
||||||
|
{
|
||||||
|
std::map<ItemType, int> perCycle;
|
||||||
|
for (const RecipeOutput& out : recipe.outputs)
|
||||||
|
{
|
||||||
|
const ItemType item{out.item};
|
||||||
|
if (type == BuildingType::ReprocessingPlant)
|
||||||
|
{
|
||||||
|
perCycle[item] = std::max(perCycle[item], out.amount);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
perCycle[item] += out.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const std::pair<const ItemType, int>& entry : perCycle)
|
||||||
|
{
|
||||||
|
caps[entry.first] = std::max(caps[entry.first], 2 * entry.second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void initBuffers(Building& b, const RecipeDef& recipe)
|
||||||
|
{
|
||||||
|
b.inputBuffer.counts.clear();
|
||||||
|
b.inputBuffer.caps.clear();
|
||||||
|
for (const RecipeIngredient& ing : recipe.inputs)
|
||||||
|
{
|
||||||
|
const ItemType type{ing.item};
|
||||||
|
b.inputBuffer.counts[type] = 0;
|
||||||
|
b.inputBuffer.caps[type] = 2 * ing.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
b.outputBuffer.items.clear();
|
||||||
|
b.outputBuffer.caps.clear();
|
||||||
|
addOutputCaps(b.outputBuffer.caps, b.type, recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
void initShipyardBuffers(const GameConfig& config, Building& b)
|
||||||
|
{
|
||||||
|
b.inputBuffer.counts.clear();
|
||||||
|
b.inputBuffer.caps.clear();
|
||||||
|
// A shipyard spawns a ship rather than producing items, so it holds no output
|
||||||
|
// buffer at all (REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
|
||||||
|
b.outputBuffer.items.clear();
|
||||||
|
b.outputBuffer.caps.clear();
|
||||||
|
const ShipDef* def = config.ships.findShipDef(b.recipeId);
|
||||||
|
if (!def)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const RecipeIngredient& ing : def->schematic.materials)
|
||||||
|
{
|
||||||
|
const ItemType type{ing.item};
|
||||||
|
b.inputBuffer.counts[type] = 0;
|
||||||
|
b.inputBuffer.caps[type] = 2 * ing.amount;
|
||||||
|
}
|
||||||
|
if (b.shipLayout.has_value())
|
||||||
|
{
|
||||||
|
for (const PlacedModule& pm : b.shipLayout->placedModules)
|
||||||
|
{
|
||||||
|
const ModuleDef* modDef = config.modules.findModuleDef(pm.moduleId);
|
||||||
|
if (!modDef)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const RecipeIngredient& ing : modDef->materials)
|
||||||
|
{
|
||||||
|
const ItemType type{ing.item};
|
||||||
|
b.inputBuffer.counts.try_emplace(type, 0);
|
||||||
|
b.inputBuffer.caps[type] += 2 * ing.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void initSalvageBayBuffer(const GameConfig& config, Building& b)
|
||||||
|
{
|
||||||
|
// Salvage Bay has no recipe-driven buffer; scrap is the only thing it ever holds,
|
||||||
|
// and that single buffer's holding size for ship drop-off is config-defined
|
||||||
|
// (REQ-BLD-SALVAGE-BAY).
|
||||||
|
b.outputBuffer.items.clear();
|
||||||
|
b.outputBuffer.caps.clear();
|
||||||
|
const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::SalvageBay);
|
||||||
|
b.outputBuffer.caps[ItemType{"scrap"}] =
|
||||||
|
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void reregisterBeltTile(BeltSystem& belts, const GameConfig& config,
|
||||||
|
const Building& building,
|
||||||
|
const std::vector<ItemType>& splitterFilterA,
|
||||||
|
const std::vector<ItemType>& splitterFilterB)
|
||||||
|
{
|
||||||
|
switch (building.type)
|
||||||
|
{
|
||||||
|
case BuildingType::Belt:
|
||||||
|
belts.placeBelt(building.anchor, building.rotation);
|
||||||
|
break;
|
||||||
|
case BuildingType::Splitter:
|
||||||
|
assert(building.outputPorts.size() >= 2);
|
||||||
|
belts.placeSplitter(building.anchor,
|
||||||
|
building.outputPorts[0].direction,
|
||||||
|
building.outputPorts[1].direction);
|
||||||
|
belts.setSplitterFilters(building.anchor, splitterFilterA, splitterFilterB);
|
||||||
|
break;
|
||||||
|
case BuildingType::TunnelEntry:
|
||||||
|
belts.placeTunnelEntry(building.anchor, building.rotation,
|
||||||
|
config.world.tunnelMaxDistance_tiles);
|
||||||
|
break;
|
||||||
|
case BuildingType::TunnelExit:
|
||||||
|
belts.placeTunnelExit(building.anchor, building.rotation);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
35
src/lib/sim/BuildingBuffers.h
Normal file
35
src/lib/sim/BuildingBuffers.h
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "BeltSystem.h"
|
||||||
|
#include "Building.h"
|
||||||
|
#include "GameConfig.h"
|
||||||
|
#include "ItemType.h"
|
||||||
|
#include "RecipesConfig.h"
|
||||||
|
|
||||||
|
// Setting a building up when it starts existing or is reconfigured: sizing its
|
||||||
|
// input/output buffers from what it will produce, and handing belt-like types back
|
||||||
|
// to BeltSystem. Free functions over the config and the building — they read no
|
||||||
|
// factory state, so both BuildingSystem and ConstructionSystem can use them.
|
||||||
|
|
||||||
|
// Buffers for a building running one known recipe: one buffer per material on each
|
||||||
|
// side, capped at twice that material's per-cycle amount (REQ-MAT-INPUT-BUFFER,
|
||||||
|
// REQ-MAT-OUTPUT-BUFFER).
|
||||||
|
void initBuffers(Building& b, const RecipeDef& recipe);
|
||||||
|
|
||||||
|
// Buffers for a shipyard: its schematic's materials plus those of every placed
|
||||||
|
// module (REQ-BLD-SHIPYARD).
|
||||||
|
void initShipyardBuffers(const GameConfig& config, Building& b);
|
||||||
|
|
||||||
|
// The Salvage Bay holds no recipe inputs; its output capacity is config-defined
|
||||||
|
// (REQ-BLD-SALVAGE-BAY).
|
||||||
|
void initSalvageBayBuffer(const GameConfig& config, Building& b);
|
||||||
|
|
||||||
|
// Registers a belt, splitter or tunnel end with BeltSystem. A splitter's filters
|
||||||
|
// live in BeltSystem and are lost by removeTile, so they are passed back in
|
||||||
|
// (REQ-BLD-SPLITTER). No-op for every other building type.
|
||||||
|
void reregisterBeltTile(BeltSystem& belts, const GameConfig& config,
|
||||||
|
const Building& building,
|
||||||
|
const std::vector<ItemType>& splitterFilterA,
|
||||||
|
const std::vector<ItemType>& splitterFilterB);
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
#include "BuildingConfig.h"
|
#include "BuildingConfig.h"
|
||||||
|
#include "FactoryQueries.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <climits>
|
#include <climits>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <map>
|
||||||
|
|
||||||
#include "BeltSystem.h"
|
#include "BeltSystem.h"
|
||||||
#include "Building.h"
|
#include "Building.h"
|
||||||
|
#include "BuildingsConfig.h"
|
||||||
#include "BuildingSystem.h"
|
#include "BuildingSystem.h"
|
||||||
|
#include "DisplayName.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
@@ -26,8 +31,8 @@ struct SelectedBuilding
|
|||||||
// (the HQ and defence stations, per REQ-UI-BLUEPRINT-CREATE).
|
// (the HQ and defence stations, per REQ-UI-BLUEPRINT-CREATE).
|
||||||
std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, BuildingId id)
|
std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, BuildingId id)
|
||||||
{
|
{
|
||||||
const Building* building = sim.getBuildings().findBuilding(id);
|
const Building* building = findBuilding(sim.getFactoryState(), id);
|
||||||
const ConstructionSite* site = building ? nullptr : sim.getBuildings().findSite(id);
|
const ConstructionSite* site = building ? nullptr : findSite(sim.getFactoryState(), id);
|
||||||
if (!building && !site)
|
if (!building && !site)
|
||||||
{
|
{
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
@@ -48,12 +53,27 @@ std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, Building
|
|||||||
resolved.bodyCells = building ? &building->bodyCells : &site->bodyCells;
|
resolved.bodyCells = building ? &building->bodyCells : &site->bodyCells;
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Position of a building type in buildings.toml, which is the order the build button
|
||||||
|
// bar lays out its buttons (REQ-UI-BUILD-BAR) and so the tie-break order for a
|
||||||
|
// blueprint's contents line. A type with no config entry sorts after every known one.
|
||||||
|
std::size_t configOrderIndex(BuildingType type, const BuildingsConfig& buildings)
|
||||||
|
{
|
||||||
|
for (std::size_t i = 0; i < buildings.buildings.size(); ++i)
|
||||||
|
{
|
||||||
|
if (buildings.buildings[i].type == type)
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buildings.buildings.size();
|
||||||
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
|
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
|
||||||
{
|
{
|
||||||
const Building* building = sim.getBuildings().findBuilding(id);
|
const Building* building = findBuilding(sim.getFactoryState(), id);
|
||||||
const ConstructionSite* site = building ? nullptr : sim.getBuildings().findSite(id);
|
const ConstructionSite* site = building ? nullptr : findSite(sim.getFactoryState(), id);
|
||||||
if (!building && !site)
|
if (!building && !site)
|
||||||
{
|
{
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
@@ -137,9 +157,9 @@ Blueprint captureBlueprintFromSelection(const Simulation& sim,
|
|||||||
building.type = e.type;
|
building.type = e.type;
|
||||||
building.rotation = e.rotation;
|
building.rotation = e.rotation;
|
||||||
building.offset = e.anchor - center;
|
building.offset = e.anchor - center;
|
||||||
// Recipe / schematic / layout / splitter-filter capture is shared with the
|
// Recipe / schematic / layout / splitter-filter capture goes through
|
||||||
// copy-settings gesture (REQ-BLD-COPY-CONFIG) via readBuildingConfig, which
|
// readBuildingConfig, which handles operational buildings and construction
|
||||||
// handles operational buildings and construction sites alike.
|
// sites alike.
|
||||||
const std::optional<BuildingConfig> config = readBuildingConfig(sim, e.id);
|
const std::optional<BuildingConfig> config = readBuildingConfig(sim, e.id);
|
||||||
if (config.has_value())
|
if (config.has_value())
|
||||||
{
|
{
|
||||||
@@ -165,3 +185,56 @@ bool selectionHasPlaceableBuilding(const Simulation& sim,
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<BlueprintContentEntry> summarizeBlueprintContents(
|
||||||
|
const Blueprint& blueprint, const BuildingsConfig& buildings)
|
||||||
|
{
|
||||||
|
std::map<BuildingType, int> counts;
|
||||||
|
for (const BlueprintBuilding& building : blueprint.buildings)
|
||||||
|
{
|
||||||
|
++counts[building.type];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The config index is carried through the sort so the comparator stays a strict
|
||||||
|
// total order; the enum value is the final tie-break, which only two config-less
|
||||||
|
// types could ever reach.
|
||||||
|
struct RankedType
|
||||||
|
{
|
||||||
|
BuildingType type;
|
||||||
|
int count;
|
||||||
|
std::size_t order;
|
||||||
|
};
|
||||||
|
std::vector<RankedType> ranked;
|
||||||
|
ranked.reserve(counts.size());
|
||||||
|
for (const std::pair<const BuildingType, int>& entry : counts)
|
||||||
|
{
|
||||||
|
ranked.push_back({entry.first, entry.second,
|
||||||
|
configOrderIndex(entry.first, buildings)});
|
||||||
|
}
|
||||||
|
std::sort(ranked.begin(), ranked.end(),
|
||||||
|
[](const RankedType& left, const RankedType& right)
|
||||||
|
{
|
||||||
|
if (left.count != right.count) { return left.count > right.count; }
|
||||||
|
if (left.order != right.order) { return left.order < right.order; }
|
||||||
|
return static_cast<int>(left.type) < static_cast<int>(right.type);
|
||||||
|
});
|
||||||
|
|
||||||
|
std::vector<BlueprintContentEntry> summary;
|
||||||
|
summary.reserve(ranked.size());
|
||||||
|
for (const RankedType& entry : ranked)
|
||||||
|
{
|
||||||
|
summary.push_back({toDisplayName(buildingTypeId(entry.type)), entry.count});
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
int computeBlueprintCost(const Blueprint& blueprint, const BuildingsConfig& buildings)
|
||||||
|
{
|
||||||
|
int total = 0;
|
||||||
|
for (const BlueprintBuilding& building : blueprint.buildings)
|
||||||
|
{
|
||||||
|
const BuildingDef* def = buildings.findBuildingDef(building.type);
|
||||||
|
if (def) { total += def->cost; }
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,11 +11,12 @@
|
|||||||
#include "ShipLayout.h"
|
#include "ShipLayout.h"
|
||||||
|
|
||||||
class Simulation;
|
class Simulation;
|
||||||
|
struct BuildingsConfig;
|
||||||
|
|
||||||
// The user-configurable settings of a single building or construction site: the
|
// The user-configurable settings of a single building or construction site: the
|
||||||
// selected recipe / ship schematic, the shipyard module layout, and (for
|
// selected recipe / ship schematic, the shipyard module layout, and (for
|
||||||
// splitters) the two output filters. Shared by the copy-settings gesture
|
// splitters) the two output filters. This is what blueprint capture records per
|
||||||
// (REQ-BLD-COPY-CONFIG) and blueprint capture (REQ-UI-BLUEPRINT-STORAGE).
|
// constituent building (REQ-UI-BLUEPRINT-STORAGE).
|
||||||
struct BuildingConfig
|
struct BuildingConfig
|
||||||
{
|
{
|
||||||
BuildingType type = BuildingType::Miner;
|
BuildingType type = BuildingType::Miner;
|
||||||
@@ -49,6 +50,29 @@ Blueprint captureBlueprintFromSelection(const Simulation& sim,
|
|||||||
const std::vector<BuildingId>& selectedIds);
|
const std::vector<BuildingId>& selectedIds);
|
||||||
|
|
||||||
// True if any selected id refers to a player-placeable building or construction site
|
// True if any selected id refers to a player-placeable building or construction site
|
||||||
// (the enable condition for the Create Blueprint button, REQ-UI-BLUEPRINT-CREATE).
|
// (the condition under which Ctrl+C opens the blueprint save dialog,
|
||||||
|
// REQ-UI-BLUEPRINT-CREATE).
|
||||||
bool selectionHasPlaceableBuilding(const Simulation& sim,
|
bool selectionHasPlaceableBuilding(const Simulation& sim,
|
||||||
const std::vector<BuildingId>& selectedIds);
|
const std::vector<BuildingId>& selectedIds);
|
||||||
|
|
||||||
|
// One "<building name> x <count>" entry of a blueprint card's contents line
|
||||||
|
// (REQ-UI-BLUEPRINT-CARD).
|
||||||
|
struct BlueprintContentEntry
|
||||||
|
{
|
||||||
|
std::string buildingName;
|
||||||
|
int count;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Summarizes what a blueprint holds: one entry per building type it contains, ordered
|
||||||
|
// by descending count with ties broken by the order the types appear in buildings.toml
|
||||||
|
// (which is the order of the build button bar, REQ-UI-BUILD-BAR). Building types absent
|
||||||
|
// from the config sort last. Derived display data for the blueprint card, kept here so
|
||||||
|
// it is unit-testable rather than buried in the dialog (REQ-UI-BLUEPRINT-CARD).
|
||||||
|
std::vector<BlueprintContentEntry> summarizeBlueprintContents(
|
||||||
|
const Blueprint& blueprint, const BuildingsConfig& buildings);
|
||||||
|
|
||||||
|
// Plain sum of the placement cost of every building in the blueprint
|
||||||
|
// (REQ-UI-BLUEPRINT-CARD). Distinct from the total charged on placement
|
||||||
|
// (REQ-UI-BLUEPRINT-PLACE), which additionally excludes locked building types and
|
||||||
|
// rotate-in-place targets; see GameWorldView's placement path.
|
||||||
|
int computeBlueprintCost(const Blueprint& blueprint, const BuildingsConfig& buildings);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user