Compare commits
40 Commits
997cbc65d3
...
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 |
@@ -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,10 +344,12 @@ width_px = 2
|
|||||||
[overlays]
|
[overlays]
|
||||||
ghost_valid = "#ffffff44" # builder-mode ghost, placement allowed (REQ-BLD-GHOST)
|
ghost_valid = "#ffffff44" # builder-mode ghost, placement allowed (REQ-BLD-GHOST)
|
||||||
ghost_invalid = "#ff000044" # builder-mode ghost, placement invalid (REQ-BLD-PLACE-VALID)
|
ghost_invalid = "#ff000044" # builder-mode ghost, placement invalid (REQ-BLD-PLACE-VALID)
|
||||||
deconstruct_tint = "#ff000033" # deconstruct-mode hover tint
|
deconstruct_tint = "#ff000033" # deconstruct-mode hover tint; its RGB also draws the
|
||||||
selection_rect = "#00ff00" # box-drag selection rectangle (REQ-UI-MULTI-SELECT)
|
# box-drag rectangle in deconstruct mode, opaque
|
||||||
|
# (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-BOX)
|
||||||
tile_highlight = "#ffffff22" # tile under cursor
|
tile_highlight = "#ffffff22" # tile under cursor
|
||||||
selected_outline = "#ffff00" # outline drawn around currently-selected building(s)
|
selected_outline = "#ffff00" # outline around currently-selected building(s), and the
|
||||||
|
# box-drag selection rectangle (REQ-UI-MULTI-SELECT)
|
||||||
config_transfer = "#33ccff66" # blueprint ghost over a configuration-transfer target (REQ-UI-BLUEPRINT-TRANSFER)
|
config_transfer = "#33ccff66" # blueprint ghost over a configuration-transfer target (REQ-UI-BLUEPRINT-TRANSFER)
|
||||||
locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion)
|
locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion)
|
||||||
modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM)
|
modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -427,7 +427,7 @@ width_px = 2
|
|||||||
ghost_valid = "#ffffff44"
|
ghost_valid = "#ffffff44"
|
||||||
ghost_invalid = "#ff000044"
|
ghost_invalid = "#ff000044"
|
||||||
deconstruct_tint = "#ff000033"
|
deconstruct_tint = "#ff000033"
|
||||||
selection_rect = "#00ff00"
|
selected_outline = "#ffff00"
|
||||||
|
|
||||||
[toast]
|
[toast]
|
||||||
bg = "#000000cc"
|
bg = "#000000cc"
|
||||||
|
|||||||
@@ -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 | |
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -145,17 +144,21 @@ 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 selection panel controls (REQ-UI-CONFIG-INLINE). Only currently unlocked recipes and schematics are offered, exactly as for operational buildings (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC, REQ-LOCK-UI-SPLITTER). The configuration is stored on the construction site and carries over unchanged when construction completes, so the building becomes operational already configured. A construction site has no input/output buffers and runs no production cycle, so the buffer and production-progress portions of the panel (REQ-UI-SINGLE-SELECTION, REQ-UI-PRODUCTION-PROGRESS) are not shown for it; only its construction progress (REQ-UI-CONSTRUCTION-PROGRESS) and its configuration controls appear. (Blueprint placement already applies a stored recipe or schematic to a construction site on placement per REQ-UI-BLUEPRINT-PLACE; this requirement additionally lets the player set or change that configuration directly on an existing site.)
|
- REQ-BLD-SITE-CONFIG: A construction site — a building that has been placed but is still queued or under construction (REQ-BLD-QUEUE) — can be selected and configured exactly like the equivalent operational building, before it finishes building. Whatever configuration the building type supports is available on the site: the recipe for a Miner, Assembler, Smelter or Reprocessing Plant (REQ-UI-SELECT-BUTTON, REQ-BLD-AUTO-RECIPE), the produced-ship schematic and its module layout for a Shipyard (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-PREVIEW, REQ-MOD-UI-DIALOG), and the output filters for a Splitter (REQ-BLD-SPLITTER) — all set through the same selection panel controls (REQ-UI-CONFIG-INLINE). Only currently unlocked recipes and schematics are offered, exactly as for operational buildings (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC, REQ-LOCK-UI-SPLITTER). The configuration is stored on the construction site and carries over unchanged when construction completes, so the building becomes operational already configured. A construction site has no input/output buffers and runs no production cycle, so the buffer and production-progress portions of the panel (REQ-UI-SINGLE-SELECTION, REQ-UI-PRODUCTION-PROGRESS) are not shown for it; only its construction progress (REQ-UI-CONSTRUCTION-PROGRESS) and its configuration controls appear. (Blueprint placement already applies a stored recipe or schematic to a construction site on placement per REQ-UI-BLUEPRINT-PLACE; this requirement additionally lets the player set or change that configuration directly on an existing site.)
|
||||||
|
|
||||||
## Building Types
|
## Building Types
|
||||||
|
|
||||||
- REQ-BLD-MINER: **Miner** (2×2): The player selects which ore type it extracts. Each ore type corresponds to a `recipes.toml [[recipe]]` entry with `building = "miner"`, defining the output item and `duration_seconds`. Every asteroid tile is equivalent for mining — any miner can produce any ore type based solely on its selected recipe. Ore never depletes. Only implicitly unlocked ore-type recipes are available for selection (REQ-LOCK-UI-RECIPE).
|
- REQ-BLD-MINER: **Miner** (2×2): The player selects which ore type it extracts. Each ore type corresponds to a `recipes.toml [[recipe]]` entry with `building = "miner"`, defining the output item and `duration_seconds`. Every asteroid tile is equivalent for mining — any miner can produce any ore type based solely on its selected recipe. Ore never depletes. Only implicitly unlocked ore-type recipes are available for selection (REQ-LOCK-UI-RECIPE).
|
||||||
- REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. 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-AUTO-RECIPE: **Automatic recipe selection.** The Smelter and the Reprocessing Plant (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are *auto-recipe buildings*. They carry a selected recipe and are configured exactly as a Miner or Assembler is — the same selection button and dialog (REQ-UI-SELECT-BUTTON), buffers sized for that one recipe alone (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER), and the same pre-configuration on a construction site (REQ-BLD-SITE-CONFIG). They differ in one respect only:
|
||||||
|
- **Selection while none is set.** When such a building has no recipe, the first material offered at any of its input ports that some recipe of its type consumes selects that recipe; the material is then accepted as normal. This holds for every intake path — a belt, splitter or tunnel exit at an input port, and a directly coupled producer (REQ-MAT-DIRECT-COUPLE) — because a building that accepts nothing would otherwise leave a coupled producer stuck at its port forever. The choice is deterministic: input ports are examined in order, and where several recipes of the type consume the offered material the first in config order wins.
|
||||||
|
- **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-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 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.
|
- 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 selection 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.
|
||||||
@@ -188,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.
|
||||||
@@ -199,9 +202,8 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- REQ-MAT-ACCEPT-DIR: A transport tile (belt, splitter, tunnel entry, or tunnel exit) accepts an incoming item only through a non-output edge; an item that would enter through one of the tile's output edges is refused. For a belt or a tunnel entry/exit the sole output edge is the one in its facing direction; for a splitter either of its two output directions is an output edge. This applies both to items pushed from an adjacent transport tile and to items deposited by a building's output port (REQ-MAT-OUTPUT-PORT).
|
- REQ-MAT-ACCEPT-DIR: A transport tile (belt, splitter, tunnel entry, or tunnel exit) accepts an incoming item only through a non-output edge; an item that would enter through one of the tile's output edges is refused. For a belt or a tunnel entry/exit the sole output edge is the one in its facing direction; for a splitter either of its two output directions is an output edge. This applies both to items pushed from an adjacent transport tile and to items deposited by a building's output port (REQ-MAT-OUTPUT-PORT).
|
||||||
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared.
|
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared.
|
||||||
- **Setting a configuration to the value it already holds is a no-op.** Selecting the recipe or schematic already set, or applying a ship layout identical to the one already configured, changes nothing: buffers are not cleared, an in-progress production cycle is not cancelled (REQ-BLD-SHIPYARD), and a construction site's progress and stored settings are untouched. This holds however the setting is applied — through the selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), a blueprint placement (REQ-UI-BLUEPRINT-PLACE), or a blueprint configuration transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a setting that genuinely differs has effects.
|
- **Setting a configuration to the value it already holds is a no-op.** Selecting the recipe or schematic already set, or applying a ship layout identical to the one already configured, changes nothing: buffers are not cleared, an in-progress production cycle is not cancelled (REQ-BLD-SHIPYARD), and a construction site's progress and stored settings are untouched. This holds however the setting is applied — through the selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), a blueprint placement (REQ-UI-BLUEPRINT-PLACE), or a blueprint configuration transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a setting that genuinely differs has effects.
|
||||||
- REQ-MAT-OUTPUT-BUFFER: Each building has 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). Re-applying a setting the building already has clears nothing, per REQ-MAT-INPUT-BUFFER.
|
- REQ-MAT-OUTPUT-BUFFER: Each building has **one output buffer per item its recipe can produce** — each output of a deterministic recipe, and every possible roll of a probabilistic one (REQ-BLD-REPROCESSING). Each per-material buffer holds up to twice that item's per-cycle amount, mirroring the input side (REQ-MAT-INPUT-BUFFER); a buffer's contents never occupy another item's capacity. An emerging item still occupies its buffer for the whole animation (REQ-MAT-OUTPUT-EMERGE). Whether a buffer without room stops production is decided by REQ-MAT-CYCLE. When the player selects a new recipe or schematic, all items in all output buffers are cleared (relevant when the adjacent belt is jammed and items have accumulated). Re-applying a setting the building already has clears nothing, per REQ-MAT-INPUT-BUFFER. Two buildings stand outside this rule: the Shipyard has no output buffer at all, since it spawns a ship rather than producing items (REQ-BLD-SHIPYARD), and the Salvage Bay has no recipe, so its single scrap buffer is sized by config instead (REQ-BLD-SALVAGE-BAY).
|
||||||
- REQ-MAT-OUTPUT-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-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-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-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
|
||||||
@@ -306,13 +308,13 @@ 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 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` 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 selection panel are recalculated, and the ship layout preview is refreshed.
|
- **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. 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.
|
||||||
|
|
||||||
@@ -372,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).
|
||||||
|
|
||||||
@@ -392,7 +394,7 @@ 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).
|
||||||
|
|
||||||
@@ -428,7 +430,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
The screen is a single column: a header bar across the top and the game world view filling the whole area below it. There is no side panel. All three permanent UI widgets float over the game world — the build button bar (REQ-UI-BUILD-BAR) at its bottom center, the selection panel (REQ-UI-SELECTION-PANEL) beside whatever is currently selected, shown only while something is selected and holding its place on the screen until the next selection, and the controls panel (REQ-UI-CONTROLS-PANEL) in its bottom-left corner, beside the build button bar and rising above it only when the two would overlap. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG):
|
The screen is a single column: a header bar across the top and the game world view filling the whole area below it. There is no side panel. All three permanent UI widgets float over the game world — the build button bar (REQ-UI-BUILD-BAR) at its bottom center, the selection panel (REQ-UI-SELECTION-PANEL) beside whatever is currently selected — or wherever the player has dragged it by its header (REQ-UI-SELECTION-PANEL-DRAG) — shown only while something is selected and holding its place on the screen until the next selection, and the controls panel (REQ-UI-CONTROLS-PANEL) in its bottom-left corner, beside the build button bar and rising above it only when the two would overlap. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG):
|
||||||
|
|
||||||
```
|
```
|
||||||
+-----------------------------------------------------------+
|
+-----------------------------------------------------------+
|
||||||
@@ -460,11 +462,24 @@ The screen is a single column: a header bar across the top and the game world vi
|
|||||||
- **Anchor rectangle.** The panel is placed against the screen rectangle of the selection **at the moment that selection started**: the footprint of the single object selected (a building or construction site, an actor, or a piece of debris), or, when the selection started as a multi-selection (REQ-UI-MULTI-SELECT), the bounding box of all the objects it started with.
|
- **Anchor rectangle.** The panel is placed against the screen rectangle of the selection **at the moment that selection started**: the footprint of the single object selected (a building or construction site, an actor, or a piece of debris), or, when the selection started as a multi-selection (REQ-UI-MULTI-SELECT), the bounding box of all the objects it started with.
|
||||||
- **Side.** The panel goes to the **right** of the anchor rectangle, separated from it by the panel's margin, whenever it fits within the view there. Otherwise it goes to the **left** of the anchor rectangle by that same margin. When it fits on neither side — a bounding box spanning most of the view, or an object too close to an edge — it is placed on whichever side leaves more room and then pushed inside the view. That is the one case in which the panel covers part of the selection.
|
- **Side.** The panel goes to the **right** of the anchor rectangle, separated from it by the 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.
|
- **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 panel **keeps its place on the screen** when the player scrolls the view (REQ-UI-SCROLL) and when a selected object moves under it (a selected ship flying away), rather than following the object — which may leave it beside nothing, or beside an object that has left the view entirely. It likewise does not move when the selection is **expanded** by adding objects or reduced by removing them (REQ-UI-MULTI-SELECT), nor when a selected object is destroyed or deconstructed. Starting a **new** selection — clicking a different object, or a box drag that replaces the selection — places the panel anew against the new anchor rectangle.
|
- **Fixed for the life of the selection.** The anchor rectangle 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; the panel's geometry is **re-solved from them** whenever its content size changes (a section appearing or disappearing as the selection's state changes), the view is resized, or the build button bar's or controls panel's rectangle changes. Re-solving keeps the two edges the panel was placed by — its top edge, and the edge facing the anchor rectangle (its left edge when it sits to the right of the selection, its right edge when it sits to the left) — so the panel grows away from the selection rather than over it, and it never switches sides for as long as the selection lasts. What re-solving may change is the vertical result: growth that would take the panel outside the view or into either of those two widgets is resolved as in **Vertical placement** above, by shifting it up and capping its height, and a panel that shrinks again regains the room.
|
- **Resizing in place.** Only the anchor rectangle and the chosen side are fixed for the life of the selection (or, once the panel has been dragged, the dragged desired position — REQ-UI-SELECTION-PANEL-DRAG); the panel's geometry is **re-solved from them** whenever its content size changes (a section appearing or disappearing as the selection's state changes), the view is resized, or the build button bar's or controls panel's rectangle changes. Re-solving keeps the two edges the panel was placed by — its top edge, and the edge facing the anchor rectangle (its left edge when it sits to the right of the selection, its right edge when it sits to the left) — so the panel grows away from the selection rather than over it, and it never switches sides for as long as the selection lasts. What re-solving may change is the vertical result: growth that would take the panel outside the view or into either of those two widgets is resolved as in **Vertical placement** above, by shifting it up and capping its height, and a panel that shrinks again regains the room.
|
||||||
- **Visibility.** The panel is shown only while at least one object is selected. With an empty selection it is not shown at all (REQ-UI-EMPTY-SELECTION), leaving the full game world view visible.
|
- **Visibility.** The panel is shown only while at least one object is selected. With an empty selection it is not shown at all (REQ-UI-EMPTY-SELECTION), leaving the full game world view visible.
|
||||||
- **Overlay behavior.** As for the build button bar (REQ-UI-BUILD-BAR): the panel occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected. It is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the panel. The panel never overlaps the build button bar or the controls panel, because it stays above both wherever their rectangles meet its own; neither of them ever moves on the panel's account (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL).
|
- **Overlay behavior.** As for the build button bar (REQ-UI-BUILD-BAR): the panel occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected. It is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the panel. The panel never overlaps the build button bar or the controls panel, because it stays above both wherever their rectangles meet its own; neither of them ever moves on the panel's account (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL).
|
||||||
- **Input.** Mouse events over the panel are consumed by the panel and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the panel does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG).
|
- **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.
|
- 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
|
||||||
@@ -477,17 +492,17 @@ The screen is a single column: a header bar across the top and the game world vi
|
|||||||
|
|
||||||
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.
|
||||||
@@ -538,17 +553,16 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
|
|||||||
- REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or piece of debris), the selection panel is not shown at all — it is hidden rather than shown empty, so the full game world view is visible (REQ-UI-SELECTION-PANEL). It reappears as soon as an object is selected.
|
- REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or piece of debris), the selection panel is not shown at all — it is hidden rather than shown empty, so the full game world view is visible (REQ-UI-SELECTION-PANEL). It reappears as soon as an object is selected.
|
||||||
- REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with debris). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus debris, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident piece of debris (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT).
|
- REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with debris). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus debris, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident piece of debris (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT).
|
||||||
- REQ-UI-SELECTION-CARD: **Card structure.** Every panel content is a card with the same three parts, top to bottom:
|
- REQ-UI-SELECTION-CARD: **Card structure.** Every panel content is a card with the same three parts, top to bottom:
|
||||||
- **Header** — always shown. It holds the selection's identity symbol on the left — the building's icon glyph (REQ-UI-WORLD-ICON), a ship's schematic color swatch, or the kind symbol of a defence station or piece of debris — the selection's name beside it, and one optional **right slot**. The right slot holds a status indicator (REQ-UI-SELECTION-STATUS), a ship's current behavior (REQ-UI-SHIP-BEHAVIOR), or an object count — never more than one of them; which one applies is stated per content in REQ-UI-SELECTION-CONTENT.
|
- **Header** — always shown. It holds the selection's identity symbol on the left — the building's icon glyph (REQ-UI-WORLD-ICON), a ship's schematic color swatch, or the kind symbol of a defence station or piece of debris — the selection's name beside it, and one optional **right slot**. The right slot holds a status indicator (REQ-UI-SELECTION-STATUS), a ship's current behavior (REQ-UI-SHIP-BEHAVIOR), or an object count — never more than one of them; which one applies is stated per content in REQ-UI-SELECTION-CONTENT. The header carries no control of its own, and doubles as the panel's drag handle (REQ-UI-SELECTION-PANEL-DRAG).
|
||||||
- **Configuration group** — the controls that change how the selected object is set up: the recipe/schematic selection control (REQ-UI-SELECT-BUTTON), a shipyard's layout preview and Configure button (REQ-MOD-UI-PREVIEW), and a splitter's output filters (REQ-BLD-SPLITTER). It is shown identically for an operational building and for a construction site of the same type (REQ-BLD-SITE-CONFIG).
|
- **Configuration group** — the controls that change how the selected object is set up: the recipe/schematic selection control (REQ-UI-SELECT-BUTTON), a shipyard's layout preview and Configure button (REQ-MOD-UI-PREVIEW), and a splitter's output filters (REQ-BLD-SPLITTER). It is shown identically for an operational building and for a construction site of the same type (REQ-BLD-SITE-CONFIG).
|
||||||
- **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.
|
- **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 buffer`); 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.
|
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:
|
- REQ-UI-SELECTION-CONTENT: **Content catalog.** Which content the panel shows follows from the selection alone:
|
||||||
|
|
||||||
| Selection | Header right slot | Configuration group | Runtime group |
|
| Selection | Header right slot | Configuration group | Runtime group |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Miner, Assembler | status | recipe control + recipe summary | buffers + production |
|
| Miner, Assembler, Smelter, Reprocessing Plant | status | recipe control + recipe summary | buffers + production |
|
||||||
| Smelter, Reprocessing Plant | status | recipe summary | buffers + production |
|
|
||||||
| Shipyard | status | schematic control + layout preview + Configure | buffers + production |
|
| Shipyard | status | schematic control + layout preview + Configure | buffers + production |
|
||||||
| Salvage Bay | status | — | buffers |
|
| Salvage Bay | status | — | buffers |
|
||||||
| HQ | — | — | block stock + HP |
|
| HQ | — | — | block stock + HP |
|
||||||
@@ -567,27 +581,38 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
|
|||||||
- **Debris** — several pieces of debris and nothing else. Their remaining scrap sums into one value (REQ-UI-DEBRIS-PANEL).
|
- **Debris** — several pieces of debris and nothing else. Their remaining scrap sums into one value (REQ-UI-DEBRIS-PANEL).
|
||||||
|
|
||||||
Every other multi-selection falls back to the count summary. In particular a selection mixing a splitter with belts does not aggregate (a splitter carries per-object output filters, which have no aggregate), and neither do several production buildings of one type (per-building buffers and cycle progress have no aggregate).
|
Every other multi-selection falls back to the count summary. In particular a selection mixing a splitter with belts does not aggregate (a splitter carries per-object output filters, which have no aggregate), and neither do several production buildings of one type (per-building buffers and cycle progress have no aggregate).
|
||||||
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows its symbol and name in the header (REQ-UI-SELECTION-CARD), its current recipe or schematic selection (REQ-UI-SELECT-BUTTON) and recipe summary (REQ-UI-RECIPE-SUMMARY) in the configuration group, and its input and output buffer contents in the runtime group. Each buffered item is shown as an **item chip** bearing that item's icon (REQ-UI-ITEM-ICON) and its current count:
|
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows its symbol and name in the header (REQ-UI-SELECTION-CARD), its current recipe or schematic selection (REQ-UI-SELECT-BUTTON) and recipe summary (REQ-UI-RECIPE-SUMMARY) in the configuration group, and its input and output buffer contents in the runtime group. Each buffered item is shown as an **item chip** bearing that item's icon on its colored square (REQ-UI-ITEM-ICON) and its current count, and hovering a chip shows that item's production tooltip (REQ-UI-ITEM-TOOLTIP):
|
||||||
- an **input** chip shows the per-cycle amount below the count (the items consumed per run, e.g. `/ 2 per cycle`), or the count alone when the building has no selected recipe or schematic to give one;
|
- an **input** chip shows the per-cycle amount below the count (the items consumed per run, e.g. `/ 2 per cycle`), or the count alone when the building has no selected recipe or schematic to give one;
|
||||||
- an **output** chip shows the count against the output buffer's capacity as `a / b` (REQ-MAT-OUTPUT-BUFFER), with the item's name below.
|
- 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, each shown only while it holds something (REQ-UI-SELECTION-CARD). The production section (REQ-UI-PRODUCTION-PROGRESS) sits **between them**, so the card reads in the direction the materials flow: what goes in, what is being made of it, what has come out. For a selected construction site the buffer sections are omitted (REQ-BLD-SITE-CONFIG).
|
Input and output chips form separately captioned sections (REQ-UI-SELECTION-CARD). A section lists a chip for **every item the building's cycle involves**, whether or not the buffer currently holds any: an empty buffer reads `0` rather than its chip disappearing, so the card keeps one shape while the building runs. A section left with no chips at all is not shown. The production section (REQ-UI-PRODUCTION-PROGRESS) sits **between them**, so the card reads in the direction the materials flow: what goes in, what is being made of it, what has come out. For a selected construction site the buffer sections are omitted (REQ-BLD-SITE-CONFIG).
|
||||||
|
|
||||||
**Only unlocked items are listed.** A building's buffers may carry entries for items the player cannot make yet — an auto-recipe building's buffers are sized over *every* recipe of its type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), including recipes that are still locked. Those entries are 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), so a Smelter shows the ores it can actually smelt rather than every ore in the game.
|
**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).
|
||||||
**An idle auto-recipe building still shows what it handles.** Having no selected recipe (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), it would otherwise show empty sections whenever it happens to be between cycles. Its input and output sections instead list the unlocked items of every recipe of its type — the same union its buffers were sized over — with a count and no per-cycle denominator, since no one recipe is in force. While a cycle is running, that cycle's recipe supplies the denominators as for any other building.
|
|
||||||
- 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 with its per-cycle amount, an arrow, each output item's icon 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. Auto-recipe buildings (Smelter, Reprocessing Plant — REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) have no player-selected recipe and so show no selection control; they show the summary of the recipe currently in production, and none while idle. A building with no recipe or schematic selected shows no summary.
|
|
||||||
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the panel's runtime group shows a captioned **production section** between the input and output buffer sections (REQ-UI-SINGLE-SELECTION): a horizontal progress bar filled to the completion of the active production cycle, with that completion beside the caption as an integer percentage (e.g. `72%`), or the text `idle` in place of the percentage and an empty bar when no production cycle is active. The cycle time is shown in the recipe summary (REQ-UI-RECIPE-SUMMARY) rather than repeated here. When no recipe or schematic is selected, the production section is not shown at all.
|
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the panel's runtime group shows a captioned **production section** between the input and output buffer sections (REQ-UI-SINGLE-SELECTION): a horizontal progress bar filled to the completion of the active production cycle, with that completion beside the caption as an integer percentage (e.g. `72%`), or the text `idle` in place of the percentage and an empty bar when no production cycle is active. The cycle time is shown in the recipe summary (REQ-UI-RECIPE-SUMMARY) rather than repeated here. When no recipe or schematic is selected, the production section is not shown at all.
|
||||||
- REQ-UI-MULTI-SELECT: The player selects multiple objects by box-drag or by Ctrl+clicking individual objects to add or remove them from the selection. Multi-select operates within a single category (REQ-UI-SELECTION-CATEGORIES). A box-drag that covers at least one building selects buildings (any field objects within the box are ignored — buildings win); a box-drag that covers no building but does cover ships, defence stations, or debris selects all of those field objects together (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-MULTI-SELECT).
|
- REQ-UI-MULTI-SELECT: The player selects multiple objects by box-drag or by Ctrl+clicking individual objects to add or remove them from the selection. Multi-select operates within a single category (REQ-UI-SELECTION-CATEGORIES). A box-drag that covers at least one building selects buildings (any field objects within the box are ignored — buildings win); a box-drag that covers no building but does cover ships, defence stations, or debris selects all of those field objects together (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-MULTI-SELECT).
|
||||||
|
- **Rectangle geometry.** The box is **not** snapped to tiles: its two corners are the exact world positions the button went down at and the cursor is at now, so the rectangle is drawn where the mouse actually went and follows it pixel by pixel. The corners are held in world coordinates rather than screen ones, so the anchor stays on the spot in the world it was placed on when the view scrolls under a held button (REQ-UI-SCROLL).
|
||||||
|
- **Coverage.** What the box covers follows the same rectangle, not the tiles it touches. An object that occupies whole tiles — a building, a construction site, a defence station — is covered when the rectangle overlaps any of its body cells, so grazing a building's tile selects it. An object that has a position rather than a footprint — a ship, a piece of debris — is covered when the rectangle contains its centre, so what the player sees enclosed by the rectangle is exactly what the release selects. This is also what makes box and click agree: a click already hit-tests ships and debris against their world positions (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT), not against the tile they stand on.
|
||||||
|
- **When the rectangle appears.** The rectangle is drawn only once the cursor has moved at least **2 pixels** from the position the button went down at — a press alone draws nothing, so a plain click does not flash a rectangle. The threshold is in screen pixels because it only separates a click from a drag, which is a question of hand steadiness. Once shown, the rectangle stays shown for the rest of the drag, including when the cursor comes back to where it started. It is measured against where the anchor sits on screen at that moment, so scrolling the view while the button is held moves no cursor but still crosses the threshold, the box having grown all the same. Below the threshold the gesture is a click, and the box it resolves on release is the **whole tile** the button went down on — that is what makes a click select or mark what it points at (REQ-UI-SELECTION-CATEGORIES, REQ-BLD-DECONSTRUCT-CLICK) rather than the empty rectangle a motionless cursor spans.
|
||||||
|
- **Rectangle color.** While dragging, the selection rectangle is drawn as an outline in `visuals.toml [overlays].selected_outline` — the same color and config entry as the outline drawn in the world around the objects that end up selected, so the box and the selection it produces read as one thing. **Exception:** while deconstruct mode is active (REQ-UI-DECONSTRUCT-BUTTON, REQ-UI-HOTKEYS), the box drag marks buildings for demolition instead (REQ-BLD-DECONSTRUCT-BOX) and the rectangle is drawn in the deconstruct color — the RGB of `visuals.toml [overlays].deconstruct_tint`, drawn **fully opaque**. That entry's alpha channel governs only the fills it tints (the deconstruct-mode hover tint and queued buildings, REQ-UI-DECONSTRUCT-BORDER, REQ-BLD-DECON-QUEUE) and is not applied to this outline, which would otherwise be too faint to see. The rectangle's geometry is the same in both modes.
|
||||||
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected and the selection does not aggregate (REQ-UI-SELECTION-AGGREGATE), the panel shows a count summary. Its header names the size of the selection as `<n> buildings` in place of an object name, and carries no symbol and nothing in its right slot. Below it is one row per selected building type — the type's symbol, its name, and the number selected as `x<count>` — one type per row, and no per-building detail. A final row shows the **total building block cost** of the selection, captioned `Total cost` with the value followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON): the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button bar); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-CARD). Construction sites count at their building type's full placement cost regardless of construction progress.
|
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected and the selection does not aggregate (REQ-UI-SELECTION-AGGREGATE), the panel shows a count summary. Its header names the size of the selection as `<n> buildings` in place of an object name, and carries no symbol and nothing in its right slot. Below it is one row per selected building type — the type's symbol, its name, and the number selected as `x<count>` — one type per row, and no per-building detail. A final row shows the **total building block cost** of the selection, captioned `Total cost` with the value followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON): the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button bar); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-CARD). Construction sites count at their building type's full placement cost regardless of construction progress.
|
||||||
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel, in its configuration group (REQ-UI-SELECTION-CARD). Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
|
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel, in its configuration group (REQ-UI-SELECTION-CARD). Recipe selection (miner, assembler, smelter, reprocessing plant) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
|
||||||
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and 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). 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 selection panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
|
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe, and the Smelter's and Reprocessing Plant's recipe per REQ-BLD-AUTO-RECIPE) and schematic selection (Shipyard) are each presented in the selection panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened) and that opens centered on the selection panel, as every modal opened from the panel does (REQ-UI-PANEL-MODAL). The dialog contains a vertical list of option buttons, one per selectable option, each describing itself (REQ-UI-SELECT-OPTIONS) — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Neither the option buttons nor the selection button carries a tooltip: an option button states what it makes on its own face, and what the building has selected is drawn beneath the selection button as the recipe summary (REQ-UI-RECIPE-SUMMARY), with the production paths of the items involved reachable by hovering the card's item chips (REQ-UI-ITEM-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selection panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
|
||||||
- REQ-UI-SELECT-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selection panel when a selection is set, displays an info tooltip:
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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>".
|
- 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.
|
||||||
- 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.
|
- 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-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. The HP comes first, as it does on every card that has it (REQ-UI-SELECTION-CARD). The HQ has no input or output buffers of its own: building blocks delivered by belt go straight into the global stock (REQ-HQ-BELT-INPUT), and showing that stock on the HQ is what tells the player to route blocks there. The HQ has no configuration group and no status indicator (REQ-UI-SELECTION-STATUS), and it is never a construction site.
|
- REQ-UI-HQ-PANEL: When the HQ is selected, the panel shows the HQ's **HP** as a bar labelled `current / maximum` (REQ-HQ-STATS, REQ-UI-HP-BARS) and, beneath it, the **global building blocks stock** — the same value as the header bar's stock display (REQ-UI-BLOCKS-ICON), rendered as an item chip (REQ-UI-SINGLE-SELECTION) carrying the `building_block` icon on its colored square — a chip is an item display, so it takes the square even though the header bar's inline block icon does not (REQ-UI-ITEM-ICON, REQ-UI-BLOCKS-ICON), and hovering it shows the item production tooltip as any other chip does (REQ-UI-ITEM-TOOLTIP). The HP comes first, as it does on every card that has it (REQ-UI-SELECTION-CARD). The HQ has no input or output buffers of its own: building blocks delivered by belt go straight into the global stock (REQ-HQ-BELT-INPUT), and showing that stock on the HQ is what tells the player to route blocks there. The HQ has no configuration group and no status indicator (REQ-UI-SELECTION-STATUS), and it is never a construction site.
|
||||||
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with debris and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a piece of debris adds to or establishes a field selection (REQ-UI-DEBRIS-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or piece of debris) clears the selection.
|
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with debris and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a piece of debris adds to or establishes a field selection (REQ-UI-DEBRIS-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or piece of debris) clears the selection.
|
||||||
- REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **ship stats panel**. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. Its header (REQ-UI-SELECTION-CARD) carries the schematic's color swatch and display name, with the ship's current behavior in the right slot (REQ-UI-SHIP-BEHAVIOR). The panel always shows all hull stats: HP (current / maximum) as a **bar** with the two values beside its caption, then max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed as label/value rows. In addition, capability module summaries are shown below the hull stats, each as its own outlined row, conditioned on which module types are installed and using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
|
- REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **ship stats panel**. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. Its header (REQ-UI-SELECTION-CARD) carries the schematic's color swatch and display name, with the ship's current behavior in the right slot (REQ-UI-SHIP-BEHAVIOR). The panel always shows all hull stats: HP (current / maximum) as a **bar** with the two values beside its caption, then max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed as label/value rows. In addition, capability module summaries are shown below the hull stats, each as its own outlined row, conditioned on which module types are installed and using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
|
||||||
- REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** in its header's right slot (REQ-UI-SELECTION-CARD) — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are:
|
- REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** in its header's right slot (REQ-UI-SELECTION-CARD) — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are:
|
||||||
@@ -599,10 +624,10 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
|
|||||||
- **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 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-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 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 (REQ-UI-DEBRIS-PANEL). 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 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, **"Scrap remaining"**, in the same label/value style as a ship hull stat row. 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. The displayed scrap value updates 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 Bar
|
### Build Button Bar
|
||||||
|
|
||||||
@@ -613,7 +638,7 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
|
|||||||
- REQ-UI-BUILD-COST: Each button is **icon-only with a cost**, its face composed of three elements: the button's **hotkey badge** in the top-left corner, the building's icon (REQ-UI-BUILD-ICON) centered below it, and the building block cost centered under the icon, shown with the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) to the right of the number in place of the trailing `Blocks` word, e.g. `2` then a small block icon. The building name is not shown on the button; it is shown in the button's hover tooltip instead (REQ-UI-BUILD-TOOLTIP). When no icon file exists for `building_block`, the cost is shown as the bare number. The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) has no cost and shows its name as a text caption in the cost's place.
|
- REQ-UI-BUILD-COST: Each button is **icon-only with a cost**, its face composed of three elements: the button's **hotkey badge** in the top-left corner, the building's icon (REQ-UI-BUILD-ICON) centered below it, and the building block cost centered under the icon, shown with the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) to the right of the number in place of the trailing `Blocks` word, e.g. `2` then a small block icon. The building name is not shown on the button; it is shown in the button's hover tooltip instead (REQ-UI-BUILD-TOOLTIP). When no icon file exists for `building_block`, the cost is shown as the bare number. The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) has no cost and shows its name as a text caption in the cost's place.
|
||||||
- **Hotkey badge.** The badge names the build hotkey that activates the button (REQ-UI-HOTKEYS), so the player can learn the shortcuts from the bar itself. It is rendered dimmer than the cost so it reads as secondary, but at the same size and in bold, because a smaller badge is not legible. A plain-digit hotkey is shown as the bare digit (`1`, `2`, `3`); a Shift+digit hotkey is shown with an upwards arrow prefixed and no separator (`↑1` … `↑6`); the Deconstruct button shows `Q`. A button whose building type has no build hotkey shows no badge and keeps the same face size, so the row stays even.
|
- **Hotkey badge.** The badge names the build hotkey that activates the button (REQ-UI-HOTKEYS), so the player can learn the shortcuts from the bar itself. It is rendered dimmer than the cost so it reads as secondary, but at the same size and in bold, because a smaller badge is not legible. A plain-digit hotkey is shown as the bare digit (`1`, `2`, `3`); a Shift+digit hotkey is shown with an upwards arrow prefixed and no separator (`↑1` … `↑6`); the Deconstruct button shows `Q`. A button whose building type has no build hotkey shows no badge and keeps the same face size, so the row stays even.
|
||||||
- REQ-UI-BUILD-ICON: Each build button shows an icon. Icons are SVG files loaded at runtime from `data/icons/buildings/` (a sibling of the config directory, read the same way as `visuals.toml`), one file per button named after the building's id (e.g. `belt.svg`, `reprocessing_plant.svg`). The shared Tunnel button (REQ-UI-BUILD-BAR) uses `tunnel_entry.svg`; the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) uses `deconstruct.svg`. Each icon is a rounded colored "chip" bearing a white line glyph, the chip color following the building's fill color in `visuals.toml`. A missing icon file leaves the button showing its building name as a text caption in place of the icon, so the button stays identifiable in the icon-only bar (REQ-UI-BUILD-COST); it is not an error.
|
- REQ-UI-BUILD-ICON: Each build button shows an icon. Icons are SVG files loaded at runtime from `data/icons/buildings/` (a sibling of the config directory, read the same way as `visuals.toml`), one file per button named after the building's id (e.g. `belt.svg`, `reprocessing_plant.svg`). The shared Tunnel button (REQ-UI-BUILD-BAR) uses `tunnel_entry.svg`; the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) uses `deconstruct.svg`. Each icon is a rounded colored "chip" bearing a white line glyph, the chip color following the building's fill color in `visuals.toml`. A missing icon file leaves the button showing its building name as a text caption in place of the icon, so the button stays identifiable in the icon-only bar (REQ-UI-BUILD-COST); it is not an error.
|
||||||
- REQ-UI-BUILD-TOOLTIP: Each building-type button shows a hover tooltip consisting of the building name followed by the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). Because the button caption is icon-only (REQ-UI-BUILD-COST), the name is always part of the tooltip; if a building type defines no tooltip text, the tooltip shows the name alone. This tooltip is distinct from the recipe/schematic selection tooltip (REQ-UI-SELECT-TOOLTIP). The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) is not a building type and so has no config-defined tooltip; it instead shows its own refund tooltip defined in REQ-UI-DECONSTRUCT-BUTTON.
|
- REQ-UI-BUILD-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 bar (REQ-UI-BUILD-BAR), as the last entry of the row and **visually separated** from the building-type buttons by a gap (not a divider line), because it toggles a mode rather than selecting a building type. Its face follows REQ-UI-BUILD-COST with two differences: its hotkey badge reads `Q`, and because it has no building block cost it shows its **Deconstruct** name as a text caption where the building-type buttons show their cost — so it is the one labelled button in the bar. It is therefore wider than the building-type buttons, which share a uniform width. Clicking it toggles deconstruct mode on and off, equivalent to the Q deconstruct toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage.
|
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button bar (REQ-UI-BUILD-BAR), as the last entry of the row and **visually separated** from the building-type buttons by a gap (not a divider line), because it toggles a mode rather than selecting a building type. Its face follows REQ-UI-BUILD-COST with two differences: its hotkey badge reads `Q`, and because it has no building block cost it shows its **Deconstruct** name as a text caption where the building-type buttons show their cost — so it is the one labelled button in the bar. It is therefore wider than the building-type buttons, which share a uniform width. Clicking it toggles deconstruct mode on and off, 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.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.h
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.h
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SelectionNames.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
|
# Shared world-space shapes so the arena keeps looking like the game
|
||||||
@@ -36,6 +39,7 @@ SET(SRCS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SelectionNames.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
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.cpp
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
|
|||||||
@@ -94,14 +94,6 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
|
|||||||
const toml::array& outputs = utility::requireArray(mt["outputs"], file, elemPath + ".outputs");
|
const toml::array& outputs = utility::requireArray(mt["outputs"], file, elemPath + ".outputs");
|
||||||
def.outputs = parseRecipeOutputs(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 = utility::requireString(mt["icon"], file, elemPath + ".icon");
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.recipes.push_back(std::move(def));
|
cfg.recipes.push_back(std::move(def));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ bool isConfigurableBuildingType(BuildingType type)
|
|||||||
{
|
{
|
||||||
case BuildingType::Miner: // recipe (REQ-BLD-MINER)
|
case BuildingType::Miner: // recipe (REQ-BLD-MINER)
|
||||||
case BuildingType::Assembler: // recipe (REQ-BLD-ASSEMBLER)
|
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::Shipyard: // schematic and layout (REQ-BLD-SHIPYARD, REQ-MOD-LAYOUT)
|
||||||
case BuildingType::Splitter: // output filters (REQ-BLD-SPLITTER)
|
case BuildingType::Splitter: // output filters (REQ-BLD-SPLITTER)
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ 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 have no player-selected recipe
|
// Smelter and Reprocessing Plant pick a recipe for themselves from the first material
|
||||||
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). They auto-process whatever inputs
|
// offered to them while they have none (REQ-BLD-AUTO-RECIPE). In every other respect
|
||||||
// they receive, matching against every recipe of their building type.
|
// their recipe is selected and held exactly as any other building's.
|
||||||
bool isAutoRecipeBuildingType(BuildingType type);
|
bool isAutoRecipeBuildingType(BuildingType type);
|
||||||
|
|
||||||
// Buildings that run a production cycle: Miner, Smelter, Assembler, Reprocessing
|
// Buildings that run a production cycle: Miner, Smelter, Assembler, Reprocessing
|
||||||
@@ -45,7 +45,9 @@ bool isProductionBuildingType(BuildingType type);
|
|||||||
bool isBeltSubsystemType(BuildingType type);
|
bool isBeltSubsystemType(BuildingType type);
|
||||||
|
|
||||||
// Building types with player-facing settings that a blueprint can carry and hand to an
|
// Building types with player-facing settings that a blueprint can carry and hand to an
|
||||||
// existing building (REQ-UI-BLUEPRINT-TRANSFER): Miner and Assembler (recipe), Shipyard
|
// existing building (REQ-UI-BLUEPRINT-TRANSFER): Miner, Assembler, Smelter and
|
||||||
// (schematic and module layout), Splitter (output filters). Every other type has nothing
|
// Reprocessing Plant (recipe -- the last two select their own when they have none,
|
||||||
// to configure, so a blueprint of one has nothing to transfer.
|
// 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);
|
bool isConfigurableBuildingType(BuildingType type);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanelPlacement.h
|
${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanelPlacement.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionBox.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.h
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -101,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,39 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#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 getAvailableBottomPx(const QRect& band, const std::vector<QRect>& occupiedRects,
|
||||||
int leftPx, int rightPx, int marginPx)
|
int leftPx, int rightPx, int marginPx)
|
||||||
{
|
{
|
||||||
@@ -51,26 +84,27 @@ QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide si
|
|||||||
const int widthPx = std::min(wantedSize.width(), band.width());
|
const int widthPx = std::min(wantedSize.width(), band.width());
|
||||||
|
|
||||||
// Against the anchor on the chosen side, growing away from it: the edge facing the
|
// 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.
|
// selection is the one that stays put as the panel's content resizes. A panel that
|
||||||
int leftPx = (side == PanelSide::Right) ? anchorRect.right() + marginPx + 1
|
// does not fit there is pushed back inside the view rather than hanging off it, which
|
||||||
: anchorRect.left() - marginPx - widthPx;
|
// is what puts it over the selection when neither side had room.
|
||||||
// A panel that does not fit there is pushed back inside the view rather than hanging
|
const int wantedLeftPx = (side == PanelSide::Right)
|
||||||
// off it, which is what puts it over the selection when neither side had room.
|
? anchorRect.right() + marginPx + 1
|
||||||
leftPx = std::min(leftPx, band.right() - widthPx + 1);
|
: anchorRect.left() - marginPx - widthPx;
|
||||||
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));
|
|
||||||
|
|
||||||
// Top-aligned with the anchor, then lifted by however much of it hangs below what is
|
// Top-aligned with the anchor, then lifted by however much of it hangs below what is
|
||||||
// free. Never above the band: a panel taller than the space left is capped instead,
|
// free.
|
||||||
// and scrolls.
|
return fitInBand(band, wantedLeftPx, anchorRect.top(), wantedSize, occupiedRects,
|
||||||
int topPx = std::max(anchorRect.top(), band.top());
|
marginPx);
|
||||||
topPx = std::min(topPx, bottomPx - heightPx + 1);
|
}
|
||||||
topPx = std::max(topPx, band.top());
|
|
||||||
|
QRect placeAtDesiredTopLeft(const QRect& band, const QPoint& desiredTopLeftPx,
|
||||||
return QRect(leftPx, topPx, widthPx, heightPx);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
#include <QRect>
|
#include <QRect>
|
||||||
#include <QSize>
|
#include <QSize>
|
||||||
|
|
||||||
@@ -41,3 +42,13 @@ PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx,
|
|||||||
QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side,
|
QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side,
|
||||||
QSize wantedSize, const std::vector<QRect>& occupiedRects,
|
QSize wantedSize, const std::vector<QRect>& occupiedRects,
|
||||||
int marginPx);
|
int 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);
|
||||||
|
|||||||
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()));
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -2,12 +2,48 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
|
#include <map>
|
||||||
|
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
#include "ItemType.h"
|
#include "ItemType.h"
|
||||||
#include "ModulesConfig.h"
|
#include "ModulesConfig.h"
|
||||||
#include "ShipsConfig.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)
|
void initBuffers(Building& b, const RecipeDef& recipe)
|
||||||
{
|
{
|
||||||
b.inputBuffer.counts.clear();
|
b.inputBuffer.counts.clear();
|
||||||
@@ -20,87 +56,18 @@ void initBuffers(Building& b, const RecipeDef& recipe)
|
|||||||
}
|
}
|
||||||
|
|
||||||
b.outputBuffer.items.clear();
|
b.outputBuffer.items.clear();
|
||||||
if (b.type == BuildingType::ReprocessingPlant)
|
b.outputBuffer.caps.clear();
|
||||||
{
|
addOutputCaps(b.outputBuffer.caps, b.type, recipe);
|
||||||
// 1× max-per-roll (REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
|
|
||||||
int maxAmount = 0;
|
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
|
||||||
{
|
|
||||||
if (out.amount > maxAmount)
|
|
||||||
{
|
|
||||||
maxAmount = out.amount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.outputBuffer.capacity = maxAmount;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// 2× per-cycle output.
|
|
||||||
int totalAmount = 0;
|
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
|
||||||
{
|
|
||||||
totalAmount += out.amount;
|
|
||||||
}
|
|
||||||
b.outputBuffer.capacity = 2 * totalAmount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void initAutoBuffers(const GameConfig& config, Building& b)
|
|
||||||
{
|
|
||||||
b.inputBuffer.counts.clear();
|
|
||||||
b.inputBuffer.caps.clear();
|
|
||||||
|
|
||||||
// Union the inputs of every recipe of this building type; the cap for each
|
|
||||||
// item is twice the largest per-cycle requirement across those recipes.
|
|
||||||
// Output capacity follows the same rules as initBuffers: the Reprocessing
|
|
||||||
// Plant holds one cycle's max output (REQ-MAT-OUTPUT-BUFFER-REPROCESSING),
|
|
||||||
// other auto buildings hold twice the largest per-cycle output.
|
|
||||||
int outputCapacity = 0;
|
|
||||||
for (const RecipeDef& recipe : config.recipes.recipes)
|
|
||||||
{
|
|
||||||
if (recipe.building != b.type)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const RecipeIngredient& ing : recipe.inputs)
|
|
||||||
{
|
|
||||||
const ItemType type{ing.item};
|
|
||||||
b.inputBuffer.counts[type] = 0;
|
|
||||||
b.inputBuffer.caps[type] =
|
|
||||||
std::max(b.inputBuffer.caps[type], 2 * ing.amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (b.type == BuildingType::ReprocessingPlant)
|
|
||||||
{
|
|
||||||
int maxAmount = 0;
|
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
|
||||||
{
|
|
||||||
maxAmount = std::max(maxAmount, out.amount);
|
|
||||||
}
|
|
||||||
outputCapacity = std::max(outputCapacity, maxAmount);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
int totalAmount = 0;
|
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
|
||||||
{
|
|
||||||
totalAmount += out.amount;
|
|
||||||
}
|
|
||||||
outputCapacity = std::max(outputCapacity, 2 * totalAmount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
b.outputBuffer.items.clear();
|
|
||||||
b.outputBuffer.capacity = outputCapacity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void initShipyardBuffers(const GameConfig& config, Building& b)
|
void initShipyardBuffers(const GameConfig& config, Building& b)
|
||||||
{
|
{
|
||||||
b.inputBuffer.counts.clear();
|
b.inputBuffer.counts.clear();
|
||||||
b.inputBuffer.caps.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.items.clear();
|
||||||
b.outputBuffer.capacity = 0;
|
b.outputBuffer.caps.clear();
|
||||||
const ShipDef* def = config.ships.findShipDef(b.recipeId);
|
const ShipDef* def = config.ships.findShipDef(b.recipeId);
|
||||||
if (!def)
|
if (!def)
|
||||||
{
|
{
|
||||||
@@ -133,11 +100,13 @@ void initShipyardBuffers(const GameConfig& config, Building& b)
|
|||||||
|
|
||||||
void initSalvageBayBuffer(const GameConfig& config, Building& b)
|
void initSalvageBayBuffer(const GameConfig& config, Building& b)
|
||||||
{
|
{
|
||||||
// Salvage Bay has no recipe-driven buffer; its output-buffer holding size for
|
// Salvage Bay has no recipe-driven buffer; scrap is the only thing it ever holds,
|
||||||
// ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY).
|
// and that single buffer's holding size for ship drop-off is config-defined
|
||||||
|
// (REQ-BLD-SALVAGE-BAY).
|
||||||
b.outputBuffer.items.clear();
|
b.outputBuffer.items.clear();
|
||||||
|
b.outputBuffer.caps.clear();
|
||||||
const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::SalvageBay);
|
const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::SalvageBay);
|
||||||
b.outputBuffer.capacity =
|
b.outputBuffer.caps[ItemType{"scrap"}] =
|
||||||
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
|
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,15 +13,11 @@
|
|||||||
// to BeltSystem. Free functions over the config and the building — they read no
|
// to BeltSystem. Free functions over the config and the building — they read no
|
||||||
// factory state, so both BuildingSystem and ConstructionSystem can use them.
|
// factory state, so both BuildingSystem and ConstructionSystem can use them.
|
||||||
|
|
||||||
// Buffers for a building running one known recipe: inputs capped at twice each
|
// Buffers for a building running one known recipe: one buffer per material on each
|
||||||
// ingredient's per-cycle amount, output at twice the per-cycle total (one cycle's
|
// side, capped at twice that material's per-cycle amount (REQ-MAT-INPUT-BUFFER,
|
||||||
// max for a Reprocessing Plant, REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
|
// REQ-MAT-OUTPUT-BUFFER).
|
||||||
void initBuffers(Building& b, const RecipeDef& recipe);
|
void initBuffers(Building& b, const RecipeDef& recipe);
|
||||||
|
|
||||||
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant), unioned over
|
|
||||||
// every recipe of its type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
|
|
||||||
void initAutoBuffers(const GameConfig& config, Building& b);
|
|
||||||
|
|
||||||
// Buffers for a shipyard: its schematic's materials plus those of every placed
|
// Buffers for a shipyard: its schematic's materials plus those of every placed
|
||||||
// module (REQ-BLD-SHIPYARD).
|
// module (REQ-BLD-SHIPYARD).
|
||||||
void initShipyardBuffers(const GameConfig& config, Building& b);
|
void initShipyardBuffers(const GameConfig& config, Building& b);
|
||||||
|
|||||||
@@ -200,12 +200,6 @@ void BuildingSystem::setRecipe(FactoryState& state, BuildingId id, const std::st
|
|||||||
{
|
{
|
||||||
if (site.id == id)
|
if (site.id == id)
|
||||||
{
|
{
|
||||||
// Auto-recipe buildings have no player-selected recipe
|
|
||||||
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING); ignore any attempt to set one.
|
|
||||||
if (isAutoRecipeBuildingType(site.type))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// No-op if the recipe is unchanged, so a redundant selection does
|
// No-op if the recipe is unchanged, so a redundant selection does
|
||||||
// not wipe an already-configured ship layout.
|
// not wipe an already-configured ship layout.
|
||||||
if (site.recipeId == recipeId)
|
if (site.recipeId == recipeId)
|
||||||
@@ -223,12 +217,6 @@ void BuildingSystem::setRecipe(FactoryState& state, BuildingId id, const std::st
|
|||||||
{
|
{
|
||||||
if (building.id == id)
|
if (building.id == id)
|
||||||
{
|
{
|
||||||
// Auto-recipe buildings have no player-selected recipe
|
|
||||||
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING); ignore any attempt to set one.
|
|
||||||
if (isAutoRecipeBuildingType(building.type))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// No-op if the recipe is unchanged, so a redundant selection does
|
// No-op if the recipe is unchanged, so a redundant selection does
|
||||||
// not wipe an already-configured ship layout or reset buffers.
|
// not wipe an already-configured ship layout or reset buffers.
|
||||||
if (building.recipeId == recipeId)
|
if (building.recipeId == recipeId)
|
||||||
@@ -240,7 +228,7 @@ void BuildingSystem::setRecipe(FactoryState& state, BuildingId id, const std::st
|
|||||||
building.inputBuffer.counts.clear();
|
building.inputBuffer.counts.clear();
|
||||||
building.inputBuffer.caps.clear();
|
building.inputBuffer.caps.clear();
|
||||||
building.outputBuffer.items.clear();
|
building.outputBuffer.items.clear();
|
||||||
building.outputBuffer.capacity = 0;
|
building.outputBuffer.caps.clear();
|
||||||
// Emerging items are part of the output buffer, so clearing it on a
|
// Emerging items are part of the output buffer, so clearing it on a
|
||||||
// recipe change discards them too (REQ-MAT-OUTPUT-EMERGE); in-transit
|
// recipe change discards them too (REQ-MAT-OUTPUT-EMERGE); in-transit
|
||||||
// input items are discarded and their reservations released
|
// input items are discarded and their reservations released
|
||||||
@@ -307,7 +295,7 @@ void BuildingSystem::setShipLayout(FactoryState& state, BuildingId id, const Shi
|
|||||||
building.inputBuffer.counts.clear();
|
building.inputBuffer.counts.clear();
|
||||||
building.inputBuffer.caps.clear();
|
building.inputBuffer.caps.clear();
|
||||||
building.outputBuffer.items.clear();
|
building.outputBuffer.items.clear();
|
||||||
building.outputBuffer.capacity = 0;
|
building.outputBuffer.caps.clear();
|
||||||
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
|
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
|
||||||
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
|
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
|
||||||
if (!building.recipeId.empty() && building.type == BuildingType::Shipyard)
|
if (!building.recipeId.empty() && building.type == BuildingType::Shipyard)
|
||||||
@@ -407,6 +395,10 @@ void BuildingSystem::tickBeltPull(FactoryState& state)
|
|||||||
{
|
{
|
||||||
const std::optional<ItemType> peeked = m_belts.peekItem(building.inputPorts[i]);
|
const std::optional<ItemType> peeked = m_belts.peekItem(building.inputPorts[i]);
|
||||||
if (!peeked) { continue; }
|
if (!peeked) { continue; }
|
||||||
|
// A Smelter or Reprocessing Plant without a recipe takes the first material
|
||||||
|
// offered to it as its selection (REQ-BLD-AUTO-RECIPE); the ports are walked
|
||||||
|
// in order, so which offer comes first is fixed.
|
||||||
|
selectAutoRecipeIfUnset(building, *peeked);
|
||||||
if (!canAcceptInput(building, i, *peeked)) { continue; }
|
if (!canAcceptInput(building, i, *peeked)) { continue; }
|
||||||
const std::optional<Item> taken = m_belts.tryTakeItem(building.inputPorts[i]);
|
const std::optional<Item> taken = m_belts.tryTakeItem(building.inputPorts[i]);
|
||||||
if (taken)
|
if (taken)
|
||||||
@@ -417,6 +409,25 @@ void BuildingSystem::tickBeltPull(FactoryState& state)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void BuildingSystem::selectAutoRecipeIfUnset(Building& building, const ItemType& offered)
|
||||||
|
{
|
||||||
|
// Only while it holds none: once set, a recipe is the player's to change
|
||||||
|
// (REQ-BLD-AUTO-RECIPE). Buildings that select their own recipe are the only ones
|
||||||
|
// this applies to; everyone else ignores an offer they have no recipe for.
|
||||||
|
if (!building.recipeId.empty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const RecipeDef* recipe = findAutoRecipeFor(m_config, building.type, offered);
|
||||||
|
if (!recipe)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
building.recipeId = recipe->id;
|
||||||
|
initBuffers(building, *recipe);
|
||||||
|
}
|
||||||
|
|
||||||
bool BuildingSystem::canAcceptInput(const Building& consumer,
|
bool BuildingSystem::canAcceptInput(const Building& consumer,
|
||||||
std::size_t inputPortIndex,
|
std::size_t inputPortIndex,
|
||||||
const ItemType& type) const
|
const ItemType& type) const
|
||||||
@@ -477,6 +488,10 @@ bool BuildingSystem::tryDirectCoupleDeposit(FactoryState& state, BuildingId prod
|
|||||||
if (in.direction != outputPort.direction) { continue; }
|
if (in.direction != outputPort.direction) { continue; }
|
||||||
if (inputBodyTile(in.tile, in.direction) != outputPort.tile) { continue; }
|
if (inputBodyTile(in.tile, in.direction) != outputPort.tile) { continue; }
|
||||||
|
|
||||||
|
// A coupling is an offer too, so an unset auto-recipe building selects from it
|
||||||
|
// (REQ-BLD-AUTO-RECIPE). Without this a Smelter placed flush against a producer
|
||||||
|
// would accept nothing and leave it stuck at its port for good.
|
||||||
|
selectAutoRecipeIfUnset(*consumer, item.type);
|
||||||
if (!canAcceptInput(*consumer, j, item.type)) { return false; }
|
if (!canAcceptInput(*consumer, j, item.type)) { return false; }
|
||||||
depositToInputBelt(*consumer, j, item);
|
depositToInputBelt(*consumer, j, item);
|
||||||
return true;
|
return true;
|
||||||
@@ -502,88 +517,92 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick)
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bool autoRecipe = isAutoRecipeBuildingType(building.type);
|
if (building.recipeId.empty())
|
||||||
if (!autoRecipe && building.recipeId.empty())
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a production cycle is active, check for completion. Completion only
|
// If a production cycle is active, check for completion. Completion only
|
||||||
// needs the already-decided outputs, so it does not depend on which
|
// needs the already-decided outputs, so it does not depend on which
|
||||||
// recipe is selected or auto-chosen.
|
// recipe is selected.
|
||||||
if (building.production)
|
if (building.production)
|
||||||
{
|
{
|
||||||
if (currentTick >= building.production->completesAt)
|
if (currentTick < building.production->completesAt)
|
||||||
{
|
{
|
||||||
for (const Item& item : building.production->chosenOutputs)
|
continue;
|
||||||
{
|
|
||||||
building.outputBuffer.items.push_back(item);
|
|
||||||
}
|
|
||||||
building.production = std::nullopt;
|
|
||||||
}
|
}
|
||||||
// Whether we just completed or are still running, do not start
|
for (const Item& item : building.production->chosenOutputs)
|
||||||
// another cycle in the same tick.
|
{
|
||||||
|
building.outputBuffer.items.push_back(item);
|
||||||
|
}
|
||||||
|
building.production = std::nullopt;
|
||||||
|
// Fall through to the start attempt below rather than idling for a tick,
|
||||||
|
// so a cycle takes exactly its recipe duration and a building fed to
|
||||||
|
// capacity produces at the configured rate (REQ-MAT-CYCLE). The start
|
||||||
|
// code runs once per building per tick, so at most one cycle begins here
|
||||||
|
// even when a duration rounds to zero ticks. The outputs just deposited
|
||||||
|
// count against the space check, so a cycle whose output no longer fits
|
||||||
|
// waits, exactly as it would have on the following tick.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle: try to start the building's one selected recipe. Every type holds
|
||||||
|
// exactly one, a Smelter and a Reprocessing Plant included -- they differ only
|
||||||
|
// in how theirs first got set (REQ-BLD-AUTO-RECIPE).
|
||||||
|
const RecipeDef* recipe = getSelectedRecipe(m_config, building);
|
||||||
|
if (!recipe)
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle: gather the candidate recipes to try. Auto-recipe buildings
|
// 1. All required inputs present?
|
||||||
// (Smelter, Reprocessing Plant) have no selected recipe and try every
|
if (!recipeInputsAvailable(building, *recipe))
|
||||||
// recipe of their type in config order, running the first whose inputs
|
|
||||||
// are satisfied (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). Other buildings
|
|
||||||
// try only their selected recipe.
|
|
||||||
const std::vector<const RecipeDef*> candidates =
|
|
||||||
gatherCandidateRecipes(m_config, building);
|
|
||||||
|
|
||||||
for (const RecipeDef* recipe : candidates)
|
|
||||||
{
|
{
|
||||||
// 1. All required inputs present?
|
continue;
|
||||||
if (!recipeInputsAvailable(building, *recipe))
|
}
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Determine chosen outputs (roll for reprocessing).
|
// 2. Room for every output this cycle could produce -- checked before anything
|
||||||
std::vector<Item> chosen;
|
// is rolled (REQ-MAT-CYCLE). The roll below is committed the moment the cycle
|
||||||
if (building.type == BuildingType::ReprocessingPlant)
|
// starts, so a plant that could not store some outcome must not start at all:
|
||||||
|
// that is what stops a stalled output belt from biasing the distribution
|
||||||
|
// towards the outputs that still fit. Emerging items count against their
|
||||||
|
// buffer (REQ-MAT-OUTPUT-EMERGE). The status light asks the same question to
|
||||||
|
// decide yellow (REQ-UI-STATUS-LIGHT), so the test lives in one place.
|
||||||
|
if (!recipeOutputsFit(building, *recipe))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Determine chosen outputs (roll for reprocessing).
|
||||||
|
std::vector<Item> chosen;
|
||||||
|
if (building.type == BuildingType::ReprocessingPlant)
|
||||||
|
{
|
||||||
|
chosen = rollReprocessingOutput(*recipe);
|
||||||
|
if (chosen.empty()) { continue; }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for (const RecipeOutput& out : recipe->outputs)
|
||||||
{
|
{
|
||||||
chosen = rollReprocessingOutput(*recipe);
|
Item item;
|
||||||
if (chosen.empty()) { continue; }
|
item.type.id = out.item;
|
||||||
}
|
for (int i = 0; i < out.amount; ++i)
|
||||||
else
|
|
||||||
{
|
|
||||||
for (const RecipeOutput& out : recipe->outputs)
|
|
||||||
{
|
{
|
||||||
Item item;
|
chosen.push_back(item);
|
||||||
item.type.id = out.item;
|
|
||||||
for (int i = 0; i < out.amount; ++i)
|
|
||||||
{
|
|
||||||
chosen.push_back(item);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Output buffer has space for chosen outputs? Emerging items still
|
|
||||||
// count against the buffer (REQ-MAT-OUTPUT-EMERGE).
|
|
||||||
const int newSize = building.getOutputItemCount()
|
|
||||||
+ static_cast<int>(chosen.size());
|
|
||||||
if (newSize > building.outputBuffer.capacity)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Consume inputs and start cycle.
|
|
||||||
for (const RecipeIngredient& ing : recipe->inputs)
|
|
||||||
{
|
|
||||||
building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
Production prod;
|
|
||||||
prod.recipeId = recipe->id;
|
|
||||||
prod.completesAt = currentTick + secondsToTicks(recipe->durationSeconds);
|
|
||||||
prod.chosenOutputs = std::move(chosen);
|
|
||||||
building.production = std::move(prod);
|
|
||||||
break; // At most one cycle starts per tick.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Consume inputs and start cycle.
|
||||||
|
for (const RecipeIngredient& ing : recipe->inputs)
|
||||||
|
{
|
||||||
|
building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
Production prod;
|
||||||
|
prod.recipeId = recipe->id;
|
||||||
|
prod.completesAt = currentTick + secondsToTicks(recipe->durationSeconds);
|
||||||
|
prod.chosenOutputs = std::move(chosen);
|
||||||
|
building.production = std::move(prod);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,26 +631,29 @@ void BuildingSystem::tickShipyardProduction(FactoryState& state, Tick currentTic
|
|||||||
// If a cycle is in progress, check for completion.
|
// If a cycle is in progress, check for completion.
|
||||||
if (building.production)
|
if (building.production)
|
||||||
{
|
{
|
||||||
if (currentTick >= building.production->completesAt)
|
if (currentTick < building.production->completesAt)
|
||||||
{
|
{
|
||||||
if (!building.outputPorts.empty())
|
continue;
|
||||||
{
|
|
||||||
const Port& p = building.outputPorts[0];
|
|
||||||
const QVector2D spawnPos(p.tile.x() + 0.5f, p.tile.y() + 0.5f);
|
|
||||||
// A shipyard builds exactly what the player configured and
|
|
||||||
// paid for. When no layout is set it produces a bare hull, so
|
|
||||||
// pass an explicit empty layout rather than nullopt: the latter
|
|
||||||
// would make ShipSystem fall back to the schematic's
|
|
||||||
// defaultModules (a wave-only loadout) and yield free weapons.
|
|
||||||
const std::optional<ShipLayoutConfig> layout =
|
|
||||||
building.shipLayout.has_value()
|
|
||||||
? building.shipLayout
|
|
||||||
: std::make_optional<ShipLayoutConfig>();
|
|
||||||
m_spawnShip(building.recipeId, spawnPos, layout);
|
|
||||||
}
|
|
||||||
building.production = std::nullopt;
|
|
||||||
}
|
}
|
||||||
continue;
|
if (!building.outputPorts.empty())
|
||||||
|
{
|
||||||
|
const Port& p = building.outputPorts[0];
|
||||||
|
const QVector2D spawnPos(p.tile.x() + 0.5f, p.tile.y() + 0.5f);
|
||||||
|
// A shipyard builds exactly what the player configured and
|
||||||
|
// paid for. When no layout is set it produces a bare hull, so
|
||||||
|
// pass an explicit empty layout rather than nullopt: the latter
|
||||||
|
// would make ShipSystem fall back to the schematic's
|
||||||
|
// defaultModules (a wave-only loadout) and yield free weapons.
|
||||||
|
const std::optional<ShipLayoutConfig> layout =
|
||||||
|
building.shipLayout.has_value()
|
||||||
|
? building.shipLayout
|
||||||
|
: std::make_optional<ShipLayoutConfig>();
|
||||||
|
m_spawnShip(building.recipeId, spawnPos, layout);
|
||||||
|
}
|
||||||
|
building.production = std::nullopt;
|
||||||
|
// Fall through and start the next cycle in this same tick, so a ship takes
|
||||||
|
// exactly its computed production time (REQ-BLD-SHIPYARD), as for the
|
||||||
|
// recipe buildings in tickProduction.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build combined materials list (base + modules).
|
// Build combined materials list (base + modules).
|
||||||
@@ -942,21 +964,22 @@ void appendItems(Hasher& hasher, const std::vector<Item>& items)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<), so both
|
||||||
|
// buffer sides hash the same way in every run.
|
||||||
|
void appendItemCounts(Hasher& hasher, const std::map<ItemType, int>& counts)
|
||||||
|
{
|
||||||
|
hasher.append(counts.size());
|
||||||
|
for (const std::pair<const ItemType, int>& entry : counts)
|
||||||
|
{
|
||||||
|
hasher.append(entry.first.id);
|
||||||
|
hasher.append(entry.second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
|
void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
|
||||||
{
|
{
|
||||||
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<).
|
appendItemCounts(hasher, buffer.counts);
|
||||||
hasher.append(buffer.counts.size());
|
appendItemCounts(hasher, buffer.caps);
|
||||||
for (const std::pair<const ItemType, int>& entry : buffer.counts)
|
|
||||||
{
|
|
||||||
hasher.append(entry.first.id);
|
|
||||||
hasher.append(entry.second);
|
|
||||||
}
|
|
||||||
hasher.append(buffer.caps.size());
|
|
||||||
for (const std::pair<const ItemType, int>& entry : buffer.caps)
|
|
||||||
{
|
|
||||||
hasher.append(entry.first.id);
|
|
||||||
hasher.append(entry.second);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@@ -976,7 +999,7 @@ void BuildingSystem::appendChecksum(const FactoryState& state, Hasher& hasher) c
|
|||||||
hasher.append(b.recipeId);
|
hasher.append(b.recipeId);
|
||||||
appendInputBuffer(hasher, b.inputBuffer);
|
appendInputBuffer(hasher, b.inputBuffer);
|
||||||
appendItems(hasher, b.outputBuffer.items);
|
appendItems(hasher, b.outputBuffer.items);
|
||||||
hasher.append(b.outputBuffer.capacity);
|
appendItemCounts(hasher, b.outputBuffer.caps);
|
||||||
hasher.append(b.emergingItems.size());
|
hasher.append(b.emergingItems.size());
|
||||||
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
|
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -188,6 +188,13 @@ private:
|
|||||||
// (on construction completion, or when un-queuing a deconstruction). No-op for
|
// (on construction completion, or when un-queuing a deconstruction). No-op for
|
||||||
// non-belt-subsystem types. Splitter filters are (re)applied after placement.
|
// non-belt-subsystem types. Splitter filters are (re)applied after placement.
|
||||||
|
|
||||||
|
// Selects a recipe for an auto-recipe building that has none, from a material being
|
||||||
|
// offered to it at one of its input ports (REQ-BLD-AUTO-RECIPE). No-op for every
|
||||||
|
// other building, for one that already holds a recipe, and for a material none of
|
||||||
|
// its recipes consumes. Called from both intake paths -- the belt pull and the
|
||||||
|
// direct coupling -- since either can be where the first material arrives.
|
||||||
|
void selectAutoRecipeIfUnset(Building& building,
|
||||||
|
const ItemType& offered);
|
||||||
// True if the consumer would accept `type` at the given input port right now:
|
// True if the consumer would accept `type` at the given input port right now:
|
||||||
// it is a required input (or a building block for the HQ), the reservation-aware
|
// it is a required input (or a building block for the HQ), the reservation-aware
|
||||||
// buffer has room, and the input belt entry is free (REQ-MAT-INPUT-INTAKE).
|
// buffer has room, and the input belt entry is free (REQ-MAT-INPUT-INTAKE).
|
||||||
|
|||||||
@@ -67,12 +67,6 @@ void ConstructionSystem::tick(FactoryState& state, BeltSystem& belts, Tick curre
|
|||||||
{
|
{
|
||||||
initSalvageBayBuffer(m_config, building);
|
initSalvageBayBuffer(m_config, building);
|
||||||
}
|
}
|
||||||
else if (isAutoRecipeBuildingType(building.type))
|
|
||||||
{
|
|
||||||
// Smelter/Reprocessing Plant need no recipe selection; buffers are set
|
|
||||||
// up from all recipes of the type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
|
|
||||||
initAutoBuffers(m_config, building);
|
|
||||||
}
|
|
||||||
else if (!building.recipeId.empty())
|
else if (!building.recipeId.empty())
|
||||||
{
|
{
|
||||||
if (building.type == BuildingType::Shipyard)
|
if (building.type == BuildingType::Shipyard)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
||||||
#include "EntityAdmin.h"
|
#include "EntityAdmin.h"
|
||||||
|
#include "SelectionBox.h"
|
||||||
#include "PositionComponent.h"
|
#include "PositionComponent.h"
|
||||||
#include "DebrisComponent.h"
|
#include "DebrisComponent.h"
|
||||||
#include "ShipIdentityComponent.h"
|
#include "ShipIdentityComponent.h"
|
||||||
@@ -82,45 +83,29 @@ entt::entity debrisAtWorldPos(EntityAdmin& admin, QVector2D worldPos)
|
|||||||
return bestDebris;
|
return bestDebris;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<entt::entity> debrisInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB)
|
std::vector<entt::entity> debrisInBox(EntityAdmin& admin, const QRectF& worldBox)
|
||||||
{
|
{
|
||||||
const int minX = std::min(tileA.x(), tileB.x());
|
|
||||||
const int maxX = std::max(tileA.x(), tileB.x());
|
|
||||||
const int minY = std::min(tileA.y(), tileB.y());
|
|
||||||
const int maxY = std::max(tileA.y(), tileB.y());
|
|
||||||
|
|
||||||
std::vector<entt::entity> result;
|
std::vector<entt::entity> result;
|
||||||
admin.forEach<DebrisComponent, PositionComponent>(
|
admin.forEach<DebrisComponent, PositionComponent>(
|
||||||
[&](entt::entity entity, const DebrisComponent& /*sd*/, const PositionComponent& pos)
|
[&](entt::entity entity, const DebrisComponent& /*sd*/, const PositionComponent& pos)
|
||||||
{
|
{
|
||||||
const int tileX = static_cast<int>(std::floor(pos.value.x()));
|
if (boxCoversPoint(worldBox, pos.value)) { result.push_back(entity); }
|
||||||
const int tileY = static_cast<int>(std::floor(pos.value.y()));
|
|
||||||
if (tileX >= minX && tileX <= maxX && tileY >= minY && tileY <= maxY)
|
|
||||||
{
|
|
||||||
result.push_back(entity);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB)
|
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, const QRectF& worldBox)
|
||||||
{
|
{
|
||||||
const int minX = std::min(tileA.x(), tileB.x());
|
|
||||||
const int maxX = std::max(tileA.x(), tileB.x());
|
|
||||||
const int minY = std::min(tileA.y(), tileB.y());
|
|
||||||
const int maxY = std::max(tileA.y(), tileB.y());
|
|
||||||
|
|
||||||
std::vector<entt::entity> result;
|
std::vector<entt::entity> result;
|
||||||
|
|
||||||
// Stations: included when any occupied body cell lies in the box.
|
// Stations occupy whole tiles: included when the box overlaps any occupied cell.
|
||||||
admin.forEach<StationBodyComponent, HealthComponent>(
|
admin.forEach<StationBodyComponent, HealthComponent>(
|
||||||
[&](entt::entity entity, const StationBodyComponent& sb, const HealthComponent& h)
|
[&](entt::entity entity, const StationBodyComponent& sb, const HealthComponent& h)
|
||||||
{
|
{
|
||||||
if (h.hp <= 0.0f) { return; }
|
if (h.hp <= 0.0f) { return; }
|
||||||
for (const QPoint& cell : sb.bodyCells)
|
for (const QPoint& cell : sb.bodyCells)
|
||||||
{
|
{
|
||||||
if (cell.x() >= minX && cell.x() <= maxX
|
if (boxCoversTile(worldBox, cell))
|
||||||
&& cell.y() >= minY && cell.y() <= maxY)
|
|
||||||
{
|
{
|
||||||
result.push_back(entity);
|
result.push_back(entity);
|
||||||
return;
|
return;
|
||||||
@@ -128,19 +113,15 @@ std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint t
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ships: included when the floored position tile lies in the box. Requiring
|
// Ships have a position rather than a footprint: included when the box contains
|
||||||
// ShipIdentityComponent excludes the HQ proxy and any station bodies.
|
// that position. Requiring ShipIdentityComponent excludes the HQ proxy and any
|
||||||
|
// station bodies.
|
||||||
admin.forEach<ShipIdentityComponent, PositionComponent, HealthComponent>(
|
admin.forEach<ShipIdentityComponent, PositionComponent, HealthComponent>(
|
||||||
[&](entt::entity entity, const ShipIdentityComponent& /*id*/,
|
[&](entt::entity entity, const ShipIdentityComponent& /*id*/,
|
||||||
const PositionComponent& pos, const HealthComponent& h)
|
const PositionComponent& pos, const HealthComponent& h)
|
||||||
{
|
{
|
||||||
if (h.hp <= 0.0f) { return; }
|
if (h.hp <= 0.0f) { return; }
|
||||||
const int tileX = static_cast<int>(std::floor(pos.value.x()));
|
if (boxCoversPoint(worldBox, pos.value)) { result.push_back(entity); }
|
||||||
const int tileY = static_cast<int>(std::floor(pos.value.y()));
|
|
||||||
if (tileX >= minX && tileX <= maxX && tileY >= minY && tileY <= maxY)
|
|
||||||
{
|
|
||||||
result.push_back(entity);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <QPoint>
|
#include <QPoint>
|
||||||
|
#include <QRectF>
|
||||||
#include <QVector2D>
|
#include <QVector2D>
|
||||||
|
|
||||||
#include "entt/entity/entity.hpp"
|
#include "entt/entity/entity.hpp"
|
||||||
@@ -16,13 +17,13 @@ entt::entity entityAtWorldPos(EntityAdmin& admin, QVector2D worldPos);
|
|||||||
// after actors: entityAtWorldPos never returns debris (debris has no HealthComponent).
|
// after actors: entityAtWorldPos never returns debris (debris has no HealthComponent).
|
||||||
entt::entity debrisAtWorldPos(EntityAdmin& admin, QVector2D worldPos);
|
entt::entity debrisAtWorldPos(EntityAdmin& admin, QVector2D worldPos);
|
||||||
|
|
||||||
// Returns every piece of debris whose position falls within the inclusive tile rectangle
|
// Returns every piece of debris the selection box covers — that is, whose position it
|
||||||
// spanned by tileA and tileB, in any corner order (REQ-UI-DEBRIS-MULTI-SELECT).
|
// contains, per boxCoversPoint (REQ-UI-DEBRIS-MULTI-SELECT). `worldBox` is in world
|
||||||
std::vector<entt::entity> debrisInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);
|
// coordinates and normalized; it is not snapped to tiles.
|
||||||
|
std::vector<entt::entity> debrisInBox(EntityAdmin& admin, const QRectF& worldBox);
|
||||||
|
|
||||||
// Returns every living actor (ship or defence station, player or enemy) that falls
|
// Returns every living actor (ship or defence station, player or enemy) the selection
|
||||||
// within the inclusive tile rectangle spanned by tileA and tileB, in any corner order
|
// box covers (REQ-UI-MULTI-SELECT, REQ-UI-ENTITY-CLICK-SELECT): a ship when the box
|
||||||
// (REQ-UI-MULTI-SELECT, REQ-UI-ENTITY-CLICK-SELECT). A ship is included when its floored
|
// contains its position, a station when the box overlaps any of its body cells — the
|
||||||
// position tile lies in the box; a station is included when any of its body cells does.
|
// two rules of SelectionBox.h. Dead actors (hp <= 0) and the HQ proxy are excluded.
|
||||||
// Dead actors (hp <= 0) and the HQ proxy are excluded.
|
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, const QRectF& worldBox);
|
||||||
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
#include <limits>
|
#include <limits>
|
||||||
|
|
||||||
#include "PortGeometry.h"
|
#include "PortGeometry.h"
|
||||||
|
#include "ProductionRules.h"
|
||||||
|
#include "SelectionBox.h"
|
||||||
#include "SurfaceMask.h"
|
#include "SurfaceMask.h"
|
||||||
|
|
||||||
#include "Item.h"
|
#include "Item.h"
|
||||||
@@ -122,12 +124,14 @@ bool deliverScrapToSalvageBay(FactoryState& state, BuildingId bayId)
|
|||||||
return false; // queued for deconstruction: stopped operating (REQ-BLD-DECON-QUEUE)
|
return false; // queued for deconstruction: stopped operating (REQ-BLD-DECON-QUEUE)
|
||||||
}
|
}
|
||||||
// Emerging scrap still counts against the bay's holding capacity
|
// Emerging scrap still counts against the bay's holding capacity
|
||||||
// (REQ-MAT-OUTPUT-EMERGE).
|
// (REQ-MAT-OUTPUT-EMERGE). Scrap is all the bay ever holds, so its single buffer is
|
||||||
if (bay->getOutputItemCount() >= bay->outputBuffer.capacity)
|
// the one being filled (REQ-BLD-SALVAGE-BAY).
|
||||||
|
const ItemType scrap{"scrap"};
|
||||||
|
if (!outputBufferHasRoom(*bay, scrap, 1))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
bay->outputBuffer.items.push_back(Item{ItemType{"scrap"}});
|
bay->outputBuffer.items.push_back(Item{scrap});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,22 +186,13 @@ getSiteSplitterInfo(const FactoryState& state, const GameConfig& config, Buildin
|
|||||||
|
|
||||||
|
|
||||||
std::vector<BuildingId> buildingsInBox(const FactoryState& state,
|
std::vector<BuildingId> buildingsInBox(const FactoryState& state,
|
||||||
QPoint cornerA, QPoint cornerB)
|
const QRectF& worldBox)
|
||||||
{
|
{
|
||||||
const int x0 = std::min(cornerA.x(), cornerB.x());
|
|
||||||
const int y0 = std::min(cornerA.y(), cornerB.y());
|
|
||||||
const int x1 = std::max(cornerA.x(), cornerB.x());
|
|
||||||
const int y1 = std::max(cornerA.y(), cornerB.y());
|
|
||||||
|
|
||||||
const auto covers = [&](const std::vector<QPoint>& bodyCells)
|
const auto covers = [&](const std::vector<QPoint>& bodyCells)
|
||||||
{
|
{
|
||||||
for (const QPoint& cell : bodyCells)
|
for (const QPoint& cell : bodyCells)
|
||||||
{
|
{
|
||||||
if (cell.x() >= x0 && cell.x() <= x1
|
if (boxCoversTile(worldBox, cell)) { return true; }
|
||||||
&& cell.y() >= y0 && cell.y() <= y1)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <QPoint>
|
#include <QPoint>
|
||||||
|
#include <QRectF>
|
||||||
#include <QVector2D>
|
#include <QVector2D>
|
||||||
|
|
||||||
#include "Building.h"
|
#include "Building.h"
|
||||||
@@ -71,11 +72,12 @@ std::optional<BeltSystem::SplitterInfo> getSiteSplitterInfo(const FactoryState&
|
|||||||
const GameConfig& config,
|
const GameConfig& config,
|
||||||
BuildingId id);
|
BuildingId id);
|
||||||
|
|
||||||
// Ids of all buildings and construction sites whose footprint intersects the tile
|
// Ids of all buildings and construction sites the selection box covers — those with
|
||||||
// box spanned by the two (unordered) corner tiles (REQ-UI-MULTI-SELECT,
|
// a body cell the box overlaps, per boxCoversTile (REQ-UI-MULTI-SELECT,
|
||||||
// REQ-BLD-DECONSTRUCT-BOX).
|
// REQ-BLD-DECONSTRUCT-BOX). `worldBox` is in world coordinates and normalized; it is
|
||||||
|
// not snapped to tiles.
|
||||||
std::vector<BuildingId> buildingsInBox(const FactoryState& state,
|
std::vector<BuildingId> buildingsInBox(const FactoryState& state,
|
||||||
QPoint cornerA, QPoint cornerB);
|
const QRectF& worldBox);
|
||||||
|
|
||||||
// Every tunnel entry and exit, built or still a construction site, indexed by its
|
// Every tunnel entry and exit, built or still a construction site, indexed by its
|
||||||
// single-cell tile. Shared by the placement preview and the selection highlight.
|
// single-cell tile. Shared by the placement preview and the selection highlight.
|
||||||
|
|||||||
@@ -1,33 +1,44 @@
|
|||||||
#include "ProductionRules.h"
|
#include "ProductionRules.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <map>
|
||||||
|
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
#include "ItemType.h"
|
#include "ItemType.h"
|
||||||
#include "ModulesConfig.h"
|
#include "ModulesConfig.h"
|
||||||
#include "ShipsConfig.h"
|
#include "ShipsConfig.h"
|
||||||
|
|
||||||
std::vector<const RecipeDef*>
|
const RecipeDef* getSelectedRecipe(const GameConfig& config, const Building& b)
|
||||||
gatherCandidateRecipes(const GameConfig& config, const Building& b)
|
|
||||||
{
|
{
|
||||||
std::vector<const RecipeDef*> candidates;
|
if (b.recipeId.empty())
|
||||||
if (isAutoRecipeBuildingType(b.type))
|
|
||||||
{
|
{
|
||||||
for (const RecipeDef& r : config.recipes.recipes)
|
return nullptr;
|
||||||
|
}
|
||||||
|
return config.recipes.findRecipeDef(b.recipeId, b.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecipeDef* findAutoRecipeFor(const GameConfig& config, BuildingType type,
|
||||||
|
const ItemType& item)
|
||||||
|
{
|
||||||
|
if (!isAutoRecipeBuildingType(type))
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config order decides where a material feeds more than one recipe of the type, so
|
||||||
|
// the same offer always picks the same recipe (REQ-BLD-AUTO-RECIPE).
|
||||||
|
for (const RecipeDef& recipe : config.recipes.recipes)
|
||||||
|
{
|
||||||
|
if (recipe.building != type) { continue; }
|
||||||
|
for (const RecipeIngredient& ing : recipe.inputs)
|
||||||
{
|
{
|
||||||
if (r.building == b.type && !r.inputs.empty())
|
if (ItemType{ing.item} == item)
|
||||||
{
|
{
|
||||||
candidates.push_back(&r);
|
return &recipe;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
return nullptr;
|
||||||
{
|
|
||||||
const RecipeDef* recipe = config.recipes.findRecipeDef(b.recipeId, b.type);
|
|
||||||
if (recipe)
|
|
||||||
{
|
|
||||||
candidates.push_back(recipe);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return candidates;
|
|
||||||
}
|
}
|
||||||
bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe)
|
bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe)
|
||||||
{
|
{
|
||||||
@@ -81,6 +92,33 @@ computeShipyardRequiredMaterials(const GameConfig& config,
|
|||||||
}
|
}
|
||||||
return requiredMaterials;
|
return requiredMaterials;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double computeShipyardProductionTimeSeconds(
|
||||||
|
const GameConfig& config, const std::string& recipeId,
|
||||||
|
const std::optional<ShipLayoutConfig>& shipLayout)
|
||||||
|
{
|
||||||
|
const ShipDef* shipDef = config.ships.findShipDef(recipeId);
|
||||||
|
if (!shipDef)
|
||||||
|
{
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double seconds = shipDef->schematic.productionTimeSeconds;
|
||||||
|
if (shipLayout.has_value())
|
||||||
|
{
|
||||||
|
for (const PlacedModule& pm : shipLayout->placedModules)
|
||||||
|
{
|
||||||
|
const ModuleDef* modDef = config.modules.findModuleDef(pm.moduleId);
|
||||||
|
if (!modDef)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seconds += modDef->productionTimeSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
|
||||||
bool hasInputsToStart(const GameConfig& config, const Building& b)
|
bool hasInputsToStart(const GameConfig& config, const Building& b)
|
||||||
{
|
{
|
||||||
if (b.type == BuildingType::Shipyard)
|
if (b.type == BuildingType::Shipyard)
|
||||||
@@ -100,18 +138,67 @@ bool hasInputsToStart(const GameConfig& config, const Building& b)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recipe buildings: startable if any candidate recipe's inputs are satisfied.
|
// Recipe buildings: startable if the selected recipe's inputs are satisfied. A Miner
|
||||||
// A Miner recipe has no inputs, so an idle Miner is always startable and its
|
// recipe has no inputs, so an idle Miner is always startable here and its only idle
|
||||||
// only idle reason is a full output buffer.
|
// reason is an output buffer without room for the next cycle.
|
||||||
for (const RecipeDef* recipe : gatherCandidateRecipes(config, b))
|
const RecipeDef* recipe = getSelectedRecipe(config, b);
|
||||||
|
return recipe != nullptr && recipeInputsAvailable(b, *recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount)
|
||||||
|
{
|
||||||
|
const std::map<ItemType, int>::const_iterator capIt = b.outputBuffer.caps.find(type);
|
||||||
|
const int cap = (capIt != b.outputBuffer.caps.end()) ? capIt->second : 0;
|
||||||
|
return b.getOutputItemCount(type) + itemCount <= cap;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe)
|
||||||
|
{
|
||||||
|
if (b.type == BuildingType::ReprocessingPlant)
|
||||||
{
|
{
|
||||||
if (recipeInputsAvailable(b, *recipe))
|
// One roll yields one of these, so each is measured on its own -- but all of them
|
||||||
|
// have to fit, since which one it will be is not known yet.
|
||||||
|
for (const RecipeOutput& out : recipe.outputs)
|
||||||
{
|
{
|
||||||
return true;
|
if (!outputBufferHasRoom(b, ItemType{out.item}, out.amount))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deterministic cycle deposits all of its outputs together. An item listed more
|
||||||
|
// than once is produced in the sum of those amounts, so it is judged once, as a sum.
|
||||||
|
std::map<ItemType, int> perCycle;
|
||||||
|
for (const RecipeOutput& out : recipe.outputs)
|
||||||
|
{
|
||||||
|
perCycle[ItemType{out.item}] += out.amount;
|
||||||
|
}
|
||||||
|
for (const std::pair<const ItemType, int>& entry : perCycle)
|
||||||
|
{
|
||||||
|
if (!outputBufferHasRoom(b, entry.first, entry.second))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool canStartCycle(const GameConfig& config, const Building& b)
|
||||||
|
{
|
||||||
|
// A shipyard's completed cycle spawns a ship instead of filling an output buffer
|
||||||
|
// (REQ-BLD-SHIPYARD), so holding the materials is the whole condition.
|
||||||
|
if (b.type == BuildingType::Shipyard)
|
||||||
|
{
|
||||||
|
return hasInputsToStart(config, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecipeDef* recipe = getSelectedRecipe(config, b);
|
||||||
|
return recipe != nullptr && recipeInputsAvailable(b, *recipe)
|
||||||
|
&& recipeOutputsFit(b, *recipe);
|
||||||
|
}
|
||||||
|
|
||||||
std::optional<ProductionStatus>
|
std::optional<ProductionStatus>
|
||||||
getProductionStatus(const GameConfig& config, const Building& building)
|
getProductionStatus(const GameConfig& config, const Building& building)
|
||||||
{
|
{
|
||||||
@@ -130,9 +217,10 @@ getProductionStatus(const GameConfig& config, const Building& building)
|
|||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grey only applies to player-configured types; auto-recipe buildings
|
// Every production building can be unconfigured, an auto-recipe building included:
|
||||||
// (Smelter, Reprocessing Plant) always run an implicit recipe.
|
// it holds no recipe until one is offered to it, and the player can hand it back to
|
||||||
if (!isAutoRecipeBuildingType(building.type) && building.recipeId.empty())
|
// automatic selection (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT).
|
||||||
|
if (building.recipeId.empty())
|
||||||
{
|
{
|
||||||
return ProductionStatus::Unconfigured;
|
return ProductionStatus::Unconfigured;
|
||||||
}
|
}
|
||||||
@@ -142,9 +230,18 @@ getProductionStatus(const GameConfig& config, const Building& building)
|
|||||||
return ProductionStatus::Producing;
|
return ProductionStatus::Producing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle: missing inputs (red) take precedence over a full output buffer
|
// Idle, but blocked by neither condition: the building is only between cycles and
|
||||||
// (yellow). If inputs are present yet the building is idle, the only remaining
|
// the simulation starts the next one on a following tick. A building running back
|
||||||
// reason it could not start a cycle is a full output buffer (REQ-MAT-CYCLE).
|
// to back sits here for exactly one tick per cycle, since tickProduction never
|
||||||
|
// starts a cycle in the tick one completed, so this must read as producing rather
|
||||||
|
// than blink (REQ-UI-STATUS-LIGHT, REQ-MAT-CYCLE).
|
||||||
|
if (canStartCycle(config, building))
|
||||||
|
{
|
||||||
|
return ProductionStatus::Producing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle for a reason: a missing input (red) takes precedence over an output buffer
|
||||||
|
// with no room for the next cycle's output (yellow).
|
||||||
return hasInputsToStart(config, building) ? ProductionStatus::Blocked
|
return hasInputsToStart(config, building) ? ProductionStatus::Blocked
|
||||||
: ProductionStatus::Starved;
|
: ProductionStatus::Starved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,16 @@ enum class ProductionStatus
|
|||||||
// Pure functions of the config and the building itself — they read no factory
|
// Pure functions of the config and the building itself — they read no factory
|
||||||
// state, so they are free functions rather than BuildingSystem members.
|
// state, so they are free functions rather than BuildingSystem members.
|
||||||
|
|
||||||
// Recipes this building could run: every recipe of its type for an auto-recipe
|
// The recipe this building runs, or null when it has none selected. Every building type
|
||||||
// building (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), otherwise just its selected one.
|
// holds exactly one, a Smelter and a Reprocessing Plant included -- they only differ in
|
||||||
std::vector<const RecipeDef*> gatherCandidateRecipes(const GameConfig& config,
|
// how theirs first gets set (REQ-BLD-AUTO-RECIPE).
|
||||||
const Building& b);
|
const RecipeDef* getSelectedRecipe(const GameConfig& config, const Building& b);
|
||||||
|
|
||||||
|
// The recipe an auto-recipe building adopts when this material is offered to it while it
|
||||||
|
// has none: the first recipe of its type, in config order, that consumes the material
|
||||||
|
// (REQ-BLD-AUTO-RECIPE). Null when no recipe of the type takes it.
|
||||||
|
const RecipeDef* findAutoRecipeFor(const GameConfig& config, BuildingType type,
|
||||||
|
const ItemType& item);
|
||||||
|
|
||||||
// True when the building's input buffer holds every ingredient the recipe needs.
|
// True when the building's input buffer holds every ingredient the recipe needs.
|
||||||
bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe);
|
bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe);
|
||||||
@@ -44,9 +50,33 @@ std::map<std::string, int> computeShipyardRequiredMaterials(
|
|||||||
const GameConfig& config, const std::string& recipeId,
|
const GameConfig& config, const std::string& recipeId,
|
||||||
const std::optional<ShipLayoutConfig>& shipLayout);
|
const std::optional<ShipLayoutConfig>& shipLayout);
|
||||||
|
|
||||||
|
// The ship's base production time plus that of every module in the configured layout
|
||||||
|
// (REQ-MOD-PRODUCTION-TIME), over a stored configuration as above.
|
||||||
|
double computeShipyardProductionTimeSeconds(
|
||||||
|
const GameConfig& config, const std::string& recipeId,
|
||||||
|
const std::optional<ShipLayoutConfig>& shipLayout);
|
||||||
|
|
||||||
// True when a production cycle could start right now, ignoring output-buffer space.
|
// True when a production cycle could start right now, ignoring output-buffer space.
|
||||||
bool hasInputsToStart(const GameConfig& config, const Building& b);
|
bool hasInputsToStart(const GameConfig& config, const Building& b);
|
||||||
|
|
||||||
|
// True when the building can take `itemCount` more items of `type` beside what it
|
||||||
|
// already holds of it. An emerging item has not left the building yet and so still
|
||||||
|
// counts against that material's capacity (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-OUTPUT-BUFFER).
|
||||||
|
bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount);
|
||||||
|
|
||||||
|
// True when every output a cycle of this recipe could produce would fit -- the gate a
|
||||||
|
// cycle has to pass before it may start (REQ-MAT-CYCLE). For a deterministic recipe that
|
||||||
|
// is its own outputs. A Reprocessing Plant rolls one of its outputs per cycle
|
||||||
|
// (REQ-BLD-REPROCESSING), so each possibility is judged on its own and all must fit: the
|
||||||
|
// roll is committed the moment the cycle starts, and testing every outcome rather than
|
||||||
|
// the rolled one is what keeps a stalled output belt from biasing the distribution.
|
||||||
|
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe);
|
||||||
|
|
||||||
|
// True when a production cycle could actually start right now: some candidate recipe
|
||||||
|
// has its inputs *and* passes recipeOutputsFit (REQ-MAT-CYCLE). Stricter than
|
||||||
|
// hasInputsToStart, which looks at the input buffers alone.
|
||||||
|
bool canStartCycle(const GameConfig& config, const Building& b);
|
||||||
|
|
||||||
// Status light for a building, or nullopt for types that show none — belts,
|
// Status light for a building, or nullopt for types that show none — belts,
|
||||||
// splitters, tunnels, HQ and defence stations (REQ-UI-STATUS-LIGHT).
|
// splitters, tunnels, HQ and defence stations (REQ-UI-STATUS-LIGHT).
|
||||||
std::optional<ProductionStatus> getProductionStatus(const GameConfig& config,
|
std::optional<ProductionStatus> getProductionStatus(const GameConfig& config,
|
||||||
|
|||||||
@@ -1000,8 +1000,9 @@ TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo",
|
|||||||
}
|
}
|
||||||
const Building* bay = findBuilding(f.state, bayId);
|
const Building* bay = findBuilding(f.state, bayId);
|
||||||
REQUIRE(bay != nullptr);
|
REQUIRE(bay != nullptr);
|
||||||
// Config-driven output-buffer capacity is applied on placement (REQ-BLD-SALVAGE-BAY).
|
// Config-driven output-buffer capacity is applied on placement, onto the single
|
||||||
REQUIRE(bay->outputBuffer.capacity == 20);
|
// scrap buffer the bay holds (REQ-BLD-SALVAGE-BAY).
|
||||||
|
REQUIRE(bay->outputBuffer.caps.at(ItemType{"scrap"}) == 20);
|
||||||
|
|
||||||
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
||||||
bay->anchor.y() + bay->footprint.height() / 2.0f);
|
bay->anchor.y() + bay->footprint.height() / 2.0f);
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <QPoint>
|
#include <QPoint>
|
||||||
|
#include <QRectF>
|
||||||
|
|
||||||
#include "BeltSystem.h"
|
#include "BeltSystem.h"
|
||||||
#include "Building.h"
|
#include "Building.h"
|
||||||
|
#include "BuildingBuffers.h"
|
||||||
#include "BuildingSystem.h"
|
#include "BuildingSystem.h"
|
||||||
#include "ConstructionSystem.h"
|
#include "ConstructionSystem.h"
|
||||||
#include "DeconstructionSystem.h"
|
#include "DeconstructionSystem.h"
|
||||||
@@ -134,6 +136,26 @@ TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building
|
|||||||
REQUIRE_FALSE(isTileOccupied(f.state, QPoint(1, 1)));
|
REQUIRE_FALSE(isTileOccupied(f.state, QPoint(1, 1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("buildingsInBox covers a body cell the box only reaches into", "[building]")
|
||||||
|
{
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0),
|
||||||
|
Rotation::East, 0).value();
|
||||||
|
|
||||||
|
// Body at (0,0),(1,0),(0,1). The box is unsnapped and lies wholly within cell
|
||||||
|
// (1,0) without filling it, which is enough: a building is covered when the box
|
||||||
|
// overlaps any of its body cells (REQ-UI-MULTI-SELECT, Coverage).
|
||||||
|
const std::vector<BuildingId> grazed =
|
||||||
|
buildingsInBox(f.state, QRectF(1.6, 0.4, 0.2, 0.2));
|
||||||
|
REQUIRE(grazed.size() == 1);
|
||||||
|
REQUIRE(grazed.front() == id);
|
||||||
|
|
||||||
|
// (1,1) is the output-port tile, not a body cell, so a box inside it covers
|
||||||
|
// nothing even though it is surrounded by the miner's cells.
|
||||||
|
REQUIRE(buildingsInBox(f.state, QRectF(1.2, 1.2, 0.5, 0.5)).empty());
|
||||||
|
}
|
||||||
|
|
||||||
// -- World-bounds rejection (REQ-BLD-PLACE-VALID) ---------------------------
|
// -- World-bounds rejection (REQ-BLD-PLACE-VALID) ---------------------------
|
||||||
|
|
||||||
TEST_CASE("BuildingSystem: place rejects a building above the world (y < 0)", "[building]")
|
TEST_CASE("BuildingSystem: place rejects a building above the world (y < 0)", "[building]")
|
||||||
@@ -495,13 +517,13 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
|
|||||||
|
|
||||||
Tick tick = 0;
|
Tick tick = 0;
|
||||||
// Construction (10s) then cycle 1 starts at tick 300 (completesAt=330).
|
// Construction (10s) then cycle 1 starts at tick 300 (completesAt=330).
|
||||||
// Cycle 1 completes at tick 330: deposit item, continue (no same-tick restart).
|
// Cycle 1 completes at tick 330 and cycle 2 starts in that same tick
|
||||||
// Cycle 2 starts at tick 331 (completesAt=361).
|
// (completesAt=360). Cycle 2 completes at tick 360: deposit item -> 2 items held,
|
||||||
// Cycle 2 completes at tick 361: deposit item → buffer=2, cycle 3 stalls.
|
// which fills the buffer (capacity 2), so cycle 3 cannot start.
|
||||||
// Need to process through tick 361: 362 ticks total.
|
// Need to process through tick 360: 361 ticks total.
|
||||||
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
|
||||||
static_cast<int>(secondsToTicks(10.0))
|
static_cast<int>(secondsToTicks(10.0))
|
||||||
+ 2 * static_cast<int>(secondsToTicks(1.0)) + 2,
|
+ 2 * static_cast<int>(secondsToTicks(1.0)) + 1,
|
||||||
tick);
|
tick);
|
||||||
|
|
||||||
const Building* b = findBuilding(f.state, id);
|
const Building* b = findBuilding(f.state, id);
|
||||||
@@ -512,6 +534,49 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
|
|||||||
REQUIRE_FALSE(b->production.has_value());
|
REQUIRE_FALSE(b->production.has_value());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: the next cycle starts on the tick the last one completed",
|
||||||
|
"[building]")
|
||||||
|
{
|
||||||
|
// A cycle takes exactly its recipe duration, so a building whose output keeps
|
||||||
|
// draining produces at the configured rate (REQ-MAT-CYCLE). An idle tick between
|
||||||
|
// cycles would cost a one-second recipe about 3% of its throughput.
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
const BuildingId id =
|
||||||
|
f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
|
||||||
|
f.bs.setRecipe(f.state, id, "mine_iron_ore");
|
||||||
|
|
||||||
|
const Tick cycleTicks = secondsToTicks(1.0); // mine_iron_ore duration
|
||||||
|
|
||||||
|
Tick tick = 0;
|
||||||
|
// Construction completes at tick 300 and cycle 1 starts in that same tick.
|
||||||
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
|
||||||
|
static_cast<int>(secondsToTicks(10.0)) + 1, tick);
|
||||||
|
const Building* b = findBuilding(f.state, id);
|
||||||
|
REQUIRE(b != nullptr);
|
||||||
|
REQUIRE(b->production.has_value());
|
||||||
|
const Tick firstCompletesAt = b->production->completesAt;
|
||||||
|
|
||||||
|
// Process up to and including that completion tick: the next cycle is already
|
||||||
|
// running, due exactly one duration later rather than one duration plus a tick.
|
||||||
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(cycleTicks), tick);
|
||||||
|
b = findBuilding(f.state, id);
|
||||||
|
REQUIRE(b->getOutputItemCount() == 1);
|
||||||
|
REQUIRE(b->production.has_value());
|
||||||
|
REQUIRE(b->production->completesAt == firstCompletesAt + cycleTicks);
|
||||||
|
|
||||||
|
// Nothing hauls the ore away here, so the buffer (capacity 2) would stall the third
|
||||||
|
// cycle. Drain it and confirm the cadence holds across the next boundary too.
|
||||||
|
f.bs.forEachBuilding(f.state, [](Building& building) {
|
||||||
|
building.outputBuffer.items.clear();
|
||||||
|
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
|
||||||
|
});
|
||||||
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(cycleTicks), tick);
|
||||||
|
b = findBuilding(f.state, id);
|
||||||
|
REQUIRE(b->production.has_value());
|
||||||
|
REQUIRE(b->production->completesAt == firstCompletesAt + 2 * cycleTicks);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// REQ-UI-DEBUG-OVERLAY production counts
|
// REQ-UI-DEBUG-OVERLAY production counts
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -585,12 +650,13 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
|
|||||||
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
|
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
|
||||||
// Output port (2,1) East. Input port example: (2,0) West.
|
// Output port (2,1) East. Input port example: (2,0) West.
|
||||||
const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
|
const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
|
||||||
// Smelters have no recipe selection (REQ-BLD-SMELTER); they auto-accept any
|
// A smelter starts with no recipe and picks one from the first material offered to
|
||||||
// ore/scrap that is an input to a smelter recipe.
|
// it (REQ-BLD-AUTO-RECIPE), which is what lets it accept the ore below.
|
||||||
|
|
||||||
// Complete construction (15s → tick 450+1 = 451 ticks).
|
// Complete construction (15s → tick 450+1 = 451 ticks).
|
||||||
Tick tick = 0;
|
Tick tick = 0;
|
||||||
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
|
||||||
|
REQUIRE(findBuilding(f.state, sid)->recipeId.empty());
|
||||||
|
|
||||||
// Place west-flowing belt at (2,0): belt flows West, delivers to smelter.
|
// Place west-flowing belt at (2,0): belt flows West, delivers to smelter.
|
||||||
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
|
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
|
||||||
@@ -601,6 +667,13 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
|
|||||||
|
|
||||||
const Building* b = findBuilding(f.state, sid);
|
const Building* b = findBuilding(f.state, sid);
|
||||||
REQUIRE(b != nullptr);
|
REQUIRE(b != nullptr);
|
||||||
|
// The ore selected the recipe that consumes it, and its buffers were sized for that
|
||||||
|
// recipe alone -- copper ore is not one of its inputs any more.
|
||||||
|
REQUIRE(b->recipeId == "iron_ingot");
|
||||||
|
REQUIRE(b->inputBuffer.caps.count(ItemType{"iron_ore"}) == 1);
|
||||||
|
REQUIRE(b->inputBuffer.caps.count(ItemType{"copper_ore"}) == 0);
|
||||||
|
REQUIRE(b->outputBuffer.caps.count(ItemType{"iron_ingot"}) == 1);
|
||||||
|
REQUIRE(b->outputBuffer.caps.count(ItemType{"copper_ingot"}) == 0);
|
||||||
// The item was accepted; it may still be travelling inward on the input belt,
|
// The item was accepted; it may still be travelling inward on the input belt,
|
||||||
// so count buffered + in-transit (REQ-MAT-INPUT-INTAKE).
|
// so count buffered + in-transit (REQ-MAT-INPUT-INTAKE).
|
||||||
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) >= 1);
|
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) >= 1);
|
||||||
@@ -705,10 +778,10 @@ TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
|
|||||||
REQUIRE(hasIronIngot);
|
REQUIRE(hasIronIngot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// With mixed inputs, the smelter runs whichever recipe is currently satisfiable
|
// A belt carrying mixed ore is the realistic case for REQ-BLD-AUTO-RECIPE: the first ore
|
||||||
// and leaves an incomplete batch of another input waiting (see the union-of-
|
// to arrive settles the recipe, and everything else on that belt is refused rather than
|
||||||
// inputs caps in initAutoBuffers).
|
// smelted alongside it.
|
||||||
TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete batch waits",
|
TEST_CASE("BuildingSystem: mixed ore on one belt leaves the smelter on the first ore's recipe",
|
||||||
"[building]")
|
"[building]")
|
||||||
{
|
{
|
||||||
PlacementFixture f(kFastBeltSpeed_tps);
|
PlacementFixture f(kFastBeltSpeed_tps);
|
||||||
@@ -718,8 +791,7 @@ TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete
|
|||||||
Tick tick = 0;
|
Tick tick = 0;
|
||||||
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
|
||||||
|
|
||||||
// Feed 1 iron_ore (iron_ingot needs 2 — incomplete) then 2 copper_ore
|
// Feed 1 iron_ore, then 2 copper_ore, via the west-flowing input belt.
|
||||||
// (copper_ingot needs 2 — satisfiable) via the west-flowing input belt.
|
|
||||||
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
|
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
|
||||||
const char* fed[] = { "iron_ore", "copper_ore", "copper_ore" };
|
const char* fed[] = { "iron_ore", "copper_ore", "copper_ore" };
|
||||||
for (const char* id : fed)
|
for (const char* id : fed)
|
||||||
@@ -729,24 +801,26 @@ TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete
|
|||||||
f.bs.tickBeltPull(f.state);
|
f.bs.tickBeltPull(f.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// copper_ingot cycle is 2.5s; run to completion.
|
|
||||||
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(2.5)) + 2, tick);
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(2.5)) + 2, tick);
|
||||||
|
|
||||||
const Building* b = findBuilding(f.state, sid);
|
const Building* b = findBuilding(f.state, sid);
|
||||||
REQUIRE(b != nullptr);
|
REQUIRE(b != nullptr);
|
||||||
|
|
||||||
// Copper was smelted; the lone iron_ore still waits for a second unit.
|
// The iron ore came first, so the smelter smelts iron and nothing else. The copper
|
||||||
bool hasCopperIngot = false;
|
// was never taken in, so no copper ingot was made.
|
||||||
|
REQUIRE(b->recipeId == "iron_ingot");
|
||||||
for (const Item& item : outputSideItems(*b))
|
for (const Item& item : outputSideItems(*b))
|
||||||
{
|
{
|
||||||
if (item.type.id == "copper_ingot") { hasCopperIngot = true; }
|
REQUIRE(item.type.id != "copper_ingot");
|
||||||
}
|
}
|
||||||
REQUIRE(hasCopperIngot);
|
REQUIRE(b->pendingInputCount(ItemType{"copper_ore"}) == 0);
|
||||||
|
|
||||||
|
// The lone iron ore still waits for a second unit: the recipe needs two.
|
||||||
const std::map<ItemType, int>::const_iterator ironIt =
|
const std::map<ItemType, int>::const_iterator ironIt =
|
||||||
b->inputBuffer.counts.find(ItemType{"iron_ore"});
|
b->inputBuffer.counts.find(ItemType{"iron_ore"});
|
||||||
REQUIRE(ironIt != b->inputBuffer.counts.end());
|
REQUIRE(ironIt != b->inputBuffer.counts.end());
|
||||||
REQUIRE(ironIt->second == 1);
|
REQUIRE(ironIt->second == 1);
|
||||||
|
REQUIRE_FALSE(b->production.has_value());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -833,7 +907,8 @@ TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stu
|
|||||||
REQUIRE(sink != nullptr);
|
REQUIRE(sink != nullptr);
|
||||||
// Nothing was delivered, and the producer's output side has backed up to its cap.
|
// Nothing was delivered, and the producer's output side has backed up to its cap.
|
||||||
REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0);
|
REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0);
|
||||||
REQUIRE(miner->getOutputItemCount() == miner->outputBuffer.capacity);
|
REQUIRE(miner->getOutputItemCount(ItemType{"iron_ore"})
|
||||||
|
== miner->outputBuffer.caps.at(ItemType{"iron_ore"}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -870,28 +945,250 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Reprocessing plant — output buffer capacity (REQ-MAT-OUTPUT-BUFFER-REPROCESSING)
|
// Reprocessing plant -- per-item output buffers (REQ-MAT-OUTPUT-BUFFER)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max output per roll",
|
TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possible roll",
|
||||||
"[building]")
|
"[building]")
|
||||||
{
|
{
|
||||||
PlacementFixture f;
|
PlacementFixture f;
|
||||||
|
|
||||||
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
|
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
|
||||||
QPoint(0, 0), Rotation::East, 0).value();
|
QPoint(0, 0), Rotation::East, 0).value();
|
||||||
// Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
|
|
||||||
// single reprocessing recipe is applied automatically on completion.
|
|
||||||
|
|
||||||
// Complete construction (25s).
|
// Complete construction (25s).
|
||||||
Tick tick = 0;
|
Tick tick = 0;
|
||||||
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
|
||||||
|
|
||||||
|
// A plant holds no buffers until it has a recipe (REQ-BLD-AUTO-RECIPE); selecting
|
||||||
|
// one sizes them, exactly as the first scrap offered to it would.
|
||||||
|
REQUIRE(findBuilding(f.state, id)->outputBuffer.caps.empty());
|
||||||
|
f.bs.setRecipe(f.state, id, "reprocessing_cycle");
|
||||||
|
|
||||||
const Building* b = findBuilding(f.state, id);
|
const Building* b = findBuilding(f.state, id);
|
||||||
REQUIRE(b != nullptr);
|
REQUIRE(b != nullptr);
|
||||||
// reprocessing_cycle outputs: 2 iron_ingot (60%), 1 circuit_board (30%),
|
// reprocessing_cycle outputs: 2 iron_ingot (60%), 1 circuit_board (30%),
|
||||||
// 1 advanced_alloy (10%). Max per roll = 2. Capacity = 2 (1× max).
|
// 1 advanced_alloy (10%). One roll yields one of them, so each buffer holds twice
|
||||||
REQUIRE(b->outputBuffer.capacity == 2);
|
// that outcome's own amount (REQ-MAT-OUTPUT-BUFFER).
|
||||||
|
REQUIRE(b->outputBuffer.caps.size() == 3);
|
||||||
|
REQUIRE(b->outputBuffer.caps.at(ItemType{"iron_ingot"}) == 4);
|
||||||
|
REQUIRE(b->outputBuffer.caps.at(ItemType{"circuit_board"}) == 2);
|
||||||
|
REQUIRE(b->outputBuffer.caps.at(ItemType{"advanced_alloy"}) == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: one full output buffer stops the plant even when the others have room",
|
||||||
|
"[building]")
|
||||||
|
{
|
||||||
|
// The gate that replaced the old one-item cap: a cycle may only start when *every*
|
||||||
|
// outcome would fit, because the roll is committed once it starts (REQ-MAT-CYCLE).
|
||||||
|
// Were the plant to roll first and skip a result that does not fit, a player could
|
||||||
|
// stall one output belt to filter the distribution towards the other items.
|
||||||
|
PlacementFixture f(kFastBeltSpeed_tps);
|
||||||
|
|
||||||
|
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
|
||||||
|
QPoint(0, 0), Rotation::East, 0).value();
|
||||||
|
Tick tick = 0;
|
||||||
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
|
||||||
|
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
|
||||||
|
|
||||||
|
// Feed a full cycle's scrap (5) so only the output side can hold it back.
|
||||||
|
f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
|
||||||
|
for (int i = 0; i < 5; ++i)
|
||||||
|
{
|
||||||
|
f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
|
||||||
|
f.belts.tick();
|
||||||
|
f.bs.tickBeltPull(f.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill the iron_ingot buffer to its cap and leave the other two empty.
|
||||||
|
f.bs.forEachBuilding(f.state, [](Building& building) {
|
||||||
|
if (building.type != BuildingType::ReprocessingPlant) { return; }
|
||||||
|
const int cap = building.outputBuffer.caps.at(ItemType{"iron_ingot"});
|
||||||
|
for (int i = 0; i < cap; ++i)
|
||||||
|
{
|
||||||
|
building.outputBuffer.items.push_back(makeItem("iron_ingot"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 5, tick);
|
||||||
|
|
||||||
|
const Building* b = findBuilding(f.state, id);
|
||||||
|
REQUIRE(b != nullptr);
|
||||||
|
// circuit_board and advanced_alloy have room, but iron_ingot does not, so no cycle
|
||||||
|
// starts at all and the scrap is still waiting.
|
||||||
|
REQUIRE(b->outputBuffer.caps.at(ItemType{"circuit_board"}) > 0);
|
||||||
|
REQUIRE(outputBufferHasRoom(*b, ItemType{"circuit_board"}, 1));
|
||||||
|
REQUIRE_FALSE(outputBufferHasRoom(*b, ItemType{"iron_ingot"}, 1));
|
||||||
|
REQUIRE_FALSE(b->production.has_value());
|
||||||
|
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5);
|
||||||
|
REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Blocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: reprocessing plant runs a second cycle while holding the first output",
|
||||||
|
"[building]")
|
||||||
|
{
|
||||||
|
// Its buffers hold twice each outcome's amount (REQ-MAT-OUTPUT-BUFFER), so a held
|
||||||
|
// result no longer stops the next cycle. The old one-item cap made this impossible:
|
||||||
|
// whatever the first roll was, the plant stalled until that item left the building.
|
||||||
|
PlacementFixture f(kFastBeltSpeed_tps);
|
||||||
|
|
||||||
|
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
|
||||||
|
QPoint(0, 0), Rotation::East, 0).value();
|
||||||
|
Tick tick = 0;
|
||||||
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
|
||||||
|
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
|
||||||
|
|
||||||
|
// Two cycles' worth of scrap (5 each), which is exactly the input cap.
|
||||||
|
f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
|
||||||
|
for (int i = 0; i < 10; ++i)
|
||||||
|
{
|
||||||
|
f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
|
||||||
|
f.belts.tick();
|
||||||
|
f.bs.tickBeltPull(f.state);
|
||||||
|
}
|
||||||
|
REQUIRE(findBuilding(f.state, id)->pendingInputCount(ItemType{"scrap"}) == 10);
|
||||||
|
|
||||||
|
// No belt carries the output away, so the first cycle's result is still held.
|
||||||
|
// reprocessing_cycle runs 3s; run through the completion tick.
|
||||||
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
|
||||||
|
static_cast<int>(secondsToTicks(3.0)) + 1, tick);
|
||||||
|
|
||||||
|
const Building* b = findBuilding(f.state, id);
|
||||||
|
REQUIRE(b != nullptr);
|
||||||
|
REQUIRE(b->getOutputItemCount() > 0);
|
||||||
|
// Whichever outcome was rolled, every outcome still fits, so the second cycle is
|
||||||
|
// already running rather than the plant sitting blocked.
|
||||||
|
REQUIRE(b->production.has_value());
|
||||||
|
REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Producing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Automatic recipe selection (REQ-BLD-AUTO-RECIPE)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Places a smelter and runs it to completion, leaving it with no recipe.
|
||||||
|
static BuildingId buildSmelter(PlacementFixture& f, QPoint anchor, Tick& tick)
|
||||||
|
{
|
||||||
|
const BuildingId id =
|
||||||
|
f.bs.place(f.state, BuildingType::Smelter, anchor, Rotation::East, 0).value();
|
||||||
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
|
||||||
|
static_cast<int>(secondsToTicks(15.0)) + 1, tick);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: an unset auto-recipe building is unconfigured", "[building]")
|
||||||
|
{
|
||||||
|
// It holds no recipe until one is offered to it, so it reads grey like any other
|
||||||
|
// unconfigured building (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT).
|
||||||
|
PlacementFixture f;
|
||||||
|
Tick tick = 0;
|
||||||
|
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
|
||||||
|
|
||||||
|
const Building* b = findBuilding(f.state, id);
|
||||||
|
REQUIRE(b != nullptr);
|
||||||
|
REQUIRE(b->recipeId.empty());
|
||||||
|
REQUIRE(b->inputBuffer.caps.empty());
|
||||||
|
REQUIRE(b->outputBuffer.caps.empty());
|
||||||
|
REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Unconfigured);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: a set recipe is never replaced by a later material",
|
||||||
|
"[building]")
|
||||||
|
{
|
||||||
|
// Once set the recipe is the player's to change: a material belonging to another of
|
||||||
|
// its recipes is simply not an accepted input (REQ-BLD-AUTO-RECIPE).
|
||||||
|
PlacementFixture f(kFastBeltSpeed_tps);
|
||||||
|
Tick tick = 0;
|
||||||
|
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
|
||||||
|
|
||||||
|
// Iron ore first, which selects the iron recipe.
|
||||||
|
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
|
||||||
|
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
|
||||||
|
f.belts.tick();
|
||||||
|
f.bs.tickBeltPull(f.state);
|
||||||
|
REQUIRE(findBuilding(f.state, id)->recipeId == "iron_ingot");
|
||||||
|
|
||||||
|
// Copper ore next: refused, and the recipe stands.
|
||||||
|
f.belts.tryPutItem(QPoint(2, 0), makeItem("copper_ore"));
|
||||||
|
f.belts.tick();
|
||||||
|
f.bs.tickBeltPull(f.state);
|
||||||
|
|
||||||
|
const Building* b = findBuilding(f.state, id);
|
||||||
|
REQUIRE(b->recipeId == "iron_ingot");
|
||||||
|
REQUIRE(b->pendingInputCount(ItemType{"copper_ore"}) == 0);
|
||||||
|
// The copper is still sitting on the belt, refused rather than swallowed.
|
||||||
|
REQUIRE(f.belts.peekItem(westPort(QPoint(2, 0))).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: a manually selected recipe is not overridden", "[building]")
|
||||||
|
{
|
||||||
|
// The player's selection is a recipe like any other, so auto-selection stays out of
|
||||||
|
// the way and the smelter refuses ore it does not smelt (REQ-BLD-AUTO-RECIPE).
|
||||||
|
PlacementFixture f(kFastBeltSpeed_tps);
|
||||||
|
Tick tick = 0;
|
||||||
|
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
|
||||||
|
|
||||||
|
f.bs.setRecipe(f.state, id, "copper_ingot");
|
||||||
|
REQUIRE(findBuilding(f.state, id)->recipeId == "copper_ingot");
|
||||||
|
|
||||||
|
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
|
||||||
|
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
|
||||||
|
f.belts.tick();
|
||||||
|
f.bs.tickBeltPull(f.state);
|
||||||
|
|
||||||
|
const Building* b = findBuilding(f.state, id);
|
||||||
|
REQUIRE(b->recipeId == "copper_ingot");
|
||||||
|
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: selecting a different recipe frees a stuck auto-recipe building",
|
||||||
|
"[building]")
|
||||||
|
{
|
||||||
|
// A smelter left holding part of a cycle nothing feeds any more is freed by
|
||||||
|
// selecting another recipe, which clears the buffers -- that is why no separate
|
||||||
|
// clear action exists (REQ-BLD-AUTO-RECIPE, REQ-MAT-INPUT-BUFFER).
|
||||||
|
PlacementFixture f(kFastBeltSpeed_tps);
|
||||||
|
Tick tick = 0;
|
||||||
|
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
|
||||||
|
|
||||||
|
// One iron ore, where the recipe needs two: it can never run.
|
||||||
|
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
|
||||||
|
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
|
||||||
|
f.belts.tick();
|
||||||
|
f.bs.tickBeltPull(f.state);
|
||||||
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 30, tick);
|
||||||
|
|
||||||
|
const Building* stuck = findBuilding(f.state, id);
|
||||||
|
REQUIRE(stuck->recipeId == "iron_ingot");
|
||||||
|
REQUIRE(stuck->pendingInputCount(ItemType{"iron_ore"}) == 1);
|
||||||
|
REQUIRE_FALSE(stuck->production.has_value());
|
||||||
|
|
||||||
|
f.bs.setRecipe(f.state, id, "copper_ingot");
|
||||||
|
const Building* freed = findBuilding(f.state, id);
|
||||||
|
REQUIRE(freed->recipeId == "copper_ingot");
|
||||||
|
REQUIRE(freed->pendingInputCount(ItemType{"iron_ore"}) == 0);
|
||||||
|
REQUIRE(freed->inputBuffer.caps.count(ItemType{"copper_ore"}) == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: selecting (Auto) returns the building to automatic selection",
|
||||||
|
"[building]")
|
||||||
|
{
|
||||||
|
// The dialog's clearing option unsets the recipe rather than leaving the building
|
||||||
|
// idle for good: the next material offered selects one again (REQ-BLD-AUTO-RECIPE).
|
||||||
|
PlacementFixture f(kFastBeltSpeed_tps);
|
||||||
|
Tick tick = 0;
|
||||||
|
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
|
||||||
|
|
||||||
|
f.bs.setRecipe(f.state, id, "copper_ingot");
|
||||||
|
f.bs.setRecipe(f.state, id, std::string()); // the "(Auto)" option
|
||||||
|
REQUIRE(findBuilding(f.state, id)->recipeId.empty());
|
||||||
|
|
||||||
|
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
|
||||||
|
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
|
||||||
|
f.belts.tick();
|
||||||
|
f.bs.tickBeltPull(f.state);
|
||||||
|
|
||||||
|
REQUIRE(findBuilding(f.state, id)->recipeId == "iron_ingot");
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then stalls",
|
TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then stalls",
|
||||||
@@ -902,8 +1199,8 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
|
|||||||
|
|
||||||
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
|
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
|
||||||
QPoint(0, 0), Rotation::East, 0).value();
|
QPoint(0, 0), Rotation::East, 0).value();
|
||||||
// Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
|
// The plant selects its recipe from the first scrap offered to it
|
||||||
// single reprocessing recipe is applied automatically on completion.
|
// (REQ-BLD-AUTO-RECIPE), which is what the belt feeding below does.
|
||||||
|
|
||||||
// Complete construction (25s).
|
// Complete construction (25s).
|
||||||
Tick tick = 0;
|
Tick tick = 0;
|
||||||
@@ -1081,10 +1378,12 @@ TEST_CASE("isConfigurableBuildingType: only types with player-facing settings",
|
|||||||
CHECK(isConfigurableBuildingType(BuildingType::Shipyard));
|
CHECK(isConfigurableBuildingType(BuildingType::Shipyard));
|
||||||
CHECK(isConfigurableBuildingType(BuildingType::Splitter));
|
CHECK(isConfigurableBuildingType(BuildingType::Splitter));
|
||||||
|
|
||||||
// Smelter and Reprocessing Plant run implicit recipes (REQ-BLD-SMELTER,
|
// Smelter and Reprocessing Plant carry a recipe like any other, even though they can
|
||||||
// REQ-BLD-REPROCESSING) and the rest have no settings whatsoever.
|
// also select it themselves (REQ-BLD-AUTO-RECIPE).
|
||||||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Smelter));
|
CHECK(isConfigurableBuildingType(BuildingType::Smelter));
|
||||||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::ReprocessingPlant));
|
CHECK(isConfigurableBuildingType(BuildingType::ReprocessingPlant));
|
||||||
|
|
||||||
|
// The rest have no settings whatsoever.
|
||||||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::SalvageBay));
|
CHECK_FALSE(isConfigurableBuildingType(BuildingType::SalvageBay));
|
||||||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Belt));
|
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Belt));
|
||||||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelEntry));
|
CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelEntry));
|
||||||
@@ -1134,8 +1433,15 @@ TEST_CASE("resolveBlueprintGhost: a partial overlap of the same type is invalid"
|
|||||||
// Both footprints stay on the asteroid, so terrain is not what fails here.
|
// Both footprints stay on the asteroid, so terrain is not what fails here.
|
||||||
f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0);
|
f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0);
|
||||||
|
|
||||||
CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
|
// Judged on where it sits, with no cursor to hit-test: the overlap is what decides.
|
||||||
|
CHECK(resolveInConstellation(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
|
||||||
== BlueprintGhostAction::Invalid);
|
== BlueprintGhostAction::Invalid);
|
||||||
|
|
||||||
|
// With the cursor on the existing smelter the single-building gesture answers first
|
||||||
|
// and hands it the settings, since a smelter carries a recipe (REQ-BLD-AUTO-RECIPE,
|
||||||
|
// REQ-UI-BLUEPRINT-TRANSFER) -- the same as for a miner.
|
||||||
|
CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
|
||||||
|
== BlueprintGhostAction::Transfer);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("resolveBlueprintGhost: a single configurable building transfers its settings",
|
TEST_CASE("resolveBlueprintGhost: a single configurable building transfers its settings",
|
||||||
@@ -1296,11 +1602,11 @@ TEST_CASE("resolveBlueprintGhost: a constellation mixes transfers and plain over
|
|||||||
REQUIRE(miner.action == BlueprintGhostAction::Transfer);
|
REQUIRE(miner.action == BlueprintGhostAction::Transfer);
|
||||||
CHECK(*miner.targetId == minerId);
|
CHECK(*miner.targetId == minerId);
|
||||||
|
|
||||||
// A smelter runs an implicit recipe (REQ-BLD-SMELTER), so there is nothing to hand
|
// A smelter carries a recipe too now (REQ-BLD-AUTO-RECIPE), so a blueprint of one
|
||||||
// over and it is simply left as it is.
|
// has something to hand over just as the miner does.
|
||||||
const BlueprintGhostResolved smelter =
|
const BlueprintGhostResolved smelter =
|
||||||
resolveInConstellation(f, BuildingType::Smelter, QPoint(-5, 0), Rotation::East);
|
resolveInConstellation(f, BuildingType::Smelter, QPoint(-5, 0), Rotation::East);
|
||||||
REQUIRE(smelter.action == BlueprintGhostAction::CompatibleOverlap);
|
REQUIRE(smelter.action == BlueprintGhostAction::Transfer);
|
||||||
CHECK(*smelter.targetId == smelterId);
|
CHECK(*smelter.targetId == smelterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1515,41 +1821,187 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
|||||||
miner.production = Production{};
|
miner.production = Production{};
|
||||||
REQUIRE(statusOf(miner) == ProductionStatus::Producing); // active cycle -> green
|
REQUIRE(statusOf(miner) == ProductionStatus::Producing); // active cycle -> green
|
||||||
|
|
||||||
// A miner has no inputs, so its only idle reason is a full output buffer.
|
// A miner has no inputs, so its only idle reason is an output buffer with no
|
||||||
|
// room for the next cycle's output.
|
||||||
miner.production = std::nullopt;
|
miner.production = std::nullopt;
|
||||||
miner.outputBuffer.capacity = 2;
|
miner.outputBuffer.caps[ItemType{"iron_ore"}] = 2;
|
||||||
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
|
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
|
||||||
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
|
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
|
||||||
|
|
||||||
|
// One item handed off: the next cycle fits again, so the idle tick between two
|
||||||
|
// cycles reads as producing rather than blinking yellow (REQ-UI-STATUS-LIGHT).
|
||||||
|
miner.outputBuffer.items.pop_back();
|
||||||
|
REQUIRE(statusOf(miner) == ProductionStatus::Producing); // -> green
|
||||||
|
|
||||||
|
// An emerging item has not left the building, so it fills the freed room and
|
||||||
|
// blocks the cycle again (REQ-MAT-OUTPUT-EMERGE).
|
||||||
|
miner.emergingItems.push_back({ BeltItemSlot{ makeItem("iron_ore"), 0.5 } });
|
||||||
|
REQUIRE(miner.getOutputItemCount(ItemType{"iron_ore"}) == 2);
|
||||||
|
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
|
||||||
|
|
||||||
|
// Another item's backlog is measured against its own buffer, so it changes
|
||||||
|
// nothing here (REQ-MAT-OUTPUT-BUFFER).
|
||||||
|
miner.outputBuffer.caps[ItemType{"copper_ore"}] = 2;
|
||||||
|
miner.outputBuffer.items.push_back(makeItem("copper_ore"));
|
||||||
|
REQUIRE(statusOf(miner) == ProductionStatus::Blocked);
|
||||||
|
miner.outputBuffer.items.clear();
|
||||||
|
REQUIRE(statusOf(miner) == ProductionStatus::Producing);
|
||||||
}
|
}
|
||||||
|
|
||||||
SECTION("Assembler: starved vs blocked, input-missing takes precedence")
|
SECTION("Assembler: starved, the transient between cycles, then blocked")
|
||||||
{
|
{
|
||||||
Building assembler; assembler.type = BuildingType::Assembler;
|
Building assembler; assembler.type = BuildingType::Assembler;
|
||||||
assembler.recipeId = assemblerRecipe->id;
|
assembler.recipeId = assemblerRecipe->id;
|
||||||
|
// Sized the way the simulation sizes it (REQ-MAT-OUTPUT-BUFFER).
|
||||||
|
initBuffers(assembler, *assemblerRecipe);
|
||||||
|
|
||||||
|
const std::string outputItemId = assemblerRecipe->outputs.front().item;
|
||||||
|
int cycleOutput = 0;
|
||||||
|
for (const RecipeOutput& out : assemblerRecipe->outputs)
|
||||||
|
{
|
||||||
|
cycleOutput += out.amount;
|
||||||
|
}
|
||||||
|
REQUIRE(cycleOutput > 0);
|
||||||
|
|
||||||
// Idle with inputs missing -> red.
|
// Idle with inputs missing -> red.
|
||||||
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
|
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
|
||||||
|
|
||||||
// Inputs present but idle -> the only remaining reason is a full output
|
// Inputs present and the output fits: nothing blocks a cycle, so the building
|
||||||
// buffer -> yellow.
|
// is merely between cycles -> green, not yellow (REQ-UI-STATUS-LIGHT).
|
||||||
for (const RecipeIngredient& ing : assemblerRecipe->inputs)
|
for (const RecipeIngredient& ing : assemblerRecipe->inputs)
|
||||||
{
|
{
|
||||||
assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
|
assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
|
||||||
}
|
}
|
||||||
|
REQUIRE(statusOf(assembler) == ProductionStatus::Producing);
|
||||||
|
|
||||||
|
// That item's own buffer filled to within less than one cycle's output of its
|
||||||
|
// capacity: no cycle can start -> yellow.
|
||||||
|
for (int i = 0; i < cycleOutput + 1; ++i)
|
||||||
|
{
|
||||||
|
assembler.outputBuffer.items.push_back(makeItem(outputItemId));
|
||||||
|
}
|
||||||
REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
|
REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
|
||||||
|
|
||||||
// Inputs missing AND output full -> red wins over yellow.
|
// Inputs missing AND output blocked -> red wins over yellow.
|
||||||
assembler.inputBuffer.counts.clear();
|
assembler.inputBuffer.counts.clear();
|
||||||
assembler.outputBuffer.capacity = 2;
|
|
||||||
assembler.outputBuffer.items = { makeItem("x"), makeItem("x") };
|
|
||||||
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
|
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
|
||||||
}
|
}
|
||||||
|
|
||||||
SECTION("Smelter (auto-recipe) is never grey")
|
SECTION("A multi-item cycle blocks before the output buffer is full")
|
||||||
{
|
{
|
||||||
|
// Free space smaller than one cycle's output stops the cycle even though the
|
||||||
|
// buffer still has room, so yellow is not the same as "full" (REQ-MAT-CYCLE).
|
||||||
|
const RecipeDef* multiOutputRecipe = nullptr;
|
||||||
|
for (const RecipeDef& r : f.cfg.recipes.recipes)
|
||||||
|
{
|
||||||
|
if (r.building != BuildingType::Assembler || r.inputs.empty()) { continue; }
|
||||||
|
int total = 0;
|
||||||
|
for (const RecipeOutput& out : r.outputs) { total += out.amount; }
|
||||||
|
if (total >= 2) { multiOutputRecipe = &r; break; }
|
||||||
|
}
|
||||||
|
REQUIRE(multiOutputRecipe != nullptr);
|
||||||
|
|
||||||
|
int cycleOutput = 0;
|
||||||
|
for (const RecipeOutput& out : multiOutputRecipe->outputs)
|
||||||
|
{
|
||||||
|
cycleOutput += out.amount;
|
||||||
|
}
|
||||||
|
const std::string outputItemId = multiOutputRecipe->outputs.front().item;
|
||||||
|
|
||||||
|
Building assembler; assembler.type = BuildingType::Assembler;
|
||||||
|
assembler.recipeId = multiOutputRecipe->id;
|
||||||
|
initBuffers(assembler, *multiOutputRecipe);
|
||||||
|
for (const RecipeIngredient& ing : multiOutputRecipe->inputs)
|
||||||
|
{
|
||||||
|
assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One item short of a full cycle's worth of free space.
|
||||||
|
for (int i = 0; i < cycleOutput + 1; ++i)
|
||||||
|
{
|
||||||
|
assembler.outputBuffer.items.push_back(makeItem(outputItemId));
|
||||||
|
}
|
||||||
|
REQUIRE(assembler.getOutputItemCount(ItemType{outputItemId})
|
||||||
|
< assembler.outputBuffer.caps.at(ItemType{outputItemId}));
|
||||||
|
REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
|
||||||
|
|
||||||
|
// Exactly one cycle's worth of free space: the cycle fits again.
|
||||||
|
assembler.outputBuffer.items.pop_back();
|
||||||
|
REQUIRE(statusOf(assembler) == ProductionStatus::Producing);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("Reprocessing Plant: blocked once any possible roll has no room")
|
||||||
|
{
|
||||||
|
// The plant rolls one of its outputs per cycle (REQ-BLD-REPROCESSING) and the
|
||||||
|
// roll is committed at cycle start, so every outcome has to fit before it may
|
||||||
|
// begin: one full buffer blocks it whatever room the others have (REQ-MAT-CYCLE).
|
||||||
|
const RecipeDef* reprocessingRecipe = nullptr;
|
||||||
|
for (const RecipeDef& r : f.cfg.recipes.recipes)
|
||||||
|
{
|
||||||
|
if (r.building == BuildingType::ReprocessingPlant && !r.inputs.empty())
|
||||||
|
{
|
||||||
|
reprocessingRecipe = &r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
REQUIRE(reprocessingRecipe != nullptr);
|
||||||
|
REQUIRE(reprocessingRecipe->outputs.size() >= 2);
|
||||||
|
|
||||||
|
Building plant; plant.type = BuildingType::ReprocessingPlant;
|
||||||
|
plant.recipeId = reprocessingRecipe->id;
|
||||||
|
initBuffers(plant, *reprocessingRecipe);
|
||||||
|
for (const RecipeIngredient& ing : reprocessingRecipe->inputs)
|
||||||
|
{
|
||||||
|
plant.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every buffer empty: whatever the roll turns out to be, it fits -> green.
|
||||||
|
REQUIRE(statusOf(plant) == ProductionStatus::Producing);
|
||||||
|
|
||||||
|
// Fill one outcome's buffer and leave the rest untouched -> yellow, even though
|
||||||
|
// the other outcomes still have room.
|
||||||
|
const std::string firstItemId = reprocessingRecipe->outputs.front().item;
|
||||||
|
const std::string lastItemId = reprocessingRecipe->outputs.back().item;
|
||||||
|
for (int i = 0; i < plant.outputBuffer.caps.at(ItemType{firstItemId}); ++i)
|
||||||
|
{
|
||||||
|
plant.outputBuffer.items.push_back(makeItem(firstItemId));
|
||||||
|
}
|
||||||
|
REQUIRE(outputBufferHasRoom(plant, ItemType{lastItemId}, 1));
|
||||||
|
REQUIRE_FALSE(outputBufferHasRoom(plant, ItemType{firstItemId}, 1));
|
||||||
|
REQUIRE(statusOf(plant) == ProductionStatus::Blocked);
|
||||||
|
|
||||||
|
// Without the scrap it is starved regardless of the buffers.
|
||||||
|
plant.inputBuffer.counts.clear();
|
||||||
|
REQUIRE(statusOf(plant) == ProductionStatus::Starved);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("Smelter: grey until it has a recipe, then judged like any other building")
|
||||||
|
{
|
||||||
|
// It holds no recipe until one is offered to it, so grey now applies to it too
|
||||||
|
// (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT).
|
||||||
Building smelter; smelter.type = BuildingType::Smelter;
|
Building smelter; smelter.type = BuildingType::Smelter;
|
||||||
// No player-selectable recipe and empty inputs -> red, not grey.
|
REQUIRE(statusOf(smelter) == ProductionStatus::Unconfigured);
|
||||||
REQUIRE(statusOf(smelter) == ProductionStatus::Starved);
|
|
||||||
|
const RecipeDef* smelterRecipe = nullptr;
|
||||||
|
for (const RecipeDef& r : f.cfg.recipes.recipes)
|
||||||
|
{
|
||||||
|
if (r.building == BuildingType::Smelter && !r.inputs.empty())
|
||||||
|
{
|
||||||
|
smelterRecipe = &r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
REQUIRE(smelterRecipe != nullptr);
|
||||||
|
|
||||||
|
smelter.recipeId = smelterRecipe->id;
|
||||||
|
initBuffers(smelter, *smelterRecipe);
|
||||||
|
REQUIRE(statusOf(smelter) == ProductionStatus::Starved); // recipe, but no ore
|
||||||
|
|
||||||
|
for (const RecipeIngredient& ing : smelterRecipe->inputs)
|
||||||
|
{
|
||||||
|
smelter.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
|
||||||
|
}
|
||||||
|
REQUIRE(statusOf(smelter) == ProductionStatus::Producing);
|
||||||
}
|
}
|
||||||
|
|
||||||
SECTION("Shipyard: unconfigured, then starved without materials, then producing")
|
SECTION("Shipyard: unconfigured, then starved without materials, then producing")
|
||||||
@@ -1567,7 +2019,7 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
|||||||
SECTION("Salvage Bay: red when empty, green when holding scrap")
|
SECTION("Salvage Bay: red when empty, green when holding scrap")
|
||||||
{
|
{
|
||||||
Building bay; bay.type = BuildingType::SalvageBay;
|
Building bay; bay.type = BuildingType::SalvageBay;
|
||||||
bay.outputBuffer.capacity = 20;
|
bay.outputBuffer.caps[ItemType{"scrap"}] = 20;
|
||||||
REQUIRE(statusOf(bay) == ProductionStatus::Starved); // empty -> red
|
REQUIRE(statusOf(bay) == ProductionStatus::Starved); // empty -> red
|
||||||
|
|
||||||
bay.outputBuffer.items = { makeItem("scrap") };
|
bay.outputBuffer.items = { makeItem("scrap") };
|
||||||
|
|||||||
@@ -390,39 +390,6 @@ duration_seconds = 1.0
|
|||||||
std::runtime_error);
|
std::runtime_error);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("Optional recipe icon field parses; absent leaves it unset", "[config]")
|
|
||||||
{
|
|
||||||
TempConfigDir dir;
|
|
||||||
writeFile(dir.path() / "recipes.toml", R"(
|
|
||||||
[[recipe]]
|
|
||||||
id = "with_icon"
|
|
||||||
building = "assembler"
|
|
||||||
inputs = [{item = "iron_ingot", amount = 1}]
|
|
||||||
outputs = [{item = "steel_plate", amount = 1}]
|
|
||||||
duration_seconds = 1.0
|
|
||||||
icon = "hardened_steel"
|
|
||||||
|
|
||||||
[[recipe]]
|
|
||||||
id = "without_icon"
|
|
||||||
building = "assembler"
|
|
||||||
inputs = [{item = "iron_ingot", amount = 1}]
|
|
||||||
outputs = [{item = "copper_wire", amount = 1}]
|
|
||||||
duration_seconds = 1.0
|
|
||||||
)");
|
|
||||||
|
|
||||||
const RecipesConfig cfg =
|
|
||||||
ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string());
|
|
||||||
|
|
||||||
const RecipeDef& withIcon = cfg.recipes.at(0);
|
|
||||||
REQUIRE(withIcon.id == "with_icon");
|
|
||||||
REQUIRE(withIcon.icon.has_value());
|
|
||||||
REQUIRE(*withIcon.icon == "hardened_steel");
|
|
||||||
|
|
||||||
const RecipeDef& withoutIcon = cfg.recipes.at(1);
|
|
||||||
REQUIRE(withoutIcon.id == "without_icon");
|
|
||||||
REQUIRE_FALSE(withoutIcon.icon.has_value());
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- unlock_requires (REQ-LOCK-PREREQ) ------------------------------------
|
// --- unlock_requires (REQ-LOCK-PREREQ) ------------------------------------
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "catch.hpp"
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include <QRectF>
|
||||||
#include <QSize>
|
#include <QSize>
|
||||||
#include <QVector2D>
|
#include <QVector2D>
|
||||||
|
|
||||||
@@ -210,17 +211,16 @@ TEST_CASE("entityAtWorldPos never returns debris", "[debris]")
|
|||||||
REQUIRE((entityAtWorldPos(admin, QVector2D(3.0f, 4.0f)) == entt::null));
|
REQUIRE((entityAtWorldPos(admin, QVector2D(3.0f, 4.0f)) == entt::null));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("debrisInBox returns exactly the debris inside the tile rectangle", "[debris]")
|
TEST_CASE("debrisInBox returns exactly the debris the box encloses", "[debris]")
|
||||||
{
|
{
|
||||||
EntityAdmin admin;
|
EntityAdmin admin;
|
||||||
DebrisSystem ss(admin);
|
DebrisSystem ss(admin);
|
||||||
|
|
||||||
const entt::entity inA = ss.spawn(QVector2D(1.2f, 2.7f), 1, 100); // tile (1,2)
|
const entt::entity inA = ss.spawn(QVector2D(1.2f, 2.7f), 1, 100);
|
||||||
const entt::entity inB = ss.spawn(QVector2D(4.9f, 5.1f), 1, 100); // tile (4,5)
|
const entt::entity inB = ss.spawn(QVector2D(4.9f, 5.1f), 1, 100);
|
||||||
const entt::entity outX = ss.spawn(QVector2D(10.0f, 10.0f), 1, 100);
|
const entt::entity outX = ss.spawn(QVector2D(10.0f, 10.0f), 1, 100);
|
||||||
|
|
||||||
// Box given in reversed corner order to confirm normalization.
|
const std::vector<entt::entity> hit = debrisInBox(admin, QRectF(0.0, 0.0, 6.0, 6.0));
|
||||||
const std::vector<entt::entity> hit = debrisInBox(admin, QPoint(5, 5), QPoint(0, 0));
|
|
||||||
|
|
||||||
REQUIRE(hit.size() == 2);
|
REQUIRE(hit.size() == 2);
|
||||||
REQUIRE(contains(hit, inA));
|
REQUIRE(contains(hit, inA));
|
||||||
@@ -228,6 +228,23 @@ TEST_CASE("debrisInBox returns exactly the debris inside the tile rectangle", "[
|
|||||||
REQUIRE_FALSE(contains(hit, outX));
|
REQUIRE_FALSE(contains(hit, outX));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("debrisInBox cuts within a tile, not along the tile grid", "[debris]")
|
||||||
|
{
|
||||||
|
EntityAdmin admin;
|
||||||
|
DebrisSystem ss(admin);
|
||||||
|
|
||||||
|
const entt::entity inTile = ss.spawn(QVector2D(1.8f, 2.5f), 1, 100);
|
||||||
|
// Same tile (1,2) as the piece above, but on the far side of the box's left edge:
|
||||||
|
// a tile-snapped box would take both (REQ-UI-MULTI-SELECT, Coverage).
|
||||||
|
const entt::entity outTile = ss.spawn(QVector2D(1.2f, 2.5f), 1, 100);
|
||||||
|
|
||||||
|
const std::vector<entt::entity> hit = debrisInBox(admin, QRectF(1.5, 2.0, 4.0, 4.0));
|
||||||
|
|
||||||
|
REQUIRE(hit.size() == 1);
|
||||||
|
REQUIRE(contains(hit, inTile));
|
||||||
|
REQUIRE_FALSE(contains(hit, outTile));
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("actorsInBox returns living ships and stations, excluding debris and dead actors",
|
TEST_CASE("actorsInBox returns living ships and stations, excluding debris and dead actors",
|
||||||
"[actor]")
|
"[actor]")
|
||||||
{
|
{
|
||||||
@@ -236,10 +253,10 @@ TEST_CASE("actorsInBox returns living ships and stations, excluding debris and d
|
|||||||
// Two living ships inside the box: one player, one enemy.
|
// Two living ships inside the box: one player, one enemy.
|
||||||
const entt::entity playerShip = admin.spawnShip(
|
const entt::entity playerShip = admin.spawnShip(
|
||||||
QVector2D(1.5f, 2.5f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
|
QVector2D(1.5f, 2.5f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
|
||||||
"fighter", false); // tile (1,2)
|
"fighter", false);
|
||||||
const entt::entity enemyShip = admin.spawnShip(
|
const entt::entity enemyShip = admin.spawnShip(
|
||||||
QVector2D(4.2f, 5.8f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
|
QVector2D(4.2f, 5.8f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
|
||||||
"raider", true); // tile (4,5)
|
"raider", true);
|
||||||
|
|
||||||
// A dead ship inside the box is excluded.
|
// A dead ship inside the box is excluded.
|
||||||
const entt::entity deadShip = admin.spawnShip(
|
const entt::entity deadShip = admin.spawnShip(
|
||||||
@@ -251,7 +268,7 @@ TEST_CASE("actorsInBox returns living ships and stations, excluding debris and d
|
|||||||
QVector2D(20.0f, 20.0f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
|
QVector2D(20.0f, 20.0f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
|
||||||
"fighter", false);
|
"fighter", false);
|
||||||
|
|
||||||
// A station is included when any body cell lies inside the box.
|
// A station is included when the box overlaps any body cell.
|
||||||
const std::vector<QPoint> stationCells{ QPoint(2, 2), QPoint(3, 2) };
|
const std::vector<QPoint> stationCells{ QPoint(2, 2), QPoint(3, 2) };
|
||||||
const entt::entity station = admin.spawnStation(
|
const entt::entity station = admin.spawnStation(
|
||||||
QPoint(2, 2), QSize(2, 1), stationCells, 200.0f, 200.0f, true);
|
QPoint(2, 2), QSize(2, 1), stationCells, 200.0f, 200.0f, true);
|
||||||
@@ -260,7 +277,7 @@ TEST_CASE("actorsInBox returns living ships and stations, excluding debris and d
|
|||||||
admin.spawnDebris(QVector2D(1.0f, 1.0f), 5, Tick(1000));
|
admin.spawnDebris(QVector2D(1.0f, 1.0f), 5, Tick(1000));
|
||||||
admin.spawnHqProxy(QVector2D(0.5f, 0.5f), 500.0f, 500.0f);
|
admin.spawnHqProxy(QVector2D(0.5f, 0.5f), 500.0f, 500.0f);
|
||||||
|
|
||||||
const std::vector<entt::entity> hit = actorsInBox(admin, QPoint(5, 5), QPoint(0, 0));
|
const std::vector<entt::entity> hit = actorsInBox(admin, QRectF(0.0, 0.0, 6.0, 6.0));
|
||||||
|
|
||||||
REQUIRE(hit.size() == 3);
|
REQUIRE(hit.size() == 3);
|
||||||
REQUIRE(contains(hit, playerShip));
|
REQUIRE(contains(hit, playerShip));
|
||||||
@@ -269,3 +286,32 @@ TEST_CASE("actorsInBox returns living ships and stations, excluding debris and d
|
|||||||
REQUIRE_FALSE(contains(hit, deadShip));
|
REQUIRE_FALSE(contains(hit, deadShip));
|
||||||
REQUIRE_FALSE(contains(hit, outsideShip));
|
REQUIRE_FALSE(contains(hit, outsideShip));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("actorsInBox takes a ship by its position and a station by its footprint",
|
||||||
|
"[actor]")
|
||||||
|
{
|
||||||
|
EntityAdmin admin;
|
||||||
|
|
||||||
|
// Ship inside the tile the box only reaches into: taken, because the box contains
|
||||||
|
// its position (REQ-UI-MULTI-SELECT, Coverage).
|
||||||
|
const entt::entity shipInside = admin.spawnShip(
|
||||||
|
QVector2D(3.9f, 3.9f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
|
||||||
|
"fighter", false);
|
||||||
|
// Same tile (3,3), outside the box: a tile-snapped box would take it too.
|
||||||
|
const entt::entity shipOutside = admin.spawnShip(
|
||||||
|
QVector2D(3.1f, 3.1f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
|
||||||
|
"fighter", false);
|
||||||
|
|
||||||
|
// The box reaches 0.5 tiles into the station's only cell, which is enough: a
|
||||||
|
// station is covered when the box overlaps its footprint.
|
||||||
|
const std::vector<QPoint> stationCells{ QPoint(4, 3) };
|
||||||
|
const entt::entity station = admin.spawnStation(
|
||||||
|
QPoint(4, 3), QSize(1, 1), stationCells, 200.0f, 200.0f, true);
|
||||||
|
|
||||||
|
const std::vector<entt::entity> hit = actorsInBox(admin, QRectF(3.5, 3.5, 1.0, 1.0));
|
||||||
|
|
||||||
|
REQUIRE(hit.size() == 2);
|
||||||
|
REQUIRE(contains(hit, shipInside));
|
||||||
|
REQUIRE(contains(hit, station));
|
||||||
|
REQUIRE_FALSE(contains(hit, shipOutside));
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,3 +154,78 @@ TEST_CASE("A panel that fits on neither side is pushed inside the view", "[layou
|
|||||||
kMarginPx);
|
kMarginPx);
|
||||||
REQUIRE(placed == QRect(700, 100, 300, 200));
|
REQUIRE(placed == QRect(700, 100, 300, 200));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Where it stands once the player has dragged it (REQ-UI-SELECTION-PANEL-DRAG)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("A dragged panel stands where it was dropped", "[layout]")
|
||||||
|
{
|
||||||
|
// With room for it there, the desired top-left corner is the answer: nothing about
|
||||||
|
// the selection it describes is consulted any more.
|
||||||
|
const QRect placed = placeAtDesiredTopLeft(makeBand(), QPoint(420, 180),
|
||||||
|
QSize(300, 200), {}, kMarginPx);
|
||||||
|
REQUIRE(placed == QRect(420, 180, 300, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A panel dragged past the view's edges is pushed back inside", "[layout]")
|
||||||
|
{
|
||||||
|
// Dropped off the right edge and below the bottom, it is brought back within the
|
||||||
|
// band, as an anchored panel is.
|
||||||
|
REQUIRE(placeAtDesiredTopLeft(makeBand(), QPoint(900, 500), QSize(300, 200), {},
|
||||||
|
kMarginPx)
|
||||||
|
== QRect(700, 400, 300, 200));
|
||||||
|
|
||||||
|
// And off the left edge and above the top, the other way.
|
||||||
|
REQUIRE(placeAtDesiredTopLeft(makeBand(), QPoint(-120, -60), QSize(300, 200), {},
|
||||||
|
kMarginPx)
|
||||||
|
== QRect(0, 0, 300, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A dragged panel rises above the widget it was dropped over", "[layout]")
|
||||||
|
{
|
||||||
|
// The player drops it over the build button bar; it lands above the bar's top by the
|
||||||
|
// margin instead, the bar staying where it is (REQ-UI-BUILD-BAR).
|
||||||
|
const std::vector<QRect> occupied = { QRect(400, 520, 200, 72) };
|
||||||
|
const QRect placed = placeAtDesiredTopLeft(makeBand(), QPoint(450, 480),
|
||||||
|
QSize(300, 200), occupied, kMarginPx);
|
||||||
|
REQUIRE(placed == QRect(450, 312, 300, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A widget beside a dragged panel's column does not move it", "[layout]")
|
||||||
|
{
|
||||||
|
// Only what the panel's own column meets is in its way: the controls panel in the
|
||||||
|
// bottom-left corner leaves a panel dropped at the right edge alone
|
||||||
|
// (REQ-UI-CONTROLS-PANEL).
|
||||||
|
const std::vector<QRect> occupied = { QRect(0, 300, 260, 300) };
|
||||||
|
const QRect placed = placeAtDesiredTopLeft(makeBand(), QPoint(600, 380),
|
||||||
|
QSize(300, 200), occupied, kMarginPx);
|
||||||
|
REQUIRE(placed == QRect(600, 380, 300, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A dragged panel too tall for the room left is capped", "[layout]")
|
||||||
|
{
|
||||||
|
// Capping is the caller's cue to scroll here too: a card asking for 700 px over a bar
|
||||||
|
// that leaves 512 px of band gets what there is, from the top of the band.
|
||||||
|
const std::vector<QRect> occupied = { QRect(400, 520, 200, 72) };
|
||||||
|
const QRect placed = placeAtDesiredTopLeft(makeBand(), QPoint(450, 100),
|
||||||
|
QSize(300, 700), occupied, kMarginPx);
|
||||||
|
REQUIRE(placed == QRect(450, 0, 300, 512));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A dragged panel returns to where it was dropped", "[layout]")
|
||||||
|
{
|
||||||
|
// The resolution never writes back to the desired position: a panel lifted above the
|
||||||
|
// build button bar goes back to the point the player dropped it at as soon as the bar
|
||||||
|
// stops meeting its column -- here because the bar's button set shrank.
|
||||||
|
const QPoint desired(450, 380);
|
||||||
|
const std::vector<QRect> wideBar = { QRect(400, 520, 200, 72) };
|
||||||
|
REQUIRE(placeAtDesiredTopLeft(makeBand(), desired, QSize(300, 200), wideBar,
|
||||||
|
kMarginPx)
|
||||||
|
== QRect(450, 312, 300, 200));
|
||||||
|
|
||||||
|
const std::vector<QRect> narrowBar = { QRect(400, 520, 40, 72) };
|
||||||
|
REQUIRE(placeAtDesiredTopLeft(makeBand(), desired, QSize(300, 200), narrowBar,
|
||||||
|
kMarginPx)
|
||||||
|
== QRect(450, 380, 300, 200));
|
||||||
|
}
|
||||||
|
|||||||
@@ -266,9 +266,7 @@ void BlueprintSelectionDialog::rebuildGrid()
|
|||||||
|
|
||||||
// Block icon shown to the right of each cost (REQ-UI-BLUEPRINT-CARD); null when no
|
// Block icon shown to the right of each cost (REQ-UI-BLUEPRINT-CARD); null when no
|
||||||
// building_block icon exists, in which case the cost is the bare number.
|
// building_block icon exists, in which case the cost is the bare number.
|
||||||
const QPixmap blockIcon = m_itemIcons->hasIcon(kBlockItemId)
|
const QPixmap blockIcon = m_itemIcons->getInlineIcon(kBlockItemId, font());
|
||||||
? m_itemIcons->getPixmap(kBlockItemId, QFontMetrics(font()).height())
|
|
||||||
: QPixmap();
|
|
||||||
|
|
||||||
const QSize cardSize = getCardSize(font());
|
const QSize cardSize = getCardSize(font());
|
||||||
|
|
||||||
|
|||||||
@@ -175,9 +175,7 @@ BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
|
|||||||
|
|
||||||
// Block icon shown to the right of each button's cost (REQ-UI-BUILD-COST); null
|
// Block icon shown to the right of each button's cost (REQ-UI-BUILD-COST); null
|
||||||
// when no building_block icon exists, in which case the cost is the bare number.
|
// when no building_block icon exists, in which case the cost is the bare number.
|
||||||
const QPixmap blockIcon = m_itemIcons->hasIcon(kBlockItemId)
|
const QPixmap blockIcon = m_itemIcons->getInlineIcon(kBlockItemId, font());
|
||||||
? m_itemIcons->getPixmap(kBlockItemId, QFontMetrics(font()).height())
|
|
||||||
: QPixmap();
|
|
||||||
|
|
||||||
for (const BuildingDef& def : config->buildings.buildings)
|
for (const BuildingDef& def : config->buildings.buildings)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h
|
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h
|
${CMAKE_CURRENT_SOURCE_DIR}/RecipeLineRow.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/OptionButton.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemProducers.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.h
|
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.h
|
||||||
@@ -59,7 +61,9 @@ SET(SRCS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/RecipeLineRow.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/OptionButton.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemProducers.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.cpp
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
|||||||
, m_debugDraw(false)
|
, m_debugDraw(false)
|
||||||
, m_rng(std::random_device{}())
|
, m_rng(std::random_device{}())
|
||||||
, m_boxSelecting(false)
|
, m_boxSelecting(false)
|
||||||
|
, m_boxDragMoved(false)
|
||||||
, m_gameOverShown(false)
|
, m_gameOverShown(false)
|
||||||
, m_schematicChoiceShown(false)
|
, m_schematicChoiceShown(false)
|
||||||
{
|
{
|
||||||
@@ -294,12 +295,11 @@ void GameWorldView::onFrame()
|
|||||||
const bool viewMoved =
|
const bool viewMoved =
|
||||||
m_camera.advance(m_panDirection, elapsed, getScrollBounds());
|
m_camera.advance(m_panDirection, elapsed, getScrollBounds());
|
||||||
|
|
||||||
// While the view scrolls, the tile under a stationary cursor changes,
|
// While the view scrolls, the world position under a stationary cursor
|
||||||
// so refresh the box-select rectangle even though no mouse move fires.
|
// changes, so refresh the box even though no mouse move fires.
|
||||||
if (m_boxSelecting && viewMoved)
|
if (m_boxSelecting && viewMoved)
|
||||||
{
|
{
|
||||||
m_boxCurrentTile =
|
updateBoxDrag(mapFromGlobal(QCursor::pos()));
|
||||||
getCoordinates().widgetToTile(mapFromGlobal(QCursor::pos()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,8 +420,14 @@ void GameWorldView::paintGL()
|
|||||||
|
|
||||||
WorldRenderFrame GameWorldView::makeRenderFrame() const
|
WorldRenderFrame GameWorldView::makeRenderFrame() const
|
||||||
{
|
{
|
||||||
return WorldRenderFrame{m_selection, m_buildMode, m_activeBeams, m_boxSelecting,
|
// A box only reaches the renderer once the gesture reads as a drag: below the
|
||||||
m_boxStartTile, m_boxCurrentTile, m_debugDraw};
|
// threshold there is nothing to draw and nothing the box marks that hovering does
|
||||||
|
// not mark already (REQ-UI-MULTI-SELECT).
|
||||||
|
std::optional<QRectF> boxWorldRect;
|
||||||
|
if (m_boxSelecting && m_boxDragMoved) { boxWorldRect = getBoxWorldRect(); }
|
||||||
|
|
||||||
|
return WorldRenderFrame{m_selection, m_buildMode, m_activeBeams, boxWorldRect,
|
||||||
|
m_debugDraw};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1221,9 +1227,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
|||||||
case ControlAction::ToggleDeconstruct:
|
case ControlAction::ToggleDeconstruct:
|
||||||
// Start a deconstruct box drag; a plain click resolves as a 1x1 box on
|
// Start a deconstruct box drag; a plain click resolves as a 1x1 box on
|
||||||
// release (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX).
|
// release (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX).
|
||||||
m_boxSelecting = true;
|
m_boxSelecting = true;
|
||||||
m_boxStartTile = tile;
|
m_boxStartWorld = coordinates.widgetToWorld(event->pos());
|
||||||
m_boxCurrentTile = tile;
|
m_boxCurrentWorld = m_boxStartWorld;
|
||||||
|
m_boxDragMoved = false;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ControlAction::Select:
|
case ControlAction::Select:
|
||||||
@@ -1236,8 +1243,9 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
|||||||
// selectAtPoint has already cleared the selection unless Ctrl is
|
// selectAtPoint has already cleared the selection unless Ctrl is
|
||||||
// preserving it for an additive drag.
|
// preserving it for an additive drag.
|
||||||
m_boxSelecting = true;
|
m_boxSelecting = true;
|
||||||
m_boxStartTile = tile;
|
m_boxStartWorld = coordinates.widgetToWorld(event->pos());
|
||||||
m_boxCurrentTile = tile;
|
m_boxCurrentWorld = m_boxStartWorld;
|
||||||
|
m_boxDragMoved = false;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -1290,8 +1298,10 @@ void GameWorldView::selectInBox(bool additive)
|
|||||||
// mode: a Ctrl box adds and never deselects, where a Ctrl click toggles.
|
// mode: a Ctrl box adds and never deselects, where a Ctrl click toggles.
|
||||||
const SelectionMode mode = additive ? SelectionMode::Add : SelectionMode::Replace;
|
const SelectionMode mode = additive ? SelectionMode::Add : SelectionMode::Replace;
|
||||||
|
|
||||||
|
const QRectF worldBox = getBoxWorldRect();
|
||||||
|
|
||||||
const std::vector<BuildingId> boxIds =
|
const std::vector<BuildingId> boxIds =
|
||||||
buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile);
|
buildingsInBox(m_sim->getFactoryState(), worldBox);
|
||||||
if (!boxIds.empty())
|
if (!boxIds.empty())
|
||||||
{
|
{
|
||||||
publishSelectionAnchor(mode, boxIds, {}, {});
|
publishSelectionAnchor(mode, boxIds, {}, {});
|
||||||
@@ -1299,10 +1309,8 @@ void GameWorldView::selectInBox(bool additive)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::vector<entt::entity> boxActors =
|
const std::vector<entt::entity> boxActors = actorsInBox(m_sim->getAdmin(), worldBox);
|
||||||
actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
|
const std::vector<entt::entity> boxDebris = debrisInBox(m_sim->getAdmin(), worldBox);
|
||||||
const std::vector<entt::entity> boxDebris =
|
|
||||||
debrisInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
|
|
||||||
if (!boxActors.empty() || !boxDebris.empty())
|
if (!boxActors.empty() || !boxDebris.empty())
|
||||||
{
|
{
|
||||||
publishSelectionAnchor(mode, {}, boxActors, boxDebris);
|
publishSelectionAnchor(mode, {}, boxActors, boxDebris);
|
||||||
@@ -1314,6 +1322,35 @@ void GameWorldView::selectInBox(bool additive)
|
|||||||
if (!additive) { m_selection.clearAll(); }
|
if (!additive) { m_selection.clearAll(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GameWorldView::updateBoxDrag(QPoint cursorWidgetPos)
|
||||||
|
{
|
||||||
|
const WorldCoordinates coordinates = getCoordinates();
|
||||||
|
m_boxCurrentWorld = coordinates.widgetToWorld(cursorWidgetPos);
|
||||||
|
|
||||||
|
// Measured against where the anchor sits on screen right now, not against where
|
||||||
|
// the button went down: a view that scrolls under a held button moves the anchor
|
||||||
|
// away from a motionless cursor, and that is a drag as much as moving the mouse
|
||||||
|
// is (REQ-UI-MULTI-SELECT).
|
||||||
|
const QPointF anchorWidgetPos = coordinates.worldToWidget(m_boxStartWorld);
|
||||||
|
const qreal travel_px = std::abs(cursorWidgetPos.x() - anchorWidgetPos.x())
|
||||||
|
+ std::abs(cursorWidgetPos.y() - anchorWidgetPos.y());
|
||||||
|
if (travel_px >= kBoxDragThresholdPixels) { m_boxDragMoved = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
QRectF GameWorldView::getBoxWorldRect() const
|
||||||
|
{
|
||||||
|
if (!m_boxDragMoved)
|
||||||
|
{
|
||||||
|
// Still a click: the rectangle it spans has no area and would cover nothing,
|
||||||
|
// so the box is the whole tile the button went down on instead — what the
|
||||||
|
// click points at (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-CLICK).
|
||||||
|
return QRectF(std::floor(m_boxStartWorld.x()), std::floor(m_boxStartWorld.y()),
|
||||||
|
1.0, 1.0);
|
||||||
|
}
|
||||||
|
return QRectF(QPointF(m_boxStartWorld.x(), m_boxStartWorld.y()),
|
||||||
|
QPointF(m_boxCurrentWorld.x(), m_boxCurrentWorld.y())).normalized();
|
||||||
|
}
|
||||||
|
|
||||||
void GameWorldView::publishSelectionAnchor(SelectionMode mode,
|
void GameWorldView::publishSelectionAnchor(SelectionMode mode,
|
||||||
const std::vector<BuildingId>& buildings,
|
const std::vector<BuildingId>& buildings,
|
||||||
const std::vector<entt::entity>& actors,
|
const std::vector<entt::entity>& actors,
|
||||||
@@ -1392,11 +1429,11 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event)
|
|||||||
else if (m_buildMode.isDeconstructMode())
|
else if (m_buildMode.isDeconstructMode())
|
||||||
{
|
{
|
||||||
m_buildMode.setDeconstructHoverBuildingId(buildingAtTile(tile));
|
m_buildMode.setDeconstructHoverBuildingId(buildingAtTile(tile));
|
||||||
if (m_boxSelecting) { m_boxCurrentTile = tile; }
|
if (m_boxSelecting) { updateBoxDrag(event->pos()); }
|
||||||
}
|
}
|
||||||
else if (m_boxSelecting)
|
else if (m_boxSelecting)
|
||||||
{
|
{
|
||||||
m_boxCurrentTile = tile;
|
updateBoxDrag(event->pos());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1417,7 +1454,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
|||||||
m_boxSelecting = false;
|
m_boxSelecting = false;
|
||||||
|
|
||||||
const std::vector<BuildingId> boxIds =
|
const std::vector<BuildingId> boxIds =
|
||||||
buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile);
|
buildingsInBox(m_sim->getFactoryState(), getBoxWorldRect());
|
||||||
|
|
||||||
const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0;
|
const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0;
|
||||||
const ControlAction dragAction =
|
const ControlAction dragAction =
|
||||||
|
|||||||
@@ -226,6 +226,17 @@ private:
|
|||||||
// which is the only case that goes on to start a box drag.
|
// which is the only case that goes on to start a box drag.
|
||||||
bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive);
|
bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive);
|
||||||
void selectInBox(bool additive);
|
void selectInBox(bool additive);
|
||||||
|
// Moves the running box drag's far corner to the world position under
|
||||||
|
// `cursorWidgetPos` and, once the cursor sits far enough from where the anchor is
|
||||||
|
// drawn, promotes the gesture from a click to a drag. Every corner update goes
|
||||||
|
// through here, including the ones a scrolling view causes under a cursor that
|
||||||
|
// has not moved (REQ-UI-MULTI-SELECT).
|
||||||
|
void updateBoxDrag(QPoint cursorWidgetPos);
|
||||||
|
// The box the drag currently spans, in world coordinates and normalized: the
|
||||||
|
// rectangle between its two corners once it reads as a drag, and the whole tile
|
||||||
|
// the button went down on before that (REQ-UI-MULTI-SELECT). Both what is drawn
|
||||||
|
// and what is selected come from here, so they can never disagree.
|
||||||
|
QRectF getBoxWorldRect() const;
|
||||||
// Publishes where on the screen the selection about to be made sits, so the
|
// Publishes where on the screen the selection about to be made sits, so the
|
||||||
// selection panel can be placed beside it (REQ-UI-SELECTION-PANEL). Called with
|
// selection panel can be placed beside it (REQ-UI-SELECTION-PANEL). Called with
|
||||||
// what is about to be selected, immediately before selecting it, and publishes
|
// what is about to be selected, immediately before selecting it, and publishes
|
||||||
@@ -260,6 +271,11 @@ private:
|
|||||||
// paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM).
|
// paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM).
|
||||||
static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3);
|
static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3);
|
||||||
|
|
||||||
|
// How far the cursor must travel from the press position, in widget pixels
|
||||||
|
// (Manhattan distance), before a box drag shows its rectangle
|
||||||
|
// (REQ-UI-MULTI-SELECT).
|
||||||
|
static constexpr int kBoxDragThresholdPixels = 2;
|
||||||
|
|
||||||
Simulation* m_sim;
|
Simulation* m_sim;
|
||||||
const GameConfig* m_config;
|
const GameConfig* m_config;
|
||||||
const VisualsConfig* m_visuals;
|
const VisualsConfig* m_visuals;
|
||||||
@@ -308,8 +324,17 @@ private:
|
|||||||
// Not owned; set after construction, so null until MainWindow has built it.
|
// Not owned; set after construction, so null until MainWindow has built it.
|
||||||
const BlueprintLibrary* m_blueprintLibrary = nullptr;
|
const BlueprintLibrary* m_blueprintLibrary = nullptr;
|
||||||
bool m_boxSelecting;
|
bool m_boxSelecting;
|
||||||
QPoint m_boxStartTile;
|
// The drag's two corners in world coordinates, unsnapped: where the button went
|
||||||
QPoint m_boxCurrentTile;
|
// down and where the cursor is now (REQ-UI-MULTI-SELECT). World rather than
|
||||||
|
// widget coordinates so the anchor keeps the spot in the world it was placed on
|
||||||
|
// when the view scrolls under a held button.
|
||||||
|
QVector2D m_boxStartWorld;
|
||||||
|
QVector2D m_boxCurrentWorld;
|
||||||
|
// Whether the cursor has moved far enough from the anchor for this to read as a
|
||||||
|
// drag. Until it has, the rectangle is not drawn and the box resolves as the
|
||||||
|
// whole anchor tile (REQ-UI-MULTI-SELECT). Sticky for the rest of the drag, so
|
||||||
|
// coming back to the press position does not hide the rectangle again.
|
||||||
|
bool m_boxDragMoved;
|
||||||
|
|
||||||
// Interprets this widget's key events into semantic actions and publishes them
|
// Interprets this widget's key events into semantic actions and publishes them
|
||||||
// (REQ-UI-HOTKEYS). Owned here for now because this is the widget that holds
|
// (REQ-UI-HOTKEYS). Owned here for now because this is the widget that holds
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include <QFontMetrics>
|
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QIcon>
|
#include <QIcon>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
@@ -120,19 +119,11 @@ void HeaderBar::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> /*e
|
|||||||
updateExpandButton();
|
updateExpandButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
QPixmap HeaderBar::blockIcon() const
|
|
||||||
{
|
|
||||||
if (!m_itemIcons->hasIcon(kBlockItemId)) { return QPixmap(); }
|
|
||||||
// Sized to the header text height so it sits inline with the caption.
|
|
||||||
const int sizePx = QFontMetrics(font()).height();
|
|
||||||
return m_itemIcons->getPixmap(kBlockItemId, sizePx);
|
|
||||||
}
|
|
||||||
|
|
||||||
void HeaderBar::updateBlocksLabel()
|
void HeaderBar::updateBlocksLabel()
|
||||||
{
|
{
|
||||||
const int blocks = m_sim->getBuildingBlocksStock();
|
const int blocks = m_sim->getBuildingBlocksStock();
|
||||||
|
|
||||||
const QPixmap icon = blockIcon();
|
const QPixmap icon = m_itemIcons->getInlineIcon(kBlockItemId, font());
|
||||||
if (icon.isNull())
|
if (icon.isNull())
|
||||||
{
|
{
|
||||||
// Fallback text form when no building_block icon exists (REQ-UI-BLOCKS-ICON).
|
// Fallback text form when no building_block icon exists (REQ-UI-BLOCKS-ICON).
|
||||||
@@ -151,7 +142,7 @@ void HeaderBar::updateExpandButton()
|
|||||||
|
|
||||||
m_expandButton->setEnabled(blocks >= expansionCost);
|
m_expandButton->setEnabled(blocks >= expansionCost);
|
||||||
|
|
||||||
const QPixmap icon = blockIcon();
|
const QPixmap icon = m_itemIcons->getInlineIcon(kBlockItemId, font());
|
||||||
if (icon.isNull())
|
if (icon.isNull())
|
||||||
{
|
{
|
||||||
// Fallback text form when no building_block icon exists (REQ-UI-EXPAND-BUTTON).
|
// Fallback text form when no building_block icon exists (REQ-UI-EXPAND-BUTTON).
|
||||||
|
|||||||
@@ -61,10 +61,6 @@ private:
|
|||||||
// (REQ-UI-BLOCKS-ICON).
|
// (REQ-UI-BLOCKS-ICON).
|
||||||
void updateBlocksLabel();
|
void updateBlocksLabel();
|
||||||
|
|
||||||
// The building_block icon at the header's text height, or a null pixmap when no
|
|
||||||
// icon file exists. Loaded once via m_itemIcons on first use.
|
|
||||||
QPixmap blockIcon() const;
|
|
||||||
|
|
||||||
QLabel* m_timeLabel;
|
QLabel* m_timeLabel;
|
||||||
QLabel* m_blocksLabel;
|
QLabel* m_blocksLabel;
|
||||||
QLabel* m_artifactsLabel;
|
QLabel* m_artifactsLabel;
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
|
|||||||
// their raw size is not the size to lay them out at.
|
// their raw size is not the size to lay them out at.
|
||||||
QSize getLogicalSize(const QPixmap& pixmap);
|
QSize getLogicalSize(const QPixmap& pixmap);
|
||||||
|
|
||||||
// Item id of the building blocks resource, whose icon stands in for the "Blocks"
|
// The items whose icons stand in for their names beside a number, drawn bare and without
|
||||||
// word wherever a cost or stock is captioned (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST,
|
// their colored square (REQ-UI-ITEM-ICON): building blocks beside a cost or a stock
|
||||||
// REQ-UI-EXPAND-BUTTON). It lives next to the caption helper because every caller of
|
// (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST, REQ-UI-EXPAND-BUTTON), and scrap beside the
|
||||||
// one is a caller of the other.
|
// amount left in debris (REQ-UI-DEBRIS-PANEL, REQ-UI-FIELD-MULTI-SELECTION). They live
|
||||||
|
// next to the caption helper because every caller of one is a caller of the other;
|
||||||
|
// ItemIconCache::getInlineIcon() is what turns an id here into that icon.
|
||||||
const char* const kBlockItemId = "building_block";
|
const char* const kBlockItemId = "building_block";
|
||||||
|
const char* const kScrapItemId = "scrap";
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
#include "ItemIconCache.h"
|
#include "ItemIconCache.h"
|
||||||
|
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QFontMetrics>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
|
#include <QRectF>
|
||||||
#include <QSvgRenderer>
|
#include <QSvgRenderer>
|
||||||
|
|
||||||
ItemIconCache::ItemIconCache(const QString& iconDir)
|
#include "VisualsConfig.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// How far the icon is inset within the item's colored square, as a fraction of the
|
||||||
|
// square's size on each side (REQ-UI-ITEM-ICON). The inset is what keeps a frame of the
|
||||||
|
// square's color visible all around the icon: each icon's viewBox is cropped tight to
|
||||||
|
// its artwork, so an icon drawn at the full rect would cover the square entirely.
|
||||||
|
const double kIconInsetFraction = 0.15;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
ItemIconCache::ItemIconCache(const QString& iconDir, const VisualsConfig* visuals)
|
||||||
: m_iconDir(iconDir)
|
: m_iconDir(iconDir)
|
||||||
|
, m_visuals(visuals)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,14 +51,65 @@ bool ItemIconCache::hasIcon(const std::string& itemId)
|
|||||||
return !getSvg(itemId).isEmpty();
|
return !getSvg(itemId).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ItemIconCache::paintItem(QPainter& painter, const QRectF& rect,
|
||||||
|
const std::string& itemId)
|
||||||
|
{
|
||||||
|
// The colored square from visuals.toml backs every item, icon or not: it is what
|
||||||
|
// gives the item contrast against the tile beneath it in the world, and its outline
|
||||||
|
// is what separates neighbouring items where they overlap on a belt (REQ-GW-TILE-SIZE,
|
||||||
|
// REQ-UI-ITEM-ICON).
|
||||||
|
if (m_visuals != nullptr)
|
||||||
|
{
|
||||||
|
const std::map<std::string, ItemVisuals>::const_iterator it =
|
||||||
|
m_visuals->items.find(itemId);
|
||||||
|
if (it != m_visuals->items.end())
|
||||||
|
{
|
||||||
|
painter.fillRect(rect, it->second.fill);
|
||||||
|
painter.setPen(QPen(it->second.outline, 1));
|
||||||
|
painter.setBrush(Qt::NoBrush);
|
||||||
|
painter.drawRect(rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasIcon(itemId)) { return; }
|
||||||
|
|
||||||
|
// The icon goes on top, inset so a frame of the square's color stays visible all
|
||||||
|
// around it. It is rasterized once at the inset pixel size and cached, so this is a
|
||||||
|
// plain pixmap blit per frame.
|
||||||
|
const double inset = kIconInsetFraction * rect.width();
|
||||||
|
const QRectF iconRect = rect.adjusted(inset, inset, -inset, -inset);
|
||||||
|
|
||||||
|
int sizePx = qRound(iconRect.width());
|
||||||
|
if (sizePx < 1) { sizePx = 1; }
|
||||||
|
painter.drawPixmap(iconRect, getPixmap(itemId, sizePx),
|
||||||
|
QRectF(0, 0, sizePx, sizePx));
|
||||||
|
}
|
||||||
|
|
||||||
|
QPixmap ItemIconCache::getSquarePixmap(const std::string& itemId, int sizePx)
|
||||||
|
{
|
||||||
|
return getPixmap("square:" + itemId, itemId, sizePx, true);
|
||||||
|
}
|
||||||
|
|
||||||
QPixmap ItemIconCache::getPixmap(const std::string& itemId, int sizePx)
|
QPixmap ItemIconCache::getPixmap(const std::string& itemId, int sizePx)
|
||||||
|
{
|
||||||
|
return getPixmap(itemId, itemId, sizePx, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
QPixmap ItemIconCache::getInlineIcon(const std::string& itemId, const QFont& font)
|
||||||
|
{
|
||||||
|
if (!hasIcon(itemId)) { return QPixmap(); }
|
||||||
|
return getPixmap(itemId, QFontMetrics(font).height());
|
||||||
|
}
|
||||||
|
|
||||||
|
QPixmap ItemIconCache::getPixmap(const std::string& cacheKey, const std::string& itemId,
|
||||||
|
int sizePx, bool withSquare)
|
||||||
{
|
{
|
||||||
if (sizePx <= 0)
|
if (sizePx <= 0)
|
||||||
{
|
{
|
||||||
return QPixmap();
|
return QPixmap();
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::pair<std::string, int> key(itemId, sizePx);
|
const std::pair<std::string, int> key(cacheKey, sizePx);
|
||||||
const std::map<std::pair<std::string, int>, QPixmap>::const_iterator cached =
|
const std::map<std::pair<std::string, int>, QPixmap>::const_iterator cached =
|
||||||
m_pixmapCache.find(key);
|
m_pixmapCache.find(key);
|
||||||
if (cached != m_pixmapCache.end())
|
if (cached != m_pixmapCache.end())
|
||||||
@@ -49,16 +117,45 @@ QPixmap ItemIconCache::getPixmap(const std::string& itemId, int sizePx)
|
|||||||
return cached->second;
|
return cached->second;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QByteArray& svg = getSvg(itemId);
|
|
||||||
QPixmap pixmap;
|
QPixmap pixmap;
|
||||||
if (!svg.isEmpty())
|
if (withSquare)
|
||||||
{
|
{
|
||||||
QSvgRenderer renderer(svg);
|
// Null unless the item has something to draw -- a square, an icon, or both --
|
||||||
pixmap = QPixmap(sizePx, sizePx);
|
// so a caller with nothing to show can fall back to text (REQ-UI-ITEM-ICON).
|
||||||
pixmap.fill(Qt::transparent);
|
const bool hasSquare = m_visuals != nullptr
|
||||||
QPainter painter(&pixmap);
|
&& m_visuals->items.find(itemId) != m_visuals->items.end();
|
||||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
if (hasSquare || hasIcon(itemId))
|
||||||
renderer.render(&painter);
|
{
|
||||||
|
pixmap = QPixmap(sizePx, sizePx);
|
||||||
|
pixmap.fill(Qt::transparent);
|
||||||
|
QPainter painter(&pixmap);
|
||||||
|
// No antialiasing: the square's edges are axis-aligned and land on pixel
|
||||||
|
// boundaries, and smoothing a 1-pixel outline only blurs it. The icon is
|
||||||
|
// blitted into a slightly smaller rect than it was rasterized at, which is
|
||||||
|
// what the smooth transform is for.
|
||||||
|
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||||
|
// One pixel short of the pixmap so the square's right and bottom edges land
|
||||||
|
// inside it rather than on its border.
|
||||||
|
paintItem(painter, QRectF(0, 0, sizePx - 1, sizePx - 1), itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const QByteArray& svg = getSvg(itemId);
|
||||||
|
if (!svg.isEmpty())
|
||||||
|
{
|
||||||
|
QSvgRenderer renderer(svg);
|
||||||
|
pixmap = QPixmap(sizePx, sizePx);
|
||||||
|
pixmap.fill(Qt::transparent);
|
||||||
|
QPainter painter(&pixmap);
|
||||||
|
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||||
|
renderer.render(&painter);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return m_pixmapCache.emplace(key, std::move(pixmap)).first->second;
|
return m_pixmapCache.emplace(key, std::move(pixmap)).first->second;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ItemIconCache::clearPixmapCache()
|
||||||
|
{
|
||||||
|
m_pixmapCache.clear();
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,41 +5,89 @@
|
|||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
|
#include <QFont>
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
||||||
// Rasterizes and caches per-item icon SVGs (REQ-UI-ITEM-ICON). Item icons are
|
class QPainter;
|
||||||
// self-contained, full-color SVGs loaded from a directory, one file per item type
|
class QRectF;
|
||||||
// named after the item's id (e.g. "iron_ore.svg"). Shared by the recipe-selection
|
struct VisualsConfig;
|
||||||
// dialog (REQ-UI-RECIPE-ICON) and the game world's belt/port item rendering so the
|
|
||||||
// rasterization is not duplicated.
|
// Rasterizes and caches per-item icon SVGs, and composes them onto the item's colored
|
||||||
|
// square (REQ-UI-ITEM-ICON). Item icons are self-contained, full-color SVGs loaded from
|
||||||
|
// a directory, one file per item type named after the item's id (e.g. "iron_ore.svg").
|
||||||
//
|
//
|
||||||
// A missing icon file is not an error: hasIcon() returns false for it and the caller
|
// The square is the item's `fill` and `outline` from visuals.toml with the icon inset
|
||||||
// falls back (a colored square in the world, a name caption in the dialog).
|
// within it, and it backs the icon both in the game world and wherever the UI displays
|
||||||
|
// an item as an item: the selection panel's item chips (REQ-UI-SINGLE-SELECTION,
|
||||||
|
// REQ-UI-HQ-PANEL), and every recipe drawn as a line -- the recipe summary, the
|
||||||
|
// selection dialog's option buttons, the item production tooltip
|
||||||
|
// (REQ-UI-RECIPE-SUMMARY, REQ-UI-SELECT-OPTIONS, REQ-UI-ITEM-TOOLTIP). Defined here
|
||||||
|
// once rather than per widget, in
|
||||||
|
// two forms: paintItem() for the world's fractional geometry, getSquarePixmap() for
|
||||||
|
// widgets that want a ready-made pixmap.
|
||||||
|
//
|
||||||
|
// The bare icon of getPixmap() has one remaining use: an inline icon standing in for the
|
||||||
|
// item's name beside a number -- building blocks beside a cost or a stock
|
||||||
|
// (REQ-UI-BLOCKS-ICON), scrap beside the amount left in debris (REQ-UI-DEBRIS-PANEL) --
|
||||||
|
// which is a decoration on a line of text rather than an item display and takes no
|
||||||
|
// square. getInlineItemIcon() in IconCaption.h is how callers ask for that form.
|
||||||
|
//
|
||||||
|
// A missing icon file is not an error: hasIcon() returns false for it and the item shows
|
||||||
|
// its colored square alone.
|
||||||
class ItemIconCache
|
class ItemIconCache
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
// iconDir is the directory holding the "<item_id>.svg" icon files
|
// iconDir is the directory holding the "<item_id>.svg" icon files (typically
|
||||||
// (typically "<configDir>/../icons/items").
|
// "<configDir>/../icons/items"). visuals supplies the per-item square colors and
|
||||||
explicit ItemIconCache(const QString& iconDir);
|
// must outlive the cache; its contents may be replaced on a restart (REQ-CFG-RELOAD),
|
||||||
|
// which is what clearPixmapCache() is for.
|
||||||
|
ItemIconCache(const QString& iconDir, const VisualsConfig* visuals);
|
||||||
|
|
||||||
// True if an icon SVG file exists for the given item id. Loads the file's bytes
|
// True if an icon SVG file exists for the given item id. Loads the file's bytes
|
||||||
// on first query and remembers the result (including absence) so repeated calls
|
// on first query and remembers the result (including absence) so repeated calls
|
||||||
// are cheap.
|
// are cheap.
|
||||||
bool hasIcon(const std::string& itemId);
|
bool hasIcon(const std::string& itemId);
|
||||||
|
|
||||||
// Returns the item's icon rasterized to a transparent sizePx*sizePx pixmap,
|
// Paints the item's colored square into rect and its icon inset within it
|
||||||
// cached per (item id, size) so it is rendered once and reused across frames and
|
// (REQ-UI-ITEM-ICON). Takes the rect as a QRectF so the world can keep painting at
|
||||||
// only re-rasterized when the target size changes (REQ-UI-ITEM-ICON). Returns a
|
// sub-pixel geometry. An item with no visuals entry gets no square, one with no icon
|
||||||
// null pixmap if the item has no icon file (callers should gate on hasIcon()).
|
// file no icon; with neither, this paints nothing.
|
||||||
|
void paintItem(QPainter& painter, const QRectF& rect, const std::string& itemId);
|
||||||
|
|
||||||
|
// The same composition rasterized to a sizePx*sizePx pixmap for widget use, cached
|
||||||
|
// per (item id, size) so it is rendered once and reused. Returns a null pixmap only
|
||||||
|
// when the item has neither a visuals entry nor an icon file, which is the one case
|
||||||
|
// in which a caller has nothing to show and falls back to text.
|
||||||
|
QPixmap getSquarePixmap(const std::string& itemId, int sizePx);
|
||||||
|
|
||||||
|
// Returns the item's icon alone, without its square, rasterized to a transparent
|
||||||
|
// sizePx*sizePx pixmap and cached per (item id, size) so it is rendered once and
|
||||||
|
// reused across frames (REQ-UI-ITEM-ICON). Returns a null pixmap if the item has no
|
||||||
|
// icon file (callers should gate on hasIcon()).
|
||||||
QPixmap getPixmap(const std::string& itemId, int sizePx);
|
QPixmap getPixmap(const std::string& itemId, int sizePx);
|
||||||
|
|
||||||
|
// The same bare icon sized to the height of `font`'s text, for the inline form above:
|
||||||
|
// an icon standing in for the item's name on a line of text. Null when the item has
|
||||||
|
// no icon file, which is not an error -- the caller names the item in words instead.
|
||||||
|
QPixmap getInlineIcon(const std::string& itemId, const QFont& font);
|
||||||
|
|
||||||
|
// Drops every rasterized pixmap. Called when the visuals are reloaded on a restart
|
||||||
|
// (REQ-CFG-RELOAD), because the composed squares carry the colors they were painted
|
||||||
|
// with; they are re-rasterized on next use.
|
||||||
|
void clearPixmapCache();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Returns the raw SVG bytes for an item id, loading and caching them on first
|
// Returns the raw SVG bytes for an item id, loading and caching them on first
|
||||||
// access. An absent file caches an empty QByteArray so it is not retried.
|
// access. An absent file caches an empty QByteArray so it is not retried.
|
||||||
const QByteArray& getSvg(const std::string& itemId);
|
const QByteArray& getSvg(const std::string& itemId);
|
||||||
|
// Shared rasterize-and-cache step. cacheKey distinguishes the bare and squared
|
||||||
|
// variants of one item within the single pixmap cache.
|
||||||
|
QPixmap getPixmap(const std::string& cacheKey, const std::string& itemId,
|
||||||
|
int sizePx, bool withSquare);
|
||||||
|
|
||||||
QString m_iconDir;
|
QString m_iconDir;
|
||||||
|
const VisualsConfig* m_visuals; // Not owned; lives in MainWindow.
|
||||||
std::map<std::string, QByteArray> m_svgById;
|
std::map<std::string, QByteArray> m_svgById;
|
||||||
std::map<std::pair<std::string, int>, QPixmap> m_pixmapCache;
|
std::map<std::pair<std::string, int>, QPixmap> m_pixmapCache;
|
||||||
};
|
};
|
||||||
|
|||||||
60
src/ui/ItemProducers.cpp
Normal file
60
src/ui/ItemProducers.cpp
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#include "ItemProducers.h"
|
||||||
|
|
||||||
|
#include "GameConfig.h"
|
||||||
|
#include "RecipesConfig.h"
|
||||||
|
#include "Simulation.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
bool producesItem(const RecipeDef& recipe, const std::string& itemId)
|
||||||
|
{
|
||||||
|
for (const RecipeOutput& output : recipe.outputs)
|
||||||
|
{
|
||||||
|
if (output.item == itemId)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isAvailable(const RecipeDef& recipe, const Simulation& sim)
|
||||||
|
{
|
||||||
|
if (recipe.building == BuildingType::Miner
|
||||||
|
|| recipe.building == BuildingType::Assembler)
|
||||||
|
{
|
||||||
|
return sim.isRecipeUnlocked(recipe.id);
|
||||||
|
}
|
||||||
|
return sim.isBuildingUnlocked(recipe.building);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ItemProduction findItemProduction(const std::string& itemId, const Simulation& sim,
|
||||||
|
const GameConfig& config)
|
||||||
|
{
|
||||||
|
ItemProduction result;
|
||||||
|
|
||||||
|
bool anyProducer = false;
|
||||||
|
for (const RecipeDef& recipe : config.recipes.recipes)
|
||||||
|
{
|
||||||
|
if (!producesItem(recipe, itemId)) { continue; }
|
||||||
|
|
||||||
|
anyProducer = true;
|
||||||
|
if (isAvailable(recipe, sim))
|
||||||
|
{
|
||||||
|
result.recipes.push_back(&recipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.recipes.empty())
|
||||||
|
{
|
||||||
|
result.origin = ItemOrigin::Produced;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.origin = anyProducer ? ItemOrigin::Undiscovered : ItemOrigin::Salvaged;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
35
src/ui/ItemProducers.h
Normal file
35
src/ui/ItemProducers.h
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
struct GameConfig;
|
||||||
|
struct RecipeDef;
|
||||||
|
class Simulation;
|
||||||
|
|
||||||
|
// Where an item comes from, as far as the player has discovered (REQ-UI-ITEM-TOOLTIP).
|
||||||
|
enum class ItemOrigin
|
||||||
|
{
|
||||||
|
Produced, // at least one recipe the player can run makes it
|
||||||
|
Undiscovered, // recipes make it, but none of them is available yet
|
||||||
|
Salvaged, // no recipe makes it at all -- scrap, which comes from debris
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ItemProduction
|
||||||
|
{
|
||||||
|
ItemOrigin origin = ItemOrigin::Salvaged;
|
||||||
|
// The available producers, in config order. Empty unless origin is Produced.
|
||||||
|
std::vector<const RecipeDef*> recipes;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every way the player can currently produce the given item, for the tooltip that
|
||||||
|
// explains an item chip (REQ-UI-ITEM-TOOLTIP).
|
||||||
|
//
|
||||||
|
// What counts as available differs by building type, because only Miner and Assembler
|
||||||
|
// recipes are individually unlocked (REQ-LOCK-UI-RECIPE): those are filtered by the
|
||||||
|
// unlock state, while a Smelter's or Reprocessing Plant's recipes
|
||||||
|
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are filtered by whether their building is
|
||||||
|
// unlocked yet (REQ-LOCK-BUILDING) -- there is no sense in naming a path through a
|
||||||
|
// plant the player cannot place.
|
||||||
|
ItemProduction findItemProduction(const std::string& itemId, const Simulation& sim,
|
||||||
|
const GameConfig& config);
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QCloseEvent>
|
#include <QCloseEvent>
|
||||||
|
#include <QDialog>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QInputDialog>
|
#include <QInputDialog>
|
||||||
@@ -55,7 +56,7 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
|
|||||||
// REQ-UI-BUILD-ICON).
|
// REQ-UI-BUILD-ICON).
|
||||||
const QString configDirPath = QString::fromStdString(m_configDir);
|
const QString configDirPath = QString::fromStdString(m_configDir);
|
||||||
m_itemIcons = std::make_unique<ItemIconCache>(
|
m_itemIcons = std::make_unique<ItemIconCache>(
|
||||||
QDir::cleanPath(configDirPath + "/../icons/items"));
|
QDir::cleanPath(configDirPath + "/../icons/items"), &m_visuals);
|
||||||
m_buildingIcons = std::make_unique<BuildingIconCache>(
|
m_buildingIcons = std::make_unique<BuildingIconCache>(
|
||||||
QDir::cleanPath(configDirPath + "/../icons/buildings"));
|
QDir::cleanPath(configDirPath + "/../icons/buildings"));
|
||||||
|
|
||||||
@@ -223,7 +224,8 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
|
|||||||
ModalPauseScope pause(*m_gameWorldView);
|
ModalPauseScope pause(*m_gameWorldView);
|
||||||
|
|
||||||
ModalDimScope dim(*m_dimOverlay);
|
ModalDimScope dim(*m_dimOverlay);
|
||||||
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, this);
|
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes,
|
||||||
|
m_itemIcons.get(), m_buildingIcons.get(), this);
|
||||||
dialog.exec();
|
dialog.exec();
|
||||||
|
|
||||||
std::shared_ptr<ApplySchematicChoiceCommand> command =
|
std::shared_ptr<ApplySchematicChoiceCommand> command =
|
||||||
@@ -283,6 +285,9 @@ std::optional<GameConfig> MainWindow::reloadConfig()
|
|||||||
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
||||||
m_visuals = std::move(newVisuals);
|
m_visuals = std::move(newVisuals);
|
||||||
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
|
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
|
||||||
|
// The composed item squares carry the colors they were painted with, so they
|
||||||
|
// are dropped for the new ones to take effect (REQ-UI-ITEM-ICON).
|
||||||
|
m_itemIcons->clearPixmapCache();
|
||||||
return newConfig;
|
return newConfig;
|
||||||
}
|
}
|
||||||
catch (const std::exception& e)
|
catch (const std::exception& e)
|
||||||
@@ -313,7 +318,10 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
|
|||||||
m_layoutBlueprints,
|
m_layoutBlueprints,
|
||||||
std::move(unlockedModuleIds),
|
std::move(unlockedModuleIds),
|
||||||
m_gameWorldView->isDebugDrawEnabled(),
|
m_gameWorldView->isDebugDrawEnabled(),
|
||||||
this);
|
m_itemIcons.get(), this);
|
||||||
|
// Opened from the panel's "Configure" button (REQ-MOD-UI-PREVIEW) or straight after
|
||||||
|
// a schematic change (REQ-MOD-UI-AUTO-DIALOG), so it opens on the panel either way.
|
||||||
|
placeOnSelectionPanel(dialog);
|
||||||
if (dialog.exec() == QDialog::Accepted && dialog.getResult().has_value())
|
if (dialog.exec() == QDialog::Accepted && dialog.getResult().has_value())
|
||||||
{
|
{
|
||||||
std::shared_ptr<SetShipLayoutCommand> command =
|
std::shared_ptr<SetShipLayoutCommand> command =
|
||||||
@@ -325,6 +333,45 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::placeOnSelectionPanel(QDialog& dialog) const
|
||||||
|
{
|
||||||
|
// The panel is up whenever one of these modals opens -- they are opened from its own
|
||||||
|
// controls, and it is shown whenever anything is selected (REQ-UI-EMPTY-SELECTION).
|
||||||
|
// Were it not, there would be no rectangle to center on and Qt's own centering on
|
||||||
|
// this window stands.
|
||||||
|
if (!m_selectionPanel->isVisible()) { return; }
|
||||||
|
|
||||||
|
// The dialog has never been shown, so it is still at its default size until its
|
||||||
|
// layout has run; centering it before that would use the wrong extent.
|
||||||
|
dialog.adjustSize();
|
||||||
|
const QSize dialogSize = dialog.size();
|
||||||
|
|
||||||
|
// The panel's live geometry, so a panel the player has dragged
|
||||||
|
// (REQ-UI-SELECTION-PANEL-DRAG) carries the modal with it.
|
||||||
|
const QRect panelRect(m_selectionPanel->mapToGlobal(QPoint(0, 0)),
|
||||||
|
m_selectionPanel->size());
|
||||||
|
const QRect windowRect(mapToGlobal(QPoint(0, 0)), size());
|
||||||
|
|
||||||
|
QPoint topLeft(panelRect.center().x() - dialogSize.width() / 2,
|
||||||
|
panelRect.center().y() - dialogSize.height() / 2);
|
||||||
|
|
||||||
|
// Pushed back inside the window, never resized to fit (REQ-UI-PANEL-MODAL). The far
|
||||||
|
// edge is clamped first and the near edge second, which is what aligns a dialog too
|
||||||
|
// large for the window with the window's top-left corner rather than pushing it off
|
||||||
|
// the opposite edge.
|
||||||
|
topLeft.setX(qMax(windowRect.left(),
|
||||||
|
qMin(topLeft.x(), windowRect.right() - dialogSize.width() + 1)));
|
||||||
|
topLeft.setY(qMax(windowRect.top(),
|
||||||
|
qMin(topLeft.y(), windowRect.bottom() - dialogSize.height() + 1)));
|
||||||
|
|
||||||
|
// Positions the dialog's frame, whose size is not known until it is first shown, so
|
||||||
|
// the result sits low by the title bar height against a true center -- measuring it
|
||||||
|
// would mean showing the dialog at the wrong place first. The move also marks the
|
||||||
|
// dialog as positioned, which is what stops QDialog from centering it on this window
|
||||||
|
// when it is shown.
|
||||||
|
dialog.move(topLeft);
|
||||||
|
}
|
||||||
|
|
||||||
void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event)
|
void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event)
|
||||||
{
|
{
|
||||||
// A construction site has no Building yet; fall back to its site record so
|
// A construction site has no Building yet; fall back to its site record so
|
||||||
@@ -381,7 +428,9 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
|
|||||||
|
|
||||||
bool autoOpenLayout = false;
|
bool autoOpenLayout = false;
|
||||||
std::string chosenSchematic;
|
std::string chosenSchematic;
|
||||||
RecipeSelectionDialog dialog(options, title, m_itemIcons.get(), this);
|
RecipeSelectionDialog dialog(options, title, m_itemIcons.get(),
|
||||||
|
m_buildingIcons.get(), this);
|
||||||
|
placeOnSelectionPanel(dialog);
|
||||||
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
|
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
|
||||||
{
|
{
|
||||||
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
|
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class BlueprintLibrary;
|
|||||||
class BuildingIconCache;
|
class BuildingIconCache;
|
||||||
class ItemIconCache;
|
class ItemIconCache;
|
||||||
class QCloseEvent;
|
class QCloseEvent;
|
||||||
|
class QDialog;
|
||||||
class QResizeEvent;
|
class QResizeEvent;
|
||||||
|
|
||||||
class MainWindow : public QWidget,
|
class MainWindow : public QWidget,
|
||||||
@@ -83,6 +84,11 @@ private:
|
|||||||
const std::string& schematicId,
|
const std::string& schematicId,
|
||||||
const ShipLayoutConfig& currentLayout);
|
const ShipLayoutConfig& currentLayout);
|
||||||
|
|
||||||
|
// Centers a modal opened from the selection panel on that panel, kept inside this
|
||||||
|
// window (REQ-UI-PANEL-MODAL). Called on the constructed dialog before exec(), and
|
||||||
|
// only for the two modals the panel opens.
|
||||||
|
void placeOnSelectionPanel(QDialog& dialog) const;
|
||||||
|
|
||||||
// Runs the blueprint selection dialog and enters placement mode for whatever the
|
// Runs the blueprint selection dialog and enters placement mode for whatever the
|
||||||
// player picked (REQ-UI-BLUEPRINT-DIALOG). Holds no pause or dim scope of its own:
|
// player picked (REQ-UI-BLUEPRINT-DIALOG). Holds no pause or dim scope of its own:
|
||||||
// both callers already hold theirs, which is what keeps the dim continuous when a
|
// both callers already hold theirs, which is what keeps the dim continuous when a
|
||||||
|
|||||||
35
src/ui/OptionButton.cpp
Normal file
35
src/ui/OptionButton.cpp
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#include "OptionButton.h"
|
||||||
|
|
||||||
|
#include <QLayout>
|
||||||
|
#include <QStyle>
|
||||||
|
#include <QStyleOptionButton>
|
||||||
|
|
||||||
|
OptionButton::OptionButton(QWidget* parent)
|
||||||
|
: QPushButton(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize OptionButton::sizeHint() const
|
||||||
|
{
|
||||||
|
if (layout() == nullptr)
|
||||||
|
{
|
||||||
|
return QPushButton::sizeHint();
|
||||||
|
}
|
||||||
|
return sizeForFace(layout()->sizeHint());
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize OptionButton::minimumSizeHint() const
|
||||||
|
{
|
||||||
|
if (layout() == nullptr)
|
||||||
|
{
|
||||||
|
return QPushButton::minimumSizeHint();
|
||||||
|
}
|
||||||
|
return sizeForFace(layout()->minimumSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize OptionButton::sizeForFace(const QSize& faceSize) const
|
||||||
|
{
|
||||||
|
QStyleOptionButton option;
|
||||||
|
initStyleOption(&option);
|
||||||
|
return style()->sizeFromContents(QStyle::CT_PushButton, &option, faceSize, this);
|
||||||
|
}
|
||||||
28
src/ui/OptionButton.h
Normal file
28
src/ui/OptionButton.h
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QSize>
|
||||||
|
|
||||||
|
// A push button whose face is built of widgets rather than of a caption: an option's
|
||||||
|
// name over the recipe line stating what it makes (REQ-UI-SELECT-OPTIONS), or a module's
|
||||||
|
// name over what it costs (REQ-MOD-UI-DIALOG).
|
||||||
|
//
|
||||||
|
// It exists only to be measured correctly. QPushButton computes its size hint from its
|
||||||
|
// text and icon and ignores a layout set on it, so a button carrying a face of widgets
|
||||||
|
// would be sized as if it were empty and clip everything in it. This asks the layout
|
||||||
|
// instead, and then lets the style add what the button's own frame needs.
|
||||||
|
class OptionButton : public QPushButton
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit OptionButton(QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
QSize sizeHint() const override;
|
||||||
|
QSize minimumSizeHint() const override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// The style's button size for the given face size, or the plain QPushButton hint
|
||||||
|
// when there is no face to measure.
|
||||||
|
QSize sizeForFace(const QSize& faceSize) const;
|
||||||
|
};
|
||||||
196
src/ui/RecipeLineRow.cpp
Normal file
196
src/ui/RecipeLineRow.cpp
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
#include "RecipeLineRow.h"
|
||||||
|
|
||||||
|
#include <QFont>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLayoutItem>
|
||||||
|
#include <QMargins>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "BuildingIconCache.h"
|
||||||
|
#include "ItemIconCache.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// Size the items' colored squares and the building's chip are drawn at on a line, in
|
||||||
|
// device-independent pixels. Larger than the artwork they carry, which the square
|
||||||
|
// insets (REQ-UI-ITEM-ICON).
|
||||||
|
const int kIconSizePx = 18;
|
||||||
|
|
||||||
|
// Adds a freshly built label to the line and shows it.
|
||||||
|
//
|
||||||
|
// The show is what makes it count: a widget created under an already-visible parent
|
||||||
|
// starts hidden, and a layout treats a hidden item as empty, so an unshown label would
|
||||||
|
// add nothing to the size hint the surrounding widget measures itself against as soon
|
||||||
|
// as this returns.
|
||||||
|
void addAndShow(QHBoxLayout* layout, QLabel* label)
|
||||||
|
{
|
||||||
|
layout->addWidget(label);
|
||||||
|
label->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empties one of the two rows, leaving the row widget and its layout in place.
|
||||||
|
void clearRow(QHBoxLayout* layout)
|
||||||
|
{
|
||||||
|
while (QLayoutItem* item = layout->takeAt(0))
|
||||||
|
{
|
||||||
|
if (item->widget())
|
||||||
|
{
|
||||||
|
item->widget()->deleteLater();
|
||||||
|
}
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
RecipeLineRow::RecipeLineRow(ItemIconCache* itemIcons, BuildingIconCache* buildingIcons,
|
||||||
|
QWidget* parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
, m_itemIcons(itemIcons)
|
||||||
|
, m_buildingIcons(buildingIcons)
|
||||||
|
{
|
||||||
|
m_outerLayout = new QVBoxLayout(this);
|
||||||
|
m_outerLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
m_outerLayout->setSpacing(1);
|
||||||
|
|
||||||
|
m_headerRow = new QWidget(this);
|
||||||
|
m_headerLayout = new QHBoxLayout(m_headerRow);
|
||||||
|
m_headerLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
m_headerLayout->setSpacing(4);
|
||||||
|
m_headerRow->hide();
|
||||||
|
m_outerLayout->addWidget(m_headerRow);
|
||||||
|
|
||||||
|
m_amountsRow = new QWidget(this);
|
||||||
|
m_amountsLayout = new QHBoxLayout(m_amountsRow);
|
||||||
|
m_amountsLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
m_amountsLayout->setSpacing(4);
|
||||||
|
m_outerLayout->addWidget(m_amountsRow);
|
||||||
|
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RecipeLineRow::setCardChrome(bool enabled)
|
||||||
|
{
|
||||||
|
// Palette colors like the item chip's chrome (REQ-UI-SELECTION-CARD), not
|
||||||
|
// visuals.toml, which is for world rendering. The selector names this class alone, so
|
||||||
|
// nothing inside the card inherits the border.
|
||||||
|
setAttribute(Qt::WA_StyledBackground, enabled);
|
||||||
|
setStyleSheet(enabled
|
||||||
|
? QStringLiteral("RecipeLineRow { border: 1px solid palette(mid); "
|
||||||
|
"border-radius: 4px; }")
|
||||||
|
: QString());
|
||||||
|
m_outerLayout->setContentsMargins(enabled ? QMargins(6, 4, 6, 4)
|
||||||
|
: QMargins(0, 0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void RecipeLineRow::setLine(const Spec& spec)
|
||||||
|
{
|
||||||
|
if (spec.isEmpty())
|
||||||
|
{
|
||||||
|
// Forgotten as well as hidden, so re-selecting the same recipe later is seen as
|
||||||
|
// a change and shows the row again.
|
||||||
|
m_spec = Spec();
|
||||||
|
hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (spec == m_spec)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_spec = spec;
|
||||||
|
rebuild(spec);
|
||||||
|
show();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RecipeLineRow::rebuild(const Spec& spec)
|
||||||
|
{
|
||||||
|
clearRow(m_headerLayout);
|
||||||
|
clearRow(m_amountsLayout);
|
||||||
|
|
||||||
|
// First line: which building runs the recipe and which recipe it is, where the line
|
||||||
|
// has to distinguish one producer from another (REQ-UI-ITEM-TOOLTIP). A building with
|
||||||
|
// no chip file leaves the icon off, as everywhere else (REQ-UI-BUILD-ICON).
|
||||||
|
if (spec.building.has_value() && m_buildingIcons != nullptr)
|
||||||
|
{
|
||||||
|
const QPixmap chip =
|
||||||
|
m_buildingIcons->getChip(buildingTypeId(*spec.building), kIconSizePx);
|
||||||
|
if (!chip.isNull())
|
||||||
|
{
|
||||||
|
QLabel* chipLabel = new QLabel(m_headerRow);
|
||||||
|
chipLabel->setPixmap(chip);
|
||||||
|
addAndShow(m_headerLayout, chipLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!spec.name.isEmpty())
|
||||||
|
{
|
||||||
|
QLabel* nameLabel = new QLabel(spec.name, m_headerRow);
|
||||||
|
QFont nameFont = nameLabel->font();
|
||||||
|
nameFont.setBold(true);
|
||||||
|
nameLabel->setFont(nameFont);
|
||||||
|
addAndShow(m_headerLayout, nameLabel);
|
||||||
|
}
|
||||||
|
const bool hasHeader = m_headerLayout->count() > 0;
|
||||||
|
if (hasHeader)
|
||||||
|
{
|
||||||
|
m_headerLayout->addStretch(1);
|
||||||
|
}
|
||||||
|
m_headerRow->setVisible(hasHeader);
|
||||||
|
|
||||||
|
// Second line: what the cycle costs, makes and takes.
|
||||||
|
addAmounts(spec.inputs);
|
||||||
|
if (!spec.inputs.empty() && !spec.outputs.empty())
|
||||||
|
{
|
||||||
|
const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW
|
||||||
|
addAndShow(m_amountsLayout, new QLabel(QString(rightArrow), m_amountsRow));
|
||||||
|
}
|
||||||
|
addAmounts(spec.outputs);
|
||||||
|
|
||||||
|
if (spec.durationSeconds.has_value() && *spec.durationSeconds > 0.0)
|
||||||
|
{
|
||||||
|
const QChar middleDot(0x00B7); // U+00B7 MIDDLE DOT
|
||||||
|
const QString time = spec.durationIsAddition
|
||||||
|
? tr("+%1 s").arg(*spec.durationSeconds, 0, 'f', 1)
|
||||||
|
: tr("%1 s").arg(*spec.durationSeconds, 0, 'f', 1);
|
||||||
|
addAndShow(m_amountsLayout, new QLabel(
|
||||||
|
QStringLiteral("%1 %2").arg(middleDot).arg(time), m_amountsRow));
|
||||||
|
}
|
||||||
|
m_amountsLayout->addStretch(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RecipeLineRow::addAmounts(const std::vector<Amount>& amounts)
|
||||||
|
{
|
||||||
|
for (const Amount& entry : amounts)
|
||||||
|
{
|
||||||
|
// Between one item and the next, so a run of icons and numbers reads as a sum
|
||||||
|
// rather than as a list.
|
||||||
|
if (&entry != &amounts.front())
|
||||||
|
{
|
||||||
|
addAndShow(m_amountsLayout, new QLabel(QStringLiteral("+"), m_amountsRow));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The item's icon on its colored square (REQ-UI-ITEM-ICON). A missing icon file
|
||||||
|
// is not an error: the square stands alone then, and only an item with no square
|
||||||
|
// either falls back to its id in text.
|
||||||
|
const QPixmap icon = (m_itemIcons != nullptr)
|
||||||
|
? m_itemIcons->getSquarePixmap(entry.itemId, kIconSizePx)
|
||||||
|
: QPixmap();
|
||||||
|
if (!icon.isNull())
|
||||||
|
{
|
||||||
|
QLabel* iconLabel = new QLabel(m_amountsRow);
|
||||||
|
iconLabel->setPixmap(icon);
|
||||||
|
addAndShow(m_amountsLayout, iconLabel);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
addAndShow(m_amountsLayout,
|
||||||
|
new QLabel(QString::fromStdString(entry.itemId), m_amountsRow));
|
||||||
|
}
|
||||||
|
addAndShow(m_amountsLayout,
|
||||||
|
new QLabel(QString::number(entry.amount), m_amountsRow));
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/ui/RecipeLineRow.h
Normal file
115
src/ui/RecipeLineRow.h
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include "BuildingType.h"
|
||||||
|
|
||||||
|
class BuildingIconCache;
|
||||||
|
class ItemIconCache;
|
||||||
|
class QHBoxLayout;
|
||||||
|
class QVBoxLayout;
|
||||||
|
|
||||||
|
// One recipe drawn: what it consumes, an arrow, what it produces, and how long a cycle
|
||||||
|
// takes (REQ-UI-RECIPE-SUMMARY), under a header naming the building that runs it and the
|
||||||
|
// recipe itself where the recipe has to identify itself. Every place the UI states what
|
||||||
|
// something makes draws this same line, so a recipe reads alike wherever it is shown:
|
||||||
|
//
|
||||||
|
// * beneath the selection button, for the recipe a building is running
|
||||||
|
// (REQ-UI-RECIPE-SUMMARY);
|
||||||
|
// * on a selection dialog's option buttons, under the option's name
|
||||||
|
// (REQ-UI-SELECT-OPTIONS);
|
||||||
|
// * in an item's production tooltip and in the unlock-choice dialog, there led by the
|
||||||
|
// icon of the building that runs the recipe and its name
|
||||||
|
// (REQ-UI-ITEM-TOOLTIP, REQ-DEF-SCHEMATIC-DROP);
|
||||||
|
// * on a module button and in the layout dialog's build cost, where there is a price
|
||||||
|
// and a time but nothing produced (REQ-MOD-UI-DIALOG, REQ-MOD-UI-STATS-PANEL).
|
||||||
|
class RecipeLineRow : public QWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
// One item and how many of it the line states.
|
||||||
|
struct Amount
|
||||||
|
{
|
||||||
|
std::string itemId;
|
||||||
|
int amount = 0;
|
||||||
|
|
||||||
|
bool operator==(const Amount& other) const
|
||||||
|
{
|
||||||
|
return itemId == other.itemId && amount == other.amount;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// What the line says. Everything but the inputs is optional, which is what lets the
|
||||||
|
// one widget serve the summary, the option buttons, the tooltip lines and the cost
|
||||||
|
// lines.
|
||||||
|
struct Spec
|
||||||
|
{
|
||||||
|
// The chip of the building running the recipe, on the header line, where the
|
||||||
|
// recipe has to say which building that is -- an item may have several producers
|
||||||
|
// (REQ-UI-ITEM-TOOLTIP). Unset wherever the surrounding widget already
|
||||||
|
// establishes the building, and the header line is then left out entirely.
|
||||||
|
std::optional<BuildingType> building;
|
||||||
|
// The recipe's name, beside the building chip on the header line. Empty where the
|
||||||
|
// name is the caption of the widget around this one instead.
|
||||||
|
QString name;
|
||||||
|
std::vector<Amount> inputs;
|
||||||
|
// Empty for a line that produces no item of its own: a ship schematic, or a
|
||||||
|
// module's price. No arrow is drawn then.
|
||||||
|
std::vector<Amount> outputs;
|
||||||
|
std::optional<double> durationSeconds;
|
||||||
|
// True where the time is added to something else rather than being a cycle of
|
||||||
|
// its own, and so reads "+3.0 s" (REQ-MOD-UI-DIALOG).
|
||||||
|
bool durationIsAddition = false;
|
||||||
|
|
||||||
|
bool operator==(const Spec& other) const
|
||||||
|
{
|
||||||
|
return building == other.building && name == other.name
|
||||||
|
&& inputs == other.inputs && outputs == other.outputs
|
||||||
|
&& durationSeconds == other.durationSeconds
|
||||||
|
&& durationIsAddition == other.durationIsAddition;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isEmpty() const { return inputs.empty() && outputs.empty(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Both caches may be null, which leaves the icons off: an item with no square and
|
||||||
|
// no icon falls back to its id, and a building with no chip to its name alone.
|
||||||
|
// Neither is owned.
|
||||||
|
RecipeLineRow(ItemIconCache* itemIcons, BuildingIconCache* buildingIcons,
|
||||||
|
QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
// Hides the row when the spec has nothing to state, which is how a building with no
|
||||||
|
// recipe shows no summary at all (REQ-UI-RECIPE-SUMMARY).
|
||||||
|
void setLine(const Spec& spec);
|
||||||
|
|
||||||
|
// Boxes the line in a card of its own, for the two places that stack several of them
|
||||||
|
// (REQ-UI-ITEM-TOOLTIP, REQ-DEF-SCHEMATIC-DROP): a run of bare lines reads as one
|
||||||
|
// field of icons and numbers, and the box is what separates one recipe from the next.
|
||||||
|
// Off by default -- a line drawn inside a button or a panel section is already framed
|
||||||
|
// by what holds it.
|
||||||
|
void setCardChrome(bool enabled);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void rebuild(const Spec& spec);
|
||||||
|
void addAmounts(const std::vector<Amount>& amounts);
|
||||||
|
|
||||||
|
ItemIconCache* m_itemIcons;
|
||||||
|
BuildingIconCache* m_buildingIcons;
|
||||||
|
QVBoxLayout* m_outerLayout;
|
||||||
|
// The two lines, each a row widget of its own so a rebuild only has to empty their
|
||||||
|
// layouts -- no nested layout to take apart. The header is hidden where the spec
|
||||||
|
// names neither a building nor a recipe.
|
||||||
|
QWidget* m_headerRow;
|
||||||
|
QHBoxLayout* m_headerLayout;
|
||||||
|
QWidget* m_amountsRow;
|
||||||
|
QHBoxLayout* m_amountsLayout;
|
||||||
|
// What the row currently shows, so a refresh at tick rate rebuilds it only when the
|
||||||
|
// recipe actually changed.
|
||||||
|
Spec m_spec;
|
||||||
|
};
|
||||||
@@ -2,60 +2,46 @@
|
|||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
|
||||||
#include <QGridLayout>
|
#include <QFont>
|
||||||
#include <QIcon>
|
#include <QFrame>
|
||||||
#include <QPixmap>
|
#include <QLabel>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QSize>
|
#include <QScrollArea>
|
||||||
#include <QStringList>
|
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
#include "Building.h"
|
#include "Building.h"
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
#include "DisplayName.h"
|
#include "DisplayName.h"
|
||||||
#include "GameConfig.h"
|
#include "GameConfig.h"
|
||||||
#include "ItemIconCache.h"
|
#include "OptionButton.h"
|
||||||
#include "RecipesConfig.h"
|
#include "RecipesConfig.h"
|
||||||
#include "RecipeTooltip.h"
|
|
||||||
#include "ShipsConfig.h"
|
#include "ShipsConfig.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
|
||||||
QString itemLine(const std::string& itemId, int amount)
|
std::vector<RecipeLineRow::Amount> toAmounts(
|
||||||
|
const std::vector<RecipeIngredient>& ingredients)
|
||||||
{
|
{
|
||||||
return QStringLiteral(" ")
|
std::vector<RecipeLineRow::Amount> amounts;
|
||||||
+ QString::fromStdString(toDisplayName(itemId))
|
amounts.reserve(ingredients.size());
|
||||||
+ QStringLiteral(" x ") + QString::number(amount);
|
for (const RecipeIngredient& ingredient : ingredients)
|
||||||
|
{
|
||||||
|
amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount });
|
||||||
|
}
|
||||||
|
return amounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString shipTooltip(const ShipDef& def)
|
std::vector<RecipeLineRow::Amount> toAmounts(const std::vector<RecipeOutput>& outputs)
|
||||||
{
|
{
|
||||||
const QString name = QString::fromStdString(toDisplayName(def.id));
|
std::vector<RecipeLineRow::Amount> amounts;
|
||||||
|
amounts.reserve(outputs.size());
|
||||||
QStringList lines;
|
for (const RecipeOutput& output : outputs)
|
||||||
lines << QObject::tr("Ship: %1").arg(name);
|
|
||||||
|
|
||||||
if (def.schematic.materials.empty())
|
|
||||||
{
|
{
|
||||||
lines << QObject::tr("Materials: none");
|
amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount });
|
||||||
}
|
}
|
||||||
else
|
return amounts;
|
||||||
{
|
|
||||||
lines << QObject::tr("Materials:");
|
|
||||||
for (const RecipeIngredient& material : def.schematic.materials)
|
|
||||||
{
|
|
||||||
lines << itemLine(material.item, material.amount);
|
|
||||||
}
|
|
||||||
lines << QObject::tr(" + installed modules");
|
|
||||||
}
|
|
||||||
|
|
||||||
lines << QObject::tr("Completion time: %1 s")
|
|
||||||
.arg(def.schematic.productionTimeSeconds);
|
|
||||||
lines << QObject::tr("Produces: 1 %1").arg(name);
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -64,16 +50,30 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
|
|||||||
BuildingType type, Simulation& sim, const GameConfig& config)
|
BuildingType type, Simulation& sim, const GameConfig& config)
|
||||||
{
|
{
|
||||||
std::vector<RecipeSelectionOption> options;
|
std::vector<RecipeSelectionOption> options;
|
||||||
options.push_back({std::string(), QObject::tr("(None)"), QString()});
|
// The clearing option. On a building that picks its own recipe it does not leave the
|
||||||
|
// building idle but hands it back to that selection, and says so
|
||||||
|
// (REQ-UI-SELECT-OPTIONS, REQ-BLD-AUTO-RECIPE).
|
||||||
|
options.push_back({std::string(),
|
||||||
|
isAutoRecipeBuildingType(type) ? QObject::tr("(Auto)")
|
||||||
|
: QObject::tr("(None)"),
|
||||||
|
RecipeLineRow::Spec()});
|
||||||
|
|
||||||
if (type == BuildingType::Shipyard)
|
if (type == BuildingType::Shipyard)
|
||||||
{
|
{
|
||||||
for (const ShipDef& def : config.ships.ships)
|
for (const ShipDef& def : config.ships.ships)
|
||||||
{
|
{
|
||||||
if (!sim.isSchematicUnlocked(def.id)) { continue; }
|
if (!sim.isSchematicUnlocked(def.id)) { continue; }
|
||||||
|
|
||||||
|
// The base materials and base production time, excluding the modules the
|
||||||
|
// player has not placed yet (REQ-UI-SELECT-OPTIONS). No output item: a ship
|
||||||
|
// has no icon, so the caption is what the option produces.
|
||||||
|
RecipeLineRow::Spec line;
|
||||||
|
line.inputs = toAmounts(def.schematic.materials);
|
||||||
|
line.durationSeconds = def.schematic.productionTimeSeconds;
|
||||||
|
|
||||||
options.push_back({def.id,
|
options.push_back({def.id,
|
||||||
QString::fromStdString(toDisplayName(def.id)),
|
QString::fromStdString(toDisplayName(def.id)),
|
||||||
shipTooltip(def)});
|
line});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -87,16 +87,15 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Icon shown on the option button (REQ-UI-RECIPE-ICON): the recipe's
|
|
||||||
// configured icon item, else its first output item.
|
RecipeLineRow::Spec line;
|
||||||
const std::string iconItemId = recipe.icon
|
line.inputs = toAmounts(recipe.inputs);
|
||||||
? *recipe.icon
|
line.outputs = toAmounts(recipe.outputs);
|
||||||
: (recipe.outputs.empty() ? std::string()
|
line.durationSeconds = recipe.durationSeconds;
|
||||||
: recipe.outputs.front().item);
|
|
||||||
options.push_back({recipe.id,
|
options.push_back({recipe.id,
|
||||||
QString::fromStdString(toDisplayName(recipe.id)),
|
QString::fromStdString(toDisplayName(recipe.id)),
|
||||||
buildRecipeTooltip(recipe),
|
line});
|
||||||
iconItemId});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,46 +104,77 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
|
|||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
// On-screen size of a recipe option button's item icon (REQ-UI-RECIPE-ICON).
|
// Height the option list is allowed to take, as the dialog's parent window's height
|
||||||
const QSize kOptionIconSize(32, 32);
|
// less what the window margins and the dialog's own frame need. Fully unlocked, an
|
||||||
|
// Assembler offers more options than any window can hold, and the modal is never
|
||||||
|
// resized to fit (REQ-UI-PANEL-MODAL) -- so the list scrolls instead.
|
||||||
|
const int kListHeightReservePx = 80;
|
||||||
|
|
||||||
|
// Fallback cap for a dialog built without a parent window to measure.
|
||||||
|
const int kFallbackListHeightPx = 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
RecipeSelectionDialog::RecipeSelectionDialog(
|
RecipeSelectionDialog::RecipeSelectionDialog(
|
||||||
const std::vector<RecipeSelectionOption>& options,
|
const std::vector<RecipeSelectionOption>& options,
|
||||||
const QString& title, ItemIconCache* itemIcons, QWidget* parent)
|
const QString& title, ItemIconCache* itemIcons, BuildingIconCache* buildingIcons,
|
||||||
|
QWidget* parent)
|
||||||
: QDialog(parent)
|
: QDialog(parent)
|
||||||
{
|
{
|
||||||
setWindowTitle(title);
|
setWindowTitle(title);
|
||||||
setModal(true);
|
setModal(true);
|
||||||
|
|
||||||
QVBoxLayout* mainLayout = new QVBoxLayout(this);
|
QVBoxLayout* mainLayout = new QVBoxLayout(this);
|
||||||
QGridLayout* grid = new QGridLayout();
|
|
||||||
mainLayout->addLayout(grid);
|
|
||||||
|
|
||||||
const int columnCount = 2;
|
// One vertical column of buttons, each stating what it makes (REQ-UI-SELECT-OPTIONS).
|
||||||
|
QScrollArea* scrollArea = new QScrollArea(this);
|
||||||
|
scrollArea->setWidgetResizable(true);
|
||||||
|
scrollArea->setFrameShape(QFrame::NoFrame);
|
||||||
|
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||||
|
scrollArea->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents);
|
||||||
|
scrollArea->setMaximumHeight(parent != nullptr
|
||||||
|
? qMax(kListHeightReservePx, parent->height() - kListHeightReservePx)
|
||||||
|
: kFallbackListHeightPx);
|
||||||
|
mainLayout->addWidget(scrollArea);
|
||||||
|
|
||||||
|
QWidget* list = new QWidget(scrollArea);
|
||||||
|
QVBoxLayout* listLayout = new QVBoxLayout(list);
|
||||||
|
listLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
listLayout->setSpacing(4);
|
||||||
|
|
||||||
for (int i = 0; i < static_cast<int>(options.size()); ++i)
|
for (int i = 0; i < static_cast<int>(options.size()); ++i)
|
||||||
{
|
{
|
||||||
const RecipeSelectionOption& option = options[static_cast<std::size_t>(i)];
|
const RecipeSelectionOption& option = options[static_cast<std::size_t>(i)];
|
||||||
|
|
||||||
QPushButton* button = new QPushButton(this);
|
// An OptionButton so the face of widgets below is what sizes the button
|
||||||
// Icon-only when the produced item has an icon (REQ-UI-RECIPE-ICON); otherwise
|
// (REQ-UI-SELECT-OPTIONS).
|
||||||
// fall back to the caption. The name stays reachable via the tooltip.
|
OptionButton* button = new OptionButton(list);
|
||||||
if (!option.iconItemId.empty() && itemIcons->hasIcon(option.iconItemId))
|
if (option.line.isEmpty())
|
||||||
{
|
{
|
||||||
button->setIcon(QIcon(itemIcons->getPixmap(
|
// The "(None)" option makes nothing and is a caption alone
|
||||||
option.iconItemId, kOptionIconSize.width())));
|
// (REQ-UI-SELECT-OPTIONS).
|
||||||
button->setIconSize(kOptionIconSize);
|
button->setText(option.caption);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
button->setText(option.caption);
|
// The name over the recipe it stands for. Both are transparent to the mouse
|
||||||
|
// so a click anywhere on the face still reaches the button beneath them.
|
||||||
|
QVBoxLayout* face = new QVBoxLayout(button);
|
||||||
|
face->setContentsMargins(8, 6, 8, 6);
|
||||||
|
face->setSpacing(2);
|
||||||
|
|
||||||
|
QLabel* nameLabel = new QLabel(option.caption, button);
|
||||||
|
QFont nameFont = nameLabel->font();
|
||||||
|
nameFont.setBold(true);
|
||||||
|
nameLabel->setFont(nameFont);
|
||||||
|
nameLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
||||||
|
face->addWidget(nameLabel);
|
||||||
|
|
||||||
|
RecipeLineRow* line = new RecipeLineRow(itemIcons, buildingIcons, button);
|
||||||
|
line->setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
||||||
|
face->addWidget(line);
|
||||||
|
line->setLine(option.line);
|
||||||
}
|
}
|
||||||
if (!option.tooltip.isEmpty())
|
listLayout->addWidget(button);
|
||||||
{
|
|
||||||
button->setToolTip(option.tooltip);
|
|
||||||
}
|
|
||||||
button->setMinimumHeight(40);
|
|
||||||
grid->addWidget(button, i / columnCount, i % columnCount);
|
|
||||||
|
|
||||||
m_optionIds.push_back(option.id);
|
m_optionIds.push_back(option.id);
|
||||||
const int index = i;
|
const int index = i;
|
||||||
@@ -153,6 +183,9 @@ RecipeSelectionDialog::RecipeSelectionDialog(
|
|||||||
onOptionClicked(index);
|
onOptionClicked(index);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
listLayout->addStretch(1);
|
||||||
|
|
||||||
|
scrollArea->setWidget(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<std::string> RecipeSelectionDialog::getChosenId() const
|
std::optional<std::string> RecipeSelectionDialog::getChosenId() const
|
||||||
|
|||||||
@@ -8,11 +8,13 @@
|
|||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
|
#include "RecipeLineRow.h"
|
||||||
|
|
||||||
struct GameConfig;
|
struct GameConfig;
|
||||||
|
class BuildingIconCache;
|
||||||
|
class ItemIconCache;
|
||||||
class Simulation;
|
class Simulation;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class ItemIconCache;
|
|
||||||
|
|
||||||
// One selectable entry in the recipe/schematic selection dialog
|
// One selectable entry in the recipe/schematic selection dialog
|
||||||
// (REQ-UI-SELECT-BUTTON). The "(None)" entry uses an empty id.
|
// (REQ-UI-SELECT-BUTTON). The "(None)" entry uses an empty id.
|
||||||
@@ -20,23 +22,23 @@ struct RecipeSelectionOption
|
|||||||
{
|
{
|
||||||
std::string id;
|
std::string id;
|
||||||
QString caption;
|
QString caption;
|
||||||
QString tooltip;
|
// What the option makes, drawn beneath its caption on the button
|
||||||
// Id of the item whose icon is shown on the option button (REQ-UI-RECIPE-ICON);
|
// (REQ-UI-SELECT-OPTIONS). Empty for the "(None)" option, which is a caption alone.
|
||||||
// empty for the "(None)" option and for Shipyard schematics, which keep their
|
// A ship schematic fills the inputs and the time and leaves the outputs empty: a
|
||||||
// caption. When set but no icon file exists, the button falls back to the caption.
|
// ship is not an item and has no icon, so the caption is what it produces.
|
||||||
std::string iconItemId;
|
RecipeLineRow::Spec line;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Builds the lock-aware list of selectable options for a production building
|
// Builds the lock-aware list of selectable options for a production building
|
||||||
// of the given type, with display captions and info tooltips
|
// of the given type, with display captions and recipe lines
|
||||||
// (REQ-UI-SELECT-BUTTON, REQ-UI-SELECT-TOOLTIP). The first entry is always a
|
// (REQ-UI-SELECT-BUTTON, REQ-UI-SELECT-OPTIONS). The first entry is always a
|
||||||
// "(None)" option with an empty id. Shared by the selection dialog and the
|
// "(None)" option with an empty id. Shared by the selection dialog and the
|
||||||
// panel selection button so both render identical captions and tooltips, and
|
// panel selection button so both render identical captions, and
|
||||||
// usable for construction sites which have no Building yet (REQ-BLD-SITE-CONFIG).
|
// usable for construction sites which have no Building yet (REQ-BLD-SITE-CONFIG).
|
||||||
std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
|
std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
|
||||||
BuildingType type, Simulation& sim, const GameConfig& config);
|
BuildingType type, Simulation& sim, const GameConfig& config);
|
||||||
|
|
||||||
// Modal dialog showing a grid of option buttons (REQ-UI-SELECT-BUTTON). The
|
// Modal dialog listing the options in one vertical column (REQ-UI-SELECT-OPTIONS). The
|
||||||
// game is paused by the caller while it is open. Clicking an option selects it
|
// game is paused by the caller while it is open. Clicking an option selects it
|
||||||
// and closes the dialog; dismissing it (close/Esc) leaves no choice.
|
// and closes the dialog; dismissing it (close/Esc) leaves no choice.
|
||||||
class RecipeSelectionDialog : public QDialog
|
class RecipeSelectionDialog : public QDialog
|
||||||
@@ -44,12 +46,10 @@ class RecipeSelectionDialog : public QDialog
|
|||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); used to
|
// The icon caches draw each option's recipe line (REQ-UI-ITEM-ICON). Not owned.
|
||||||
// render recipe options icon-only (REQ-UI-RECIPE-ICON). Options whose item has
|
|
||||||
// no icon file fall back to their caption text. Not owned.
|
|
||||||
RecipeSelectionDialog(const std::vector<RecipeSelectionOption>& options,
|
RecipeSelectionDialog(const std::vector<RecipeSelectionOption>& options,
|
||||||
const QString& title, ItemIconCache* itemIcons,
|
const QString& title, ItemIconCache* itemIcons,
|
||||||
QWidget* parent = nullptr);
|
BuildingIconCache* buildingIcons, QWidget* parent = nullptr);
|
||||||
|
|
||||||
std::optional<std::string> getChosenId() const;
|
std::optional<std::string> getChosenId() const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
#include "RecipeTooltip.h"
|
|
||||||
|
|
||||||
#include <QObject>
|
|
||||||
#include <QStringList>
|
|
||||||
|
|
||||||
#include "DisplayName.h"
|
|
||||||
#include "RecipesConfig.h"
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
|
|
||||||
QString itemLine(const std::string& itemId, int amount)
|
|
||||||
{
|
|
||||||
return QStringLiteral(" ")
|
|
||||||
+ QString::fromStdString(toDisplayName(itemId))
|
|
||||||
+ QStringLiteral(" x ") + QString::number(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
QString buildRecipeTooltip(const RecipeDef& recipe)
|
|
||||||
{
|
|
||||||
QStringList lines;
|
|
||||||
lines << QObject::tr("Recipe: %1")
|
|
||||||
.arg(QString::fromStdString(toDisplayName(recipe.id)));
|
|
||||||
|
|
||||||
if (recipe.inputs.empty())
|
|
||||||
{
|
|
||||||
lines << QObject::tr("Inputs: none");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
lines << QObject::tr("Inputs:");
|
|
||||||
for (const RecipeIngredient& ingredient : recipe.inputs)
|
|
||||||
{
|
|
||||||
lines << itemLine(ingredient.item, ingredient.amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines << QObject::tr("Completion time: %1 s").arg(recipe.durationSeconds);
|
|
||||||
|
|
||||||
if (!recipe.outputs.empty())
|
|
||||||
{
|
|
||||||
lines << QObject::tr("Produces:");
|
|
||||||
for (const RecipeOutput& output : recipe.outputs)
|
|
||||||
{
|
|
||||||
lines << itemLine(output.item, output.amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QString>
|
|
||||||
|
|
||||||
struct RecipeDef;
|
|
||||||
|
|
||||||
// Builds the recipe info tooltip text (REQ-UI-SELECT-TOOLTIP): the recipe name,
|
|
||||||
// each input item name and quantity, the completion time, and the produced
|
|
||||||
// output item name and quantity. Shared by the recipe-selection dialog and the
|
|
||||||
// schematic choice dialog's "Unlocks recipes:" list so both render identically.
|
|
||||||
QString buildRecipeTooltip(const RecipeDef& recipe);
|
|
||||||
@@ -6,12 +6,35 @@
|
|||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
#include "DisplayName.h"
|
#include "DisplayName.h"
|
||||||
#include "RecipeTooltip.h"
|
#include "RecipeLineRow.h"
|
||||||
#include "RecipesConfig.h"
|
#include "RecipesConfig.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
|
||||||
|
std::vector<RecipeLineRow::Amount> toAmounts(
|
||||||
|
const std::vector<RecipeIngredient>& ingredients)
|
||||||
|
{
|
||||||
|
std::vector<RecipeLineRow::Amount> amounts;
|
||||||
|
amounts.reserve(ingredients.size());
|
||||||
|
for (const RecipeIngredient& ingredient : ingredients)
|
||||||
|
{
|
||||||
|
amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount });
|
||||||
|
}
|
||||||
|
return amounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<RecipeLineRow::Amount> toAmounts(const std::vector<RecipeOutput>& outputs)
|
||||||
|
{
|
||||||
|
std::vector<RecipeLineRow::Amount> amounts;
|
||||||
|
amounts.reserve(outputs.size());
|
||||||
|
for (const RecipeOutput& output : outputs)
|
||||||
|
{
|
||||||
|
amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount });
|
||||||
|
}
|
||||||
|
return amounts;
|
||||||
|
}
|
||||||
|
|
||||||
QString grantKindLabel(SchematicType type)
|
QString grantKindLabel(SchematicType type)
|
||||||
{
|
{
|
||||||
switch (type)
|
switch (type)
|
||||||
@@ -28,7 +51,8 @@ QString grantKindLabel(SchematicType type)
|
|||||||
|
|
||||||
SchematicChoiceDialog::SchematicChoiceDialog(
|
SchematicChoiceDialog::SchematicChoiceDialog(
|
||||||
const std::vector<SchematicChoiceOption>& options,
|
const std::vector<SchematicChoiceOption>& options,
|
||||||
const RecipesConfig& recipes,
|
const RecipesConfig& recipes, ItemIconCache* itemIcons,
|
||||||
|
BuildingIconCache* buildingIcons,
|
||||||
QWidget* parent)
|
QWidget* parent)
|
||||||
: QDialog(parent)
|
: QDialog(parent)
|
||||||
, m_chosenIndex(0)
|
, m_chosenIndex(0)
|
||||||
@@ -56,7 +80,12 @@ SchematicChoiceDialog::SchematicChoiceDialog(
|
|||||||
|
|
||||||
QWidget* card = new QWidget(this);
|
QWidget* card = new QWidget(this);
|
||||||
QVBoxLayout* cardLayout = new QVBoxLayout(card);
|
QVBoxLayout* cardLayout = new QVBoxLayout(card);
|
||||||
card->setStyleSheet("QWidget { border: 1px solid gray; padding: 8px; }");
|
// Scoped to the card by object name. An unscoped "QWidget" selector cascades to
|
||||||
|
// every descendant, which boxed each label and each icon of the recipe lines
|
||||||
|
// inside it as well.
|
||||||
|
card->setObjectName(QStringLiteral("schematicChoiceCard"));
|
||||||
|
card->setStyleSheet(QStringLiteral(
|
||||||
|
"QWidget#schematicChoiceCard { border: 1px solid gray; padding: 8px; }"));
|
||||||
|
|
||||||
QLabel* nameLabel = new QLabel(QString::fromStdString(option.displayName), card);
|
QLabel* nameLabel = new QLabel(QString::fromStdString(option.displayName), card);
|
||||||
QFont nameFont = nameLabel->font();
|
QFont nameFont = nameLabel->font();
|
||||||
@@ -108,14 +137,24 @@ SchematicChoiceDialog::SchematicChoiceDialog(
|
|||||||
{
|
{
|
||||||
for (const std::string& recipeId : option.newlyUnlockedRecipeIds)
|
for (const std::string& recipeId : option.newlyUnlockedRecipeIds)
|
||||||
{
|
{
|
||||||
QLabel* recipeLabel = new QLabel(
|
const RecipeDef* def = recipes.findRecipeDef(recipeId);
|
||||||
QString::fromStdString(toDisplayName(recipeId)), card);
|
if (def == nullptr) { continue; }
|
||||||
recipeLabel->setAlignment(Qt::AlignCenter);
|
|
||||||
if (const RecipeDef* def = recipes.findRecipeDef(recipeId))
|
// The recipe drawn as it is everywhere else, led by the building
|
||||||
{
|
// that runs it and its name, so the line says everything there is
|
||||||
recipeLabel->setToolTip(buildRecipeTooltip(*def));
|
// to say about it and carries no tooltip (REQ-DEF-SCHEMATIC-DROP).
|
||||||
}
|
RecipeLineRow::Spec spec;
|
||||||
cardLayout->addWidget(recipeLabel);
|
spec.building = def->building;
|
||||||
|
spec.name = QString::fromStdString(toDisplayName(def->id));
|
||||||
|
spec.inputs = toAmounts(def->inputs);
|
||||||
|
spec.outputs = toAmounts(def->outputs);
|
||||||
|
spec.durationSeconds = def->durationSeconds;
|
||||||
|
|
||||||
|
RecipeLineRow* line =
|
||||||
|
new RecipeLineRow(itemIcons, buildingIcons, card);
|
||||||
|
line->setCardChrome(true);
|
||||||
|
cardLayout->addWidget(line);
|
||||||
|
line->setLine(spec);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,19 @@
|
|||||||
#include "SchematicChoiceOption.h"
|
#include "SchematicChoiceOption.h"
|
||||||
|
|
||||||
struct RecipesConfig;
|
struct RecipesConfig;
|
||||||
|
class BuildingIconCache;
|
||||||
|
class ItemIconCache;
|
||||||
|
|
||||||
class SchematicChoiceDialog : public QDialog
|
class SchematicChoiceDialog : public QDialog
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
// The icon caches draw the recipe lines of the "Unlocks recipes" list
|
||||||
|
// (REQ-DEF-SCHEMATIC-DROP, REQ-UI-ITEM-ICON, REQ-UI-BUILD-ICON). Not owned.
|
||||||
SchematicChoiceDialog(const std::vector<SchematicChoiceOption>& options,
|
SchematicChoiceDialog(const std::vector<SchematicChoiceOption>& options,
|
||||||
const RecipesConfig& recipes,
|
const RecipesConfig& recipes, ItemIconCache* itemIcons,
|
||||||
|
BuildingIconCache* buildingIcons,
|
||||||
QWidget* parent = nullptr);
|
QWidget* parent = nullptr);
|
||||||
|
|
||||||
int getChosenIndex() const;
|
int getChosenIndex() const;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ std::optional<QRectF> getBuildingWidgetRect(const FactoryState& state,
|
|||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<QRectF> getActorWidgetRect(EntityAdmin& admin,
|
std::optional<QRectF> getActorWidgetRect(const EntityAdmin& admin,
|
||||||
const WorldCoordinates& coordinates,
|
const WorldCoordinates& coordinates,
|
||||||
entt::entity actor)
|
entt::entity actor)
|
||||||
{
|
{
|
||||||
@@ -68,7 +68,7 @@ std::optional<QRectF> getActorWidgetRect(EntityAdmin& admin,
|
|||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<QRectF> getDebrisWidgetRect(EntityAdmin& admin,
|
std::optional<QRectF> getDebrisWidgetRect(const EntityAdmin& admin,
|
||||||
const WorldCoordinates& coordinates,
|
const WorldCoordinates& coordinates,
|
||||||
entt::entity debris)
|
entt::entity debris)
|
||||||
{
|
{
|
||||||
@@ -82,7 +82,7 @@ std::optional<QRectF> getDebrisWidgetRect(EntityAdmin& admin,
|
|||||||
return QRectF(center.x() - radius, center.y() - radius, 2.0 * radius, 2.0 * radius);
|
return QRectF(center.x() - radius, center.y() - radius, 2.0 * radius, 2.0 * radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
QRect getSelectionWidgetRect(Simulation& sim, const WorldCoordinates& coordinates,
|
QRect getSelectionWidgetRect(const Simulation& sim, const WorldCoordinates& coordinates,
|
||||||
const std::vector<BuildingId>& buildings,
|
const std::vector<BuildingId>& buildings,
|
||||||
const std::vector<entt::entity>& actors,
|
const std::vector<entt::entity>& actors,
|
||||||
const std::vector<entt::entity>& debris)
|
const std::vector<entt::entity>& debris)
|
||||||
|
|||||||
@@ -29,22 +29,20 @@ std::optional<QRectF> getBuildingWidgetRect(const FactoryState& state,
|
|||||||
BuildingId id);
|
BuildingId id);
|
||||||
|
|
||||||
// The body of a ship or of a defence station (REQ-UI-ENTITY-CLICK-SELECT): a station's
|
// The body of a ship or of a defence station (REQ-UI-ENTITY-CLICK-SELECT): a station's
|
||||||
// footprint, or the square the ship's triangle is drawn in. The admin is taken by
|
// footprint, or the square the ship's triangle is drawn in.
|
||||||
// non-const reference only because EntityAdmin::hasAll() is not const; nothing here
|
std::optional<QRectF> getActorWidgetRect(const EntityAdmin& admin,
|
||||||
// writes to it.
|
|
||||||
std::optional<QRectF> getActorWidgetRect(EntityAdmin& admin,
|
|
||||||
const WorldCoordinates& coordinates,
|
const WorldCoordinates& coordinates,
|
||||||
entt::entity actor);
|
entt::entity actor);
|
||||||
|
|
||||||
// The circle a piece of debris is drawn as (REQ-UI-DEBRIS-CLICK-SELECT).
|
// The circle a piece of debris is drawn as (REQ-UI-DEBRIS-CLICK-SELECT).
|
||||||
std::optional<QRectF> getDebrisWidgetRect(EntityAdmin& admin,
|
std::optional<QRectF> getDebrisWidgetRect(const EntityAdmin& admin,
|
||||||
const WorldCoordinates& coordinates,
|
const WorldCoordinates& coordinates,
|
||||||
entt::entity debris);
|
entt::entity debris);
|
||||||
|
|
||||||
// The rectangle covering a whole selection -- one object, or the bounding box of all of
|
// The rectangle covering a whole selection -- one object, or the bounding box of all of
|
||||||
// them (REQ-UI-SELECTION-PANEL). Objects that no longer resolve are skipped; the result
|
// them (REQ-UI-SELECTION-PANEL). Objects that no longer resolve are skipped; the result
|
||||||
// is null when none of them does.
|
// is null when none of them does.
|
||||||
QRect getSelectionWidgetRect(Simulation& sim, const WorldCoordinates& coordinates,
|
QRect getSelectionWidgetRect(const Simulation& sim, const WorldCoordinates& coordinates,
|
||||||
const std::vector<BuildingId>& buildings,
|
const std::vector<BuildingId>& buildings,
|
||||||
const std::vector<entt::entity>& actors,
|
const std::vector<entt::entity>& actors,
|
||||||
const std::vector<entt::entity>& debris);
|
const std::vector<entt::entity>& debris);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "SelectionPanel.h"
|
#include "SelectionPanel.h"
|
||||||
|
|
||||||
|
#include <QMouseEvent>
|
||||||
#include <QScrollArea>
|
#include <QScrollArea>
|
||||||
#include <QScrollBar>
|
#include <QScrollBar>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
@@ -103,6 +104,56 @@ void SelectionPanel::invalidateLayout()
|
|||||||
std::make_shared<FloatingLayoutInvalidatedEvent>());
|
std::make_shared<FloatingLayoutInvalidatedEvent>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SelectionPanel::mousePressEvent(QMouseEvent* event)
|
||||||
|
{
|
||||||
|
// Only the card's header moves the panel (REQ-UI-SELECTION-PANEL-DRAG). Presses on
|
||||||
|
// the header's labels arrive here by ordinary propagation, none of them being a
|
||||||
|
// widget that accepts mouse events, and carry a position in this panel's coordinates.
|
||||||
|
if (event->button() == Qt::LeftButton && m_content != nullptr)
|
||||||
|
{
|
||||||
|
QWidget* header = m_content->getHeaderWidget();
|
||||||
|
QRect headerRect(header->mapTo(this, QPoint(0, 0)), header->size());
|
||||||
|
// A card long enough to scroll can have its header scrolled out of sight; what is
|
||||||
|
// hidden is not a handle, and the pixels it would claim show other parts of the
|
||||||
|
// card.
|
||||||
|
QWidget* viewport = m_scrollArea->viewport();
|
||||||
|
headerRect &= QRect(viewport->mapTo(this, QPoint(0, 0)), viewport->size());
|
||||||
|
if (headerRect.contains(event->pos()))
|
||||||
|
{
|
||||||
|
m_dragGrabOffsetPx = event->pos();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepted whether or not it started a drag: no mouse event over the panel reaches
|
||||||
|
// the game world (REQ-UI-SELECTION-PANEL).
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionPanel::mouseMoveEvent(QMouseEvent* event)
|
||||||
|
{
|
||||||
|
if (m_dragGrabOffsetPx.has_value() && parentWidget() != nullptr)
|
||||||
|
{
|
||||||
|
// Read from the cursor's position on the screen rather than from the panel's own
|
||||||
|
// coordinates, which move under the cursor as the drag places the panel again.
|
||||||
|
const QPoint topLeftPx =
|
||||||
|
parentWidget()->mapFromGlobal(event->globalPos()) - *m_dragGrabOffsetPx;
|
||||||
|
m_desiredTopLeftPx = topLeftPx - m_viewOriginPx;
|
||||||
|
// Where the panel actually lands follows from the desired position by the
|
||||||
|
// ordinary rules, the widgets it steps around included (REQ-UI-SELECTION-PANEL-DRAG).
|
||||||
|
invalidateLayout();
|
||||||
|
}
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionPanel::mouseReleaseEvent(QMouseEvent* event)
|
||||||
|
{
|
||||||
|
// Qt's implicit grab has kept the drag alive while the cursor was outside the panel
|
||||||
|
// or outside the view; it ends here, wherever the button was let go
|
||||||
|
// (REQ-UI-SELECTION-PANEL-DRAG). The desired position stays as dropped.
|
||||||
|
m_dragGrabOffsetPx.reset();
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
void SelectionPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
|
void SelectionPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
|
||||||
{
|
{
|
||||||
m_request.buildings = event->ids;
|
m_request.buildings = event->ids;
|
||||||
@@ -126,6 +177,13 @@ void SelectionPanel::handleEvent(
|
|||||||
// panel is placed, the two widgets being siblings in the same parent.
|
// panel is placed, the two widgets being siblings in the same parent.
|
||||||
m_anchorRect = event->rectPx;
|
m_anchorRect = event->rectPx;
|
||||||
m_side.reset();
|
m_side.reset();
|
||||||
|
// A position the player dragged the panel to belongs to the selection it was set in.
|
||||||
|
// A new selection places the panel anew against its own anchor
|
||||||
|
// (REQ-UI-SELECTION-PANEL-DRAG); this event is published only when one starts, and
|
||||||
|
// not when an existing selection is added to or reduced, which is exactly the scope
|
||||||
|
// the dragged position keeps.
|
||||||
|
m_desiredTopLeftPx.reset();
|
||||||
|
m_dragGrabOffsetPx.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
void SelectionPanel::handleEvent(
|
void SelectionPanel::handleEvent(
|
||||||
@@ -238,6 +296,11 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& oc
|
|||||||
|
|
||||||
const QRect band = viewRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
|
const QRect band = viewRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
|
||||||
|
|
||||||
|
// What the view's coordinates and this panel's differ by, kept for the drag gesture,
|
||||||
|
// which learns of the cursor in the latter and stores its result in the former
|
||||||
|
// (REQ-UI-SELECTION-PANEL-DRAG).
|
||||||
|
m_viewOriginPx = viewRect.topLeft();
|
||||||
|
|
||||||
// The anchor is published in the world view's coordinates and this panel is placed in
|
// The anchor is published in the world view's coordinates and this panel is placed in
|
||||||
// its parent's; the two widgets are siblings, so the view's own origin is the whole
|
// its parent's; the two widgets are siblings, so the view's own origin is the whole
|
||||||
// difference. Without an anchor the panel falls back to the top-right corner, by
|
// difference. Without an anchor the panel falls back to the top-right corner, by
|
||||||
@@ -278,6 +341,22 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& oc
|
|||||||
return m_body->sizeHint();
|
return m_body->sizeHint();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Where a panel of that size stands: beside the selection, or at the position the
|
||||||
|
// player dragged the panel to, which replaces both the anchor and the side for the
|
||||||
|
// rest of the selection (REQ-UI-SELECTION-PANEL-DRAG). Either way the result is
|
||||||
|
// resolved against the view's edges and the widgets placed before this one, so a
|
||||||
|
// dragged panel steps around them exactly as an anchored one does.
|
||||||
|
auto solve = [&](QSize wantedSize) -> QRect
|
||||||
|
{
|
||||||
|
if (m_desiredTopLeftPx.has_value())
|
||||||
|
{
|
||||||
|
return placeAtDesiredTopLeft(band, *m_desiredTopLeftPx + viewRect.topLeft(),
|
||||||
|
wantedSize, occupiedRects, kMarginPx);
|
||||||
|
}
|
||||||
|
return placeBesideAnchor(band, anchorRect, *m_side, wantedSize, occupiedRects,
|
||||||
|
kMarginPx);
|
||||||
|
};
|
||||||
|
|
||||||
// Run twice. Parts of a card report an unstyled size until the style has actually
|
// Run twice. Parts of a card report an unstyled size until the style has actually
|
||||||
// reached them, which for a freshly built card happens during the first round of
|
// reached them, which for a freshly built card happens during the first round of
|
||||||
// measuring; the second round then measures a card that is fully laid out and
|
// measuring; the second round then measures a card that is fully laid out and
|
||||||
@@ -307,9 +386,8 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& oc
|
|||||||
// pass settles it again against the wider column. Asking for the whole band's
|
// pass settles it again against the wider column. Asking for the whole band's
|
||||||
// height is what makes the answer the most the panel could have there.
|
// height is what makes the answer the most the panel could have there.
|
||||||
const int maxHeightPx =
|
const int maxHeightPx =
|
||||||
placeBesideAnchor(band, anchorRect, *m_side,
|
solve(QSize(contentWidthPx + 2 * borderPx, band.height())).height()
|
||||||
QSize(contentWidthPx + 2 * borderPx, band.height()),
|
- 2 * borderPx;
|
||||||
occupiedRects, kMarginPx).height() - 2 * borderPx;
|
|
||||||
if (maxHeightPx <= 0)
|
if (maxHeightPx <= 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -343,8 +421,6 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& oc
|
|||||||
const int panelWidthPx = contentWidthPx + 2 * borderPx;
|
const int panelWidthPx = contentWidthPx + 2 * borderPx;
|
||||||
const int panelHeightPx = contentHeightPx + 2 * borderPx;
|
const int panelHeightPx = contentHeightPx + 2 * borderPx;
|
||||||
|
|
||||||
setGeometry(placeBesideAnchor(band, anchorRect, *m_side,
|
setGeometry(solve(QSize(panelWidthPx, panelHeightPx)));
|
||||||
QSize(panelWidthPx, panelHeightPx),
|
|
||||||
occupiedRects, kMarginPx));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
#include <QRect>
|
#include <QRect>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ class BuildingIconCache;
|
|||||||
class ItemIconCache;
|
class ItemIconCache;
|
||||||
class SelectionContent;
|
class SelectionContent;
|
||||||
class Simulation;
|
class Simulation;
|
||||||
|
class QMouseEvent;
|
||||||
class QScrollArea;
|
class QScrollArea;
|
||||||
class QVBoxLayout;
|
class QVBoxLayout;
|
||||||
|
|
||||||
@@ -64,6 +66,14 @@ public:
|
|||||||
void placeIn(const QRect& viewRect,
|
void placeIn(const QRect& viewRect,
|
||||||
const std::vector<QRect>& occupiedRects) override;
|
const std::vector<QRect>& occupiedRects) override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// The panel is moved by dragging its card header (REQ-UI-SELECTION-PANEL-DRAG). Every
|
||||||
|
// other mouse event over the panel is swallowed here so that none of them reaches the
|
||||||
|
// game world beneath (REQ-UI-SELECTION-PANEL).
|
||||||
|
void mousePressEvent(QMouseEvent* event) override;
|
||||||
|
void mouseMoveEvent(QMouseEvent* event) override;
|
||||||
|
void mouseReleaseEvent(QMouseEvent* event) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
|
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const SelectionAnchorChangedEvent> event) override;
|
void handleEvent(std::shared_ptr<const SelectionAnchorChangedEvent> event) override;
|
||||||
@@ -98,10 +108,23 @@ private:
|
|||||||
// scrolling view or a moving ship, the side because a card that grows must not flip
|
// scrolling view or a moving ship, the side because a card that grows must not flip
|
||||||
// the panel across the object (REQ-UI-SELECTION-PANEL). The side is resolved on the
|
// the panel across the object (REQ-UI-SELECTION-PANEL). The side is resolved on the
|
||||||
// first placement after a new anchor, being the first point at which the panel's
|
// first placement after a new anchor, being the first point at which the panel's
|
||||||
// width is known.
|
// width is known. Dragging the panel supersedes the pair for the rest of the
|
||||||
|
// selection (REQ-UI-SELECTION-PANEL-DRAG).
|
||||||
QRect m_anchorRect;
|
QRect m_anchorRect;
|
||||||
std::optional<PanelSide> m_side;
|
std::optional<PanelSide> m_side;
|
||||||
|
|
||||||
|
// Where the player dragged the panel, in the game world view's coordinates, and the
|
||||||
|
// cursor's offset within the panel while a drag is running
|
||||||
|
// (REQ-UI-SELECTION-PANEL-DRAG). The desired position is kept exactly as dropped:
|
||||||
|
// resolving it against the view's edges and the widgets the panel steps around never
|
||||||
|
// writes back to it, so the panel returns to it once the room is there again. It
|
||||||
|
// lasts as long as the selection it was set in.
|
||||||
|
std::optional<QPoint> m_desiredTopLeftPx;
|
||||||
|
std::optional<QPoint> m_dragGrabOffsetPx;
|
||||||
|
// Origin of the view the panel was last placed in, which is what the two coordinate
|
||||||
|
// systems differ by -- the panel is a sibling of the view, not a child of it.
|
||||||
|
QPoint m_viewOriginPx;
|
||||||
|
|
||||||
// Scrolls the card once it outgrows the space the panel has (REQ-UI-SELECTION-PANEL).
|
// Scrolls the card once it outgrows the space the panel has (REQ-UI-SELECTION-PANEL).
|
||||||
// The card is a child of m_body, not of the panel itself.
|
// The card is a child of m_body, not of the panel itself.
|
||||||
QScrollArea* m_scrollArea;
|
QScrollArea* m_scrollArea;
|
||||||
|
|||||||
@@ -5,11 +5,16 @@
|
|||||||
#include <functional>
|
#include <functional>
|
||||||
|
|
||||||
#include "DisplayName.h"
|
#include "DisplayName.h"
|
||||||
|
#include "OptionButton.h"
|
||||||
|
#include "ProductionRules.h"
|
||||||
|
#include "RecipeLineRow.h"
|
||||||
|
#include "SectionBox.h"
|
||||||
|
|
||||||
#include <QGridLayout>
|
#include <QGridLayout>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QInputDialog>
|
#include <QInputDialog>
|
||||||
#include <QKeyEvent>
|
#include <QKeyEvent>
|
||||||
|
#include <QLabel>
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
@@ -22,6 +27,18 @@
|
|||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
|
||||||
|
std::vector<RecipeLineRow::Amount> toAmounts(
|
||||||
|
const std::vector<RecipeIngredient>& ingredients)
|
||||||
|
{
|
||||||
|
std::vector<RecipeLineRow::Amount> amounts;
|
||||||
|
amounts.reserve(ingredients.size());
|
||||||
|
for (const RecipeIngredient& ingredient : ingredients)
|
||||||
|
{
|
||||||
|
amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount });
|
||||||
|
}
|
||||||
|
return amounts;
|
||||||
|
}
|
||||||
|
|
||||||
const int kCellSize = 32;
|
const int kCellSize = 32;
|
||||||
|
|
||||||
// Empty ship tiles pulse between this grey and white at 1 Hz while a module is
|
// Empty ship tiles pulse between this grey and white at 1 Hz while a module is
|
||||||
@@ -401,9 +418,11 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
|
|||||||
std::vector<ShipLayoutBlueprint>& allBlueprints,
|
std::vector<ShipLayoutBlueprint>& allBlueprints,
|
||||||
std::set<std::string> unlockedModuleIds,
|
std::set<std::string> unlockedModuleIds,
|
||||||
bool debugDraw,
|
bool debugDraw,
|
||||||
|
ItemIconCache* itemIcons,
|
||||||
QWidget* parent)
|
QWidget* parent)
|
||||||
: QDialog(parent)
|
: QDialog(parent)
|
||||||
, m_config(config)
|
, m_config(config)
|
||||||
|
, m_itemIcons(itemIcons)
|
||||||
, m_shipId(shipId)
|
, m_shipId(shipId)
|
||||||
, m_unlockedModuleIds(std::move(unlockedModuleIds))
|
, m_unlockedModuleIds(std::move(unlockedModuleIds))
|
||||||
, m_rows(0)
|
, m_rows(0)
|
||||||
@@ -463,10 +482,22 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
|
|||||||
// Middle: three-column area (stats | module buttons | blueprints).
|
// Middle: three-column area (stats | module buttons | blueprints).
|
||||||
QHBoxLayout* columnsLayout = new QHBoxLayout();
|
QHBoxLayout* columnsLayout = new QHBoxLayout();
|
||||||
|
|
||||||
// Left column: ship stats panel.
|
// Left column: ship stats panel, and beneath it what the layout being configured
|
||||||
|
// would cost to build (REQ-MOD-UI-DIALOG). The cost sits outside the stats panel
|
||||||
|
// because that widget is shared with the live ship card and the balancing tool,
|
||||||
|
// neither of which costs anything -- and the balancing tool is deliberately built
|
||||||
|
// without the item icons this line draws with.
|
||||||
|
QVBoxLayout* leftLayout = new QVBoxLayout();
|
||||||
m_statsPanel = new ShipStatsPanel(config, this);
|
m_statsPanel = new ShipStatsPanel(config, this);
|
||||||
m_statsPanel->setDebugDrawEnabled(m_debugDraw);
|
m_statsPanel->setDebugDrawEnabled(m_debugDraw);
|
||||||
columnsLayout->addWidget(m_statsPanel);
|
leftLayout->addWidget(m_statsPanel);
|
||||||
|
|
||||||
|
m_buildCostSection = new SectionBox(tr("Build cost"), this);
|
||||||
|
m_buildCostLine = new RecipeLineRow(m_itemIcons, nullptr, m_buildCostSection);
|
||||||
|
m_buildCostSection->getContentLayout()->addWidget(m_buildCostLine);
|
||||||
|
leftLayout->addWidget(m_buildCostSection);
|
||||||
|
leftLayout->addStretch(1);
|
||||||
|
columnsLayout->addLayout(leftLayout);
|
||||||
|
|
||||||
// Center column: module selection buttons.
|
// Center column: module selection buttons.
|
||||||
QVBoxLayout* centerLayout = new QVBoxLayout();
|
QVBoxLayout* centerLayout = new QVBoxLayout();
|
||||||
@@ -487,11 +518,34 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
|
|||||||
m_moduleButtons.push_back(nullptr);
|
m_moduleButtons.push_back(nullptr);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const QString label = QString::fromStdString(toDisplayName(def.id))
|
// The module's name over what it costs -- its materials and the production time
|
||||||
+ "\n" + QString::fromStdString(def.glyph);
|
// it adds (REQ-MOD-UI-DIALOG). The glyph is how a module is identified on the
|
||||||
QPushButton* btn = new QPushButton(label, this);
|
// layout grid, not here. Both children are transparent to the mouse so a click
|
||||||
|
// anywhere on the face still reaches the button.
|
||||||
|
OptionButton* btn = new OptionButton(this);
|
||||||
btn->setCheckable(true);
|
btn->setCheckable(true);
|
||||||
btn->setFixedHeight(48);
|
|
||||||
|
QVBoxLayout* face = new QVBoxLayout(btn);
|
||||||
|
face->setContentsMargins(8, 4, 8, 4);
|
||||||
|
face->setSpacing(1);
|
||||||
|
|
||||||
|
QLabel* nameLabel =
|
||||||
|
new QLabel(QString::fromStdString(toDisplayName(def.id)), btn);
|
||||||
|
nameLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
||||||
|
face->addWidget(nameLabel);
|
||||||
|
|
||||||
|
RecipeLineRow::Spec cost;
|
||||||
|
cost.inputs = toAmounts(def.materials);
|
||||||
|
cost.durationSeconds = def.productionTimeSeconds;
|
||||||
|
cost.durationIsAddition = true;
|
||||||
|
|
||||||
|
RecipeLineRow* costLine = new RecipeLineRow(m_itemIcons, nullptr, btn);
|
||||||
|
costLine->setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
||||||
|
face->addWidget(costLine);
|
||||||
|
costLine->setLine(cost);
|
||||||
|
|
||||||
|
// The config tooltip stays: it says what the module does, which the cost line
|
||||||
|
// does not (REQ-MOD-UI-MODULE-TOOLTIP).
|
||||||
if (def.tooltip)
|
if (def.tooltip)
|
||||||
{
|
{
|
||||||
btn->setToolTip(QString::fromStdString(*def.tooltip));
|
btn->setToolTip(QString::fromStdString(*def.tooltip));
|
||||||
@@ -731,6 +785,29 @@ void ShipLayoutDialog::updateGridWidget()
|
|||||||
void ShipLayoutDialog::updateStats()
|
void ShipLayoutDialog::updateStats()
|
||||||
{
|
{
|
||||||
m_statsPanel->refresh(m_shipId, m_placedModules);
|
m_statsPanel->refresh(m_shipId, m_placedModules);
|
||||||
|
|
||||||
|
// What the layout as it currently stands would cost to build, so the price follows
|
||||||
|
// every placement and removal (REQ-MOD-UI-DIALOG). The sums come from the
|
||||||
|
// simulation's own rules rather than being added up a second time here
|
||||||
|
// (REQ-MOD-MATERIALS, REQ-MOD-PRODUCTION-TIME).
|
||||||
|
ShipLayoutConfig layout;
|
||||||
|
layout.placedModules = m_placedModules;
|
||||||
|
const std::map<std::string, int> materials =
|
||||||
|
computeShipyardRequiredMaterials(*m_config, m_shipId, layout);
|
||||||
|
|
||||||
|
RecipeLineRow::Spec cost;
|
||||||
|
cost.inputs.reserve(materials.size());
|
||||||
|
for (const std::pair<const std::string, int>& entry : materials)
|
||||||
|
{
|
||||||
|
cost.inputs.push_back(RecipeLineRow::Amount{ entry.first, entry.second });
|
||||||
|
}
|
||||||
|
cost.durationSeconds =
|
||||||
|
computeShipyardProductionTimeSeconds(*m_config, m_shipId, layout);
|
||||||
|
|
||||||
|
m_buildCostLine->setLine(cost);
|
||||||
|
// A schematic costing no materials at all has no line to draw, and the section then
|
||||||
|
// stays out of the way.
|
||||||
|
m_buildCostSection->setVisible(!cost.inputs.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ShipLayoutDialog::canPlaceModule(const ModuleDef& def, QPoint position,
|
bool ShipLayoutDialog::canPlaceModule(const ModuleDef& def, QPoint position,
|
||||||
|
|||||||
@@ -14,7 +14,10 @@
|
|||||||
#include "ShipLayout.h"
|
#include "ShipLayout.h"
|
||||||
#include "ShipLayoutBlueprint.h"
|
#include "ShipLayoutBlueprint.h"
|
||||||
|
|
||||||
|
class ItemIconCache;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
|
class RecipeLineRow;
|
||||||
|
class SectionBox;
|
||||||
class ShipStatsPanel;
|
class ShipStatsPanel;
|
||||||
|
|
||||||
class ShipLayoutDialog : public QDialog
|
class ShipLayoutDialog : public QDialog
|
||||||
@@ -22,12 +25,15 @@ class ShipLayoutDialog : public QDialog
|
|||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
// itemIcons draws what each module costs and what the layout costs in total
|
||||||
|
// (REQ-MOD-UI-DIALOG, REQ-MOD-UI-STATS-PANEL). Not owned.
|
||||||
ShipLayoutDialog(const GameConfig* config,
|
ShipLayoutDialog(const GameConfig* config,
|
||||||
const std::string& shipId,
|
const std::string& shipId,
|
||||||
const ShipLayoutConfig& currentLayout,
|
const ShipLayoutConfig& currentLayout,
|
||||||
std::vector<ShipLayoutBlueprint>& allBlueprints,
|
std::vector<ShipLayoutBlueprint>& allBlueprints,
|
||||||
std::set<std::string> unlockedModuleIds,
|
std::set<std::string> unlockedModuleIds,
|
||||||
bool debugDraw,
|
bool debugDraw,
|
||||||
|
ItemIconCache* itemIcons,
|
||||||
QWidget* parent = nullptr);
|
QWidget* parent = nullptr);
|
||||||
|
|
||||||
std::optional<ShipLayoutConfig> getResult() const;
|
std::optional<ShipLayoutConfig> getResult() const;
|
||||||
@@ -60,6 +66,7 @@ private:
|
|||||||
void loadLayoutBlueprint(const std::vector<PlacedModule>& modules);
|
void loadLayoutBlueprint(const std::vector<PlacedModule>& modules);
|
||||||
|
|
||||||
const GameConfig* m_config;
|
const GameConfig* m_config;
|
||||||
|
ItemIconCache* m_itemIcons;
|
||||||
std::string m_shipId;
|
std::string m_shipId;
|
||||||
std::set<std::string> m_unlockedModuleIds;
|
std::set<std::string> m_unlockedModuleIds;
|
||||||
std::vector<std::string> m_shipLayout;
|
std::vector<std::string> m_shipLayout;
|
||||||
@@ -81,6 +88,8 @@ private:
|
|||||||
QPushButton* m_removeButton;
|
QPushButton* m_removeButton;
|
||||||
QWidget* m_gridWidget;
|
QWidget* m_gridWidget;
|
||||||
ShipStatsPanel* m_statsPanel;
|
ShipStatsPanel* m_statsPanel;
|
||||||
|
SectionBox* m_buildCostSection;
|
||||||
|
RecipeLineRow* m_buildCostLine;
|
||||||
bool m_debugDraw;
|
bool m_debugDraw;
|
||||||
|
|
||||||
std::optional<ShipLayoutConfig> m_result;
|
std::optional<ShipLayoutConfig> m_result;
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ struct OverlayVisuals
|
|||||||
QColor ghostValid;
|
QColor ghostValid;
|
||||||
QColor ghostInvalid;
|
QColor ghostInvalid;
|
||||||
QColor deconstructTint;
|
QColor deconstructTint;
|
||||||
QColor selectionRect;
|
|
||||||
QColor tileHighlight;
|
QColor tileHighlight;
|
||||||
QColor selectedOutline;
|
QColor selectedOutline;
|
||||||
QColor configTransfer; // blueprint ghost over a transfer target (REQ-UI-BLUEPRINT-TRANSFER)
|
QColor configTransfer; // blueprint ghost over a transfer target (REQ-UI-BLUEPRINT-TRANSFER)
|
||||||
|
|||||||
@@ -221,7 +221,6 @@ VisualsConfig VisualsLoader::load(const std::string& path)
|
|||||||
cfg.overlays.ghostValid = parseColor(requireString(ov, "ghost_valid", "overlays"), "overlays.ghost_valid");
|
cfg.overlays.ghostValid = parseColor(requireString(ov, "ghost_valid", "overlays"), "overlays.ghost_valid");
|
||||||
cfg.overlays.ghostInvalid = parseColor(requireString(ov, "ghost_invalid", "overlays"), "overlays.ghost_invalid");
|
cfg.overlays.ghostInvalid = parseColor(requireString(ov, "ghost_invalid", "overlays"), "overlays.ghost_invalid");
|
||||||
cfg.overlays.deconstructTint = parseColor(requireString(ov, "deconstruct_tint", "overlays"), "overlays.deconstruct_tint");
|
cfg.overlays.deconstructTint = parseColor(requireString(ov, "deconstruct_tint", "overlays"), "overlays.deconstruct_tint");
|
||||||
cfg.overlays.selectionRect = parseColor(requireString(ov, "selection_rect", "overlays"), "overlays.selection_rect");
|
|
||||||
cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight");
|
cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight");
|
||||||
cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline");
|
cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline");
|
||||||
cfg.overlays.configTransfer = parseColor(requireString(ov, "config_transfer", "overlays"), "overlays.config_transfer");
|
cfg.overlays.configTransfer = parseColor(requireString(ov, "config_transfer", "overlays"), "overlays.config_transfer");
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl)
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
WorldRenderer::WorldRenderer(Simulation& sim, const VisualsConfig& visuals,
|
WorldRenderer::WorldRenderer(const Simulation& sim, const VisualsConfig& visuals,
|
||||||
ItemIconCache* itemIcons, const std::string& configDir)
|
ItemIconCache* itemIcons, const std::string& configDir)
|
||||||
: m_sim(sim)
|
: m_sim(sim)
|
||||||
, m_visuals(visuals)
|
, m_visuals(visuals)
|
||||||
@@ -520,8 +520,8 @@ void WorldRenderer::drawPortItems(QPainter& painter, const WorldCoordinates& coo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared with belt items (REQ-GW-TILE-SIZE): a half-tile item icon, or the
|
// Shared with belt items (REQ-GW-TILE-SIZE): a half-tile colored square carrying the
|
||||||
// colored-square fallback (REQ-UI-ITEM-ICON), via the same draw path.
|
// item's icon (REQ-UI-ITEM-ICON), via the same draw path.
|
||||||
const std::function<void(const ItemType&, QPointF)> drawItem =
|
const std::function<void(const ItemType&, QPointF)> drawItem =
|
||||||
[&](const ItemType& type, QPointF worldPos)
|
[&](const ItemType& type, QPointF worldPos)
|
||||||
{
|
{
|
||||||
@@ -541,28 +541,14 @@ void WorldRenderer::drawPortItems(QPainter& painter, const WorldCoordinates& coo
|
|||||||
void WorldRenderer::drawWorldItem(QPainter& painter, const std::string& itemId,
|
void WorldRenderer::drawWorldItem(QPainter& painter, const std::string& itemId,
|
||||||
QPointF center, float halfPx)
|
QPointF center, float halfPx)
|
||||||
{
|
{
|
||||||
|
if (!m_itemIcons) { return; }
|
||||||
|
|
||||||
|
// The colored square carrying the item's icon (REQ-GW-TILE-SIZE, REQ-UI-ITEM-ICON).
|
||||||
|
// The composition lives in the icon cache, which draws it the same way here and in
|
||||||
|
// the UI's item displays, so an item reads the same on a belt as in a panel.
|
||||||
const QRectF itemRect(center.x() - halfPx, center.y() - halfPx,
|
const QRectF itemRect(center.x() - halfPx, center.y() - halfPx,
|
||||||
halfPx * 2, halfPx * 2);
|
halfPx * 2, halfPx * 2);
|
||||||
|
m_itemIcons->paintItem(painter, itemRect, itemId);
|
||||||
// Prefer the item's icon (REQ-UI-ITEM-ICON); it is rasterized once at the current
|
|
||||||
// half-tile pixel size and cached, so this is a plain pixmap blit per frame.
|
|
||||||
if (m_itemIcons && m_itemIcons->hasIcon(itemId))
|
|
||||||
{
|
|
||||||
int sizePx = qRound(static_cast<qreal>(halfPx * 2.0f));
|
|
||||||
if (sizePx < 1) { sizePx = 1; }
|
|
||||||
painter.drawPixmap(itemRect, m_itemIcons->getPixmap(itemId, sizePx),
|
|
||||||
QRectF(0, 0, sizePx, sizePx));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: the colored square from visuals.toml (REQ-GW-TILE-SIZE).
|
|
||||||
const std::map<std::string, ItemVisuals>::const_iterator it =
|
|
||||||
m_visuals.items.find(itemId);
|
|
||||||
if (it == m_visuals.items.end()) { return; }
|
|
||||||
painter.fillRect(itemRect, it->second.fill);
|
|
||||||
painter.setPen(QPen(it->second.outline, 1));
|
|
||||||
painter.setBrush(Qt::NoBrush);
|
|
||||||
painter.drawRect(itemRect);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void WorldRenderer::drawBeltItems(QPainter& painter, const WorldCoordinates& coordinates,
|
void WorldRenderer::drawBeltItems(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
@@ -998,9 +984,9 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
|
|||||||
|
|
||||||
// Deconstruct tint: while dragging a deconstruct box, tint every covered
|
// Deconstruct tint: while dragging a deconstruct box, tint every covered
|
||||||
// building/site (REQ-BLD-DECONSTRUCT-BOX); otherwise tint the hovered one.
|
// building/site (REQ-BLD-DECONSTRUCT-BOX); otherwise tint the hovered one.
|
||||||
if (frame.buildMode.isDeconstructMode() && frame.isBoxSelecting)
|
if (frame.buildMode.isDeconstructMode() && frame.boxWorldRect.has_value())
|
||||||
{
|
{
|
||||||
for (BuildingId id : buildingsInBox(m_sim.getFactoryState(), frame.boxStartTile, frame.boxCurrentTile))
|
for (BuildingId id : buildingsInBox(m_sim.getFactoryState(), *frame.boxWorldRect))
|
||||||
{
|
{
|
||||||
const Building* b = findBuilding(m_sim.getFactoryState(), id);
|
const Building* b = findBuilding(m_sim.getFactoryState(), id);
|
||||||
if (b && b->type == BuildingType::Hq) { continue; }
|
if (b && b->type == BuildingType::Hq) { continue; }
|
||||||
@@ -1033,16 +1019,25 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Box-select rectangle
|
// Box-select rectangle, drawn from the world rectangle itself and unsnapped, so
|
||||||
if (frame.isBoxSelecting)
|
// the outline sits where the mouse went rather than on the tile grid
|
||||||
|
// (REQ-UI-MULTI-SELECT).
|
||||||
|
if (frame.boxWorldRect.has_value())
|
||||||
{
|
{
|
||||||
const QPoint tl(std::min(frame.boxStartTile.x(), frame.boxCurrentTile.x()),
|
const QRectF selRect(
|
||||||
std::min(frame.boxStartTile.y(), frame.boxCurrentTile.y()));
|
coordinates.worldToWidget(QVector2D(frame.boxWorldRect->topLeft())),
|
||||||
const QPoint br(std::max(frame.boxStartTile.x(), frame.boxCurrentTile.x()) + 1,
|
coordinates.worldToWidget(QVector2D(frame.boxWorldRect->bottomRight())));
|
||||||
std::max(frame.boxStartTile.y(), frame.boxCurrentTile.y()) + 1);
|
// In deconstruct mode the box marks buildings for demolition, so it is
|
||||||
const QRectF selRect(coordinates.tileToWidget(tl),
|
// drawn in the deconstruct red instead of the selection color; the
|
||||||
coordinates.tileToWidget(br));
|
// tint's alpha governs only the fills it tints, never this outline
|
||||||
painter.setPen(QPen(m_visuals.overlays.selectionRect, 1));
|
// (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-BOX).
|
||||||
|
QColor rectColor = m_visuals.overlays.selectedOutline;
|
||||||
|
if (frame.buildMode.isDeconstructMode())
|
||||||
|
{
|
||||||
|
rectColor = m_visuals.overlays.deconstructTint;
|
||||||
|
rectColor.setAlpha(255);
|
||||||
|
}
|
||||||
|
painter.setPen(QPen(rectColor, 1));
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
painter.drawRect(selRect);
|
painter.drawRect(selRect);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,9 +54,11 @@ struct WorldRenderFrame
|
|||||||
const SelectionController& selection;
|
const SelectionController& selection;
|
||||||
const BuildModeController& buildMode;
|
const BuildModeController& buildMode;
|
||||||
const std::vector<ActiveBeam>& beams;
|
const std::vector<ActiveBeam>& beams;
|
||||||
bool isBoxSelecting;
|
// The box being dragged, in world coordinates and normalized, or nullopt when no
|
||||||
QPoint boxStartTile;
|
// drag is in progress — a press that has not passed the movement threshold is
|
||||||
QPoint boxCurrentTile;
|
// still a click and offers none (REQ-UI-MULTI-SELECT). It is the same rectangle
|
||||||
|
// the view selects by, so what is drawn and what is selected cannot disagree.
|
||||||
|
std::optional<QRectF> boxWorldRect;
|
||||||
bool isDebugDrawEnabled;
|
bool isDebugDrawEnabled;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,7 +79,7 @@ public:
|
|||||||
// `itemIcons` is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); not
|
// `itemIcons` is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); not
|
||||||
// owned, must outlive this renderer. `configDir` is used once, to load the
|
// owned, must outlive this renderer. `configDir` is used once, to load the
|
||||||
// per-building world icons.
|
// per-building world icons.
|
||||||
WorldRenderer(Simulation& sim, const VisualsConfig& visuals,
|
WorldRenderer(const Simulation& sim, const VisualsConfig& visuals,
|
||||||
ItemIconCache* itemIcons, const std::string& configDir);
|
ItemIconCache* itemIcons, const std::string& configDir);
|
||||||
~WorldRenderer();
|
~WorldRenderer();
|
||||||
|
|
||||||
@@ -113,9 +115,9 @@ private:
|
|||||||
void drawOverlays(QPainter& painter, const WorldCoordinates& coordinates,
|
void drawOverlays(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
const WorldRenderFrame& frame);
|
const WorldRenderFrame& frame);
|
||||||
// Draws a single item centered at widget-space `center`, spanning `halfPx` in
|
// Draws a single item centered at widget-space `center`, spanning `halfPx` in
|
||||||
// each direction (a half-tile). Uses the item's icon when one exists
|
// each direction (a half-tile): the colored square from visuals.toml, carrying
|
||||||
// (REQ-UI-ITEM-ICON), otherwise falls back to the colored square from
|
// the item's icon inset on top of it when one exists (REQ-UI-ITEM-ICON).
|
||||||
// visuals.toml. Shared by drawBeltItems and drawPortItems.
|
// Shared by drawBeltItems and drawPortItems.
|
||||||
void drawWorldItem(QPainter& painter, const std::string& itemId,
|
void drawWorldItem(QPainter& painter, const std::string& itemId,
|
||||||
QPointF center, float halfPx);
|
QPointF center, float halfPx);
|
||||||
void drawPortGlyph(QPainter& painter, const WorldCoordinates& coordinates,
|
void drawPortGlyph(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
@@ -141,9 +143,8 @@ private:
|
|||||||
|
|
||||||
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
||||||
|
|
||||||
// Non-const only because EntityAdmin's component accessors are; the renderer
|
// The renderer reads the simulation and never writes it.
|
||||||
// reads the simulation and never writes it.
|
const Simulation& m_sim;
|
||||||
Simulation& m_sim;
|
|
||||||
const VisualsConfig& m_visuals;
|
const VisualsConfig& m_visuals;
|
||||||
|
|
||||||
// Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by
|
// Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
#include "AutoProductionContent.h"
|
|
||||||
|
|
||||||
#include "Building.h"
|
|
||||||
#include "BuildingTarget.h"
|
|
||||||
#include "GameConfig.h"
|
|
||||||
#include "ProductionRules.h"
|
|
||||||
|
|
||||||
AutoProductionContent::AutoProductionContent(const SelectionContext& context,
|
|
||||||
const SelectionRequest& request,
|
|
||||||
QWidget* parent)
|
|
||||||
: BufferedBuildingContent(context, request.buildings.front(), parent)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
BufferedBuildingContent::CycleInfo AutoProductionContent::getCycleInfo(
|
|
||||||
const BuildingTarget& target) const
|
|
||||||
{
|
|
||||||
CycleInfo info;
|
|
||||||
// An auto-recipe building always runs an implicit recipe (REQ-BLD-SMELTER,
|
|
||||||
// REQ-BLD-REPROCESSING), so its production section is always shown -- but only a
|
|
||||||
// running cycle names a recipe, so while it is idle there is no cycle to describe.
|
|
||||||
info.runsProduction = true;
|
|
||||||
|
|
||||||
if (target.building)
|
|
||||||
{
|
|
||||||
// What the building handles at all, so its buffers are not blank whenever it
|
|
||||||
// happens to be between cycles (REQ-UI-SINGLE-SELECTION). This is the same union
|
|
||||||
// of every recipe of its type that the simulation sized the buffers over, and
|
|
||||||
// the locked ones among them are dropped when the card lists them.
|
|
||||||
for (const RecipeDef* recipe :
|
|
||||||
gatherCandidateRecipes(*getContext().config, *target.building))
|
|
||||||
{
|
|
||||||
for (const RecipeIngredient& ingredient : recipe->inputs)
|
|
||||||
{
|
|
||||||
info.handledInputs.push_back(ingredient.item);
|
|
||||||
}
|
|
||||||
for (const RecipeOutput& output : recipe->outputs)
|
|
||||||
{
|
|
||||||
info.handledOutputs.push_back(output.item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!target.building || !target.building->production.has_value())
|
|
||||||
{
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RecipeDef* recipe = getContext().config->recipes.findRecipeDef(
|
|
||||||
target.building->production->recipeId, target.type);
|
|
||||||
if (!recipe)
|
|
||||||
{
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const RecipeIngredient& ingredient : recipe->inputs)
|
|
||||||
{
|
|
||||||
info.perCycleInputs[ingredient.item] = ingredient.amount;
|
|
||||||
}
|
|
||||||
for (const RecipeOutput& output : recipe->outputs)
|
|
||||||
{
|
|
||||||
info.perCycleOutputs[output.item] = output.amount;
|
|
||||||
}
|
|
||||||
info.durationSeconds = recipe->durationSeconds;
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "BufferedBuildingContent.h"
|
|
||||||
#include "SelectionContentFactory.h"
|
|
||||||
|
|
||||||
// The card for a Smelter or a Reprocessing Plant (REQ-UI-SELECTION-CONTENT). Both
|
|
||||||
// auto-process whatever they receive and have no player-facing recipe selection
|
|
||||||
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), so the card has no configuration group at
|
|
||||||
// all; its cycle is whichever recipe is currently in production.
|
|
||||||
class AutoProductionContent : public BufferedBuildingContent
|
|
||||||
{
|
|
||||||
Q_OBJECT
|
|
||||||
|
|
||||||
public:
|
|
||||||
AutoProductionContent(const SelectionContext& context,
|
|
||||||
const SelectionRequest& request, QWidget* parent = nullptr);
|
|
||||||
|
|
||||||
protected:
|
|
||||||
CycleInfo getCycleInfo(const BuildingTarget& target) const override;
|
|
||||||
};
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
#include "DisplayName.h"
|
#include "DisplayName.h"
|
||||||
#include "FactoryQueries.h"
|
#include "FactoryQueries.h"
|
||||||
#include "ProductionSection.h"
|
#include "ProductionSection.h"
|
||||||
#include "RecipeSummaryRow.h"
|
#include "RecipeLineRow.h"
|
||||||
#include "SectionBox.h"
|
#include "SectionBox.h"
|
||||||
#include "SelectionNames.h"
|
#include "SelectionNames.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
@@ -18,13 +18,13 @@
|
|||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
|
||||||
std::vector<RecipeSummaryRow::Amount> toAmounts(const std::map<std::string, int>& map)
|
std::vector<RecipeLineRow::Amount> toAmounts(const std::map<std::string, int>& map)
|
||||||
{
|
{
|
||||||
std::vector<RecipeSummaryRow::Amount> amounts;
|
std::vector<RecipeLineRow::Amount> amounts;
|
||||||
amounts.reserve(map.size());
|
amounts.reserve(map.size());
|
||||||
for (const std::pair<const std::string, int>& entry : map)
|
for (const std::pair<const std::string, int>& entry : map)
|
||||||
{
|
{
|
||||||
amounts.push_back(RecipeSummaryRow::Amount{ entry.first, entry.second });
|
amounts.push_back(RecipeLineRow::Amount{ entry.first, entry.second });
|
||||||
}
|
}
|
||||||
return amounts;
|
return amounts;
|
||||||
}
|
}
|
||||||
@@ -66,17 +66,17 @@ BufferedBuildingContent::BufferedBuildingContent(const SelectionContext& context
|
|||||||
{
|
{
|
||||||
// The summary is configuration -- what the building will do -- so it sits with the
|
// The summary is configuration -- what the building will do -- so it sits with the
|
||||||
// selection control and is shown for a construction site too (REQ-UI-RECIPE-SUMMARY).
|
// selection control and is shown for a construction site too (REQ-UI-RECIPE-SUMMARY).
|
||||||
m_recipeSummary = new RecipeSummaryRow(context.itemIcons, this);
|
m_recipeSummary = new RecipeLineRow(context.itemIcons, context.buildingIcons, this);
|
||||||
getConfigurationLayout()->addWidget(m_recipeSummary);
|
getConfigurationLayout()->addWidget(m_recipeSummary);
|
||||||
|
|
||||||
m_inputSection = new SectionBox(tr("Input buffers"), this);
|
m_inputSection = new SectionBox(tr("Input buffers"), this);
|
||||||
m_inputChips = new ItemChipRow(context.itemIcons, m_inputSection);
|
m_inputChips = new ItemChipRow(context, m_inputSection);
|
||||||
m_inputSection->getContentLayout()->addWidget(m_inputChips);
|
m_inputSection->getContentLayout()->addWidget(m_inputChips);
|
||||||
|
|
||||||
m_production = new ProductionSection(this);
|
m_production = new ProductionSection(this);
|
||||||
|
|
||||||
m_outputSection = new SectionBox(tr("Output buffer"), this);
|
m_outputSection = new SectionBox(tr("Output buffers"), this);
|
||||||
m_outputChips = new ItemChipRow(context.itemIcons, m_outputSection);
|
m_outputChips = new ItemChipRow(context, m_outputSection);
|
||||||
m_outputSection->getContentLayout()->addWidget(m_outputChips);
|
m_outputSection->getContentLayout()->addWidget(m_outputChips);
|
||||||
|
|
||||||
// In the direction the materials flow: what goes in, what is being made of it, what
|
// In the direction the materials flow: what goes in, what is being made of it, what
|
||||||
@@ -98,10 +98,14 @@ void BufferedBuildingContent::refreshConfiguration()
|
|||||||
|
|
||||||
setBuildingIdentity(target.type, getBuildingTypeName(target.type));
|
setBuildingIdentity(target.type, getBuildingTypeName(target.type));
|
||||||
|
|
||||||
|
// The card already names the building in its header, so the summary states the cycle
|
||||||
|
// alone -- no building chip, no recipe name (REQ-UI-RECIPE-SUMMARY).
|
||||||
const CycleInfo cycle = getCycleInfo(target);
|
const CycleInfo cycle = getCycleInfo(target);
|
||||||
m_recipeSummary->setSummary(toAmounts(cycle.perCycleInputs),
|
RecipeLineRow::Spec summary;
|
||||||
toAmounts(cycle.perCycleOutputs),
|
summary.inputs = toAmounts(cycle.perCycleInputs);
|
||||||
cycle.durationSeconds);
|
summary.outputs = toAmounts(cycle.perCycleOutputs);
|
||||||
|
summary.durationSeconds = cycle.durationSeconds;
|
||||||
|
m_recipeSummary->setLine(summary);
|
||||||
|
|
||||||
refreshControls(target);
|
refreshControls(target);
|
||||||
}
|
}
|
||||||
@@ -196,11 +200,16 @@ std::vector<ItemChipRow::Entry> BufferedBuildingContent::buildOutputEntries(
|
|||||||
|
|
||||||
ItemChipRow::Entry chip;
|
ItemChipRow::Entry chip;
|
||||||
chip.itemId = itemId;
|
chip.itemId = itemId;
|
||||||
// Counted against the buffer's capacity, which is what production stops at
|
// Counted against this item's own buffer capacity, which is what production
|
||||||
// (REQ-MAT-OUTPUT-BUFFER).
|
// stops at (REQ-MAT-OUTPUT-BUFFER, REQ-UI-SINGLE-SELECTION). A chip for an item
|
||||||
chip.countText = building.outputBuffer.capacity > 0
|
// the building has no buffer for -- one left over from a previous recipe --
|
||||||
? tr("%1 / %2").arg(lookUp(buffered, itemId))
|
// carries the bare count, as an unsized buffer has no denominator to state.
|
||||||
.arg(building.outputBuffer.capacity)
|
const std::map<ItemType, int>::const_iterator capIt =
|
||||||
|
building.outputBuffer.caps.find(ItemType{itemId});
|
||||||
|
const int cap =
|
||||||
|
(capIt != building.outputBuffer.caps.end()) ? capIt->second : 0;
|
||||||
|
chip.countText = cap > 0
|
||||||
|
? tr("%1 / %2").arg(lookUp(buffered, itemId)).arg(cap)
|
||||||
: QString::number(lookUp(buffered, itemId));
|
: QString::number(lookUp(buffered, itemId));
|
||||||
chip.subLine = QString::fromStdString(toDisplayName(itemId));
|
chip.subLine = QString::fromStdString(toDisplayName(itemId));
|
||||||
entries.push_back(chip);
|
entries.push_back(chip);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
struct Building;
|
struct Building;
|
||||||
struct BuildingTarget;
|
struct BuildingTarget;
|
||||||
class ProductionSection;
|
class ProductionSection;
|
||||||
class RecipeSummaryRow;
|
class RecipeLineRow;
|
||||||
class SectionBox;
|
class SectionBox;
|
||||||
|
|
||||||
// Shared body of the four cards that show one building with buffers -- the Miner and
|
// Shared body of the four cards that show one building with buffers -- the Miner and
|
||||||
@@ -44,8 +44,9 @@ protected:
|
|||||||
// REQ-BLD-SALVAGE-BAY) or has no recipe or schematic selected yet: the
|
// REQ-BLD-SALVAGE-BAY) or has no recipe or schematic selected yet: the
|
||||||
// production section is then not shown (REQ-UI-PRODUCTION-PROGRESS).
|
// production section is then not shown (REQ-UI-PRODUCTION-PROGRESS).
|
||||||
bool runsProduction = false;
|
bool runsProduction = false;
|
||||||
// 0 while an auto-recipe building sits between cycles, when no single recipe
|
// 0 only when no recipe describes the cycle at all -- an auto-recipe building
|
||||||
// names a cycle time; the progress line then reads "idle".
|
// that has yet to run one (REQ-UI-RECIPE-SUMMARY). The progress line reads
|
||||||
|
// "idle" whenever no cycle is actually running, whatever this says.
|
||||||
double durationSeconds = 0.0;
|
double durationSeconds = 0.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ private:
|
|||||||
|
|
||||||
BuildingId m_id;
|
BuildingId m_id;
|
||||||
|
|
||||||
RecipeSummaryRow* m_recipeSummary;
|
RecipeLineRow* m_recipeSummary;
|
||||||
|
|
||||||
// Input buffers, production progress, output buffer -- in that order, so the card
|
// Input buffers, production progress, output buffer -- in that order, so the card
|
||||||
// reads the way the materials flow (REQ-UI-SINGLE-SELECTION).
|
// reads the way the materials flow (REQ-UI-SINGLE-SELECTION).
|
||||||
|
|||||||
@@ -14,13 +14,12 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.h
|
${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSummaryRow.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemTooltip.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.h
|
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BufferedBuildingContent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BufferedBuildingContent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeProductionContent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/RecipeProductionContent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/AutoProductionContent.h
|
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipyardContent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipyardContent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/StorageContent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/StorageContent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/HqContent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/HqContent.h
|
||||||
@@ -49,13 +48,12 @@ SET(SRCS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSummaryRow.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemTooltip.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BufferedBuildingContent.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BufferedBuildingContent.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeProductionContent.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/RecipeProductionContent.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/AutoProductionContent.cpp
|
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipyardContent.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipyardContent.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/StorageContent.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/StorageContent.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/HqContent.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/HqContent.cpp
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
#include "DebrisScrap.h"
|
#include "DebrisScrap.h"
|
||||||
|
#include "IconCaption.h"
|
||||||
|
#include "ItemIconCache.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
#include "StatRow.h"
|
#include "StatRow.h"
|
||||||
|
|
||||||
@@ -10,8 +12,16 @@ DebrisContent::DebrisContent(const SelectionContext& context,
|
|||||||
const SelectionRequest& request, QWidget* parent)
|
const SelectionRequest& request, QWidget* parent)
|
||||||
: SelectionContent(context, std::nullopt, parent)
|
: SelectionContent(context, std::nullopt, parent)
|
||||||
, m_debris(request.debris)
|
, m_debris(request.debris)
|
||||||
|
, m_scrapIcon(context.itemIcons->getInlineIcon(kScrapItemId, font()))
|
||||||
{
|
{
|
||||||
m_scrapRow = new StatRow(tr("Scrap remaining"), this);
|
// The row states the amount and the bare scrap icon, the icon standing in for the
|
||||||
|
// word so the row spells out neither the item nor its unit -- the form the header bar
|
||||||
|
// states the block stock in (REQ-UI-DEBRIS-PANEL, REQ-UI-ITEM-ICON,
|
||||||
|
// REQ-UI-BLOCKS-ICON). Without an icon file the caption has to name the item again,
|
||||||
|
// since nothing else on the row would.
|
||||||
|
m_scrapRow = new StatRow(
|
||||||
|
m_scrapIcon.isNull() ? tr("Scrap remaining") : tr("Remaining"), this);
|
||||||
|
m_scrapRow->setValueIcon(m_scrapIcon);
|
||||||
m_scrapRow->setValueEmphasized(true);
|
m_scrapRow->setValueEmphasized(true);
|
||||||
getRuntimeLayout()->addWidget(m_scrapRow);
|
getRuntimeLayout()->addWidget(m_scrapRow);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QPixmap>
|
||||||
|
|
||||||
#include "entt/entity/entity.hpp"
|
#include "entt/entity/entity.hpp"
|
||||||
|
|
||||||
#include "SelectionContent.h"
|
#include "SelectionContent.h"
|
||||||
@@ -28,4 +30,8 @@ protected:
|
|||||||
private:
|
private:
|
||||||
std::vector<entt::entity> m_debris;
|
std::vector<entt::entity> m_debris;
|
||||||
StatRow* m_scrapRow;
|
StatRow* m_scrapRow;
|
||||||
|
// The bare scrap icon the row states its amount with, or null where there is no icon
|
||||||
|
// file and the row names the item in words instead (REQ-UI-DEBRIS-PANEL). Taken once:
|
||||||
|
// it depends on the card's font, not on the value.
|
||||||
|
QPixmap m_scrapIcon;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
#include "DisplayName.h"
|
#include "DisplayName.h"
|
||||||
#include "EntityAdmin.h"
|
#include "EntityAdmin.h"
|
||||||
#include "FactionComponent.h"
|
#include "FactionComponent.h"
|
||||||
|
#include "IconCaption.h"
|
||||||
|
#include "ItemIconCache.h"
|
||||||
#include "ShipIdentityComponent.h"
|
#include "ShipIdentityComponent.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
#include "StatRow.h"
|
#include "StatRow.h"
|
||||||
@@ -20,6 +22,7 @@ FieldMultiContent::FieldMultiContent(const SelectionContext& context,
|
|||||||
: SelectionContent(context, std::nullopt, parent)
|
: SelectionContent(context, std::nullopt, parent)
|
||||||
, m_debris(request.debris)
|
, m_debris(request.debris)
|
||||||
, m_scrapRow(nullptr)
|
, m_scrapRow(nullptr)
|
||||||
|
, m_scrapIcon(context.itemIcons->getInlineIcon(kScrapItemId, font()))
|
||||||
{
|
{
|
||||||
setIdentity(QPixmap(), tr("Mixed selection"));
|
setIdentity(QPixmap(), tr("Mixed selection"));
|
||||||
setCountSlot(static_cast<int>(request.actors.size() + request.debris.size()));
|
setCountSlot(static_cast<int>(request.actors.size() + request.debris.size()));
|
||||||
@@ -84,9 +87,11 @@ void FieldMultiContent::buildSummary(const std::vector<entt::entity>& actors)
|
|||||||
getRuntimeLayout()->addWidget(new CountRow(
|
getRuntimeLayout()->addWidget(new CountRow(
|
||||||
QPixmap(), tr("Debris"), static_cast<int>(m_debris.size()), this));
|
QPixmap(), tr("Debris"), static_cast<int>(m_debris.size()), this));
|
||||||
|
|
||||||
// Indented under the debris row, so the total reads as belonging to it
|
// Indented under the debris row, so the total reads as belonging to it, and
|
||||||
// (REQ-UI-DEBRIS-PANEL).
|
// stated in the same amount-plus-bare-icon form the debris card uses
|
||||||
|
// (REQ-UI-DEBRIS-PANEL, REQ-UI-FIELD-MULTI-SELECTION).
|
||||||
m_scrapRow = new StatRow(tr("holding"), this);
|
m_scrapRow = new StatRow(tr("holding"), this);
|
||||||
|
m_scrapRow->setValueIcon(m_scrapIcon);
|
||||||
m_scrapRow->setIndented(true);
|
m_scrapRow->setIndented(true);
|
||||||
m_scrapRow->setValueEmphasized(true);
|
m_scrapRow->setValueEmphasized(true);
|
||||||
getRuntimeLayout()->addWidget(m_scrapRow);
|
getRuntimeLayout()->addWidget(m_scrapRow);
|
||||||
@@ -99,7 +104,9 @@ void FieldMultiContent::refreshRuntime()
|
|||||||
// selection and rebuilds this card -- but the scrap falls as the debris is collected.
|
// selection and rebuilds this card -- but the scrap falls as the debris is collected.
|
||||||
if (m_scrapRow)
|
if (m_scrapRow)
|
||||||
{
|
{
|
||||||
m_scrapRow->setValue(tr("%1 scrap")
|
const int scrap = sumDebrisScrap(getContext().sim->getAdmin(), m_debris);
|
||||||
.arg(sumDebrisScrap(getContext().sim->getAdmin(), m_debris)));
|
// The icon names the item; only without one does the value have to say the word.
|
||||||
|
m_scrapRow->setValue(m_scrapIcon.isNull() ? tr("%1 scrap").arg(scrap)
|
||||||
|
: QString::number(scrap));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QPixmap>
|
||||||
|
|
||||||
#include "entt/entity/entity.hpp"
|
#include "entt/entity/entity.hpp"
|
||||||
|
|
||||||
#include "SelectionContent.h"
|
#include "SelectionContent.h"
|
||||||
@@ -30,4 +32,7 @@ private:
|
|||||||
// Null unless debris is part of the selection; the only value here that changes
|
// Null unless debris is part of the selection; the only value here that changes
|
||||||
// while the selection stands.
|
// while the selection stands.
|
||||||
StatRow* m_scrapRow;
|
StatRow* m_scrapRow;
|
||||||
|
// The bare scrap icon the sub-row states its total with, or null where there is no
|
||||||
|
// icon file and the total names the item in words instead (REQ-UI-DEBRIS-PANEL).
|
||||||
|
QPixmap m_scrapIcon;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ HqContent::HqContent(const SelectionContext& context, const SelectionRequest& re
|
|||||||
m_hpBar = new BarRow(tr("HP"), this);
|
m_hpBar = new BarRow(tr("HP"), this);
|
||||||
|
|
||||||
m_stockSection = new SectionBox(tr("Building blocks"), this);
|
m_stockSection = new SectionBox(tr("Building blocks"), this);
|
||||||
m_stockChips = new ItemChipRow(context.itemIcons, m_stockSection);
|
m_stockChips = new ItemChipRow(context, m_stockSection);
|
||||||
m_stockSection->getContentLayout()->addWidget(m_stockChips);
|
m_stockSection->getContentLayout()->addWidget(m_stockChips);
|
||||||
|
|
||||||
// HP first, as on every card that has it (REQ-UI-SELECTION-CARD, REQ-UI-HQ-PANEL).
|
// HP first, as on every card that has it (REQ-UI-SELECTION-CARD, REQ-UI-HQ-PANEL).
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
#include "ItemChip.h"
|
#include "ItemChip.h"
|
||||||
|
|
||||||
|
#include <QCursor>
|
||||||
#include <QFont>
|
#include <QFont>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QPalette>
|
#include <QPalette>
|
||||||
|
#include <QTimer>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "ItemTooltip.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -13,11 +17,18 @@ namespace
|
|||||||
const int kCountSizeRisePt = 2;
|
const int kCountSizeRisePt = 2;
|
||||||
const int kSubLineSizeDropPt = 1;
|
const int kSubLineSizeDropPt = 1;
|
||||||
|
|
||||||
|
// How long the cursor rests on a chip before its tooltip appears, in milliseconds.
|
||||||
|
// Matches the feel of an ordinary tooltip, which this one replaces (REQ-UI-ITEM-TOOLTIP).
|
||||||
|
const int kHoverDelayMs = 500;
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
ItemChip::ItemChip(const QPixmap& icon, QWidget* parent)
|
ItemChip::ItemChip(const SelectionContext& context, const std::string& itemId,
|
||||||
|
const QPixmap& icon, QWidget* parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
|
, m_context(context)
|
||||||
|
, m_itemId(itemId)
|
||||||
{
|
{
|
||||||
// Its own boxed chrome, drawn with palette colors like the rest of the panel's
|
// Its own boxed chrome, drawn with palette colors like the rest of the panel's
|
||||||
// furniture rather than from visuals.toml, which is for world rendering.
|
// furniture rather than from visuals.toml, which is for world rendering.
|
||||||
@@ -58,6 +69,11 @@ ItemChip::ItemChip(const QPixmap& icon, QWidget* parent)
|
|||||||
|
|
||||||
layout->addWidget(m_iconLabel);
|
layout->addWidget(m_iconLabel);
|
||||||
layout->addWidget(text);
|
layout->addWidget(text);
|
||||||
|
|
||||||
|
m_hoverTimer = new QTimer(this);
|
||||||
|
m_hoverTimer->setSingleShot(true);
|
||||||
|
m_hoverTimer->setInterval(kHoverDelayMs);
|
||||||
|
connect(m_hoverTimer, &QTimer::timeout, this, &ItemChip::showTooltip);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ItemChip::setCount(const QString& count)
|
void ItemChip::setCount(const QString& count)
|
||||||
@@ -70,3 +86,28 @@ void ItemChip::setSubLine(const QString& subLine)
|
|||||||
m_subLineLabel->setText(subLine);
|
m_subLineLabel->setText(subLine);
|
||||||
m_subLineLabel->setVisible(!subLine.isEmpty());
|
m_subLineLabel->setVisible(!subLine.isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ItemChip::enterEvent(QEvent* event)
|
||||||
|
{
|
||||||
|
m_hoverTimer->start();
|
||||||
|
QWidget::enterEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ItemChip::leaveEvent(QEvent* event)
|
||||||
|
{
|
||||||
|
m_hoverTimer->stop();
|
||||||
|
if (m_tooltip != nullptr)
|
||||||
|
{
|
||||||
|
m_tooltip->hide();
|
||||||
|
}
|
||||||
|
QWidget::leaveEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ItemChip::showTooltip()
|
||||||
|
{
|
||||||
|
if (m_tooltip == nullptr)
|
||||||
|
{
|
||||||
|
m_tooltip = new ItemTooltip(m_context, m_itemId, this);
|
||||||
|
}
|
||||||
|
m_tooltip->showAt(QCursor::pos());
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,51 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include "SelectionContext.h"
|
||||||
|
|
||||||
|
class ItemTooltip;
|
||||||
|
class QEvent;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
|
class QTimer;
|
||||||
|
|
||||||
// One buffered item: its icon, its count in a larger type, and a sub-line beneath the
|
// One buffered item: its icon, its count in a larger type, and a sub-line beneath the
|
||||||
// count (REQ-UI-SINGLE-SELECTION). Boxed so a row of them reads as separate quantities
|
// count (REQ-UI-SINGLE-SELECTION). Boxed so a row of them reads as separate quantities
|
||||||
// rather than as a run of text.
|
// rather than as a run of text.
|
||||||
|
//
|
||||||
|
// Hovering it explains where its item comes from (REQ-UI-ITEM-TOOLTIP).
|
||||||
class ItemChip : public QWidget
|
class ItemChip : public QWidget
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// An empty icon leaves the icon off and the chip laid out around the text alone.
|
// An empty icon leaves the icon off and the chip laid out around the text alone.
|
||||||
ItemChip(const QPixmap& icon, QWidget* parent = nullptr);
|
ItemChip(const SelectionContext& context, const std::string& itemId,
|
||||||
|
const QPixmap& icon, QWidget* parent = nullptr);
|
||||||
|
|
||||||
void setCount(const QString& count);
|
void setCount(const QString& count);
|
||||||
// Left empty for an item with nothing to say beneath its count.
|
// Left empty for an item with nothing to say beneath its count.
|
||||||
void setSubLine(const QString& subLine);
|
void setSubLine(const QString& subLine);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void enterEvent(QEvent* event) override;
|
||||||
|
void leaveEvent(QEvent* event) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QLabel* m_iconLabel;
|
void showTooltip();
|
||||||
QLabel* m_countLabel;
|
|
||||||
QLabel* m_subLineLabel;
|
SelectionContext m_context;
|
||||||
|
std::string m_itemId;
|
||||||
|
QLabel* m_iconLabel;
|
||||||
|
QLabel* m_countLabel;
|
||||||
|
QLabel* m_subLineLabel;
|
||||||
|
// Held back so a cursor crossing the chip on its way elsewhere does not flash the
|
||||||
|
// tooltip, the way an ordinary tooltip is.
|
||||||
|
QTimer* m_hoverTimer;
|
||||||
|
// Built on the first hover, because most chips are never hovered at all.
|
||||||
|
ItemTooltip* m_tooltip = nullptr;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
|
||||||
// Size the item icon is drawn at inside a chip, in device-independent pixels.
|
// Size the item's colored square is drawn at inside a chip, in device-independent
|
||||||
const int kChipIconSizePx = 18;
|
// pixels. Larger than the artwork it carries, which the square insets (REQ-UI-ITEM-ICON).
|
||||||
|
const int kChipIconSizePx = 22;
|
||||||
|
|
||||||
// Chips per row. Two fit the panel's capped width side by side; a third would force the
|
// Chips per row. Two fit the panel's capped width side by side; a third would force the
|
||||||
// counts to shrink.
|
// counts to shrink.
|
||||||
@@ -18,9 +19,9 @@ const int kChipsPerRow = 2;
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
ItemChipRow::ItemChipRow(ItemIconCache* itemIcons, QWidget* parent)
|
ItemChipRow::ItemChipRow(const SelectionContext& context, QWidget* parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
, m_itemIcons(itemIcons)
|
, m_context(context)
|
||||||
{
|
{
|
||||||
m_layout = new QGridLayout(this);
|
m_layout = new QGridLayout(this);
|
||||||
m_layout->setContentsMargins(0, 0, 0, 0);
|
m_layout->setContentsMargins(0, 0, 0, 0);
|
||||||
@@ -64,13 +65,13 @@ void ItemChipRow::rebuildChips(const std::vector<Entry>& entries)
|
|||||||
for (std::size_t index = 0; index < entries.size(); ++index)
|
for (std::size_t index = 0; index < entries.size(); ++index)
|
||||||
{
|
{
|
||||||
const std::string& itemId = entries[index].itemId;
|
const std::string& itemId = entries[index].itemId;
|
||||||
// A missing icon file is not an error (REQ-UI-ITEM-ICON): the chip is then laid
|
// The item's icon on its colored square (REQ-UI-ITEM-ICON). A missing icon file
|
||||||
// out around its count alone.
|
// is not an error: the chip then carries the square alone, and only an item with
|
||||||
const QPixmap icon = m_itemIcons->hasIcon(itemId)
|
// no square either leaves the chip laid out around its count alone.
|
||||||
? m_itemIcons->getPixmap(itemId, kChipIconSizePx)
|
const QPixmap icon =
|
||||||
: QPixmap();
|
m_context.itemIcons->getSquarePixmap(itemId, kChipIconSizePx);
|
||||||
|
|
||||||
ItemChip* chip = new ItemChip(icon, this);
|
ItemChip* chip = new ItemChip(m_context, itemId, icon, this);
|
||||||
m_layout->addWidget(chip, static_cast<int>(index) / kChipsPerRow,
|
m_layout->addWidget(chip, static_cast<int>(index) / kChipsPerRow,
|
||||||
static_cast<int>(index) % kChipsPerRow);
|
static_cast<int>(index) % kChipsPerRow);
|
||||||
// Shown right away, because the panel measures itself as soon as this returns. A
|
// Shown right away, because the panel measures itself as soon as this returns. A
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include "SelectionContext.h"
|
||||||
|
|
||||||
class ItemChip;
|
class ItemChip;
|
||||||
class ItemIconCache;
|
|
||||||
class QGridLayout;
|
class QGridLayout;
|
||||||
|
|
||||||
// The buffered items of one building, each as a chip carrying the item's icon, its
|
// The buffered items of one building, each as a chip carrying the item's icon, its
|
||||||
@@ -28,16 +29,18 @@ public:
|
|||||||
QString subLine;
|
QString subLine;
|
||||||
};
|
};
|
||||||
|
|
||||||
// itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); an item with
|
// The context supplies the icon cache each chip draws its item with
|
||||||
// no icon file simply shows no icon, which is not an error. Not owned.
|
// (REQ-UI-ITEM-ICON) -- an item with no icon file simply shows no icon, which is not
|
||||||
explicit ItemChipRow(ItemIconCache* itemIcons, QWidget* parent = nullptr);
|
// an error -- and what a chip's tooltip reads to explain its item
|
||||||
|
// (REQ-UI-ITEM-TOOLTIP).
|
||||||
|
explicit ItemChipRow(const SelectionContext& context, QWidget* parent = nullptr);
|
||||||
|
|
||||||
void setEntries(const std::vector<Entry>& entries);
|
void setEntries(const std::vector<Entry>& entries);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void rebuildChips(const std::vector<Entry>& entries);
|
void rebuildChips(const std::vector<Entry>& entries);
|
||||||
|
|
||||||
ItemIconCache* m_itemIcons;
|
SelectionContext m_context;
|
||||||
QGridLayout* m_layout;
|
QGridLayout* m_layout;
|
||||||
std::vector<std::string> m_itemIds; // what the chips currently stand for
|
std::vector<std::string> m_itemIds; // what the chips currently stand for
|
||||||
std::vector<ItemChip*> m_chips;
|
std::vector<ItemChip*> m_chips;
|
||||||
|
|||||||
166
src/ui/selection/ItemTooltip.cpp
Normal file
166
src/ui/selection/ItemTooltip.cpp
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
#include "ItemTooltip.h"
|
||||||
|
|
||||||
|
#include <QFont>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLayoutItem>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QRect>
|
||||||
|
#include <QScreen>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "DisplayName.h"
|
||||||
|
#include "GameConfig.h"
|
||||||
|
#include "ItemProducers.h"
|
||||||
|
#include "RecipeLineRow.h"
|
||||||
|
#include "RecipesConfig.h"
|
||||||
|
#include "Simulation.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// Gap between the cursor and the tooltip's corner, in device-independent pixels. Wide
|
||||||
|
// enough that the cursor never covers the heading.
|
||||||
|
const int kCursorOffsetPx = 16;
|
||||||
|
|
||||||
|
std::vector<RecipeLineRow::Amount> toAmounts(
|
||||||
|
const std::vector<RecipeIngredient>& ingredients)
|
||||||
|
{
|
||||||
|
std::vector<RecipeLineRow::Amount> amounts;
|
||||||
|
amounts.reserve(ingredients.size());
|
||||||
|
for (const RecipeIngredient& ingredient : ingredients)
|
||||||
|
{
|
||||||
|
amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount });
|
||||||
|
}
|
||||||
|
return amounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<RecipeLineRow::Amount> toAmounts(const std::vector<RecipeOutput>& outputs)
|
||||||
|
{
|
||||||
|
std::vector<RecipeLineRow::Amount> amounts;
|
||||||
|
amounts.reserve(outputs.size());
|
||||||
|
for (const RecipeOutput& output : outputs)
|
||||||
|
{
|
||||||
|
amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount });
|
||||||
|
}
|
||||||
|
return amounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The screen the cursor is on, falling back to the primary screen when the position is
|
||||||
|
// on none of them (a cursor between two screens of different heights).
|
||||||
|
QRect availableScreenRect(const QPoint& globalPos)
|
||||||
|
{
|
||||||
|
const QScreen* screen = QGuiApplication::screenAt(globalPos);
|
||||||
|
if (screen == nullptr)
|
||||||
|
{
|
||||||
|
screen = QGuiApplication::primaryScreen();
|
||||||
|
}
|
||||||
|
return (screen != nullptr) ? screen->availableGeometry() : QRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
ItemTooltip::ItemTooltip(const SelectionContext& context, const std::string& itemId,
|
||||||
|
QWidget* parent)
|
||||||
|
: QFrame(parent, Qt::ToolTip)
|
||||||
|
, m_context(context)
|
||||||
|
, m_itemId(itemId)
|
||||||
|
{
|
||||||
|
// The palette's tooltip colors behind a themed border, so it reads as a tooltip
|
||||||
|
// rather than as a stray window.
|
||||||
|
setFrameShape(QFrame::StyledPanel);
|
||||||
|
setAutoFillBackground(true);
|
||||||
|
QPalette tooltipPalette = palette();
|
||||||
|
tooltipPalette.setColor(QPalette::Window,
|
||||||
|
tooltipPalette.color(QPalette::ToolTipBase));
|
||||||
|
tooltipPalette.setColor(QPalette::WindowText,
|
||||||
|
tooltipPalette.color(QPalette::ToolTipText));
|
||||||
|
setPalette(tooltipPalette);
|
||||||
|
|
||||||
|
m_layout = new QVBoxLayout(this);
|
||||||
|
m_layout->setContentsMargins(6, 4, 6, 4);
|
||||||
|
m_layout->setSpacing(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ItemTooltip::showAt(const QPoint& globalPos)
|
||||||
|
{
|
||||||
|
rebuild();
|
||||||
|
adjustSize();
|
||||||
|
|
||||||
|
// Kept on the screen the cursor is on: a tooltip for a chip near the right or
|
||||||
|
// bottom edge would otherwise be cut off.
|
||||||
|
const QRect screen = availableScreenRect(globalPos);
|
||||||
|
QPoint topLeft(globalPos.x() + kCursorOffsetPx, globalPos.y() + kCursorOffsetPx);
|
||||||
|
if (screen.isValid())
|
||||||
|
{
|
||||||
|
topLeft.setX(qMax(screen.left(),
|
||||||
|
qMin(topLeft.x(), screen.right() - width() + 1)));
|
||||||
|
topLeft.setY(qMax(screen.top(),
|
||||||
|
qMin(topLeft.y(), screen.bottom() - height() + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
move(topLeft);
|
||||||
|
show();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ItemTooltip::rebuild()
|
||||||
|
{
|
||||||
|
while (QLayoutItem* item = m_layout->takeAt(0))
|
||||||
|
{
|
||||||
|
if (item->widget())
|
||||||
|
{
|
||||||
|
item->widget()->deleteLater();
|
||||||
|
}
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The heading names the item. For an input chip this is the only place it is named
|
||||||
|
// at all, since such a chip carries a count and no name (REQ-UI-SINGLE-SELECTION).
|
||||||
|
QLabel* heading =
|
||||||
|
new QLabel(QString::fromStdString(toDisplayName(m_itemId)), this);
|
||||||
|
QFont headingFont = heading->font();
|
||||||
|
headingFont.setBold(true);
|
||||||
|
heading->setFont(headingFont);
|
||||||
|
m_layout->addWidget(heading);
|
||||||
|
heading->show();
|
||||||
|
|
||||||
|
const ItemProduction production =
|
||||||
|
findItemProduction(m_itemId, *m_context.sim, *m_context.config);
|
||||||
|
|
||||||
|
// Scrap is salvaged from debris rather than crafted (REQ-RES-DEBRIS-DROP), so it
|
||||||
|
// says where it comes from in place of the caption and lists nothing.
|
||||||
|
QLabel* caption = new QLabel(production.origin == ItemOrigin::Salvaged
|
||||||
|
? tr("Salvaged from debris")
|
||||||
|
: tr("Produced by"), this);
|
||||||
|
m_layout->addWidget(caption);
|
||||||
|
caption->show();
|
||||||
|
|
||||||
|
if (production.origin == ItemOrigin::Undiscovered)
|
||||||
|
{
|
||||||
|
// Made somehow, but by nothing the player has unlocked -- named without being
|
||||||
|
// shown a path they cannot take yet (REQ-UI-ITEM-TOOLTIP).
|
||||||
|
QLabel* undiscovered = new QLabel(tr("Undiscovered"), this);
|
||||||
|
m_layout->addWidget(undiscovered);
|
||||||
|
undiscovered->show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const RecipeDef* recipe : production.recipes)
|
||||||
|
{
|
||||||
|
RecipeLineRow::Spec spec;
|
||||||
|
spec.building = recipe->building;
|
||||||
|
spec.name = QString::fromStdString(toDisplayName(recipe->id));
|
||||||
|
spec.inputs = toAmounts(recipe->inputs);
|
||||||
|
spec.outputs = toAmounts(recipe->outputs);
|
||||||
|
spec.durationSeconds = recipe->durationSeconds;
|
||||||
|
|
||||||
|
// Boxed, because an item with several producers stacks several of these and a run
|
||||||
|
// of bare lines reads as one field of icons (REQ-UI-ITEM-TOOLTIP).
|
||||||
|
RecipeLineRow* line =
|
||||||
|
new RecipeLineRow(m_context.itemIcons, m_context.buildingIcons, this);
|
||||||
|
line->setCardChrome(true);
|
||||||
|
m_layout->addWidget(line);
|
||||||
|
line->setLine(spec);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/ui/selection/ItemTooltip.h
Normal file
36
src/ui/selection/ItemTooltip.h
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <QFrame>
|
||||||
|
|
||||||
|
#include "SelectionContext.h"
|
||||||
|
|
||||||
|
class QVBoxLayout;
|
||||||
|
|
||||||
|
// The tooltip an item chip shows on hover: the item's name, and every way the player
|
||||||
|
// can currently produce it, each drawn as a recipe line (REQ-UI-ITEM-TOOLTIP).
|
||||||
|
//
|
||||||
|
// A window of its own rather than a Qt tooltip, because Qt's are text and this one is
|
||||||
|
// drawn -- item squares, building chips and all. It is a Qt::ToolTip window, so it
|
||||||
|
// floats above everything and takes no input.
|
||||||
|
class ItemTooltip : public QFrame
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
ItemTooltip(const SelectionContext& context, const std::string& itemId,
|
||||||
|
QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
// Rebuilds the content and shows the tooltip near the given screen position, kept
|
||||||
|
// inside the screen. Rebuilt on every show because what produces an item changes as
|
||||||
|
// the player unlocks recipes (REQ-LOCK-UI-RECIPE).
|
||||||
|
void showAt(const QPoint& globalPos);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void rebuild();
|
||||||
|
|
||||||
|
SelectionContext m_context;
|
||||||
|
std::string m_itemId;
|
||||||
|
QVBoxLayout* m_layout;
|
||||||
|
};
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
#include "CountRow.h"
|
#include "CountRow.h"
|
||||||
#include "FactoryQueries.h"
|
#include "FactoryQueries.h"
|
||||||
#include "GameConfig.h"
|
#include "GameConfig.h"
|
||||||
|
#include "IconCaption.h"
|
||||||
|
#include "ItemIconCache.h"
|
||||||
#include "SelectionNames.h"
|
#include "SelectionNames.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
#include "StatRow.h"
|
#include "StatRow.h"
|
||||||
@@ -92,7 +94,13 @@ void MultiBuildingContent::buildSummary()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The block icon stands beside the total in place of the word, as it does on a build
|
||||||
|
// button and a blueprint card (REQ-UI-MULTI-SELECTION, REQ-UI-BLOCKS-ICON). With no
|
||||||
|
// icon file the total is the bare number, as it is there
|
||||||
|
// (REQ-UI-BUILD-COST, REQ-UI-BLUEPRINT-CARD).
|
||||||
StatRow* totalRow = new StatRow(tr("Total cost"), this);
|
StatRow* totalRow = new StatRow(tr("Total cost"), this);
|
||||||
|
totalRow->setValueIcon(
|
||||||
|
getContext().itemIcons->getInlineIcon(kBlockItemId, font()));
|
||||||
totalRow->setValue(QString::number(totalCost));
|
totalRow->setValue(QString::number(totalCost));
|
||||||
totalRow->setValueEmphasized(true);
|
totalRow->setValueEmphasized(true);
|
||||||
getRuntimeLayout()->addWidget(totalRow);
|
getRuntimeLayout()->addWidget(totalRow);
|
||||||
|
|||||||
@@ -45,13 +45,14 @@ void RecipeSelectionControl::setRecipeId(const std::string& recipeId)
|
|||||||
if (option.id == recipeId && !option.id.empty())
|
if (option.id == recipeId && !option.id.empty())
|
||||||
{
|
{
|
||||||
m_button->setText(option.caption);
|
m_button->setText(option.caption);
|
||||||
m_button->setToolTip(option.tooltip);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No tooltip of its own: what the building has selected is drawn beneath the button
|
||||||
|
// as the recipe summary, and where its items come from is reachable by hovering the
|
||||||
|
// card's item chips (REQ-UI-SELECT-BUTTON, REQ-UI-ITEM-TOOLTIP).
|
||||||
m_button->setText(m_type == BuildingType::Shipyard
|
m_button->setText(m_type == BuildingType::Shipyard
|
||||||
? tr("Select schematic")
|
? tr("Select schematic")
|
||||||
: tr("Select recipe"));
|
: tr("Select recipe"));
|
||||||
m_button->setToolTip(QString());
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
#include "RecipeSummaryRow.h"
|
|
||||||
|
|
||||||
#include <QHBoxLayout>
|
|
||||||
#include <QLabel>
|
|
||||||
#include <QLayoutItem>
|
|
||||||
#include <QPixmap>
|
|
||||||
|
|
||||||
#include "ItemIconCache.h"
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
|
|
||||||
// Size the item icons are drawn at on the summary line, in device-independent pixels.
|
|
||||||
const int kSummaryIconSizePx = 14;
|
|
||||||
|
|
||||||
// Adds a freshly built label to the summary and shows it.
|
|
||||||
//
|
|
||||||
// The show is what makes it count: a widget created under an already-visible parent
|
|
||||||
// starts hidden, and a layout treats a hidden item as empty, so an unshown label would
|
|
||||||
// add nothing to the size hint the panel measures itself against as soon as this
|
|
||||||
// returns.
|
|
||||||
void addAndShow(QHBoxLayout* layout, QLabel* label)
|
|
||||||
{
|
|
||||||
layout->addWidget(label);
|
|
||||||
label->show();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
|
|
||||||
RecipeSummaryRow::RecipeSummaryRow(ItemIconCache* itemIcons, QWidget* parent)
|
|
||||||
: QWidget(parent)
|
|
||||||
, m_itemIcons(itemIcons)
|
|
||||||
{
|
|
||||||
m_layout = new QHBoxLayout(this);
|
|
||||||
m_layout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
m_layout->setSpacing(4);
|
|
||||||
|
|
||||||
hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
void RecipeSummaryRow::setSummary(const std::vector<Amount>& inputs,
|
|
||||||
const std::vector<Amount>& outputs,
|
|
||||||
double durationSeconds)
|
|
||||||
{
|
|
||||||
if (outputs.empty() && inputs.empty())
|
|
||||||
{
|
|
||||||
// Forgotten as well as hidden, so re-selecting the same recipe later is seen as
|
|
||||||
// a change and shows the row again.
|
|
||||||
m_inputs.clear();
|
|
||||||
m_outputs.clear();
|
|
||||||
m_durationSeconds = -1.0;
|
|
||||||
hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (inputs == m_inputs && outputs == m_outputs
|
|
||||||
&& durationSeconds == m_durationSeconds)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_inputs = inputs;
|
|
||||||
m_outputs = outputs;
|
|
||||||
m_durationSeconds = durationSeconds;
|
|
||||||
rebuild(inputs, outputs, durationSeconds);
|
|
||||||
show();
|
|
||||||
}
|
|
||||||
|
|
||||||
void RecipeSummaryRow::rebuild(const std::vector<Amount>& inputs,
|
|
||||||
const std::vector<Amount>& outputs,
|
|
||||||
double durationSeconds)
|
|
||||||
{
|
|
||||||
while (QLayoutItem* item = m_layout->takeAt(0))
|
|
||||||
{
|
|
||||||
if (item->widget())
|
|
||||||
{
|
|
||||||
item->widget()->deleteLater();
|
|
||||||
}
|
|
||||||
delete item;
|
|
||||||
}
|
|
||||||
|
|
||||||
addAmounts(inputs);
|
|
||||||
if (!inputs.empty())
|
|
||||||
{
|
|
||||||
const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW
|
|
||||||
addAndShow(m_layout, new QLabel(QString(rightArrow), this));
|
|
||||||
}
|
|
||||||
addAmounts(outputs);
|
|
||||||
|
|
||||||
if (durationSeconds > 0.0)
|
|
||||||
{
|
|
||||||
const QChar middleDot(0x00B7); // U+00B7 MIDDLE DOT
|
|
||||||
addAndShow(m_layout, new QLabel(
|
|
||||||
QStringLiteral("%1 %2").arg(middleDot).arg(
|
|
||||||
tr("%1 s").arg(durationSeconds, 0, 'f', 1)), this));
|
|
||||||
}
|
|
||||||
m_layout->addStretch(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
void RecipeSummaryRow::addAmounts(const std::vector<Amount>& amounts)
|
|
||||||
{
|
|
||||||
for (const Amount& entry : amounts)
|
|
||||||
{
|
|
||||||
// A missing icon file is not an error (REQ-UI-ITEM-ICON): the item's id then
|
|
||||||
// stands in for its icon.
|
|
||||||
if (m_itemIcons->hasIcon(entry.itemId))
|
|
||||||
{
|
|
||||||
QLabel* iconLabel = new QLabel(this);
|
|
||||||
iconLabel->setPixmap(
|
|
||||||
m_itemIcons->getPixmap(entry.itemId, kSummaryIconSizePx));
|
|
||||||
addAndShow(m_layout, iconLabel);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
addAndShow(m_layout,
|
|
||||||
new QLabel(QString::fromStdString(entry.itemId), this));
|
|
||||||
}
|
|
||||||
addAndShow(m_layout, new QLabel(QString::number(entry.amount), this));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include <QWidget>
|
|
||||||
|
|
||||||
class ItemIconCache;
|
|
||||||
class QHBoxLayout;
|
|
||||||
|
|
||||||
// What one production cycle does, on a line: each input item with its per-cycle amount,
|
|
||||||
// an arrow, each output with its amount, and the cycle time
|
|
||||||
// (REQ-UI-RECIPE-SUMMARY). It restates the selected recipe without the player having to
|
|
||||||
// open the selection dialog, and it is the panel's only display of the cycle time.
|
|
||||||
class RecipeSummaryRow : public QWidget
|
|
||||||
{
|
|
||||||
Q_OBJECT
|
|
||||||
|
|
||||||
public:
|
|
||||||
struct Amount
|
|
||||||
{
|
|
||||||
std::string itemId;
|
|
||||||
int amount = 0;
|
|
||||||
|
|
||||||
bool operator==(const Amount& other) const
|
|
||||||
{
|
|
||||||
return itemId == other.itemId && amount == other.amount;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); an item with
|
|
||||||
// no icon file falls back to its id. Not owned.
|
|
||||||
explicit RecipeSummaryRow(ItemIconCache* itemIcons, QWidget* parent = nullptr);
|
|
||||||
|
|
||||||
// Hides the row when there is nothing selected to produce, which is how a building
|
|
||||||
// with no recipe shows no summary at all.
|
|
||||||
void setSummary(const std::vector<Amount>& inputs,
|
|
||||||
const std::vector<Amount>& outputs, double durationSeconds);
|
|
||||||
|
|
||||||
private:
|
|
||||||
void rebuild(const std::vector<Amount>& inputs, const std::vector<Amount>& outputs,
|
|
||||||
double durationSeconds);
|
|
||||||
void addAmounts(const std::vector<Amount>& amounts);
|
|
||||||
|
|
||||||
ItemIconCache* m_itemIcons;
|
|
||||||
QHBoxLayout* m_layout;
|
|
||||||
// What the row currently shows, so a refresh at tick rate rebuilds it only when the
|
|
||||||
// recipe actually changed.
|
|
||||||
std::vector<Amount> m_inputs;
|
|
||||||
std::vector<Amount> m_outputs;
|
|
||||||
double m_durationSeconds = -1.0;
|
|
||||||
};
|
|
||||||
@@ -32,38 +32,6 @@ const int kSymbolSizePx = 20;
|
|||||||
const int kCardSpacingPx = 6;
|
const int kCardSpacingPx = 6;
|
||||||
const int kHeaderSpacingPx = 6;
|
const int kHeaderSpacingPx = 6;
|
||||||
|
|
||||||
// The Salvage Bay has no recipe and no cycle: its two states say whether it is holding
|
|
||||||
// scrap, not whether it is producing (REQ-BLD-SALVAGE-BAY, REQ-UI-SELECTION-STATUS).
|
|
||||||
QString getStatusCaption(ProductionStatus status, bool isSalvageBay)
|
|
||||||
{
|
|
||||||
switch (status)
|
|
||||||
{
|
|
||||||
case ProductionStatus::Unconfigured: return QObject::tr("no recipe");
|
|
||||||
case ProductionStatus::Producing:
|
|
||||||
return isSalvageBay ? QObject::tr("holding scrap")
|
|
||||||
: QObject::tr("producing");
|
|
||||||
case ProductionStatus::Starved:
|
|
||||||
return isSalvageBay ? QObject::tr("empty") : QObject::tr("missing input");
|
|
||||||
case ProductionStatus::Blocked: return QObject::tr("output full");
|
|
||||||
}
|
|
||||||
return QString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every caption the status slot can end up showing for this building, so the header can
|
|
||||||
// keep its width as the state changes rather than the panel jumping with it.
|
|
||||||
QStringList getAllStatusCaptions(bool isSalvageBay)
|
|
||||||
{
|
|
||||||
QStringList captions;
|
|
||||||
for (ProductionStatus status : { ProductionStatus::Unconfigured,
|
|
||||||
ProductionStatus::Producing,
|
|
||||||
ProductionStatus::Starved,
|
|
||||||
ProductionStatus::Blocked })
|
|
||||||
{
|
|
||||||
captions << getStatusCaption(status, isSalvageBay);
|
|
||||||
}
|
|
||||||
return captions;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
@@ -82,26 +50,30 @@ SelectionContent::SelectionContent(const SelectionContext& context,
|
|||||||
|
|
||||||
// Header: identity symbol, name, and the right slot pushed to the far edge
|
// Header: identity symbol, name, and the right slot pushed to the far edge
|
||||||
// (REQ-UI-SELECTION-CARD).
|
// (REQ-UI-SELECTION-CARD).
|
||||||
QWidget* header = new QWidget(this);
|
m_header = new QWidget(this);
|
||||||
QHBoxLayout* headerLayout = new QHBoxLayout(header);
|
// The header doubles as the panel's drag handle (REQ-UI-SELECTION-PANEL-DRAG); the
|
||||||
|
// cursor says so, as the controls panel's heading does for the click that collapses
|
||||||
|
// it. The gesture itself belongs to SelectionPanel, which owns the placement.
|
||||||
|
m_header->setCursor(Qt::SizeAllCursor);
|
||||||
|
QHBoxLayout* headerLayout = new QHBoxLayout(m_header);
|
||||||
headerLayout->setContentsMargins(0, 0, 0, 0);
|
headerLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
headerLayout->setSpacing(kHeaderSpacingPx);
|
headerLayout->setSpacing(kHeaderSpacingPx);
|
||||||
|
|
||||||
m_symbolLabel = new QLabel(header);
|
m_symbolLabel = new QLabel(m_header);
|
||||||
m_symbolLabel->hide();
|
m_symbolLabel->hide();
|
||||||
|
|
||||||
m_nameLabel = new QLabel(header);
|
m_nameLabel = new QLabel(m_header);
|
||||||
QFont nameFont = m_nameLabel->font();
|
QFont nameFont = m_nameLabel->font();
|
||||||
nameFont.setBold(true);
|
nameFont.setBold(true);
|
||||||
m_nameLabel->setFont(nameFont);
|
m_nameLabel->setFont(nameFont);
|
||||||
|
|
||||||
m_statusPill = new StatusPill(header);
|
m_statusPill = new StatusPill(m_header);
|
||||||
|
|
||||||
headerLayout->addWidget(m_symbolLabel);
|
headerLayout->addWidget(m_symbolLabel);
|
||||||
headerLayout->addWidget(m_nameLabel);
|
headerLayout->addWidget(m_nameLabel);
|
||||||
headerLayout->addStretch(1);
|
headerLayout->addStretch(1);
|
||||||
headerLayout->addWidget(m_statusPill);
|
headerLayout->addWidget(m_statusPill);
|
||||||
cardLayout->addWidget(header);
|
cardLayout->addWidget(m_header);
|
||||||
|
|
||||||
m_configurationGroup = new QWidget(this);
|
m_configurationGroup = new QWidget(this);
|
||||||
QVBoxLayout* configurationLayout = new QVBoxLayout(m_configurationGroup);
|
QVBoxLayout* configurationLayout = new QVBoxLayout(m_configurationGroup);
|
||||||
@@ -200,26 +172,25 @@ void SelectionContent::setProductionStatusSlot(const Building& building)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bool isSalvageBay = (building.type == BuildingType::SalvageBay);
|
|
||||||
// Room for every caption this building can show, so the panel keeps its width as the
|
|
||||||
// building's state changes rather than jumping with it.
|
|
||||||
reserveSlotFor(getAllStatusCaptions(isSalvageBay));
|
|
||||||
|
|
||||||
const StatusLightVisuals& colors = m_context.visuals->statusLight;
|
const StatusLightVisuals& colors = m_context.visuals->statusLight;
|
||||||
QColor dotColor;
|
// The Salvage Bay has no recipe and no cycle: its two states say whether it is
|
||||||
|
// holding scrap, not whether it is producing (REQ-BLD-SALVAGE-BAY).
|
||||||
|
const bool isSalvageBay = (building.type == BuildingType::SalvageBay);
|
||||||
switch (*status)
|
switch (*status)
|
||||||
{
|
{
|
||||||
case ProductionStatus::Unconfigured: dotColor = colors.grey; break;
|
case ProductionStatus::Unconfigured:
|
||||||
case ProductionStatus::Producing: dotColor = colors.green; break;
|
setSlot(colors.grey, tr("no recipe"));
|
||||||
case ProductionStatus::Starved: dotColor = colors.red; break;
|
break;
|
||||||
case ProductionStatus::Blocked: dotColor = colors.yellow; break;
|
case ProductionStatus::Producing:
|
||||||
|
setSlot(colors.green, isSalvageBay ? tr("holding scrap") : tr("producing"));
|
||||||
|
break;
|
||||||
|
case ProductionStatus::Starved:
|
||||||
|
setSlot(colors.red, isSalvageBay ? tr("empty") : tr("missing input"));
|
||||||
|
break;
|
||||||
|
case ProductionStatus::Blocked:
|
||||||
|
setSlot(colors.yellow, tr("output full"));
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
setSlot(dotColor, getStatusCaption(*status, isSalvageBay));
|
|
||||||
}
|
|
||||||
|
|
||||||
void SelectionContent::reserveSlotFor(const QStringList& captions)
|
|
||||||
{
|
|
||||||
m_statusPill->reserveFor(captions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void SelectionContent::refreshConstruction()
|
void SelectionContent::refreshConstruction()
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
#include <QColor>
|
#include <QColor>
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
#include "BuildingId.h"
|
#include "BuildingId.h"
|
||||||
@@ -46,6 +45,11 @@ public:
|
|||||||
// SelectionPanel owns that decision.
|
// SelectionPanel owns that decision.
|
||||||
void refresh();
|
void refresh();
|
||||||
|
|
||||||
|
// The card's header (REQ-UI-SELECTION-CARD), which is also the handle the panel is
|
||||||
|
// dragged by (REQ-UI-SELECTION-PANEL-DRAG). The panel hit-tests against it; the card
|
||||||
|
// itself does nothing with the gesture.
|
||||||
|
QWidget* getHeaderWidget() const { return m_header; }
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
// constructionSiteId is set only while the card shows a construction site, in which
|
// constructionSiteId is set only while the card shows a construction site, in which
|
||||||
// case this base builds and drives the construction section in place of whatever the
|
// case this base builds and drives the construction section in place of whatever the
|
||||||
@@ -80,11 +84,6 @@ protected:
|
|||||||
void setCountSlot(int count);
|
void setCountSlot(int count);
|
||||||
void clearSlot();
|
void clearSlot();
|
||||||
|
|
||||||
// Reserves header room for the widest caption the slot will ever show. The panel is
|
|
||||||
// sized to its card (REQ-UI-SELECTION-PANEL), so without this it changes width every
|
|
||||||
// time the caption does -- and a card whose chips wrap changes height with it.
|
|
||||||
void reserveSlotFor(const QStringList& captions);
|
|
||||||
|
|
||||||
// Fills the right slot from the building's production status, mapped to the same
|
// Fills the right slot from the building's production status, mapped to the same
|
||||||
// colors and states the world's status light uses (REQ-UI-SELECTION-STATUS). Leaves
|
// colors and states the world's status light uses (REQ-UI-SELECTION-STATUS). Leaves
|
||||||
// the slot empty for a type that has no status light.
|
// the slot empty for a type that has no status light.
|
||||||
@@ -97,6 +96,7 @@ private:
|
|||||||
// Set while this card shows a construction site rather than a finished object.
|
// Set while this card shows a construction site rather than a finished object.
|
||||||
std::optional<BuildingId> m_siteId;
|
std::optional<BuildingId> m_siteId;
|
||||||
|
|
||||||
|
QWidget* m_header;
|
||||||
QLabel* m_symbolLabel;
|
QLabel* m_symbolLabel;
|
||||||
QLabel* m_nameLabel;
|
QLabel* m_nameLabel;
|
||||||
StatusPill* m_statusPill;
|
StatusPill* m_statusPill;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#include "SelectionContentFactory.h"
|
#include "SelectionContentFactory.h"
|
||||||
|
|
||||||
#include "AutoProductionContent.h"
|
|
||||||
#include "BeltContent.h"
|
#include "BeltContent.h"
|
||||||
#include "DebrisContent.h"
|
#include "DebrisContent.h"
|
||||||
#include "FactoryQueries.h"
|
#include "FactoryQueries.h"
|
||||||
@@ -27,10 +26,9 @@ SelectionContentKind getKindForType(BuildingType type)
|
|||||||
{
|
{
|
||||||
case BuildingType::Miner:
|
case BuildingType::Miner:
|
||||||
case BuildingType::Assembler:
|
case BuildingType::Assembler:
|
||||||
return SelectionContentKind::RecipeProduction;
|
|
||||||
case BuildingType::Smelter:
|
case BuildingType::Smelter:
|
||||||
case BuildingType::ReprocessingPlant:
|
case BuildingType::ReprocessingPlant:
|
||||||
return SelectionContentKind::AutoProduction;
|
return SelectionContentKind::RecipeProduction;
|
||||||
case BuildingType::Shipyard:
|
case BuildingType::Shipyard:
|
||||||
return SelectionContentKind::Shipyard;
|
return SelectionContentKind::Shipyard;
|
||||||
case BuildingType::SalvageBay:
|
case BuildingType::SalvageBay:
|
||||||
@@ -160,8 +158,6 @@ SelectionContent* createContent(const ContentKey& key, const SelectionRequest& r
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
case SelectionContentKind::RecipeProduction:
|
case SelectionContentKind::RecipeProduction:
|
||||||
return new RecipeProductionContent(context, request, parent);
|
return new RecipeProductionContent(context, request, parent);
|
||||||
case SelectionContentKind::AutoProduction:
|
|
||||||
return new AutoProductionContent(context, request, parent);
|
|
||||||
case SelectionContentKind::Shipyard:
|
case SelectionContentKind::Shipyard:
|
||||||
return new ShipyardContent(context, request, parent);
|
return new ShipyardContent(context, request, parent);
|
||||||
case SelectionContentKind::Storage:
|
case SelectionContentKind::Storage:
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ struct SelectionRequest
|
|||||||
enum class SelectionContentKind
|
enum class SelectionContentKind
|
||||||
{
|
{
|
||||||
None, // nothing selected: the panel hides itself entirely
|
None, // nothing selected: the panel hides itself entirely
|
||||||
RecipeProduction, // Miner, Assembler
|
RecipeProduction, // Miner, Assembler, Smelter, Reprocessing Plant
|
||||||
AutoProduction, // Smelter, Reprocessing Plant
|
|
||||||
Shipyard,
|
Shipyard,
|
||||||
Storage, // Salvage Bay
|
Storage, // Salvage Bay
|
||||||
Hq,
|
Hq,
|
||||||
|
|||||||
@@ -33,16 +33,3 @@ QString getBehaviorLabel(BehaviorKind kind)
|
|||||||
}
|
}
|
||||||
return QString();
|
return QString();
|
||||||
}
|
}
|
||||||
|
|
||||||
QStringList getAllBehaviorLabels()
|
|
||||||
{
|
|
||||||
QStringList labels;
|
|
||||||
for (BehaviorKind kind : { BehaviorKind::Retreat, BehaviorKind::Attack,
|
|
||||||
BehaviorKind::SalvageScrap, BehaviorKind::Repair,
|
|
||||||
BehaviorKind::Rally, BehaviorKind::Standby,
|
|
||||||
BehaviorKind::Advance })
|
|
||||||
{
|
|
||||||
labels << getBehaviorLabel(kind);
|
|
||||||
}
|
|
||||||
return labels;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
|
||||||
|
|
||||||
#include "BehaviorKind.h"
|
#include "BehaviorKind.h"
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
@@ -14,8 +13,3 @@ QString getBuildingTypeName(BuildingType type);
|
|||||||
// Name of the behavior currently governing a ship, for the ship card's header slot
|
// Name of the behavior currently governing a ship, for the ship card's header slot
|
||||||
// (REQ-UI-SHIP-BEHAVIOR). Empty when no behavior has won yet, which shows no slot.
|
// (REQ-UI-SHIP-BEHAVIOR). Empty when no behavior has won yet, which shows no slot.
|
||||||
QString getBehaviorLabel(BehaviorKind kind);
|
QString getBehaviorLabel(BehaviorKind kind);
|
||||||
|
|
||||||
// Every name getBehaviorLabel can return. A ship's behavior changes as it fights, so the
|
|
||||||
// header reserves room for the widest of these rather than letting the panel change
|
|
||||||
// width each time (REQ-UI-SELECTION-PANEL).
|
|
||||||
QStringList getAllBehaviorLabels();
|
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ ShipContent::ShipContent(const SelectionContext& context,
|
|||||||
m_statsPanel = new ShipStatsPanel(context.config, this);
|
m_statsPanel = new ShipStatsPanel(context.config, this);
|
||||||
getRuntimeLayout()->addWidget(m_statsPanel);
|
getRuntimeLayout()->addWidget(m_statsPanel);
|
||||||
|
|
||||||
// A ship's behavior changes as it fights, so the header holds room for the longest
|
|
||||||
// name rather than the panel resizing under the player each time it does.
|
|
||||||
reserveSlotFor(getAllBehaviorLabels());
|
|
||||||
|
|
||||||
EntityAdmin& admin = context.sim->getAdmin();
|
EntityAdmin& admin = context.sim->getAdmin();
|
||||||
if (admin.isValid(m_entity) && admin.hasAll<ShipIdentityComponent>(m_entity))
|
if (admin.isValid(m_entity) && admin.hasAll<ShipIdentityComponent>(m_entity))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -83,20 +83,11 @@ BufferedBuildingContent::CycleInfo ShipyardContent::getCycleInfo(
|
|||||||
*getContext().config, target.recipeId, target.shipLayout);
|
*getContext().config, target.recipeId, target.shipLayout);
|
||||||
|
|
||||||
// A shipyard's output is the ship itself, which never lands in an item buffer -- so
|
// A shipyard's output is the ship itself, which never lands in an item buffer -- so
|
||||||
// the summary shows one ship rather than an item id.
|
// the summary shows no output item at all. The time is the schematic's plus its
|
||||||
info.durationSeconds = shipDef->schematic.productionTimeSeconds;
|
// modules', from the same place the layout dialog's build cost reads it
|
||||||
if (target.shipLayout.has_value())
|
// (REQ-MOD-PRODUCTION-TIME).
|
||||||
{
|
info.durationSeconds = computeShipyardProductionTimeSeconds(
|
||||||
for (const PlacedModule& placed : target.shipLayout->placedModules)
|
*getContext().config, target.recipeId, target.shipLayout);
|
||||||
{
|
|
||||||
const ModuleDef* moduleDef =
|
|
||||||
getContext().config->modules.findModuleDef(placed.moduleId);
|
|
||||||
if (moduleDef)
|
|
||||||
{
|
|
||||||
info.durationSeconds += moduleDef->productionTimeSeconds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info.runsProduction = true;
|
info.runsProduction = true;
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QPalette>
|
#include <QPalette>
|
||||||
|
|
||||||
|
#include "IconCaption.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ const int kIndentPx = 12;
|
|||||||
|
|
||||||
StatRow::StatRow(const QString& label, QWidget* parent)
|
StatRow::StatRow(const QString& label, QWidget* parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
|
, m_valueEmphasized(false)
|
||||||
{
|
{
|
||||||
QHBoxLayout* layout = new QHBoxLayout(this);
|
QHBoxLayout* layout = new QHBoxLayout(this);
|
||||||
layout->setContentsMargins(0, 0, 0, 0);
|
layout->setContentsMargins(0, 0, 0, 0);
|
||||||
@@ -36,19 +39,48 @@ void StatRow::setLabel(const QString& label)
|
|||||||
|
|
||||||
void StatRow::setValue(const QString& value)
|
void StatRow::setValue(const QString& value)
|
||||||
{
|
{
|
||||||
m_valueLabel->setText(value);
|
m_value = value;
|
||||||
|
updateValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
void StatRow::setValueIcon(const QPixmap& icon)
|
||||||
|
{
|
||||||
|
m_valueIcon = icon;
|
||||||
|
updateValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
void StatRow::setValueEmphasized(bool emphasized)
|
void StatRow::setValueEmphasized(bool emphasized)
|
||||||
{
|
{
|
||||||
|
m_valueEmphasized = emphasized;
|
||||||
|
|
||||||
QPalette valuePalette = m_valueLabel->palette();
|
QPalette valuePalette = m_valueLabel->palette();
|
||||||
valuePalette.setColor(QPalette::WindowText,
|
valuePalette.setColor(QPalette::WindowText, getValueColor());
|
||||||
palette().color(emphasized ? QPalette::Highlight
|
|
||||||
: QPalette::WindowText));
|
|
||||||
m_valueLabel->setPalette(valuePalette);
|
m_valueLabel->setPalette(valuePalette);
|
||||||
|
|
||||||
|
updateValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
void StatRow::setIndented(bool indented)
|
void StatRow::setIndented(bool indented)
|
||||||
{
|
{
|
||||||
layout()->setContentsMargins(indented ? kIndentPx : 0, 0, 0, 0);
|
layout()->setContentsMargins(indented ? kIndentPx : 0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void StatRow::updateValue()
|
||||||
|
{
|
||||||
|
if (m_valueIcon.isNull())
|
||||||
|
{
|
||||||
|
m_valueLabel->setText(m_value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A label shows either text or a pixmap, so the two are composed into one -- the same
|
||||||
|
// way the header bar states the block stock (REQ-UI-BLOCKS-ICON). The color is passed
|
||||||
|
// in rather than left to the palette, which a pixmap does not follow.
|
||||||
|
m_valueLabel->setPixmap(renderCaptionWithIcon(m_value, m_valueIcon,
|
||||||
|
m_valueLabel->font(), getValueColor()));
|
||||||
|
}
|
||||||
|
|
||||||
|
QColor StatRow::getValueColor() const
|
||||||
|
{
|
||||||
|
return palette().color(m_valueEmphasized ? QPalette::Highlight
|
||||||
|
: QPalette::WindowText);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QPixmap>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
@@ -20,6 +22,12 @@ public:
|
|||||||
|
|
||||||
void setLabel(const QString& label);
|
void setLabel(const QString& label);
|
||||||
void setValue(const QString& value);
|
void setValue(const QString& value);
|
||||||
|
// Draws an item icon after the value, bare and without its colored square: the icon
|
||||||
|
// stands in for the item's name beside the number rather than displaying the item
|
||||||
|
// (REQ-UI-ITEM-ICON), which is how a debris card states its scrap
|
||||||
|
// (REQ-UI-DEBRIS-PANEL). A null pixmap -- what a missing icon file yields -- leaves
|
||||||
|
// the value plain text, and the caller names the item in the label instead.
|
||||||
|
void setValueIcon(const QPixmap& icon);
|
||||||
// Draws the value in the palette's highlight color rather than its text color, for
|
// Draws the value in the palette's highlight color rather than its text color, for
|
||||||
// the one value a card is really about.
|
// the one value a card is really about.
|
||||||
void setValueEmphasized(bool emphasized);
|
void setValueEmphasized(bool emphasized);
|
||||||
@@ -28,6 +36,15 @@ public:
|
|||||||
void setIndented(bool indented);
|
void setIndented(bool indented);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Re-states the value in whichever form it currently takes. Needed because the icon
|
||||||
|
// form is one composed pixmap, which has to be built anew whenever the text or the
|
||||||
|
// color it is drawn in changes.
|
||||||
|
void updateValue();
|
||||||
|
QColor getValueColor() const;
|
||||||
|
|
||||||
QLabel* m_labelLabel;
|
QLabel* m_labelLabel;
|
||||||
QLabel* m_valueLabel;
|
QLabel* m_valueLabel;
|
||||||
|
QString m_value;
|
||||||
|
QPixmap m_valueIcon;
|
||||||
|
bool m_valueEmphasized;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#include "StatusPill.h"
|
#include "StatusPill.h"
|
||||||
|
|
||||||
#include <QFontMetrics>
|
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
@@ -50,23 +49,6 @@ StatusPill::StatusPill(QWidget* parent)
|
|||||||
hide();
|
hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
void StatusPill::reserveFor(const QStringList& captions)
|
|
||||||
{
|
|
||||||
if (captions == m_reservedFor)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
m_reservedFor = captions;
|
|
||||||
|
|
||||||
const QFontMetrics metrics(m_captionLabel->font());
|
|
||||||
int widestPx = 0;
|
|
||||||
for (const QString& caption : captions)
|
|
||||||
{
|
|
||||||
widestPx = qMax(widestPx, metrics.horizontalAdvance(caption));
|
|
||||||
}
|
|
||||||
m_captionLabel->setMinimumWidth(widestPx);
|
|
||||||
}
|
|
||||||
|
|
||||||
void StatusPill::setStatus(const QColor& dotColor, const QColor& outlineColor,
|
void StatusPill::setStatus(const QColor& dotColor, const QColor& outlineColor,
|
||||||
const QString& caption)
|
const QString& caption)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
#include <QColor>
|
#include <QColor>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
class QLabel;
|
class QLabel;
|
||||||
@@ -24,16 +23,7 @@ public:
|
|||||||
void setStatus(const QColor& dotColor, const QColor& outlineColor,
|
void setStatus(const QColor& dotColor, const QColor& outlineColor,
|
||||||
const QString& caption);
|
const QString& caption);
|
||||||
|
|
||||||
// Reserves room for the widest of the captions this pill can show, so the header --
|
|
||||||
// and with it the panel, which is sized to its content (REQ-UI-SELECTION-PANEL) --
|
|
||||||
// keeps its width as the caption changes. Without it the whole panel jumps every
|
|
||||||
// time a building's status does, and a card whose chips wrap changes height with it.
|
|
||||||
void reserveFor(const QStringList& captions);
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QLabel* m_dotLabel;
|
QLabel* m_dotLabel;
|
||||||
QLabel* m_captionLabel;
|
QLabel* m_captionLabel;
|
||||||
// What the width is currently reserved for, so re-reserving the same set on every
|
|
||||||
// refresh costs nothing.
|
|
||||||
QStringList m_reservedFor;
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user