Compare commits
70 Commits
90e40dddbc
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| ade15c19d5 | |||
| abe8c8f448 | |||
| 8292bc6134 | |||
| b51a9c5c77 | |||
| b7fe5cfcf2 | |||
| 1ffacae2fb | |||
| 44ec3c51f1 | |||
| dcc801f017 | |||
| 099d614395 | |||
| 1554d99739 | |||
| 0e64f45a5e | |||
| fb65cbabf4 | |||
| 9b88deff69 | |||
| f03003194f | |||
| 7327343b2a | |||
| da3f417464 | |||
| 01aa1a08b0 | |||
| 5968e5f40a | |||
| 1e24b87640 | |||
| b0fa0da813 | |||
| 731b8874c9 | |||
| 392a2b8d00 | |||
| abaccc45b5 | |||
| b2148f00ac | |||
| 9490a96e12 | |||
| fd0c246bc0 | |||
| 9c275e283c | |||
| 41c45d73ce | |||
| f39e4f3506 | |||
| 3c63205c6c | |||
| 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,51 +94,47 @@ 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
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "iron_ingot"
|
|
||||||
amount = 1
|
|
||||||
probability = 0.3
|
probability = 0.3
|
||||||
|
items = [{item = "iron_ingot", amount = 1}]
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "copper_ingot"
|
|
||||||
amount = 1
|
|
||||||
probability = 0.3
|
probability = 0.3
|
||||||
|
items = [{item = "copper_ingot", amount = 1}]
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "silicon"
|
|
||||||
amount = 1
|
|
||||||
probability = 0.2
|
probability = 0.2
|
||||||
|
items = [{item = "silicon", amount = 1}]
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "voidsteel"
|
|
||||||
amount = 1
|
|
||||||
probability = 0.2
|
probability = 0.2
|
||||||
|
items = [{item = "voidsteel", amount = 1}]
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Tier 2 — early intermediates (clean ratios, ~2:3)
|
# Tier 2 — early intermediates (clean ratios, ~2:3)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
[[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 +145,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 +157,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 +173,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 +202,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 +223,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 +253,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 +292,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 +303,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 +314,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 +329,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 +351,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 +411,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 +422,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 +433,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,12 +344,15 @@ 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)
|
||||||
|
next_expansion = "#00000040" # lighter tint over the columns the next expansion unlocks (REQ-UI-LOCKED-ASTEROID)
|
||||||
modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM)
|
modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM)
|
||||||
tunnel_preview = "#00ff0055" # tunnel connection preview: matched end + tiles between (REQ-BLD-TUNNEL-MODE)
|
tunnel_preview = "#00ff0055" # tunnel connection preview: matched end + tiles between (REQ-BLD-TUNNEL-MODE)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[world]
|
[world]
|
||||||
height_tiles = 40
|
height_tiles = 30
|
||||||
refund_percentage = 100
|
refund_percentage = 100
|
||||||
deconstruction_time_seconds = 0.1
|
deconstruction_time_seconds = 0.1
|
||||||
starting_building_blocks = 200
|
starting_building_blocks = 200
|
||||||
@@ -11,11 +11,10 @@ tunnel_max_distance_tiles = 10
|
|||||||
departure_interval_seconds = 20
|
departure_interval_seconds = 20
|
||||||
orbit_factor = 0.8
|
orbit_factor = 0.8
|
||||||
rally_orbit_radius_tiles = 5.0
|
rally_orbit_radius_tiles = 5.0
|
||||||
building_blocks_tooltip = "Building blocks are the currency for construction. Spend them to place buildings and to expand the asteroid. Produce building blocks in your assemblers and deliver them to the HQ on a belt to grow your stock."
|
|
||||||
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
|
||||||
|
|||||||
@@ -75,20 +75,17 @@ building = "reprocessing_plant"
|
|||||||
inputs = [{item = "scrap", amount = 5}]
|
inputs = [{item = "scrap", amount = 5}]
|
||||||
duration_seconds = 3.0
|
duration_seconds = 3.0
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "iron_ingot"
|
|
||||||
amount = 2
|
|
||||||
probability = 0.6
|
probability = 0.6
|
||||||
|
items = [{item = "iron_ingot", amount = 2}]
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "circuit_board"
|
|
||||||
amount = 1
|
|
||||||
probability = 0.3
|
probability = 0.3
|
||||||
|
items = [{item = "circuit_board", amount = 1}]
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "advanced_alloy"
|
|
||||||
amount = 1
|
|
||||||
probability = 0.1
|
probability = 0.1
|
||||||
|
items = [{item = "advanced_alloy", amount = 1}]
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# Extra recipes for ThreatCostCalculator unit tests (fixes 6-9)
|
# Extra recipes for ThreatCostCalculator unit tests (fixes 6-9)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ tunnel_max_distance_tiles = 10
|
|||||||
departure_interval_seconds = 20
|
departure_interval_seconds = 20
|
||||||
orbit_factor = 0.8
|
orbit_factor = 0.8
|
||||||
rally_orbit_radius_tiles = 5.0
|
rally_orbit_radius_tiles = 5.0
|
||||||
building_blocks_tooltip = "Spend building blocks to build; deliver them to the HQ to gain more."
|
|
||||||
artifact_tooltip = "Choose the artifact reward when destroying enemy stations; collect enough to win."
|
artifact_tooltip = "Choose the artifact reward when destroying enemy stations; collect enough to win."
|
||||||
|
|
||||||
[regions]
|
[regions]
|
||||||
|
|||||||
@@ -124,12 +124,31 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
|
|||||||
Three product targets plus tests:
|
Three product targets plus tests:
|
||||||
|
|
||||||
- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
|
- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
|
||||||
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selection panel, build button bar. Depends on `lib` and on Qt's OpenGL widgets module.
|
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selection panel, build button bar, controls panel. Depends on `lib` and on Qt's OpenGL widgets module.
|
||||||
|
- `ui/selection/` — the selection panel's contents. `SelectionPanel` itself only arbitrates between the two selection categories, picks a card from the catalog (`SelectionContentFactory`), and hosts one at a time; each kind of selection has its own `SelectionContent` subclass assembled from shared parts (REQ-UI-SELECTION-CARD, REQ-UI-SELECTION-CONTENT).
|
||||||
- `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
|
- `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
|
||||||
- `tests/` — Catch2 tests. Links only against `lib`.
|
- `tests/` — Catch2 tests. Links only against `lib`.
|
||||||
|
|
||||||
Directory discipline inside `lib/` keeps the internal sim/config seam clear; sim code must not reach into config parsing and vice versa.
|
Directory discipline inside `lib/` keeps the internal sim/config seam clear; sim code must not reach into config parsing and vice versa.
|
||||||
|
|
||||||
|
## Player Input
|
||||||
|
|
||||||
|
Every player control is declared once, in `lib/core/ControlAction.h`, and read by three consumers that must never disagree about it:
|
||||||
|
|
||||||
|
* **`ControlsPanel`** asks which actions apply and draws a row per action (REQ-UI-CONTROLS-CONTENT).
|
||||||
|
* **`InputMapper`** resolves a key press to an action and fires the event that action stands for.
|
||||||
|
* **`GameWorldView`** resolves a mouse gesture to an action and runs the branch that carries it out.
|
||||||
|
|
||||||
|
The file declares; it never performs. It holds no simulation access, fires no events, and names nothing — display strings live in `ui/ControlActionText.h`, which renders each badge from the binding the resolver actually matches, so a chip cannot claim a key that does nothing. What an action *does* stays in the widget that always did it: the drag state machines, hit-testing, and command enqueuing were not moved.
|
||||||
|
|
||||||
|
Three invariants are easy to break here:
|
||||||
|
|
||||||
|
* **Do not add a shortcut straight to `InputMapper`'s switch or `mousePressEvent`'s branches.** Add the action and its binding to the table; the handler switches on the resolved action. A binding added directly is invisible to the panel, which is the drift the table exists to prevent. (Build hotkeys and `F3`/`F4` are deliberate exceptions, documented in the header and in REQ-UI-CONTROLS-ACCURACY.)
|
||||||
|
* **Availability and display are different questions.** An action can be live in a context the panel does not advertise it in — `Ctrl`+click with an empty selection is the standing example. `isControlActionAvailable` answers the first, the per-context row lists answer the second, and `ControlActionTest` asserts the pairing that matters: every row's bindings resolve back to that row's action.
|
||||||
|
* **Gesture state is shared, not owned by an action.** Whether a belt drag is in progress decides what the right mouse button means, so it lives on `BuildModeController` where the resolver can see it — as does the hovered-transfer flag. `m_boxSelecting` is likewise one gesture serving two actions (box select and deconstruct area).
|
||||||
|
|
||||||
|
`ControlContext` is a plain snapshot rather than references to the live controllers, which is what keeps the rules testable without a world and stops an action reaching into the simulation: if a rule needs a fact, the fact is named in the struct and the caller supplies it. When bindings become player-configurable, only the binding tables in `ControlAction.cpp` turn from hard-coded data into loaded data.
|
||||||
|
|
||||||
## Belt Subsystem
|
## Belt Subsystem
|
||||||
|
|
||||||
Belts and splitters are their own specialized subsystem. Belt items are **not** entities — they are transient data flowing through the belt representation. They do not have identities that persist across ticks.
|
Belts and splitters are their own specialized subsystem. Belt items are **not** entities — they are transient data flowing through the belt representation. They do not have identities that persist across ticks.
|
||||||
@@ -408,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 | |
|
||||||
|
|||||||
@@ -230,8 +230,8 @@ supporting different fleet doctrines feel structurally different to build.
|
|||||||
**smelting** (same basic materials as ore — the safe, boring option) and
|
**smelting** (same basic materials as ore — the safe, boring option) and
|
||||||
**reprocessing** (probabilistic higher intermediates, including the
|
**reprocessing** (probabilistic higher intermediates, including the
|
||||||
late-game input — the gamble that eventually becomes mandatory).
|
late-game input — the gamble that eventually becomes mandatory).
|
||||||
- The reprocessing output pool renormalizes over implicitly unlocked items
|
- The reprocessing output pool renormalizes over the output groups whose items
|
||||||
(REQ-LOCK-REPROCESSING-POOL), so its output quality improves
|
are implicitly unlocked (REQ-LOCK-OUTPUT-POOL), so its output quality improves
|
||||||
automatically as the run progresses. **Rule:** weights are authored for
|
automatically as the run progresses. **Rule:** weights are authored for
|
||||||
the *fully unlocked* pool state; early-game behavior falls out of
|
the *fully unlocked* pool state; early-game behavior falls out of
|
||||||
renormalization for free and needs no separate staging.
|
renormalization for free and needs no separate staging.
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
|
|
||||||
Config files use the TOML format. The following config files drive game parameters:
|
Config files use the TOML format. The following config files drive game parameters:
|
||||||
|
|
||||||
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, building deconstruction time, wave timing, boss wave timing, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, scrap-per-threat conversion, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, artifact win count, view pan speeds (slow and fast horizontal pan speed and pan ramp band width), an optional building blocks tooltip string (shown as the header bar's building blocks stock hover tooltip, REQ-UI-BLOCKS-TOOLTIP; omitted when unset), and an optional artifact tooltip string (shown as the header bar's artifact count hover tooltip, REQ-UI-ARTIFACTS-TOOLTIP; omitted when unset).
|
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, building deconstruction time, wave timing, boss wave timing, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, scrap-per-threat conversion, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, artifact win count, view pan speeds (slow and fast horizontal pan speed and pan ramp band width), and an optional artifact tooltip string (shown as the header bar's artifact count tooltip, REQ-UI-ARTIFACTS-TOOLTIP; omitted when unset).
|
||||||
- **buildings.toml** — building block cost and construction time per building type, plus an optional tooltip description string per building type (shown as the build button's hover tooltip, REQ-UI-BUILD-TOOLTIP; omitted when unset). Whether a building type is available from game start or must be unlocked during play is not defined here but in **unlocks.toml** (REQ-LOCK-EXPLICIT): a building type granted by an unlock group starts locked and is hidden from the build menu until its group is awarded (REQ-LOCK-BUILDING).
|
- **buildings.toml** — building block cost and construction time per building type, plus an optional tooltip description string per building type (shown as the build button's hover tooltip, REQ-UI-BUILD-TOOLTIP; omitted when unset). Whether a building type is available from game start or must be unlocked during play is not defined here but in **unlocks.toml** (REQ-LOCK-EXPLICIT): a building type granted by an unlock group starts locked and is hidden from the build menu until its group is awarded (REQ-LOCK-BUILDING).
|
||||||
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlocked_at_start` (boolean, default false): when true the recipe is available from game start regardless of the implicit item graph — used for base recipes that no schematic's materials reach (such as building blocks; see REQ-LOCK-IMPLICIT). Which assembler recipes must instead be awarded during play (explicitly gated) is defined in **unlocks.toml**, not here (REQ-LOCK-EXPLICIT); every remaining assembler recipe is implicitly unlocked through the item graph (REQ-LOCK-IMPLICIT). 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, output groups with their quantities and probability weights, and durations (REQ-MAT-OUTPUT-GROUP). Assembler recipe entries may optionally define `unlocked_at_start` (boolean, default false): when true the recipe is available from game start regardless of the implicit item graph — used for base recipes that no schematic's materials reach (such as building blocks; see REQ-LOCK-IMPLICIT). Which assembler recipes must instead be awarded during play (explicitly gated) is defined in **unlocks.toml**, not here (REQ-LOCK-EXPLICIT); every remaining assembler recipe is implicitly unlocked through the item graph (REQ-LOCK-IMPLICIT).- **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as plain values, required build materials, a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES). Whether a ship schematic is available from game start or must be unlocked during play is defined in **unlocks.toml** (REQ-LOCK-EXPLICIT), not here.
|
||||||
- **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`.
|
||||||
@@ -102,7 +101,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- **Enemy buffer zone** — space between the enemy's defence stations and the enemy spawn boundary (`enemy_buffer_width`).
|
- **Enemy buffer zone** — space between the enemy's defence stations and the enemy spawn boundary (`enemy_buffer_width`).
|
||||||
- REQ-GW-SCROLL-LIMIT: The player can scroll the view horizontally from the asteroid's left edge to the current set of enemy defence stations (the enemy buffer zone is not visible).
|
- REQ-GW-SCROLL-LIMIT: The player can scroll the view horizontally from the asteroid's left edge to the current set of enemy defence stations (the enemy buffer zone is not visible).
|
||||||
- REQ-GW-PUSH-EXPAND: When the player destroys a set of enemy defence stations, the scrollable area is extended by `world.toml [push].push_expand_columns` tiles. A new enemy buffer zone of `world.toml [regions].enemy_buffer_width` tiles is added beyond the new enemy defence stations.
|
- REQ-GW-PUSH-EXPAND: When the player destroys a set of enemy defence stations, the scrollable area is extended by `world.toml [push].push_expand_columns` tiles. A new enemy buffer zone of `world.toml [regions].enemy_buffer_width` tiles is added beyond the new enemy defence stations.
|
||||||
- REQ-GW-ASTEROID-EXPAND: When the player unlocks an asteroid expansion, `world.toml [expansion].columns_per_expansion` tile columns are added to the left of the asteroid.
|
- REQ-GW-ASTEROID-EXPAND: When the player unlocks an asteroid expansion, `world.toml [expansion].columns_per_expansion` tile columns are added to the left of the asteroid. Those are the columns drawn as locked ground before the purchase (REQ-UI-LOCKED-ASTEROID) and the ones the expansion button stands on (REQ-UI-EXPAND-BUTTON).
|
||||||
|
|
||||||
## HQ & Game Over
|
## HQ & Game Over
|
||||||
|
|
||||||
@@ -110,7 +109,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- REQ-HQ-BELT-INPUT: The HQ has a belt input port. Building blocks delivered to it are added to the global building blocks stock.
|
- REQ-HQ-BELT-INPUT: The HQ has a belt input port. Building blocks delivered to it are added to the global building blocks stock.
|
||||||
- REQ-HQ-STATS: HQ stats (HP) are read from `stations.toml [hq]`.
|
- REQ-HQ-STATS: HQ stats (HP) are read from `stations.toml [hq]`.
|
||||||
- REQ-HQ-STARTING-BLOCKS: At game start, the global building blocks stock is initialized to `world.toml [world].starting_building_blocks` (default 100).
|
- REQ-HQ-STARTING-BLOCKS: At game start, the global building blocks stock is initialized to `world.toml [world].starting_building_blocks` (default 100).
|
||||||
- REQ-HQ-GAME-OVER: If the HQ is destroyed, the game ends. A game-over screen shows the final survival time and offers "Restart" and "Quit" buttons.
|
- REQ-HQ-GAME-OVER: If the HQ is destroyed, the game ends. A game-over screen, drawn by the game as every modal is (REQ-UI-MODAL-CHROME), shows the final survival time and offers "Restart" and "Quit" buttons.
|
||||||
- REQ-HQ-INVULNERABLE: Factory buildings (other than the HQ) are never targeted or destroyed by enemies.
|
- REQ-HQ-INVULNERABLE: Factory buildings (other than the HQ) are never targeted or destroyed by enemies.
|
||||||
|
|
||||||
## Win Condition
|
## Win Condition
|
||||||
@@ -123,8 +122,8 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- REQ-BLD-COST: The player places buildings from a build menu. Placement costs building blocks from the global stock. The cost per building type is read from `buildings.toml [[building]].cost`.
|
- REQ-BLD-COST: The player places buildings from a build menu. Placement costs building blocks from the global stock. The cost per building type is read from `buildings.toml [[building]].cost`.
|
||||||
- REQ-BLD-QUEUE: Placed buildings enter a construction queue and are built one at a time. Each building takes a duration defined in `buildings.toml [[building]].construction_time_seconds` to construct.
|
- REQ-BLD-QUEUE: Placed buildings enter a construction queue and are built one at a time. Each building takes a duration defined in `buildings.toml [[building]].construction_time_seconds` to construct.
|
||||||
- REQ-BLD-ASTEROID-ONLY: Buildings can only be placed on asteroid tiles (per surface_mask; tiles marked `S` may extend into space).
|
- REQ-BLD-ASTEROID-ONLY: Buildings can only be placed on asteroid tiles (per surface_mask; tiles marked `S` may extend into space).
|
||||||
- REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type. Builder mode is exited by right-clicking in the game world or clicking the same build button again. (Exception: while a belt drag placement is in progress, right-clicking cancels that drag instead of exiting, and builder mode stays active — REQ-BLD-BELT-DRAG.)
|
- REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type, clearing the selection as entering any build mode does (REQ-UI-SELECTION-EXCLUSIVE). Builder mode is exited by right-clicking in the game world or clicking the same build button again. (Exception: while a belt drag placement is in progress, right-clicking cancels that drag instead of exiting, and builder mode stays active — REQ-BLD-BELT-DRAG.)
|
||||||
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. The ghost is drawn semi-transparently in the building type's own visuals — its `fill` and `outline` colors and `glyph` from `visuals.toml` — so that different building types are visually distinguishable in builder mode rather than all looking alike. When the current cursor position is invalid, the ghost instead uses the distinct "invalid" color (REQ-BLD-PLACE-VALID), which overrides the per-building coloring.
|
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. The ghost follows the tile the cursor points at, which includes the view scrolling (REQ-UI-SCROLL) under a cursor that has not moved: the ghost then moves across the world with the scroll rather than sticking to the tile it was last placed on by a mouse move, exactly as a running selection box does (REQ-UI-MULTI-SELECT). The same holds for everything else the hovered position determines — placement validity (REQ-BLD-PLACE-VALID), the tunnel end being placed (REQ-BLD-TUNNEL-MODE), a belt drag's path (REQ-BLD-BELT-DRAG), the blueprint ghost (REQ-UI-BLUEPRINT-MODE), and the deconstruct hover (REQ-UI-DECONSTRUCT-BUTTON). A cursor that is not over the game world — resting on one of the floating panels (REQ-UI-CONTROLS-PANEL, REQ-UI-BUILD-BAR, REQ-UI-SELECTION-PANEL), or outside the window — points at no tile and therefore hovers nothing: no builder ghost and no blueprint ghost is drawn, no tunnel connection is previewed, and no building is tinted as the deconstruct hover, until the cursor returns to the world. The mode itself is unaffected — the player is still building, just not over anything — and the position the ghost had is not remembered: it is re-derived from wherever the cursor comes back. This applies whenever the cursor points elsewhere, including at the moment a mode is entered, so a mode entered from a build button (REQ-UI-BUILD-BAR) shows its ghost only once the cursor is over the world, while one entered by hotkey (REQ-UI-HOTKEYS) under a cursor already there shows it at once. The one exception is a gesture that holds the mouse button — a belt drag (REQ-BLD-BELT-DRAG) or a selection or deconstruct box (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-BOX) — which goes on following the cursor across the panels and beyond the window until the button is released. The ghost is drawn semi-transparently in the building type's own visuals — its `fill` and `outline` colors and `glyph` from `visuals.toml` — so that different building types are visually distinguishable in builder mode rather than all looking alike. When the current cursor position is invalid, the ghost instead uses the distinct "invalid" color (REQ-BLD-PLACE-VALID), which overrides the per-building coloring.
|
||||||
- REQ-BLD-ROTATE: While in builder mode, pressing Shift+R rotates the ghost 90° clockwise and R rotates it 90° counter-clockwise. Rotation affects the direction of the output port.
|
- REQ-BLD-ROTATE: While in builder mode, pressing Shift+R rotates the ghost 90° clockwise and R rotates it 90° counter-clockwise. Rotation affects the direction of the output port.
|
||||||
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. (For belts, placement is instead deferred to a drag gesture and happens on mouse release — REQ-BLD-BELT-DRAG.)
|
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. (For belts, placement is instead deferred to a drag gesture and happens on mouse release — REQ-BLD-BELT-DRAG.)
|
||||||
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE (builder mode) or REQ-UI-BLUEPRINT-OVERLAP and REQ-UI-BLUEPRINT-TRANSFER (blueprint placement mode), and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions.
|
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE (builder mode) or REQ-UI-BLUEPRINT-OVERLAP and REQ-UI-BLUEPRINT-TRANSFER (blueprint placement mode), and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions.
|
||||||
@@ -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 what that recipe produces (REQ-MAT-OUTPUT-GROUP) at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. Only implicitly unlocked recipes are available for selection (REQ-LOCK-UI-RECIPE).
|
||||||
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one 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 and returns a higher-tier material chosen by chance, as the value-preserving counterpart to smelting scrap down (REQ-BLD-SMELTER) and the only source of voidsteel. Its inputs, output groups and weights are ordinary recipe config (REQ-MAT-OUTPUT-GROUP) with `building = "reprocessing_plant"`; nothing about its behaviour is specific to the building. Reprocessing recipes take no part in the implicit unlock traversal (REQ-LOCK-IMPLICIT), so what it can yield is governed by REQ-LOCK-OUTPUT-POOL alone.
|
||||||
|
- REQ-BLD-AUTO-RECIPE: **Automatic recipe selection.** The Smelter and the Reprocessing Plant (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are *auto-recipe buildings*. They carry a selected recipe and are configured exactly as a Miner or Assembler is — the same selection button and dialog (REQ-UI-SELECT-BUTTON), buffers sized for that one recipe alone (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER), and the same pre-configuration on a construction site (REQ-BLD-SITE-CONFIG). They differ in one respect only:
|
||||||
|
- **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,13 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- REQ-MAT-ACCEPT-DIR: A transport tile (belt, splitter, tunnel entry, or tunnel exit) accepts an incoming item only through a non-output edge; an item that would enter through one of the tile's output edges is refused. For a belt or a tunnel entry/exit the sole output edge is the one in its facing direction; for a splitter either of its two output directions is an output edge. This applies both to items pushed from an adjacent transport tile and to items deposited by a building's output port (REQ-MAT-OUTPUT-PORT).
|
- REQ-MAT-ACCEPT-DIR: A transport tile (belt, splitter, tunnel entry, or tunnel exit) accepts an incoming item only through a non-output edge; an item that would enter through one of the tile's output edges is refused. For a belt or a tunnel entry/exit the sole output edge is the one in its facing direction; for a splitter either of its two output directions is an output edge. This applies both to items pushed from an adjacent transport tile and to items deposited by a building's output port (REQ-MAT-OUTPUT-PORT).
|
||||||
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared.
|
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared.
|
||||||
- **Setting a configuration to the value it already holds is a no-op.** Selecting the recipe or schematic already set, or applying a ship layout identical to the one already configured, changes nothing: buffers are not cleared, an in-progress production cycle is not cancelled (REQ-BLD-SHIPYARD), and a construction site's progress and stored settings are untouched. This holds however the setting is applied — through the selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), a blueprint placement (REQ-UI-BLUEPRINT-PLACE), or a blueprint configuration transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a setting that genuinely differs has effects.
|
- **Setting a configuration to the value it already holds is a no-op.** Selecting the recipe or schematic already set, or applying a ship layout identical to the one already configured, changes nothing: buffers are not cleared, an in-progress production cycle is not cancelled (REQ-BLD-SHIPYARD), and a construction site's progress and stored settings are untouched. This holds however the setting is applied — through the selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), a blueprint placement (REQ-UI-BLUEPRINT-PLACE), or a blueprint configuration transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a setting that genuinely differs has effects.
|
||||||
- REQ-MAT-OUTPUT-BUFFER: Each building has 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-GROUP: **What a recipe produces.** A recipe's output is defined as one or more **output groups**, each carrying a probability weight and a list of item/amount pairs. One cycle produces the items of exactly **one** group, decided at cycle start (REQ-MAT-CYCLE); the items within that group are all produced together.
|
||||||
- REQ-MAT-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.
|
- A recipe with a **single group** always produces it. There is nothing to choose, so no weight is consulted, no randomness is involved, and no eligibility is tested (REQ-LOCK-OUTPUT-POOL). This is the ordinary recipe, and the shape of all but the reprocessing ones.
|
||||||
- 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.
|
- A recipe with **several groups** picks one by weight at cycle start. Weights are normalized at load time; their sum does not need to equal 1.
|
||||||
|
- There is no separate deterministic and probabilistic kind of recipe: the two are one shape with one group and with several. Every rule about outputs is written over groups, so none of them needs to distinguish the two cases — or which building runs the recipe.
|
||||||
|
- **Config shape.** A group is a `[[recipe.output_group]]` entry with `probability` and `items = [{item, amount}, ...]`. The single-group case may instead be written as `outputs = [{item, amount}, ...]`, which means exactly one group holding those items. A recipe uses one form or the other; both present fails config load.
|
||||||
|
- REQ-MAT-OUTPUT-BUFFER: Each building has **one output buffer per item its recipe can produce** — every item of every one of its output groups (REQ-MAT-OUTPUT-GROUP). Each per-material buffer holds up to twice that item's per-cycle amount, meaning the largest total any single group produces of it, mirroring the input side (REQ-MAT-INPUT-BUFFER); a buffer's contents never occupy another item's capacity. An emerging item still occupies its buffer for the whole animation (REQ-MAT-OUTPUT-EMERGE). Whether a buffer without room stops production is decided by REQ-MAT-CYCLE. When the player selects a new recipe or schematic, all items in all output buffers are cleared (relevant when the adjacent belt is jammed and items have accumulated). Re-applying a setting the building already has clears nothing, per REQ-MAT-INPUT-BUFFER. Two buildings stand outside this rule: the Shipyard has no output buffer at all, since it spawns a ship rather than producing items (REQ-BLD-SHIPYARD), and the Salvage Bay has no recipe, so its single scrap buffer is sized by config instead (REQ-BLD-SALVAGE-BAY).
|
||||||
|
- REQ-MAT-CYCLE: Production cycle lifecycle. When a building is idle, it attempts to start a new cycle: (a) all required inputs must be present in the per-material input buffers, and (b) **every one** of the recipe's output groups must fit — for each group, the items it would produce must fit in their own output buffers (REQ-MAT-OUTPUT-GROUP, REQ-MAT-OUTPUT-BUFFER). With a single group (b) is simply that group. With several it means every possible outcome, tested **before** one is picked: the pick is committed the moment the cycle begins, and a cycle whose result could not be stored must not be started at all. Testing every outcome rather than the picked one is what keeps a stalled output belt from biasing the distribution — with one item type's buffer full the building stops entirely instead of going on producing only the others. On cycle start the group is picked (by weight where there is more than one, REQ-MAT-OUTPUT-GROUP), inputs are consumed immediately, and the production timer begins. On cycle completion the (already-decided) items are deposited into their output buffers and the building returns to idle.
|
||||||
- REQ-MAT-GLOBAL-STOCK: The building blocks stock is the only global inventory. All other materials exist only in building buffers or on belt tiles.
|
- REQ-MAT-GLOBAL-STOCK: The building blocks stock is the only global inventory. All other materials exist only in building buffers or on belt tiles.
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
@@ -266,9 +273,9 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
|
|
||||||
### Module Placement
|
### Module Placement
|
||||||
|
|
||||||
- REQ-MOD-PLACEMENT: In the layout configuration dialog (REQ-MOD-UI-DIALOG), the player places modules onto the ship's layout grid. Clicking a module button in the module selection grid enters module placement mode for that module type. While in placement mode, a ghost of the module's surface mask is rendered at the cell under the cursor. Clicking a valid position places one instance of the module. A position is valid if every `O` cell in the module's (rotated) surface mask coincides with an unoccupied buildable cell of the ship's layout. The player may place unlimited instances of the same module type.
|
- REQ-MOD-PLACEMENT: In the layout configuration dialog (REQ-MOD-UI-DIALOG), the player places modules onto the ship's layout grid. Clicking a module button in the module selection grid enters module placement mode for that module type. While in placement mode, a ghost of the module's surface mask is rendered at the cell under the cursor. Clicking a valid position places one instance of the module. A position is valid if every `O` cell in the module's (rotated) surface mask coincides with an unoccupied buildable cell of the ship's layout. The player may place unlimited instances of the same module type. Placement mode is left by pressing Q or clicking outside the dialog, either of which clears the selected module before it reaches the dialog itself (REQ-UI-DIALOG-DISMISS), and by the existing gestures: clicking the same module button again, picking another module, or entering remove mode (REQ-MOD-REMOVE).
|
||||||
- REQ-MOD-ROTATION: While in module placement mode, pressing R rotates the module ghost 90° counter-clockwise and Shift+R rotates it 90° clockwise. Rotation transforms the surface mask grid identically to building rotation (REQ-BLD-ROTATE).
|
- REQ-MOD-ROTATION: While in module placement mode, pressing R rotates the module ghost 90° counter-clockwise and Shift+R rotates it 90° clockwise. Rotation transforms the surface mask grid identically to building rotation (REQ-BLD-ROTATE).
|
||||||
- REQ-MOD-REMOVE: The module selection grid includes a "Remove" button. Clicking it enters remove mode. In remove mode, clicking on a cell occupied by a placed module removes that entire module instance from the layout. Remove mode is exited by clicking the Remove button again or by selecting a module for placement.
|
- REQ-MOD-REMOVE: The module selection grid includes a "Remove" button. Clicking it enters remove mode. In remove mode, clicking on a cell occupied by a placed module removes that entire module instance from the layout. Remove mode is exited by clicking the Remove button again, by selecting a module for placement, or by pressing Q or clicking outside the dialog, either of which leaves the mode before it reaches the dialog itself (REQ-UI-DIALOG-DISMISS).
|
||||||
|
|
||||||
### Module Effects
|
### Module Effects
|
||||||
|
|
||||||
@@ -283,7 +290,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- **Miner recipe**: `duration_seconds / output_amount`, where `output_amount` is the number of units produced per cycle.
|
- **Miner recipe**: `duration_seconds / output_amount`, where `output_amount` is the number of units produced per cycle.
|
||||||
- **Smelter recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
|
- **Smelter recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
|
||||||
- **Assembler recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
|
- **Assembler recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
|
||||||
- **Reprocessing-only item** (an item type that has no miner, smelter, or assembler recipe producing it, and is only obtainable via reprocessing): `(scrap_threat × scrap_per_cycle + duration_seconds) / probability`, where `scrap_threat` is the threat value of scrap (see REQ-THREAT-SCRAP), `scrap_per_cycle` is the number of scrap consumed per reprocessing cycle, `duration_seconds` is the reprocessing cycle time, and `probability` is the normalized weight of that item in the reprocessing output pool. (Reprocessing output amounts are 1 in practice, so per-unit division is already implicit in the formula.)
|
- **Item from a recipe that picks between output groups** (an item type produced by no single-group recipe, and so obtainable only where a cycle picks one outcome of several — REQ-MAT-OUTPUT-GROUP): `(scrap_threat × scrap_per_cycle + duration_seconds) / probability / output_amount`, where `scrap_threat` is the threat value of scrap (see REQ-THREAT-SCRAP), `scrap_per_cycle` is the scrap consumed per cycle, `duration_seconds` is the cycle time, `probability` is the group's normalized weight, and `output_amount` is how many units of the item that group yields. The cycle's cost is divided by the odds of getting the group at all, and then by how many units it yields, so the value is per unit as everywhere else in this requirement.
|
||||||
- **Multiple recipes**: if an item type can be produced by more than one non-reprocessing recipe (miner, smelter, or assembler), its threat value is the **maximum** across **all** such eligible recipes, and the threat is committed only once every eligible recipe is computable (so a shallow shortcut recipe that resolves earlier than a deeper base recipe cannot lower the item's threat). The reprocessing path is only used when no other recipe exists. If recipe cycles prevent full resolution, the max over the currently computable subset is used as a fallback.
|
- **Multiple recipes**: if an item type can be produced by more than one non-reprocessing recipe (miner, smelter, or assembler), its threat value is the **maximum** across **all** such eligible recipes, and the threat is committed only once every eligible recipe is computable (so a shallow shortcut recipe that resolves earlier than a deeper base recipe cannot lower the item's threat). The reprocessing path is only used when no other recipe exists. If recipe cycles prevent full resolution, the max over the currently computable subset is used as a fallback.
|
||||||
- **Scrap-consuming recipe fallback**: a non-reprocessing recipe that takes `scrap` as an input participates in an item's threat computation only if no scrap-free recipe (miner, smelter, or assembler) produces that item. This mirrors the reprocessing fallback rule and prevents the scrap-to-ingot smelter recipe from inflating basic material threats via the max rule.
|
- **Scrap-consuming recipe fallback**: a non-reprocessing recipe that takes `scrap` as an input participates in an item's threat computation only if no scrap-free recipe (miner, smelter, or assembler) produces that item. This mirrors the reprocessing fallback rule and prevents the scrap-to-ingot smelter recipe from inflating basic material threats via the max rule.
|
||||||
|
|
||||||
@@ -306,21 +313,21 @@ 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; Q and a click outside the dialog are two further ways to reach it, each once nothing is left to back out of within the dialog (REQ-UI-DIALOG-DISMISS). Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selection panel are recalculated, and the ship layout preview is refreshed.
|
||||||
|
|
||||||
- REQ-MOD-UI-EMPTY-PULSE: While a module is selected for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG), the empty buildable cells of the layout grid pulse smoothly around their normal fill shade, oscillating between a slightly darker and a slightly brighter shade at approximately 1 Hz (one full cycle per second), to draw the player's attention to where the module can be placed. All empty buildable cells pulse in phase. When no module is selected for placement (including remove mode), empty buildable cells render at their normal static shade. Non-buildable cells and cells occupied by a placed module do not pulse.
|
- REQ-MOD-UI-EMPTY-PULSE: While a module is selected for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG), the empty buildable cells of the layout grid pulse smoothly around their normal fill shade, oscillating between a slightly darker and a slightly brighter shade at approximately 1 Hz (one full cycle per second), to draw the player's attention to where the module can be placed. All empty buildable cells pulse in phase. When no module is selected for placement (including remove mode), empty buildable cells render at their normal static shade. Non-buildable cells and cells occupied by a placed module do not pulse.
|
||||||
|
|
||||||
- REQ-MOD-UI-AUTO-DIALOG: When the player selects a schematic for a shipyard (operational building or construction site) through the schematic selection dialog (REQ-UI-SELECT-BUTTON), and the chosen schematic **differs** from the shipyard's current schematic, the layout configuration dialog (REQ-MOD-UI-DIALOG) opens automatically and immediately once the selection dialog closes — exactly as if the player had then clicked "Configure". Re-selecting the schematic already set does not reopen the dialog. This auto-open applies only to the manual schematic selection dialog; a schematic applied by blueprint placement (REQ-UI-BLUEPRINT-PLACE) does **not** auto-open the dialog. The player may still cancel the auto-opened dialog (REQ-MOD-UI-DIALOG), which leaves the newly selected schematic in place with its default empty layout; the "Configure" button (REQ-MOD-UI-PREVIEW) remains available to open the dialog again later.
|
- REQ-MOD-UI-AUTO-DIALOG: When the player selects a schematic for a shipyard (operational building or construction site) through the schematic selection dialog (REQ-UI-SELECT-BUTTON), and the chosen schematic **differs** from the shipyard's current schematic, the layout configuration dialog (REQ-MOD-UI-DIALOG) opens automatically and immediately once the selection dialog closes — exactly as if the player had then clicked "Configure". Re-selecting the schematic already set does not reopen the dialog. Neither does **clearing** the shipyard: the `(None)` option (REQ-UI-SELECT-OPTIONS) differs from whatever was set, but it names no schematic and leaves nothing to configure, so no dialog opens — the same condition under which the "Configure" button is disabled (REQ-MOD-UI-PREVIEW). This auto-open applies only to the manual schematic selection dialog; a schematic applied by blueprint placement (REQ-UI-BLUEPRINT-PLACE) does **not** auto-open the dialog. The player may still cancel the auto-opened dialog (REQ-MOD-UI-DIALOG), which leaves the newly selected schematic in place with its default empty layout; the "Configure" button (REQ-MOD-UI-PREVIEW) remains available to open the dialog again later.
|
||||||
|
|
||||||
- REQ-MOD-UI-MODULE-TOOLTIP: Each module selection button in the layout configuration dialog (REQ-MOD-UI-DIALOG) shows a hover tooltip with the descriptive text defined for that module type in `modules.toml` (the optional per-module tooltip field). If a module type defines no tooltip text, its button shows no tooltip. The "Remove" button is not a module type and has no config-defined tooltip.
|
- REQ-MOD-UI-MODULE-TOOLTIP: Each module selection button in the layout configuration dialog (REQ-MOD-UI-DIALOG) shows a hover tooltip — hover only, its click selecting the module instead (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS) — with the descriptive text defined for that module type in `modules.toml` (the optional per-module tooltip field). If a module type defines no tooltip text, its button shows no tooltip. The material icons on the button face carry none of their own either way (REQ-UI-ITEM-VALUE-TOOLTIP): the button is a described control, and a module whose description happens to be unset must not read differently from the one beside it. The "Remove" button is not a module type and has no config-defined tooltip.
|
||||||
|
|
||||||
- REQ-MOD-UI-STATS-PANEL: The **ship stats panel** in the layout configuration dialog shows the stats of the currently configured ship layout as they would be computed, incorporating all passive module modifiers per REQ-MOD-STAT-CALC. The panel updates in real time whenever modules are placed or removed in the layout grid.
|
- REQ-MOD-UI-STATS-PANEL: The **ship stats panel** in the layout configuration dialog shows the stats of the currently configured ship layout as they would be computed, incorporating all passive module modifiers per REQ-MOD-STAT-CALC. The panel updates in real time whenever modules are placed or removed in the layout grid.
|
||||||
|
|
||||||
@@ -349,7 +356,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
|
|
||||||
- REQ-MOD-UI-BLUEPRINT-PANEL: The right column of the layout configuration dialog is the layout blueprint panel. It shows only blueprints whose `ship_type` matches the schematic of the shipyard for which the dialog was opened. The panel contains, from top to bottom: a "Create Blueprint" button, followed by a scrollable list of blueprint entries (one per matching blueprint, in creation order).
|
- REQ-MOD-UI-BLUEPRINT-PANEL: The right column of the layout configuration dialog is the layout blueprint panel. It shows only blueprints whose `ship_type` matches the schematic of the shipyard for which the dialog was opened. The panel contains, from top to bottom: a "Create Blueprint" button, followed by a scrollable list of blueprint entries (one per matching blueprint, in creation order).
|
||||||
|
|
||||||
- REQ-MOD-UI-BLUEPRINT-CREATE: Clicking "Create Blueprint" opens a modal dialog prompting for a name. The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the module layout currently shown in the left-side layout grid (the in-progress state of the dialog, not the previously confirmed shipyard layout) and appends it to the blueprint list.
|
- REQ-MOD-UI-BLUEPRINT-CREATE: Clicking "Create Blueprint" opens a modal dialog prompting for a name, drawn by the game like every other modal (REQ-UI-MODAL-CHROME) and, like the other name dialog, dismissed by neither Q nor a click outside it (REQ-UI-DIALOG-DISMISS). The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the module layout currently shown in the left-side layout grid (the in-progress state of the dialog, not the previously confirmed shipyard layout) and appends it to the blueprint list.
|
||||||
|
|
||||||
- REQ-MOD-UI-BLUEPRINT-ENTRY: Each blueprint entry shows the blueprint name and a delete icon ("×") to the right of the name. Clicking the entry (name area) loads that blueprint's module list into the left-side layout grid, replacing all currently placed modules. Module instances that are invalid for the current ship layout (unknown module type, locked module type, position outside the grid, position on a non-buildable cell, or overlapping another module in the same blueprint) are silently skipped; the remaining valid instances are placed. Clicking the delete icon ("×") removes that blueprint entry from the list immediately.
|
- REQ-MOD-UI-BLUEPRINT-ENTRY: Each blueprint entry shows the blueprint name and a delete icon ("×") to the right of the name. Clicking the entry (name area) loads that blueprint's module list into the left-side layout grid, replacing all currently placed modules. Module instances that are invalid for the current ship layout (unknown module type, locked module type, position outside the grid, position on a non-buildable cell, or overlapping another module in the same blueprint) are silently skipped; the remaining valid instances are placed. Clicking the delete icon ("×") removes that blueprint entry from the list immediately.
|
||||||
|
|
||||||
@@ -372,9 +379,11 @@ 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 describing it, as in the selection dialog (REQ-UI-SELECT-OPTIONS); the items the line names do explain themselves, on hover or click: the card is not itself clicked, the option being taken with the button beneath it (REQ-UI-ITEM-VALUE-TOOLTIP). If no recipes would be newly unlocked, the list shows "None".
|
||||||
|
|
||||||
|
**The dialog cannot be dismissed.** Clicking an option is the only thing that closes it: it has no close button, no Cancel, neither Escape nor Q dismisses it, and a click outside it does nothing (REQ-UI-DIALOG-DISMISS). The drop is a reward the player has earned by destroying the station set, and every way out of the dialog would have to either forfeit it or pick an option the player did not — so there is no way out but choosing. The dialog is modal and the game is paused meanwhile, so nothing is waiting on the decision.
|
||||||
|
|
||||||
The player selects one option by clicking it. If the player selects the artifact option, the player's artifact count is incremented by 1 (REQ-WIN-ARTIFACT-COUNT) and the dialog closes; no unlock is applied. Otherwise the selected unlock group is awarded and the dialog closes: every ship, module, building, and assembler recipe the group grants becomes unlocked at once — ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG); building types become available in the build menu (REQ-LOCK-BUILDING); assembler recipes become available in the assembler recipe-selection dialog (subject to REQ-LOCK-UI-RECIPE). The unlock group is removed from the pool permanently (REQ-LOCK-EXPLICIT), and the implicit unlock set is recomputed (REQ-LOCK-IMPLICIT).
|
The player selects one option by clicking it. If the player selects the artifact option, the player's artifact count is incremented by 1 (REQ-WIN-ARTIFACT-COUNT) and the dialog closes; no unlock is applied. Otherwise the selected unlock group is awarded and the dialog closes: every ship, module, building, and assembler recipe the group grants becomes unlocked at once — ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG); building types become available in the build menu (REQ-LOCK-BUILDING); assembler recipes become available in the assembler recipe-selection dialog (subject to REQ-LOCK-UI-RECIPE). The unlock group is removed from the pool permanently (REQ-LOCK-EXPLICIT), and the implicit unlock set is recomputed (REQ-LOCK-IMPLICIT).
|
||||||
|
|
||||||
@@ -385,14 +394,16 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- REQ-LOCK-PREREQ: An unlock group may optionally define `requires` — a list of prerequisite **unlock-group ids** that must already have been awarded before this group may enter the drop pool. A prerequisite is **satisfied** only when the unlock group it names has been awarded (REQ-LOCK-EXPLICIT). This check is applied in addition to the conditions in REQ-DEF-SCHEMATIC-DROP: a group enters the eligible pool only when its `station_level` condition is met, it has not yet been awarded, and every id in its `requires` is satisfied. `requires` defaults to empty (no prerequisites). The check is re-evaluated against the current set of awarded unlock groups every time a drop pool is built (after each REQ-DEF-SCHEMATIC-DROP and on Restart per REQ-CFG-RELOAD), so a gated group becomes eligible in the first drop after its last prerequisite is awarded. Every id listed in any `requires` must resolve to an unlock group defined in `unlocks.toml`; an id that names no such group is a configuration error that fails config load with a descriptive message (config is loaded at startup and reloaded on Restart, REQ-CFG-RELOAD). An unlock group that lists itself, or a cycle of mutually dependent prerequisites, is not a load error but can never become eligible, since no group in the cycle can be the first to be awarded.
|
- REQ-LOCK-PREREQ: An unlock group may optionally define `requires` — a list of prerequisite **unlock-group ids** that must already have been awarded before this group may enter the drop pool. A prerequisite is **satisfied** only when the unlock group it names has been awarded (REQ-LOCK-EXPLICIT). This check is applied in addition to the conditions in REQ-DEF-SCHEMATIC-DROP: a group enters the eligible pool only when its `station_level` condition is met, it has not yet been awarded, and every id in its `requires` is satisfied. `requires` defaults to empty (no prerequisites). The check is re-evaluated against the current set of awarded unlock groups every time a drop pool is built (after each REQ-DEF-SCHEMATIC-DROP and on Restart per REQ-CFG-RELOAD), so a gated group becomes eligible in the first drop after its last prerequisite is awarded. Every id listed in any `requires` must resolve to an unlock group defined in `unlocks.toml`; an id that names no such group is a configuration error that fails config load with a descriptive message (config is loaded at startup and reloaded on Restart, REQ-CFG-RELOAD). An unlock group that lists itself, or a cycle of mutually dependent prerequisites, is not a load error but can never become eligible, since no group in the cycle can be the first to be awarded.
|
||||||
|
|
||||||
- REQ-LOCK-IMPLICIT: Item types and miner/assembler recipes are **implicitly** unlocked or locked based on the current set of unlocked ship, module, and assembler recipe schematics. The implicit unlock set is recomputed whenever any schematic changes lock state (on Restart or after REQ-DEF-SCHEMATIC-DROP). Computation:
|
- REQ-LOCK-IMPLICIT: Item types and miner/assembler recipes are **implicitly** unlocked or locked based on the current set of unlocked ship, module, and assembler recipe schematics. The implicit unlock set is recomputed whenever any schematic changes lock state (on Restart or after REQ-DEF-SCHEMATIC-DROP). Computation:
|
||||||
1. Start with the union of: (a) all item types listed in `materials` across all currently unlocked ship schematics and all currently unlocked module schematics, and (b) the output item type of every assembler recipe that is currently **explicitly available** — that is, either flagged `unlocked_at_start` in `recipes.toml`, or granted by an unlock group that has been awarded (REQ-LOCK-EXPLICIT).
|
1. Start with the union of: (a) all item types listed in `materials` across all currently unlocked ship schematics and all currently unlocked module schematics, and (b) the output item types of every assembler recipe that is currently **explicitly available** — that is, either flagged `unlocked_at_start` in `recipes.toml`, or granted by an unlock group that has been awarded (REQ-LOCK-EXPLICIT).
|
||||||
2. For each item type in the current set: for every recipe (miner, smelter, or assembler) that produces it — skipping any assembler recipe that is granted by an unlock group whose group has not yet been awarded — add each of that recipe's input item types to the set. If the recipe is a miner recipe, or an assembler recipe that is not granted by any unlock group, mark it as implicitly unlocked. Assembler recipes that are explicitly available (flagged `unlocked_at_start`, or granted by an awarded unlock group) are available in the assembler recipe-selection dialog by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step.
|
2. For each item type in the current set: for every recipe (miner, smelter, or assembler) that produces it — skipping any assembler recipe that is granted by an unlock group whose group has not yet been awarded — add each of that recipe's input item types to the set. If the recipe is a miner recipe, or an assembler recipe that is not granted by any unlock group, mark it as implicitly unlocked. Assembler recipes that are explicitly available (flagged `unlocked_at_start`, or granted by an awarded unlock group) are available in the assembler recipe-selection dialog by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step.
|
||||||
3. Repeat step 2 until no new item types are added.
|
3. Repeat step 2 until no new item types are added.
|
||||||
Item types and miner/assembler recipes not reached by this process (and not explicitly unlocked) are locked. Smelter recipes participate in the traversal to propagate unlocking to their inputs but are never themselves shown in any UI dropdown.
|
Item types and miner/assembler recipes not reached by this process (and not explicitly unlocked) are locked. Smelter recipes participate in the traversal to propagate unlocking to their inputs but are never themselves gated by it: they are not granted by unlock groups either (REQ-LOCK-EXPLICIT), so a Smelter's dialog offers all of them once the building is unlocked (REQ-LOCK-UI-RECIPE, REQ-BLD-AUTO-RECIPE).
|
||||||
|
|
||||||
- REQ-LOCK-REPROCESSING-POOL: The pool of possible outputs for a Reprocessing Plant cycle (REQ-BLD-REPROCESSING) is restricted to item types that are currently implicitly unlocked (REQ-LOCK-IMPLICIT). Weights are renormalized over the eligible outputs. If no eligible outputs remain, the Reprocessing Plant cannot start a production cycle.
|
- REQ-LOCK-OUTPUT-POOL: **The choice between output groups is restricted to what the player can use.** Where a recipe has several output groups (REQ-MAT-OUTPUT-GROUP), only groups whose items are **all** currently implicitly unlocked (REQ-LOCK-IMPLICIT) are eligible; a group holding any locked item is dropped whole, since its items are produced together and taking it would hand the player a locked one. Weights are renormalized over the eligible groups, and if none remains the cycle cannot start.
|
||||||
|
|
||||||
- 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).
|
The restriction is on the **choice**, not on production: a recipe with a single group has no choice to restrict and is never tested for eligibility. This distinction is load-bearing rather than an optimization. Implicit unlocking is derived from demand — an item becomes unlocked because something the player can build needs it (REQ-LOCK-IMPLICIT) — so an ordinary recipe's output can be perfectly producible while nothing yet calls for it. Testing eligibility there would not gate a drop, it would stop the building producing at all. In practice this governs the Reprocessing Plant, the only building whose recipes have several groups (REQ-BLD-REPROCESSING).
|
||||||
|
|
||||||
|
- REQ-LOCK-UI-RECIPE: Locked miner ore-type recipes and assembler recipes are not shown in their respective recipe-selection dialogs (REQ-UI-SELECT-BUTTON). Smelter and Reprocessing Plant recipes are never granted by an unlock group (REQ-LOCK-EXPLICIT restricts `recipes` grants to assembler recipes), so they are available from the start and their dialogs list them all; what gates them in practice is whether the building itself is unlocked (REQ-LOCK-BUILDING).
|
||||||
|
|
||||||
- REQ-LOCK-UI-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).
|
||||||
|
|
||||||
@@ -426,9 +437,27 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
|
|
||||||
## UI
|
## UI
|
||||||
|
|
||||||
|
### Tooltips
|
||||||
|
|
||||||
|
Several UI elements carry a tooltip: the header bar's artifact count (REQ-UI-ARTIFACTS-TOOLTIP), every display that names an item — item chips, inline item values, and recipe summary icons, the header bar's building blocks stock among them (REQ-UI-ITEM-TOOLTIP, REQ-UI-ITEM-VALUE-TOOLTIP), the build button bar's building-type buttons (REQ-UI-BUILD-TOOLTIP) and Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON), the layout configuration dialog's module selection buttons (REQ-MOD-UI-MODULE-TOOLTIP), and the blueprint card's delete icon (REQ-UI-BLUEPRINT-DELETE). Each of those requirements defines what its tooltip says; the first two requirements here define how every one of them is shown and hidden, and the third says which displays carry an item's tooltip without being an item chip. An element not named there describes itself or says nothing: the modal header's close button (REQ-UI-MODAL-CHROME) says what it does by being a close button, and the selection dialog's option buttons describe themselves on their own faces (REQ-UI-SELECT-OPTIONS) — carrying no tooltip of their own, though the items they name still explain themselves (REQ-UI-ITEM-VALUE-TOOLTIP).
|
||||||
|
|
||||||
|
- REQ-UI-TOOLTIP-TRIGGER: A tooltip is shown by **hovering** the element it belongs to, after the usual short hover delay. On an element that has **no click action of its own** it is in addition shown **immediately on left click**, without waiting the delay out. That covers the header bar's building blocks stock display and artifact count and the selection panel's item chips: these display a value and do nothing when clicked, so a click on them is free to mean "tell me what this is", which is quicker than waiting on a hover and is the obvious thing to try. It does not cover elements whose click already does something — the building-type buttons, the Deconstruct button, and the module selection buttons — whose tooltips are reached by hovering alone, so that one gesture never both acts and explains. Nor does it cover an item named on such a control (REQ-UI-ITEM-VALUE-TOOLTIP): the click there belongs to the control, whatever the pointer is resting on. Only the left mouse button shows a tooltip; the right and middle buttons never do. Clicking an element whose tooltip is already up leaves it up.
|
||||||
|
- REQ-UI-TOOLTIP-DISMISS: A tooltip stays visible until the pointer leaves **both the tooltip and the element it belongs to**, however it was triggered. It does **not time out**: there is no display duration after which it vanishes on its own, so a tooltip listing several recipe lines (REQ-UI-ITEM-TOOLTIP) can be read at whatever pace the player needs. The tooltip is itself part of the area that keeps it open — moving the pointer off the element and onto the tooltip does not hide it — which is what fixes where it is placed: its **top-left corner sits at the pointer**, at the position the pointer had the moment the tooltip was triggered. The pointer therefore starts on the tooltip's own corner and reaches the rest of it without crossing a gap. A tooltip that would run off the screen is pushed back onto it, which only moves it further over the pointer. **A tooltip whose element moves goes with it**, holding the place it was given in relation to that element rather than the place it was given on the screen: the expansion button rides the scrolling world (REQ-UI-EXPAND-BUTTON), and a tooltip left standing where the button used to be reads as belonging to nothing. Moving with it is also what ends it, without any rule of its own — the element travels out from under the pointer and takes the tooltip along, and once the pointer is on neither, the tooltip hides. Nothing else dismisses it, and nothing else needs to: a click elsewhere is a click the pointer has already travelled to, so the tooltip is gone before it lands.
|
||||||
|
- REQ-UI-ITEM-VALUE-TOOLTIP: **Wherever the UI names an item, that spot explains the item.** An item is named by its icon, and it is named in three forms (REQ-UI-ITEM-ICON): as an **item chip**, as a **value** — a number followed by the bare inline icon standing in for the item's name — and as an **icon on its colored square within a recipe summary** (REQ-UI-RECIPE-SUMMARY), each with its per-cycle amount. All three show that item's production tooltip (REQ-UI-ITEM-TOOLTIP). The player can therefore ask what an item is wherever it is named, rather than only where it happens to be drawn as a chip.
|
||||||
|
|
||||||
|
The **whole value carries the tooltip** — the amount and the icon together, in every form: the two are one statement, and an icon alone is a target no taller than a line of text. Nothing else on the line is part of it: the caption beside a value (`Remaining`, `Total cost`) describes the number rather than the item, and a recipe summary's arrow, separators and cycle time belong to no item at all. None of these displays has a click action of its own, so a click shows the tooltip at once, as it does on a chip (REQ-UI-TOOLTIP-TRIGGER). This covers the header bar's building blocks stock (REQ-UI-BLOCKS-ICON), the building multi-selection's total cost (REQ-UI-MULTI-SELECTION), the remaining scrap of the debris panel and the field multi-selection (REQ-UI-DEBRIS-PANEL, REQ-UI-FIELD-MULTI-SELECTION), every input and output icon of the selection panel's recipe summary, a Shipyard's schematic materials included (REQ-UI-RECIPE-SUMMARY), and the layout configuration dialog's build cost line, which states a ship's materials in that same form (REQ-MOD-UI-DIALOG). For a value it holds whether or not the icon file exists: where a missing icon leaves the fallback text (`Stock: <n> Blocks`, `Scrap remaining`), the value still states an amount of an item and still explains it.
|
||||||
|
|
||||||
|
**An item named on something that already carries a tooltip of its own carries none.** One display shows one tooltip, and the one it was given for its own sake is the one that wins: the building block cost on a build button says nothing about blocks, because the button is there to explain the building (REQ-UI-BUILD-COST, REQ-UI-BUILD-TOOLTIP). The module selection buttons are excluded on the same ground even though a module without a `modules.toml` description leaves its button silent (REQ-MOD-UI-DIALOG, REQ-MOD-UI-MODULE-TOOLTIP): the button is a described control by design, and whether one module happens to carry the text is a gap in the config rather than a decision about the UI — the same material icon must not explain itself on one button and not on the one beside it.
|
||||||
|
|
||||||
|
**Everywhere else the item is explained, on a clickable control or not**: the asteroid expansion button's cost line (REQ-UI-EXPAND-BUTTON), the blueprint cards' costs (REQ-UI-BLUEPRINT-CARD), the recipe summaries on the selection dialog's option buttons (REQ-UI-SELECT-OPTIONS), and the recipe lines of the unlock-choice dialog's options (REQ-DEF-SCHEMATIC-DROP). These say what they do and nothing about the items they name, and the two answers do not compete: an option button says what a recipe makes, an item tooltip says where an input comes from.
|
||||||
|
|
||||||
|
**On a clickable control the tooltip shows on hover only.** The click belongs to the control — it buys the expansion, picks the blueprint, selects the recipe — so it cannot also be the gesture that explains (REQ-UI-TOOLTIP-TRIGGER). A click shows the tooltip at once wherever the item is named on something with no click action of its own: an item chip, a value in a row, the selection panel's recipe summary, and the recipe lines of an unlock-choice option, whose card is chosen through a button of its own rather than by being clicked (REQ-DEF-SCHEMATIC-DROP).
|
||||||
|
|
||||||
|
**An item named inside a tooltip carries none either.** The recipe lines of the item production tooltip name items as a recipe summary does, but a tooltip opened from a tooltip would replace the one being read, since only one is ever on screen and the pointer holds up only what it rests on (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS).
|
||||||
|
|
||||||
### 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. Both permanent UI widgets float over the game world — the build button bar (REQ-UI-BUILD-BAR) at its bottom center, and the selection panel (REQ-UI-SELECTION-PANEL) at its right edge, centered vertically within the height left above the build button bar and shown only while something is selected. 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. Each of the three keeps a place on the screen; the asteroid expansion button (REQ-UI-EXPAND-BUTTON) floats over the world as well but keeps a place in the **world** instead, standing on the ground it would buy and panning away with it, which is why it is not drawn below. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG):
|
||||||
|
|
||||||
```
|
```
|
||||||
+-----------------------------------------------------------+
|
+-----------------------------------------------------------+
|
||||||
@@ -439,29 +468,67 @@ The screen is a single column: a header bar across the top and the game world vi
|
|||||||
| Game World | Panel | |
|
| Game World | Panel | |
|
||||||
| +-----------+ |
|
| +-----------+ |
|
||||||
| |
|
| |
|
||||||
| +------------------+ |
|
| +----------+ |
|
||||||
| | Build Button Bar | |
|
| | Controls | +------------------+ |
|
||||||
+------------------+------------------+---------------------+
|
| | Panel | | Build Button Bar | |
|
||||||
|
+-+----------+-----+------------------+---------------------+
|
||||||
(full window width)
|
(full window width)
|
||||||
```
|
```
|
||||||
|
|
||||||
- REQ-UI-HEADER: The header bar spans the full width of the game window and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) and an asteroid expansion button (REQ-UI-EXPAND-BUTTON) to the left of the speed buttons, and game speed controls on the right.
|
- REQ-UI-HEADER: The header bar spans the full width of the game window and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) to the left of the speed buttons, and game speed controls on the right. The asteroid expansion button is not among them: it stands in the world on the ground it would buy (REQ-UI-EXPAND-BUTTON).
|
||||||
- REQ-UI-BLOCKS-ICON: In the header bar (REQ-UI-HEADER), the global building blocks stock is displayed as `Stock: <n>` followed by the `building_block` item icon (REQ-UI-ITEM-ICON) — e.g. `Stock: 200` then a small block icon — replacing the `Building Blocks: <n>` text label. The icon is sized to the header text height. When no icon file exists for `building_block` (a missing icon is not an error, REQ-UI-ITEM-ICON), the display falls back to the `Stock: <n> Blocks` text. The hover tooltip (REQ-UI-BLOCKS-TOOLTIP) applies in either form.
|
- REQ-UI-BLOCKS-ICON: In the header bar (REQ-UI-HEADER), the global building blocks stock is displayed as `Stock: <n>` followed by the `building_block` item icon (REQ-UI-ITEM-ICON) — e.g. `Stock: 200` then a small block icon — replacing the `Building Blocks: <n>` text label. The icon is sized to the header text height. When no icon file exists for `building_block` (a missing icon is not an error, REQ-UI-ITEM-ICON), the display falls back to the `Stock: <n> Blocks` text. The display states an amount of an item, so it shows that item's production tooltip in either form (REQ-UI-ITEM-VALUE-TOOLTIP).
|
||||||
- REQ-UI-BLOCKS-TOOLTIP: The header bar's building blocks stock display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].building_blocks_tooltip` — intended to tell the player what building blocks are used for and how to obtain them. If the field is unset, the stock display shows no tooltip. This tooltip is distinct from the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
|
- REQ-UI-ARTIFACTS-TOOLTIP: The header bar's artifact count display (REQ-UI-HEADER) shows a tooltip — on hover and, the display having no click action of its own, on click as well (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS) — with the descriptive text defined in `world.toml [world].artifact_tooltip` — intended to tell the player what artifacts are, how they are obtained (REQ-DEF-SCHEMATIC-DROP), and that collecting `world.toml [world].artifact_win_count` of them wins the game (REQ-WIN-ARTIFACT-COUNT). If the field is unset, the artifact count display shows no tooltip. An artifact count is not an amount of an item, so unlike the block stock beside it (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-VALUE-TOOLTIP) it has no production path to show and states configured text instead — the one display in the header bar that does. It is distinct as well from the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
|
||||||
- REQ-UI-ARTIFACTS-TOOLTIP: The header bar's artifact count display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].artifact_tooltip` — intended to tell the player what artifacts are, how they are obtained (REQ-DEF-SCHEMATIC-DROP), and that collecting `world.toml [world].artifact_win_count` of them wins the game (REQ-WIN-ARTIFACT-COUNT). If the field is unset, the artifact count display shows no tooltip. This tooltip is distinct from the building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP) and the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
|
|
||||||
- REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs.
|
- REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs.
|
||||||
- REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 10× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game.
|
- REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 10× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game.
|
||||||
- REQ-UI-PAUSE-BORDER: While the game is paused (speed 0×, whether set via the speed controls (REQ-UI-SPEED), the Space toggle (REQ-UI-HOTKEYS), or an auto-pausing modal), a vignette border is drawn around the edges of the game world view to make the paused state hard to miss. The border is black and fades in the alpha channel from fully transparent at its inner (center-facing) edge to 50% opacity at the viewport edge, over a thickness of 100 pixels (capped at half the smaller viewport dimension on very small views).
|
- REQ-UI-PAUSE-BORDER: While the game is paused (speed 0×, whether set via the speed controls (REQ-UI-SPEED), the Space toggle (REQ-UI-HOTKEYS), or an auto-pausing modal), a vignette border is drawn around the edges of the game world view to make the paused state hard to miss. The border is black and fades in the alpha channel from fully transparent at its inner (center-facing) edge to 50% opacity at the viewport edge, over a thickness of 100 pixels (capped at half the smaller viewport dimension on very small views).
|
||||||
- REQ-UI-DECONSTRUCT-BORDER: While deconstruct mode is active (REQ-UI-DECONSTRUCT-BUTTON, REQ-UI-HOTKEYS), a vignette border is drawn around the edges of the game world view to signal the mode, matching the geometry of the paused-state vignette (REQ-UI-PAUSE-BORDER): a 100-pixel thickness (capped at half the smaller viewport dimension on very small views) with the four sides meeting along mitred corner diagonals. It fades in the alpha channel from fully transparent at its inner (center-facing) edge to the deconstruct tint color at the viewport edge. The color — including its alpha, which sets the peak opacity at the viewport edge — is read from `visuals.toml [overlays].deconstruct_tint`, the same deconstruct-mode color used for the hover tint. The border is presentation-only and has no effect on the simulation. If the game is both paused and in deconstruct mode, both vignettes are drawn and compose over each other.
|
- REQ-UI-DECONSTRUCT-BORDER: While deconstruct mode is active (REQ-UI-DECONSTRUCT-BUTTON, REQ-UI-HOTKEYS), a vignette border is drawn around the edges of the game world view to signal the mode, matching the geometry of the paused-state vignette (REQ-UI-PAUSE-BORDER): a 100-pixel thickness (capped at half the smaller viewport dimension on very small views) with the four sides meeting along mitred corner diagonals. It fades in the alpha channel from fully transparent at its inner (center-facing) edge to the deconstruct tint color at the viewport edge. The color — including its alpha, which sets the peak opacity at the viewport edge — is read from `visuals.toml [overlays].deconstruct_tint`, the same deconstruct-mode color used for the hover tint. The border is presentation-only and has no effect on the simulation. If the game is both paused and in deconstruct mode, both vignettes are drawn and compose over each other.
|
||||||
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x>` followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). When no icon file exists for `building_block`, the caption falls back to the `Expand: <x> Blocks` text. Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
|
- REQ-UI-EXPAND-BUTTON: **The asteroid expansion button stands on the ground it would buy.** It is not a fixed control in the header bar: it floats over the game world view (REQ-UI-WORLD-SIZE) **centered on the columns the next expansion unlocks** — the `world.toml [expansion].columns_per_expansion_tiles` locked columns immediately left of the buildable edge (REQ-UI-LOCKED-ASTEROID, REQ-GW-ASTEROID-EXPAND). That is a position in the world, not on the screen, so the button **pans with the view** (REQ-UI-SCROLL) as the tiles beneath it do, and leaves the view entirely when they do: it is part of that piece of ground rather than a control the screen always carries. Nothing brings it back while the player looks elsewhere, and nothing needs to — the pan limit keeps the columns it stands on within reach at all times, since the leftmost pan centers the buildable edge and those columns lie just left of it (REQ-GW-SCROLL-LIMIT). The middle of those columns is the middle of the view vertically, the asteroid running the world's full height (REQ-GW-HEIGHT, REQ-GW-TILE-SIZE).
|
||||||
- REQ-UI-WORLD-SIZE: The game world view occupies the full width of the game window and the full height below the header bar. No widget insets it: the build button bar (REQ-UI-BUILD-BAR) and the selection panel (REQ-UI-SELECTION-PANEL) float over it.
|
|
||||||
- REQ-UI-SELECTION-PANEL: The **selection panel** (the panel described under Selection Panel, REQ-UI-SINGLE-SELECTION and following) is a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), anchored to the view's right edge with a small margin. It is **sized to its content in both width and height**, so it grows and shrinks as the selection changes, staying right-anchored as its size changes.
|
|
||||||
|
|
||||||
The panel's vertical placement and maximum height are defined against an **available band**: the height of the game world view, less the panel's top and bottom margins, less the height of the horizontal strip occupied by the build button bar at the bottom of the view (the bar's height plus its bottom margin, REQ-UI-BUILD-BAR) — the bar's band is subtracted over the full view width, regardless of how wide the bar currently is. The panel is **vertically centered within that available band**, not within the full view height, so it sits slightly above the view's vertical center. Should its content ever be taller than the band, the panel's height is capped at the band height and the content scrolls vertically within it.
|
The button keeps its size from its face rather than filling the columns it marks, and that face is **two lines**: `Expand` on the first, and the cost on the second as `<x>` followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of a trailing `Blocks` word, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). When no icon file exists for `building_block`, the second line falls back to the `<x> Blocks` text. Two lines rather than one caption because the cost is then a display of its own: **the cost line is what carries the item production tooltip**, as every other value stating an amount of an item does (REQ-UI-ITEM-VALUE-TOOLTIP), on hover only — the click is the button's. The word `Expand` carries nothing; it names the action, not an item. Clicking it unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). Its caption and position both update with each purchased expansion: the cost rises and the button moves a further `columns_per_expansion_tiles` to the left, onto the next stretch of locked ground.
|
||||||
|
|
||||||
|
- **Input.** Mouse events over the button are consumed by it and never reach the game world, as they are over the floating panels (REQ-UI-BUILD-BAR, REQ-UI-SELECTION-PANEL): hovering it shows no builder-mode ghost on the tile beneath, and clicking it neither places a building nor changes the selection. Unlike those panels it is not part of their placement pass (REQ-UI-SELECTION-PANEL), and they do not step around it: the button marks a place in the world, so a panel may cover it as it covers any other world content.
|
||||||
|
- REQ-UI-WORLD-SIZE: The game world view occupies the full width of the game window and the full height below the header bar. No widget insets it: the build button bar (REQ-UI-BUILD-BAR), the selection panel (REQ-UI-SELECTION-PANEL), and the controls panel (REQ-UI-CONTROLS-PANEL) float over it.
|
||||||
|
- REQ-UI-SELECTION-PANEL: The **selection panel** (the panel described under Selection Panel, REQ-UI-SINGLE-SELECTION and following) is a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), placed **beside the objects it describes** rather than at a fixed corner of the view, so it appears where the player is already looking. It is **sized to its content in both width and height**, so it grows and shrinks as the content changes. Two distances shape its placement, and they are deliberately different: a small **edge margin** it keeps from the view's edges and from the widgets it steps around, and a larger **selection gap** of half a tile (REQ-GW-TILE-SIZE) it keeps from the selection itself, so the panel stands clear of the objects it describes instead of touching them. The selection gap applies **horizontally only**, on the side facing the selection; vertically the panel stays level with the selection (see **Vertical placement**).
|
||||||
|
- **Anchor rectangle.** The panel is placed against the screen rectangle of the selection **at the moment that selection started**: the footprint of the single object selected (a building or construction site, an actor, or a piece of debris), or, when the selection started as a multi-selection (REQ-UI-MULTI-SELECT), the bounding box of all the objects it started with.
|
||||||
|
- **Side.** The panel goes to the **right** of the anchor rectangle, separated from it by the selection gap, whenever it fits within the view there. Otherwise it goes to the **left** of the anchor rectangle by that same gap. The room a side offers is measured accordingly: from the anchor rectangle's edge to the view's edge, less the selection gap and less the edge margin. When it fits on neither side — a bounding box spanning most of the view, or an object too close to an edge — it is placed on whichever side leaves more room and then pushed inside the view. That is the one case in which the panel covers part of the selection.
|
||||||
|
- **Vertical placement.** The panel's **top edge is aligned with the anchor rectangle's top edge** and it extends downward. The selection gap plays no part here: the panel's top sits level with the top of the topmost object it describes, the gap separating the two horizontally alone. Its bottom is limited by the lowest of: the view's bottom edge less the edge margin; and the top edge, less that same margin, of the build button bar (REQ-UI-BUILD-BAR) or the controls panel (REQ-UI-CONTROLS-PANEL) — but each of those two only where the panel's own horizontal extent actually overlaps that widget's current rectangle, so a panel whose column misses them is not shortened by them. Should the panel not fit above that limit, it is shifted up, as far as the edge margin at the view's top and no further; if it still does not fit, its height is capped at the space available there and the content scrolls vertically within it.
|
||||||
|
- **Fixed for the life of the selection.** The anchor rectangle, the selection gap, and the side are determined once, when the selection starts, and are not revisited while that selection lasts; the panel's own size is the only thing that may still move it (see **Resizing in place** below). The gap is therefore half a tile as the tile stood in that moment, and a view resize that changes the tile size (REQ-GW-TILE-SIZE) does not change it: the anchor rectangle it is measured from is a screen rectangle frozen in the same moment, and re-measuring one against a later tile size than the other has no meaning. The player may override the resulting position by dragging the panel's header (REQ-UI-SELECTION-PANEL-DRAG); the dragged position then takes the anchor rectangle's and the side's place for the rest of that selection. The panel **keeps its place on the screen** when the player scrolls the view (REQ-UI-SCROLL) and when a selected object moves under it (a selected ship flying away), rather than following the object — which may leave it beside nothing, or beside an object that has left the view entirely. It likewise does not move when the selection is **expanded** by adding objects or reduced by removing them (REQ-UI-MULTI-SELECT), nor when a selected object is destroyed or deconstructed. Starting a **new** selection — clicking a different object, or a box drag that replaces the selection — places the panel anew against the new anchor rectangle.
|
||||||
|
- **Resizing in place.** Only the anchor rectangle, the selection gap, and the chosen side are fixed for the life of the selection (or, once the panel has been dragged, the dragged desired position — REQ-UI-SELECTION-PANEL-DRAG); the panel's geometry is **re-solved from them** whenever its content size changes (a section appearing or disappearing as the selection's state changes), the view is resized, or the build button bar's or controls panel's rectangle changes. Re-solving keeps the two edges the panel was placed by — its top edge, and the edge facing the anchor rectangle (its left edge when it sits to the right of the selection, its right edge when it sits to the left) — so the panel grows away from the selection rather than over it, and it never switches sides for as long as the selection lasts. What re-solving may change is the vertical result: growth that would take the panel outside the view or into either of those two widgets is resolved as in **Vertical placement** above, by shifting it up and capping its height, and a panel that shrinks again regains the room.
|
||||||
- **Visibility.** The panel is shown only while at least one object is selected. With an empty selection it is not shown at all (REQ-UI-EMPTY-SELECTION), leaving the full game world view visible.
|
- **Visibility.** The panel is shown only while at least one object is selected. With an empty selection it is not shown at all (REQ-UI-EMPTY-SELECTION), leaving the full game world view visible.
|
||||||
- **Overlay behavior.** As for the build button bar (REQ-UI-BUILD-BAR): the panel occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected. It is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full right-hand band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the panel. The panel and the build button bar never overlap, because the panel's available band excludes the bar's strip; the bar itself never moves on the panel's account (REQ-UI-BUILD-BAR).
|
- **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-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, and the selection panel, REQ-UI-SELECTION-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-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 edge margin from the view's edges; its bottom is limited by the top edge, less that same margin, of the build button bar (REQ-UI-BUILD-BAR) and of the controls panel (REQ-UI-CONTROLS-PANEL), but each only where the panel's own horizontal extent actually overlaps that widget's current rectangle; and a panel that does not fit above that limit is shifted up as far as the edge margin at the view's top and, failing that, capped in height with its content scrolling. The player therefore cannot park the panel over either widget, and neither widget ever moves on the panel's account (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL) — stepping around them stays entirely the panel's job.
|
||||||
|
- **The desired position survives the resolution.** Resolving does not overwrite what the player set: the desired position is retained as dropped, so a panel that had to be shifted up or shortened returns to it as soon as the obstruction stops overlapping it — its content shrinks, the bar's button set changes (REQ-LOCK-BUILDING), the controls panel's context changes, or the view is resized. A desired position that the current view cannot honour at all is likewise kept, so enlarging the window brings the panel back to it.
|
||||||
|
- **What the drag replaces.** From the first drag on, the desired position replaces the anchor rectangle and the side (REQ-UI-SELECTION-PANEL) for the rest of the current selection; the panel no longer has a side and never switches to one. The selection gap goes with the anchor rectangle it was measured from and plays no further part: a player who drags the panel onto the selection is free to put it there. Re-solving (the **Resizing in place** rule of REQ-UI-SELECTION-PANEL) then keeps the top and left edges of the desired position, in place of the top edge and the edge facing the anchor, so the panel still grows away from where the player put it rather than over it. The panel may be dragged repeatedly; each drag replaces the previous desired position.
|
||||||
|
- **Scope: the current selection.** The desired position lasts as long as the selection it was set in — across the panel's own resizing, view resizes, and view scrolling (REQ-UI-SELECTION-PANEL), and across the selection being expanded or reduced (REQ-UI-MULTI-SELECT). Starting a **new** selection discards it: the panel is placed anew against the new anchor rectangle (REQ-UI-SELECTION-PANEL), and the player drags it again if they want it elsewhere.
|
||||||
|
- **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. A modal that cannot fit the window in a dimension is aligned with the window's top or left edge in that dimension, so the part read first stays visible, and the content that does not fit is reached by scrolling within the modal rather than by the modal growing past the window (REQ-UI-MODAL-CHROME).
|
||||||
|
- **Placement is all that changes.** The modal is modal as before, pauses the game and restores the speed on close as before (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-DIALOG), shows the dim over the entire window including the panel it sits on (REQ-UI-MODAL-DIM), and is dismissed the same way. That it covers the panel costs nothing: while it is open the panel is dimmed and takes no input anyway — a click on the part of the panel the modal leaves uncovered is a click outside the modal and dismisses it, like any other click beyond its frame (REQ-UI-DIALOG-DISMISS).
|
||||||
|
- **Only these modals.** Every other modal is placed as before, centered on the game window: the escape menu (REQ-UI-GAME-MENU), the blueprint save and selection dialogs (REQ-UI-BLUEPRINT-CREATE, REQ-UI-BLUEPRINT-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), none of which is opened from the panel. So is a modal opened from another modal rather than from the panel — the Create Blueprint name dialog within the layout configuration dialog (REQ-MOD-UI-BLUEPRINT-CREATE) — which is placed against the modal that opened it.
|
||||||
|
- **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-CHROME: **Every modal the player meets while playing is drawn by the game.** A modal is not an operating-system window: it has no title bar, no window border, and no window-manager close button, and the player can neither move it, resize it, nor drag it out of the game window. It is drawn inside the game window, on the dim that covers that window (REQ-UI-MODAL-DIM), which is the surface it sits on rather than a scrim behind a separate window. This holds for every one of them: the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), the blueprint save and selection dialogs (REQ-UI-BLUEPRINT-CREATE, REQ-UI-BLUEPRINT-DIALOG), the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE), the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), the escape menu (REQ-UI-GAME-MENU), and the game-over and win screens (REQ-HQ-GAME-OVER, REQ-WIN-SCREEN). One look for all of them, and one place the player's attention stays.
|
||||||
|
- **Each draws its own header.** Where a modal has a title it is a line the modal draws, as the blueprint selection dialog already draws `Blueprints` (REQ-UI-BLUEPRINT-DIALOG); where it has a close control it is a drawn button, as that dialog's `×` is. A modal without either shows neither — the schematic choice dialog has no way out but choosing (REQ-DEF-SCHEMATIC-DROP), and nothing about being a drawn modal gives it one.
|
||||||
|
- **It never leaves the window.** A modal is placed inside the game window (REQ-UI-PANEL-MODAL) and stays there. Content too large for the window **scrolls within the modal** rather than extending past the window's edge, so nothing is placed where the player cannot reach it.
|
||||||
|
- **The exception is failure reporting.** A failure that leaves nothing to draw on — a config file that fails to load at startup or on the reload a Restart performs (REQ-CFG-RELOAD), a blueprint file that cannot be read (REQ-UI-BLUEPRINT-LOAD) — is reported by a plain system message box instead. Such a message must reach the player when the game window is not in a state to host anything, so it cannot be built on the machinery whose failure it is reporting.
|
||||||
|
- **Presentation only.** How a modal is framed has no effect on the simulation and never enters the replay stream.
|
||||||
|
- REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the widgets floating over it (the build button bar, REQ-UI-BUILD-BAR, the selection panel, REQ-UI-SELECTION-PANEL, and the controls panel, REQ-UI-CONTROLS-PANEL) — behind that modal, so the game reads as inactive while the modal holds focus. The modal is drawn on that overlay rather than in a window of its own (REQ-UI-MODAL-CHROME), so the dim is the surface it sits on and not a separate layer that has to be kept in step with it. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE), and the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The same applies when one modal hands directly off to another — the blueprint save dialog opening the blueprint selection dialog on confirm (REQ-UI-BLUEPRINT-CREATE): the dim persists across the handoff rather than flickering off and back on, and the simulation is not resumed in between. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
|
||||||
|
|
||||||
|
- REQ-UI-DIALOG-DISMISS: **An open dialog is dismissed by Q or by a click outside it.** Either gesture does exactly what that dialog's own Cancel or close already does and nothing else, and both reach the same three dialogs. Q is the key that backs the player out in the game world (REQ-UI-HOTKEYS), doing the same on a dialog: a second key alongside Escape rather than a new behavior, so the player backs out of a dialog with the key their hand is already on. The click is the gesture that already puts a thing away by landing beside it — a click on empty world space clears the selection and with it the selection panel (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-EMPTY-SELECTION) — so a dialog is left the same way, by clicking away from it. The three dialogs that take both:
|
||||||
|
- **The recipe/schematic selection dialog** (REQ-UI-SELECT-BUTTON) — closes with the current recipe or schematic unchanged.
|
||||||
|
- **The blueprint selection dialog** (REQ-UI-BLUEPRINT-DIALOG) — closes with no other effect, as its close button does.
|
||||||
|
- **The layout configuration dialog** (REQ-MOD-UI-DIALOG) — the dismissal is its Cancel, discarding every change made in this dialog session. Here it backs out **one step at a time**, as Q does in the game world: while a module is selected for placement (REQ-MOD-PLACEMENT) it clears that selection, while remove mode is active (REQ-MOD-REMOVE) it leaves remove mode, and only with neither active does it cancel the dialog. A single press or click therefore never both leaves a mode and discards the session — which is what keeps a mis-aimed click outside the frame from costing the player a layout they were in the middle of placing.
|
||||||
|
|
||||||
|
In each case the simulation speed is restored as it is on any other close (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-DIALOG, REQ-UI-BLUEPRINT-DIALOG), and a dialog opened from another (REQ-MOD-UI-BLUEPRINT-CREATE) is dismissed before the one that opened it, Q reaching whichever holds focus.
|
||||||
|
|
||||||
|
**What counts as a click outside.** A left button press and release that both land outside the dialog's own rectangle. The dialog is modal and dims the whole window (REQ-UI-MODAL-DIM), so "outside" is everything beyond that rectangle without distinction — the dimmed game world, the floating panels, the header bar — including the selection panel a panel-centered modal is sitting on (REQ-UI-PANEL-MODAL). A gesture that *begins* inside the dialog and ends outside it — a scrollbar dragged past the frame, the cursor leaving the layout grid before the button comes up — is not a click outside and dismisses nothing, so no drag can end by discarding the dialog it was made in. Only the left button does this; a right click outside has no effect.
|
||||||
|
|
||||||
|
**The click is spent on the dismissal.** It closes the dialog and does nothing to whatever it landed on: it selects no building and clears no selection, presses no button, and places nothing. The window behind a modal takes no input while the modal is open, and dismissing the modal does not turn that click into one — the player clicks away once to close, and again if they then want to act on what is underneath.
|
||||||
|
|
||||||
|
**Where neither gesture is a dismissal.** Q stays an ordinary character in the two dialogs that take a typed name — the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE) and the Create Blueprint name dialog of the layout configuration dialog (REQ-MOD-UI-BLUEPRINT-CREATE) — where a dismissal key would be unable to tell a name from a command; a click outside them dismisses nothing either, for the related reason that a half-typed name is work in progress that no stray gesture may discard. They are left by their own Cancel or Escape. While one of them holds focus nothing behind it is dismissible either: the dialog that opened it becomes reachable again only once the one on top is gone. The escape menu (REQ-UI-GAME-MENU) keeps Escape and its Continue button, the game-over and win screens (REQ-HQ-GAME-OVER, REQ-WIN-SCREEN) are ended by their own buttons, and the schematic choice dialog is not dismissible at all — by key, by click outside, or otherwise (REQ-DEF-SCHEMATIC-DROP).
|
||||||
|
|
||||||
### Game World
|
### Game World
|
||||||
|
|
||||||
@@ -473,37 +540,50 @@ 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. It still stands in for the item's name, so the value it sits in explains that item as an item chip does, unless it sits inside a button (REQ-UI-ITEM-VALUE-TOOLTIP). 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.
|
||||||
|
- REQ-UI-LOCKED-ASTEROID: **The asteroid the player has not unlocked yet is drawn, tinted — and the part of it one purchase away is tinted apart from the rest.** Every asteroid column left of the buildable edge — the edge that moves left with each expansion (REQ-GW-ASTEROID-EXPAND) — is drawn in the asteroid's own fill under a tint, so the player sees the ground expansion will win them rather than empty space. Two tints, both read from `visuals.toml [overlays]`:
|
||||||
|
- the `world.toml [expansion].columns_per_expansion_tiles` columns immediately left of the buildable edge — the ones the next purchase unlocks, and the ones the expansion button stands on (REQ-UI-EXPAND-BUTTON) — take **`next_expansion`**;
|
||||||
|
- everything further left takes **`locked_asteroid`**.
|
||||||
|
|
||||||
|
`next_expansion` is the **more transparent** of the two, so the ground one purchase away reads as nearer to being the player's than the ground behind it, and the boundary between them shows exactly how far the next expansion reaches. Both are tints over the same asteroid fill: the distinction is how much of that fill shows through, not a different color of ground. The tinted area has **no left edge of its own**: expansions are unbounded (REQ-EXP-COST prices them but never stops them), so the tint runs as far left as the view reaches. What bounds it in practice is the pan limit (REQ-GW-SCROLL-LIMIT): the view stops with the buildable edge at its center, so the locked area fills the left half of the view at the leftmost pan and no more of it is ever seen. Locked tiles are ground the player cannot build on until they are unlocked (REQ-BLD-PLACE-VALID).
|
||||||
- REQ-UI-NO-ZOOM: The view has a fixed zoom level; the player cannot zoom in or out.
|
- REQ-UI-NO-ZOOM: The view has a fixed zoom level; the player cannot zoom in or out.
|
||||||
- REQ-UI-HOTKEYS: Global keyboard shortcuts:
|
- REQ-UI-HOTKEYS: Global keyboard shortcuts:
|
||||||
- **Space** — toggles pause. Pressing Space pauses (sets speed to 0×) and stores the previously selected non-zero speed; pressing Space again restores that speed.
|
- **Space** — toggles pause. Pressing Space pauses (sets speed to 0×) and stores the previously selected non-zero speed; pressing Space again restores that speed.
|
||||||
- **W** — increases game speed by one step in the sequence 0×, 0.5×, 1×, 2×, 10× (no wrap-around past 10×).
|
- **W** — increases game speed by one step in the sequence 0×, 0.5×, 1×, 2×, 10× (no wrap-around past 10×).
|
||||||
- **S** — decreases game speed by one step in the same sequence (no wrap-around past 0×).
|
- **S** — decreases game speed by one step in the same sequence (no wrap-around past 0×).
|
||||||
- **A / D** — scroll the view left / right (REQ-UI-SCROLL).
|
- **A / D** — scroll the view left / right (REQ-UI-SCROLL).
|
||||||
- **Q** — context-sensitive. If a build mode is active (builder mode or blueprint placement mode), pressing Q exits it. Otherwise, pressing Q toggles deconstruct mode: it enters deconstruct mode if inactive, or exits deconstruct mode if already active. (See also REQ-UI-DECONSTRUCT-BUTTON for the equivalent button.)
|
- **Q** — context-sensitive: one key that backs out of whatever the player is currently in, and enters deconstruct mode when they are in nothing. Its cases are evaluated in order:
|
||||||
|
- A dialog that takes Q is open — Q dismisses it, and the cases below do not apply: the dialog holds focus, and the game world is not what the player is backing out of (REQ-UI-DIALOG-DISMISS).
|
||||||
|
- A build mode is active (builder mode, blueprint placement mode, or deconstruct mode) — Q exits it.
|
||||||
|
- Something is selected — Q clears the selection (REQ-UI-EMPTY-SELECTION). The two cases never both apply, a selection and a build mode being mutually exclusive (REQ-UI-SELECTION-EXCLUSIVE); the order is stated only to fix the reading.
|
||||||
|
- Neither — Q enters deconstruct mode (see also REQ-UI-DECONSTRUCT-BUTTON for the equivalent button, which enters it from any of these states and clears the selection in doing so).
|
||||||
|
|
||||||
|
Entering deconstruct mode by keyboard while holding a selection therefore takes two presses — the first clears it, the second enters the mode — whereas the Deconstruct button does it in one click.
|
||||||
- **R / Shift+R** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE).
|
- **R / Shift+R** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE).
|
||||||
- **C** — create a temporary blueprint from the current selection and enter its placement mode (REQ-UI-BLUEPRINT-TEMP). Has effect only when at least one player-placeable building is selected; otherwise it does nothing.
|
- **C** — create a temporary blueprint from the current selection and enter its placement mode (REQ-UI-BLUEPRINT-TEMP). Has effect only when at least one player-placeable building is selected; otherwise it does nothing.
|
||||||
- **V** — re-enter placement mode for the last temporary blueprint created with C (REQ-UI-BLUEPRINT-TEMP). Does nothing when no temporary blueprint exists.
|
- **V** — re-enter placement mode for the last temporary blueprint created with C (REQ-UI-BLUEPRINT-TEMP). Does nothing when no temporary blueprint exists.
|
||||||
- **Ctrl+C** — save the current selection as a named blueprint: opens the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE). Has effect only when at least one player-placeable building is selected; otherwise it does nothing.
|
- **Ctrl+C** — save the current selection as a named blueprint: opens the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE). Has effect only when at least one player-placeable building is selected; otherwise it does nothing.
|
||||||
- **Ctrl+V** — opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG), from which a saved blueprint is picked for placement. It is available whenever the game is being played, regardless of the current selection or of which build mode is active, and opens the dialog even when no blueprints are saved yet.
|
- **Ctrl+V** — opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG), from which a saved blueprint is picked for placement. It is available whenever the game is being played, regardless of the current selection or of which build mode is active, and opens the dialog even when no blueprints are saved yet.
|
||||||
- **Escape** — opens the escape menu (REQ-UI-GAME-MENU). While a blueprint dialog is open, Escape closes that dialog instead (REQ-UI-BLUEPRINT-DIALOG).
|
- **Escape** — opens the escape menu (REQ-UI-GAME-MENU). While a dismissible dialog is open, Escape closes that dialog instead — the blueprint dialogs (REQ-UI-BLUEPRINT-DIALOG) and the two dialogs Q also dismisses (REQ-UI-DIALOG-DISMISS). The schematic choice dialog is the exception it cannot close (REQ-DEF-SCHEMATIC-DROP).
|
||||||
- **Build mode selection** — pressing a build hotkey activates builder mode for the corresponding building type, equivalent to clicking its build button (REQ-BLD-BUILDER-MODE):
|
- **Build mode selection** — pressing a build hotkey activates builder mode for the corresponding building type, equivalent to clicking its build button (REQ-BLD-BUILDER-MODE):
|
||||||
- **1** — Belt, **2** — Splitter, **3** — Tunnel (the unified tunnel build mode, REQ-BLD-TUNNEL-MODE). Hotkey 4 is unused.
|
- **1** — Belt, **2** — Splitter, **3** — Tunnel (the unified tunnel build mode, REQ-BLD-TUNNEL-MODE). Hotkey 4 is unused.
|
||||||
- **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant.
|
- **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant.
|
||||||
|
|
||||||
|
These shortcuts are the definition; the controls panel (REQ-UI-CONTROLS-PANEL) displays the subset of them that applies to the player's current situation, and the build buttons carry the build hotkeys on their badges (REQ-UI-BUILD-COST). Neither display defines a binding of its own.
|
||||||
|
|
||||||
### Debug Draw
|
### Debug Draw
|
||||||
|
|
||||||
- REQ-UI-DEBUG-DRAW: A debug draw mode can be toggled on and off with the **F3** key. It is inactive by default. While active, the sensor range of every ship — both player and enemy — is drawn as a circle centered on the ship, using that ship schematic's outline color from `visuals.toml`.
|
- REQ-UI-DEBUG-DRAW: A debug draw mode can be toggled on and off with the **F3** key. It is inactive by default. While active, the sensor range of every ship — both player and enemy — is drawn as a circle centered on the ship, using that ship schematic's outline color from `visuals.toml`.
|
||||||
@@ -517,7 +597,7 @@ The screen is a single column: a header bar across the top and the game world vi
|
|||||||
|
|
||||||
### Escape Menu
|
### Escape Menu
|
||||||
|
|
||||||
- REQ-UI-GAME-MENU: Pressing Escape at any time opens the escape menu as a modal dialog and pauses the simulation (sets speed to 0×). On close, the simulation speed is restored to what it was before the menu was opened — so if the game was already paused, it remains paused. The menu contains three buttons:
|
- REQ-UI-GAME-MENU: Pressing Escape at any time opens the escape menu as a modal dialog drawn by the game (REQ-UI-MODAL-CHROME) and pauses the simulation (sets speed to 0×). On close, the simulation speed is restored to what it was before the menu was opened — so if the game was already paused, it remains paused. The menu contains three buttons:
|
||||||
- **Continue** — closes the menu and returns to the game.
|
- **Continue** — closes the menu and returns to the game.
|
||||||
- **Restart** — resets the simulation to its initial state and closes the menu at 1× speed.
|
- **Restart** — resets the simulation to its initial state and closes the menu at 1× speed.
|
||||||
- **Quit** — closes the application.
|
- **Quit** — closes the application.
|
||||||
@@ -531,18 +611,20 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
|
|||||||
|
|
||||||
- REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or piece of debris), the selection panel is not shown at all — it is hidden rather than shown empty, so the full game world view is visible (REQ-UI-SELECTION-PANEL). It reappears as soon as an object is selected.
|
- REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or piece of debris), the selection panel is not shown at all — it is hidden rather than shown empty, so the full game world view is visible (REQ-UI-SELECTION-PANEL). It reappears as soon as an object is selected.
|
||||||
- REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with debris). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus debris, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident piece of debris (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT).
|
- REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with debris). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus debris, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident piece of debris (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT).
|
||||||
|
- REQ-UI-SELECTION-EXCLUSIVE: **A selection and a build mode are mutually exclusive.** At any moment the player is either holding a selection or in one of the build modes — builder mode (REQ-BLD-BUILDER-MODE), blueprint placement mode (REQ-UI-BLUEPRINT-MODE), or deconstruct mode (REQ-UI-DECONSTRUCT-BUTTON) — never both. **Entering any build mode clears the selection**, whichever way the mode is entered: a build button, a build hotkey, the Deconstruct button, the Q deconstruct toggle, C, or picking a blueprint card (REQ-UI-HOTKEYS, REQ-UI-BLUEPRINT-TEMP, REQ-UI-BLUEPRINT-DIALOG). Switching directly from one build mode to another therefore has no selection left to clear. The converse direction needs no rule of its own: while a build mode is active there is no gesture that selects — a left click places, marks for demolition, or transfers settings instead (REQ-BLD-BUILDER-MODE, REQ-BLD-DECONSTRUCT-CLICK, REQ-UI-BLUEPRINT-TRANSFER) — so a selection can only be made after the mode is left.
|
||||||
|
- **A selection is read before it is cleared.** The gestures that act on the selection and then enter a mode capture it first: C builds its temporary blueprint from the selection and only then enters placement mode (REQ-UI-BLUEPRINT-TEMP), and Ctrl+C's save dialog creates its blueprint before the selection dialog it opens can start a placement (REQ-UI-BLUEPRINT-CREATE). Neither loses what it was invoked on.
|
||||||
|
- **What follows from it.** The selection panel is never shown while a build mode is active, being hidden on an empty selection (REQ-UI-EMPTY-SELECTION), and the world draws no selection outlines there. The controls panel's contexts (REQ-UI-CONTROLS-CONTENT) become a partition of the state rather than a precedence rule: exactly one of General, Selection, Build, Blueprint, and Deconstruct applies, and the Selection context's "no build mode active" clause restates the exclusivity instead of resolving an overlap. Nothing here reaches the simulation — selection and build mode are both presentation state, and clearing a selection is not a player command, never enters the replay stream, and has no effect on the simulation.
|
||||||
- REQ-UI-SELECTION-CARD: **Card structure.** Every panel content is a card with the same three parts, top to bottom:
|
- REQ-UI-SELECTION-CARD: **Card structure.** Every panel content is a card with the same three parts, top to bottom:
|
||||||
- **Header** — always shown. It holds the selection's identity symbol on the left — the building's icon glyph (REQ-UI-WORLD-ICON), a ship's schematic color swatch, or the kind symbol of a defence station or piece of debris — the selection's name beside it, and one optional **right slot**. The right slot holds a status indicator (REQ-UI-SELECTION-STATUS), a ship's current behavior (REQ-UI-SHIP-BEHAVIOR), or an object count — never more than one of them; which one applies is stated per content in REQ-UI-SELECTION-CONTENT.
|
- **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). 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). The configuration group is 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 |
|
||||||
@@ -561,23 +643,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 or clicking a chip shows that item's production tooltip (REQ-UI-ITEM-TOOLTIP):
|
||||||
- an **input** chip shows the per-cycle amount below the count (the items consumed per run, e.g. `/ 2 per cycle`), or the count alone when the building has no selected recipe or schematic to give one;
|
- an **input** chip shows the per-cycle amount below the count (the items consumed per run, e.g. `/ 2 per cycle`), or the count alone when the building has no selected recipe or schematic to give one;
|
||||||
- an **output** chip shows the count against 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 for every item any of its output groups can produce (REQ-MAT-OUTPUT-GROUP).
|
||||||
|
|
||||||
Input and output chips form separately captioned sections, each shown only while it holds something (REQ-UI-SELECTION-CARD). 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).
|
||||||
- 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**: 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.
|
**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, then what the recipe produces, and the cycle time in seconds. The output side lists each item of an output group with its icon and per-cycle amount, the items within a group separated by `+` as the inputs are — they are produced together — and the **groups separated by `/`**, since only one of them happens (REQ-MAT-OUTPUT-GROUP). A recipe with a single group therefore reads exactly as before, and a Reprocessing Plant's reads as the alternatives it is rather than as one combined yield. It restates what the building will do without opening the selection dialog, and it is the panel's only display of the cycle time. Each input and output icon names an item and explains it on hover or click, together with the amount beside it (REQ-UI-ITEM-VALUE-TOOLTIP) — so the summary is also where the player follows a recipe's inputs back to what makes them, without the item having to be in a buffer to be asked about. For a Shipyard the summary is built from the schematic's materials and production time including the placed modules' contributions (REQ-BLD-SHIPYARD, REQ-MOD-STAT-CALC), matching the buffers beneath it. A building with no recipe or schematic selected shows no summary — including an auto-recipe building that has yet to select one (REQ-BLD-AUTO-RECIPE), which shows none until it does and keeps it from then on, so the card does not resize in step with the building's status (REQ-UI-SELECTION-STATUS, REQ-UI-SELECTION-PANEL).
|
||||||
|
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the panel's runtime group shows a captioned **production section** between the input and output buffer sections (REQ-UI-SINGLE-SELECTION): a horizontal progress bar filled to the completion of the active production cycle, with that completion beside the caption as an integer percentage (e.g. `72%`), or the text `idle` in place of the percentage and an empty bar when no production cycle is active. The cycle time is shown in the recipe summary (REQ-UI-RECIPE-SUMMARY) rather than repeated here. When no recipe or schematic is selected, the production section is not shown at all.
|
||||||
- REQ-UI-MULTI-SELECT: The player selects multiple objects by box-drag or by Ctrl+clicking individual objects to add or remove them from the selection. Multi-select operates within a single category (REQ-UI-SELECTION-CATEGORIES). A box-drag that covers at least one building selects buildings (any field objects within the box are ignored — buildings win); a box-drag that covers no building but does cover ships, defence stations, or debris selects all of those field objects together (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-MULTI-SELECT).
|
- REQ-UI-MULTI-SELECT: The player selects multiple objects by box-drag or by Ctrl+clicking individual objects to add or remove them from the selection. Multi-select operates within a single category (REQ-UI-SELECTION-CATEGORIES). A box-drag that covers at least one building selects buildings (any field objects within the box are ignored — buildings win); a box-drag that covers no building but does cover ships, defence stations, or debris selects all of those field objects together (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-MULTI-SELECT).
|
||||||
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected 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.
|
- **Rectangle geometry.** The box is **not** snapped to tiles: its two corners are the exact world positions the button went down at and the cursor is at now, so the rectangle is drawn where the mouse actually went and follows it pixel by pixel. The corners are held in world coordinates rather than screen ones, so the anchor stays on the spot in the world it was placed on when the view scrolls under a held button (REQ-UI-SCROLL).
|
||||||
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel, 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).
|
- **Coverage.** What the box covers follows the same rectangle, not the tiles it touches. An object that occupies whole tiles — a building, a construction site, a defence station — is covered when the rectangle overlaps any of its body cells, so grazing a building's tile selects it. An object that has a position rather than a footprint — a ship, a piece of debris — is covered when the rectangle contains its centre, so what the player sees enclosed by the rectangle is exactly what the release selects. This is also what makes box and click agree: a click already hit-tests ships and debris against their world positions (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT), not against the tile they stand on.
|
||||||
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the 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).
|
- **When the rectangle appears.** The rectangle is drawn only once the cursor has moved at least **2 pixels** from the position the button went down at — a press alone draws nothing, so a plain click does not flash a rectangle. The threshold is in screen pixels because it only separates a click from a drag, which is a question of hand steadiness. Once shown, the rectangle stays shown for the rest of the drag, including when the cursor comes back to where it started. It is measured against where the anchor sits on screen at that moment, so scrolling the view while the button is held moves no cursor but still crosses the threshold, the box having grown all the same. Below the threshold the gesture is a click, and the box it resolves on release is the **whole tile** the button went down on — that is what makes a click select or mark what it points at (REQ-UI-SELECTION-CATEGORIES, REQ-BLD-DECONSTRUCT-CLICK) rather than the empty rectangle a motionless cursor spans.
|
||||||
- REQ-UI-SELECT-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selection panel when a selection is set, displays an info tooltip:
|
- **Rectangle color.** While dragging, the selection rectangle is drawn as an outline in `visuals.toml [overlays].selected_outline` — the same color and config entry as the outline drawn in the world around the objects that end up selected, so the box and the selection it produces read as one thing. **Exception:** while deconstruct mode is active (REQ-UI-DECONSTRUCT-BUTTON, REQ-UI-HOTKEYS), the box drag marks buildings for demolition instead (REQ-BLD-DECONSTRUCT-BOX) and the rectangle is drawn in the deconstruct color — the RGB of `visuals.toml [overlays].deconstruct_tint`, drawn **fully opaque**. That entry's alpha channel governs only the fills it tints (the deconstruct-mode hover tint and queued buildings, REQ-UI-DECONSTRUCT-BORDER, REQ-BLD-DECON-QUEUE) and is not applied to this outline, which would otherwise be too faint to see. The rectangle's geometry is the same in both modes.
|
||||||
- For a **recipe** (Miner or Assembler): the recipe name; the name and quantity of each input item (no inputs are listed for miner recipes, which consume nothing); the completion time (`duration_seconds`); and the name and quantity of the produced output item.
|
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected and the selection does not aggregate (REQ-UI-SELECTION-AGGREGATE), the panel shows a count summary. Its header names the size of the selection as `<n> buildings` in place of an object name, and carries no symbol and nothing in its right slot. Below it is one row per selected building type — the type's symbol, its name, and the number selected as `x<count>` — one type per row, and no per-building detail. A final row shows the **total building block cost** of the selection, captioned `Total cost` with the value followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) and showing that item's production tooltip (REQ-UI-ITEM-VALUE-TOOLTIP): the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button bar); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-CARD). Construction sites count at their building type's full placement cost regardless of construction progress.
|
||||||
- For a **ship schematic** (Shipyard): the ship's `display_name`; the name and quantity of each base required material (`[ship.schematic].materials`, excluding any module contributions); the base production time (`[ship.schematic].production_time_seconds`); and "Produces: 1 <ship display name>".
|
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel, in its configuration group (REQ-UI-SELECTION-CARD). Recipe selection (miner, assembler, smelter, reprocessing plant) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
|
||||||
- REQ-UI-RECIPE-ICON: In the recipe-selection dialog (REQ-UI-SELECT-BUTTON) for a Miner or Assembler, each recipe option button shows the icon of the recipe's produced item **instead of** its name caption (icon-only). The item shown is the recipe's `icon` field if set, otherwise its first output item; the icon is that item's icon per REQ-UI-ITEM-ICON. When the item has no icon file, the button falls back to the recipe/item name caption. The recipe name and details remain available on hover via the selection info tooltip (REQ-UI-SELECT-TOOLTIP). The `(None)` option keeps its text caption. This applies only to recipe options; the Shipyard schematic-selection dialog is unaffected and continues to show ship name captions.
|
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe, and the Smelter's and Reprocessing Plant's recipe per REQ-BLD-AUTO-RECIPE) and schematic selection (Shipyard) are each presented in the selection panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened) and that opens centered on the selection panel, as every modal opened from the panel does (REQ-UI-PANEL-MODAL). The dialog contains a vertical list of option buttons, one per selectable option, each describing itself (REQ-UI-SELECT-OPTIONS) — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Neither the option buttons nor the selection button carries a tooltip **of its own** — the items they name explain themselves as they do everywhere else (REQ-UI-ITEM-VALUE-TOOLTIP), which is a different question from what the option does: 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 from that summary's own icons and from the card's item chips (REQ-UI-ITEM-TOOLTIP, REQ-UI-ITEM-VALUE-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selection panel. The dialog can be dismissed without changing the current selection — by closing it without clicking an option, by Escape, by Q, or by clicking outside it (REQ-UI-DIALOG-DISMISS). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
|
||||||
|
- REQ-UI-SELECT-OPTIONS: **Option list of the selection dialog.** The selection dialog (REQ-UI-SELECT-BUTTON) lists its options as a **single vertical column** of buttons, one per option, rather than a grid: each button is as wide as the dialog and states what the option does, which needs a line of its own. No option button carries a tooltip describing the option — 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. What the button face does not say is where an input comes from, and that the items it names answer themselves, on hover (REQ-UI-ITEM-VALUE-TOOLTIP): the click stays the gesture that picks the option. Should the column be taller than the space the window leaves the dialog — a fully unlocked Assembler offers more options than any window can hold, and a modal is never resized to fit (REQ-UI-PANEL-MODAL) — the list **scrolls** within the dialog rather than the dialog growing past the window.
|
||||||
|
- A **recipe** option shows the **recipe name** on its first line and, beneath it, that recipe drawn as the recipe summary line draws it (REQ-UI-RECIPE-SUMMARY): each input item's icon on its colored square with its per-cycle amount, an arrow, its output groups with their amounts, and the cycle time. A miner recipe consumes nothing, so its line begins at the arrow.
|
||||||
|
- A **ship schematic** option (Shipyard) shows the ship's `display_name` on its first line and, beneath it, the icons and quantities of its base required materials (`[ship.schematic].materials`, excluding any module contributions) with the base production time (`[ship.schematic].production_time_seconds`). A ship is not an item and has no icon of its own, so this line names no output: the button's caption is what it produces.
|
||||||
|
- 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. A chip has no click action of its own, so clicking one shows the same tooltip at once (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS). It has a heading and a body:
|
||||||
|
- the heading is the **chip's item name**. For an input chip this is the only place the item is named at all, since such a chip carries a count and no name (REQ-UI-SINGLE-SELECTION).
|
||||||
|
- the body is the caption `Produced by` followed by one **recipe line** per unlocked recipe that produces the item. A recipe line is a small **card** of two rows: the icon of the building that runs the recipe (REQ-UI-BUILD-ICON) and the recipe's name on the first, and the recipe itself on the second, drawn as the recipe summary draws it (REQ-UI-RECIPE-SUMMARY) — each input item's icon on its colored square with its per-cycle amount, the inputs separated by `+`, an arrow, its output groups with their amounts, and the cycle time. Two rows rather than one because an identity and a cycle read as different things, and a single row of icons, names and numbers runs too long to scan; a card around each because several producers stacked as bare lines read as one field of icons and numbers rather than as separate recipes. A line drawn where its surroundings already frame it — on an option button (REQ-UI-SELECT-OPTIONS), or as the panel's recipe summary — takes no card of its own. An item may have several producers — an iron ingot is smelted from ore, smelted from scrap, and recovered by reprocessing — and the building icon and recipe name are what tell those lines apart and tell the player which building to place for which path.
|
||||||
|
|
||||||
|
**Only recipes the player can run are listed**, consistent with the rest of the UI hiding what the player cannot make yet. What that means differs by building, because only Miner and Assembler recipes are unlocked individually (REQ-LOCK-UI-RECIPE): those are listed once unlocked, while a Smelter's or Reprocessing Plant's recipes (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are listed once **their building** is unlocked (REQ-LOCK-BUILDING) — there is no sense in naming a path through a plant the player cannot place. Two cases have no recipe line to show, and each says so in place of the list rather than leaving the tooltip bare:
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
Which displays show this tooltip is REQ-UI-ITEM-VALUE-TOOLTIP: item chips, the values that state an amount of an item with a bare inline icon, and the icons of a recipe summary wherever one is drawn — on the option buttons of the selection dialog too, whose faces describe the option and say nothing about its items (REQ-UI-SELECT-OPTIONS). The exceptions are an item named on a display that carries a tooltip of its own, and the recipe lines within this tooltip itself, which are already inside one.
|
||||||
- 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 **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 — and the HQ's **HP** as a bar labelled `current / maximum` (REQ-HQ-STATS, REQ-UI-HP-BARS). The HQ has no input or output buffers of its own: building blocks delivered by belt go straight into the global stock (REQ-HQ-BELT-INPUT), and showing that stock on the HQ is what tells the player to route blocks there. The HQ has no configuration group and no status indicator (REQ-UI-SELECTION-STATUS), and it is never a construction site.
|
- REQ-UI-HQ-PANEL: When the HQ is selected, the panel shows the HQ's **HP** as a bar labelled `current / maximum` (REQ-HQ-STATS, REQ-UI-HP-BARS) and, beneath it, the **global building blocks stock** — the same value as the header bar's stock display (REQ-UI-BLOCKS-ICON), rendered as an item chip (REQ-UI-SINGLE-SELECTION) carrying the `building_block` icon on its colored square — a chip is an item display, so it takes the square even though the header bar's inline block icon does not (REQ-UI-ITEM-ICON, REQ-UI-BLOCKS-ICON), and hovering or clicking it shows the item production tooltip as any other chip does (REQ-UI-ITEM-TOOLTIP). The HP comes first, as it does on every card that has it (REQ-UI-SELECTION-CARD). The HQ has no input or output buffers of its own: building blocks delivered by belt go straight into the global stock (REQ-HQ-BELT-INPUT), and showing that stock on the HQ is what tells the player to route blocks there. The HQ has no configuration group and no status indicator (REQ-UI-SELECTION-STATUS), and it is never a construction site.
|
||||||
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with debris and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a piece of debris adds to or establishes a field selection (REQ-UI-DEBRIS-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or piece of debris) clears the selection.
|
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with debris and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a piece of debris adds to or establishes a field selection (REQ-UI-DEBRIS-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or piece of debris) clears the selection.
|
||||||
- REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **ship stats panel**. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. Its header (REQ-UI-SELECTION-CARD) carries the schematic's color swatch and display name, with the ship's current behavior in the right slot (REQ-UI-SHIP-BEHAVIOR). The panel always shows all hull stats: HP (current / maximum) as a **bar** with the two values beside its caption, then max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed as label/value rows. In addition, capability module summaries are shown below the hull stats, each as its own outlined row, conditioned on which module types are installed and using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
|
- REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **ship stats panel**. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. Its header (REQ-UI-SELECTION-CARD) carries the schematic's color swatch and display name, with the ship's current behavior in the right slot (REQ-UI-SHIP-BEHAVIOR). The panel always shows all hull stats: HP (current / maximum) as a **bar** with the two values beside its caption, then max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed as label/value rows. In addition, capability module summaries are shown below the hull stats, each as its own outlined row, conditioned on which module types are installed and using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
|
||||||
- REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** in its header's right slot (REQ-UI-SELECTION-CARD) — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are:
|
- REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** in its header's right slot (REQ-UI-SELECTION-CARD) — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are:
|
||||||
@@ -589,36 +686,110 @@ 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 and with the same tooltip (REQ-UI-DEBRIS-PANEL, REQ-UI-ITEM-ICON, REQ-UI-ITEM-VALUE-TOOLTIP). 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 and why the value shows the scrap production tooltip — `Salvaged from debris`, for an item no recipe makes (REQ-UI-ITEM-VALUE-TOOLTIP, REQ-UI-ITEM-TOOLTIP); 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
|
||||||
|
|
||||||
- REQ-UI-BUILD-BAR: All placeable building types are shown as a **single horizontal row** of buttons with no grouping and no wrapping, inside a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), horizontally centered and anchored at the bottom edge with a small margin. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each. The bar is sized to its buttons and re-centers whenever the set of shown buttons changes (REQ-LOCK-BUILDING) or the view is resized.
|
- REQ-UI-BUILD-BAR: All placeable building types are shown as a **single horizontal row** of buttons with no grouping and no wrapping, inside a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), horizontally centered and anchored at the bottom edge with a small margin. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each. The bar is sized to its buttons and re-centers whenever the set of shown buttons changes (REQ-LOCK-BUILDING) or the view is resized.
|
||||||
- **Overlay behavior.** The bar occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected (the world is not inset for the bar). The bar is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full 100-pixel bottom band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the bar.
|
- **Overlay behavior.** The bar occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected (the world is not inset for the bar). The bar is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full 100-pixel bottom band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the bar.
|
||||||
- **No overlap with the selection panel.** The bar and the selection panel (REQ-UI-SELECTION-PANEL) never overlap, and keeping them apart is entirely the panel's job: the bar's position depends only on its own button set and the view size, and it never moves, re-centers, or resizes because the panel appears, disappears, or changes size. The panel instead confines itself to the view height less the bar's strip (REQ-UI-SELECTION-PANEL).
|
- **No overlap with the selection panel.** The bar and the selection panel (REQ-UI-SELECTION-PANEL) never overlap, and keeping them apart is entirely the panel's job: the bar's position depends only on its own button set and the view size, and it never moves, re-centers, or resizes because the panel appears, disappears, or changes size. The panel instead steps around the bar's current rectangle wherever its own column would meet it (REQ-UI-SELECTION-PANEL).
|
||||||
- **Input.** Mouse events over the bar are consumed by the bar and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the bar does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG).
|
- **Input.** Mouse events over the bar are consumed by the bar and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the bar does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG).
|
||||||
- REQ-UI-BUILD-COST: Each button is **icon-only with a cost**, its face composed of three elements: the button's **hotkey badge** in the top-left corner, the building's icon (REQ-UI-BUILD-ICON) centered below it, and the building block cost centered under the icon, shown with the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) to the right of the number in place of the trailing `Blocks` word, e.g. `2` then a small block icon. The building name is not shown on the button; it is shown in the button's hover tooltip instead (REQ-UI-BUILD-TOOLTIP). When no icon file exists for `building_block`, the cost is shown as the bare number. The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) has no cost and shows its name as a text caption in the cost's place.
|
- 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 — hover only, its click entering builder mode instead (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS) — consisting of the building name followed by the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). Because the button caption is icon-only (REQ-UI-BUILD-COST), the name is always part of the tooltip; if a building type defines no tooltip text, the tooltip shows the name alone. It is what keeps the block cost on the button face silent, a display carrying only one tooltip and this being the one the button is for (REQ-UI-ITEM-VALUE-TOOLTIP); the item production tooltip of the selection panel's item chips is a different tooltip again (REQ-UI-ITEM-TOOLTIP). The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) is not a building type and so has no config-defined tooltip; it instead shows its own refund tooltip defined in REQ-UI-DECONSTRUCT-BUTTON.
|
||||||
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained.
|
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained.
|
||||||
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button bar (REQ-UI-BUILD-BAR), as the last entry of the row and **visually separated** from the building-type buttons by a gap (not a divider line), because it toggles a mode rather than selecting a building type. Its face follows REQ-UI-BUILD-COST with two differences: its hotkey badge reads `Q`, and because it has no building block cost it shows its **Deconstruct** name as a text caption where the building-type buttons show their cost — so it is the one labelled button in the bar. It is therefore wider than the building-type buttons, which share a uniform width. Clicking it toggles deconstruct mode on and off, equivalent to the Q deconstruct toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage.
|
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button bar (REQ-UI-BUILD-BAR), as the last entry of the row and **visually separated** from the building-type buttons by a gap (not a divider line), because it toggles a mode rather than selecting a building type. Its face follows REQ-UI-BUILD-COST with two differences: its hotkey badge reads `Q`, and because it has no building block cost it shows its **Deconstruct** name as a text caption where the building-type buttons show their cost — so it is the one labelled button in the bar. It is therefore wider than the building-type buttons, which share a uniform width. Clicking it toggles deconstruct mode on and off; entering the mode clears the selection (REQ-UI-SELECTION-EXCLUSIVE), which is the one way in which it differs from the Q key (REQ-UI-HOTKEYS): Q clears a selection before it enters the mode, so with something selected the button gets there in one click and Q in two. The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip — hover only, its click toggling the mode instead (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS) — stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage.
|
||||||
|
|
||||||
|
### Controls Panel
|
||||||
|
|
||||||
|
The controls panel tells the player which controls are available right now. It is context-sensitive: the game is always in exactly one **control context**, derived from the active build mode and the current selection, and the panel shows that context's rows and no others. Its position, size, and overlay behavior are defined in REQ-UI-CONTROLS-PANEL; its structure in REQ-UI-CONTROLS-CARD; and which rows each context shows in REQ-UI-CONTROLS-CONTENT. The panel never defines a binding: every row restates one already defined in REQ-UI-HOTKEYS or in the mouse gestures cited beside it.
|
||||||
|
|
||||||
|
- REQ-UI-CONTROLS-PANEL: The **controls panel** is a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), anchored to the view's **bottom-left corner** with a small margin on both edges. It is **sized to its content in both width and height**, growing and shrinking upward from that corner as the context changes. Should its content ever be taller than the view, the panel's height is capped at the view height less its margins and the content scrolls vertically within it. The selection panel is placed beside the current selection (REQ-UI-SELECTION-PANEL) and so can reach this corner; the two never overlap, and keeping them apart is entirely the selection panel's job — this panel's position depends only on its own content, the view size, and the build button bar, and it never moves or resizes because the selection panel appears, disappears, or changes size.
|
||||||
|
- **Stepping around the build button bar.** Like the selection panel (REQ-UI-SELECTION-PANEL), the panel does **not** confine itself to the band above the build button bar's strip: the bar is horizontally centered and sized to its buttons (REQ-UI-BUILD-BAR), so it normally leaves this corner free, and the panel shares the view's bottom edge with it. The panel **rises only to avoid an actual overlap**: whenever the panel at the bottom-left corner would intersect the bar's current rectangle, it is moved up so that its bottom edge clears the bar's top by the same margin it keeps from the view's edges, and its height is capped at the space that leaves. Whether it rises therefore depends on how wide the bar and the panel currently are, and it returns to the corner as soon as they no longer meet. The bar never moves on the panel's account (REQ-UI-BUILD-BAR).
|
||||||
|
- **Visibility.** The panel is shown whenever the game is being played. Unlike the selection panel it has no empty state (REQ-UI-EMPTY-SELECTION): every context has rows, so there is never nothing to show.
|
||||||
|
- **Collapsing.** Clicking anywhere on the panel's header (REQ-UI-CONTROLS-CARD) toggles the panel between **expanded** and **collapsed**. Collapsed, it shows its header alone, keeping the context name visible and the header clickable so the panel can be expanded again; expanded, it shows the header followed by every row of the current context. The panel starts **expanded**. Changing context does not change the collapsed state: a panel collapsed in one context stays collapsed in the next, and its header updates in place. The collapsed state is presentation-only — it is not a player command, never enters the replay stream, and has no effect on the simulation. It persists for as long as the application runs, including across a restart from the escape menu (REQ-UI-GAME-MENU), and is not saved to disk.
|
||||||
|
- **Overlay behavior.** As for the build button bar and the selection panel (REQ-UI-BUILD-BAR, REQ-UI-SELECTION-PANEL): the panel occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected. It is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full left-hand band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the panel.
|
||||||
|
- **Input.** Mouse events over the panel are consumed by the panel and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the panel does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG). The only control the panel itself offers is the header click that collapses and expands it.
|
||||||
|
- REQ-UI-CONTROLS-CARD: **Card structure.** The panel is a card with two parts, top to bottom:
|
||||||
|
- **Header** — always shown, and the panel's only interactive element (REQ-UI-CONTROLS-PANEL). It holds a colored context dot on the left, the context's name beside it in upper case, and, for contexts that define one, a **detail suffix** separated by a middle dot (`BUILD MODE · Assembler`). The name and detail per context are given in REQ-UI-CONTROLS-CONTENT.
|
||||||
|
- **Rows** — one per available control, shown only while the panel is expanded. Each row is one or more **key badges** on the left — the key or mouse button drawn as a small bordered chip — and a **label** beside them naming what it does. An action reachable two ways carries both badges in the same row (`RMB` `Q` — Exit placement) rather than occupying two rows. A row whose action hands the current context back — leaving a build mode, or clearing the selection (REQ-UI-SELECTION-EXCLUSIVE) — is drawn with the destructive badge styling, distinguishing it from the rows that act within the context; to the player both are the one key that backs out, so both are marked alike. No row is ever drawn greyed or otherwise disabled: a control the player cannot currently use is not shown at all (REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
|
||||||
|
The rows that are live in every context (REQ-UI-CONTROLS-CONTENT) are shown last, under a divider and the caption `ALWAYS AVAILABLE`. This holds in every context including the General one, which has context rows of its own above the divider like any other, so the card is read the same way wherever the player is.
|
||||||
|
- REQ-UI-CONTROLS-CONTENT: **Content catalog.** The control context follows from the active build mode and the selection alone. Build modes are mutually exclusive (REQ-BLD-BUILDER-MODE), so exactly one context applies at any moment:
|
||||||
|
|
||||||
|
| Context | When | Header name | Header detail |
|
||||||
|
|---|---|---|---|
|
||||||
|
| General | no build mode active, nothing selected | `GENERAL` | — |
|
||||||
|
| Selection | no build mode active, at least one object selected | `SELECTION` | `<n> buildings` or `<n> objects` |
|
||||||
|
| Build | builder mode active (REQ-BLD-BUILDER-MODE) | `BUILD MODE` | the name of the building type that would be placed at the hovered position, so in tunnel mode it follows the resolved end and reads `Tunnel Entry` or `Tunnel Exit` (REQ-BLD-TUNNEL-MODE) |
|
||||||
|
| Blueprint | blueprint placement mode active (REQ-UI-BLUEPRINT-MODE) | `BLUEPRINT MODE` | the blueprint's name, or `Temporary` for a temporary blueprint (REQ-UI-BLUEPRINT-TEMP) |
|
||||||
|
| Deconstruct | deconstruct mode active (REQ-UI-DECONSTRUCT-BUTTON) | `DECONSTRUCT MODE` | — |
|
||||||
|
|
||||||
|
The Selection context's detail counts the selection and names its category (REQ-UI-SELECTION-CATEGORIES): `<n> buildings` for a building selection, `<n> objects` for a field selection, in the singular at a count of one. The Build and Blueprint contexts show **the same rows** and differ only in their header.
|
||||||
|
|
||||||
|
**The row that hands the context back is last.** Where a context has one — `Q` — Clear selection in the Selection context, `RMB` `Q` — Exit placement in Build and Blueprint, `RMB` `Q` — Exit deconstruct mode in Deconstruct — it is the final context row, below the rows that act within the context, and it carries the destructive badge styling (REQ-UI-CONTROLS-CARD). The General context has none: with no mode to leave and nothing selected, its `Q` row enters deconstruct mode rather than leaving anything, and is an ordinary row that happens to come last.
|
||||||
|
|
||||||
|
**Always-available rows**, shown in every context:
|
||||||
|
|
||||||
|
| Badges | Label | Shown |
|
||||||
|
|---|---|---|
|
||||||
|
| `A` `D` | Move | always |
|
||||||
|
| `W` `S` | Game speed | always |
|
||||||
|
| `Space` | Toggle pause | always |
|
||||||
|
| `V` | Paste last | only while a temporary blueprint exists (REQ-UI-BLUEPRINT-TEMP) |
|
||||||
|
| `Ctrl` `V` | Blueprints | always |
|
||||||
|
| `Esc` | Menu | always |
|
||||||
|
|
||||||
|
**Context rows**, shown above the always-available block:
|
||||||
|
|
||||||
|
| Context | Badges | Label |
|
||||||
|
|---|---|---|
|
||||||
|
| General | `LMB` | Select |
|
||||||
|
| | `LMB` drag | Select area |
|
||||||
|
| | `Q` | Deconstruct mode |
|
||||||
|
| Selection | `LMB` | Select / clear selection |
|
||||||
|
| | `LMB` drag | Select area |
|
||||||
|
| | `Ctrl` `LMB` | Add / remove from selection |
|
||||||
|
| | `Ctrl` `LMB` drag | Add area to selection |
|
||||||
|
| | `C` | Copy to temporary blueprint |
|
||||||
|
| | `Ctrl` `C` | Create blueprint |
|
||||||
|
| | `Q` | Clear selection |
|
||||||
|
| Build, Blueprint | `LMB` | Place |
|
||||||
|
| | `LMB` drag | Place belt line |
|
||||||
|
| | `R` / `Shift` `R` | Rotate |
|
||||||
|
| | `RMB` `Q` | Exit placement |
|
||||||
|
| Deconstruct | `LMB` | Toggle deconstruct |
|
||||||
|
| | `LMB` drag | Deconstruct area |
|
||||||
|
| | `RMB` `Q` | Exit deconstruct mode |
|
||||||
|
|
||||||
|
Four rows are conditional on more than the context, because the binding behind them is:
|
||||||
|
- **`C` / `Ctrl` `C`** are shown only while the selection holds at least one player-placeable building, the condition under which those keys do anything (REQ-UI-HOTKEYS). A selection of ships, defence stations, or debris shows neither.
|
||||||
|
- **`LMB` drag — Place belt line** is shown only in builder mode for the Belt type, the only type placed by dragging (REQ-BLD-BELT-DRAG). Every other builder type and blueprint placement omit the row.
|
||||||
|
- **`RMB` `Q` — Exit placement** splits into two rows **while a belt drag is in progress**, because the two bindings then part company (REQ-BLD-BELT-DRAG): `RMB` reads **Cancel belt line** and cancels the drag while leaving builder mode active, and `Q` reads **Exit placement** and leaves the mode outright.
|
||||||
|
- **`LMB` — Place** reads **Apply settings** instead whenever the ghost under the cursor resolves to a configuration transfer (REQ-UI-BLUEPRINT-TRANSFER) — a single-building blueprint hovering a same-type building of a configurable type, which is the case in which clicking hands over settings rather than placing anything. A blueprint holding more than one building keeps the `Place` label, since its click both places and transfers (REQ-UI-BLUEPRINT-PLACE).
|
||||||
|
- REQ-UI-CONTROLS-ACCURACY: **The panel never advertises a binding that would do nothing.** Every row shown must, if triggered in the situation the panel is showing it in, have the effect its label names; a binding that is inert in the current context is omitted rather than shown greyed (REQ-UI-CONTROLS-CARD). The relation holds in one direction only: the panel may omit a binding that is available, and deliberately does so in three cases:
|
||||||
|
- **Build hotkeys** (REQ-UI-HOTKEYS) are live in every context, but are advertised on the build buttons' badges (REQ-UI-BUILD-COST) instead of taking eleven rows in every context of this panel.
|
||||||
|
- **`Ctrl` `LMB` and `Ctrl` `LMB` drag** work with nothing selected — they select the object under the cursor much as a plain click would — but are shown only in the Selection context. "Add / remove from selection" names an operation on a selection, and there is none to operate on until something is selected; the plain `LMB` row already covers what the gesture does before then.
|
||||||
|
- **`F3` and `F4`** (REQ-UI-DEBUG-DRAW) are development controls rather than player controls and appear in no context.
|
||||||
|
|
||||||
|
This asymmetry between what is available and what is shown is why the two are separate questions in the implementation, and why the tests assert that a resolvable input is *available* rather than that it is displayed.
|
||||||
|
|
||||||
### Blueprints
|
### Blueprints
|
||||||
|
|
||||||
Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from the current selection (REQ-UI-BLUEPRINT-CREATE) and picked for placement from the blueprint selection dialog, opened with **Ctrl+V** (REQ-UI-BLUEPRINT-DIALOG). The unmodified **C** and **V** keys are the throwaway counterparts of the same two gestures: they capture and re-place a single unnamed temporary blueprint that is never saved and never listed (REQ-UI-BLUEPRINT-TEMP). Blueprints have no widget on the game screen at all. (The ship layout blueprint panel of the layout configuration dialog, REQ-MOD-UI-BLUEPRINT-PANEL, is a separate feature and is unaffected.)
|
Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from the current selection (REQ-UI-BLUEPRINT-CREATE) and picked for placement from the blueprint selection dialog, opened with **Ctrl+V** (REQ-UI-BLUEPRINT-DIALOG). The unmodified **C** and **V** keys are the throwaway counterparts of the same two gestures: they capture and re-place a single unnamed temporary blueprint that is never saved and never listed (REQ-UI-BLUEPRINT-TEMP). Blueprints have no widget on the game screen at all. (The ship layout blueprint panel of the layout configuration dialog, REQ-MOD-UI-BLUEPRINT-PANEL, is a separate feature and is unaffected.)
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-CREATE: Pressing **Ctrl+C** (REQ-UI-HOTKEYS) opens the modal **blueprint save dialog**, which pauses the simulation and dims the game window (REQ-UI-MODAL-DIM). It has effect only when at least one player-placeable building (i.e. a building with a button in the build button bar) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition, and pressing Ctrl+C with an empty selection or a selection of only non-player-placeable buildings does nothing (no dialog opens). A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). The dialog prompts the player to enter a name and has Confirm and Cancel buttons. Clicking Cancel — or pressing Escape, or closing the dialog — closes it with no effect and does not open the blueprint selection dialog. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, appends it to the blueprint list, closes the save dialog, and immediately opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) showing the new blueprint among the others.
|
- REQ-UI-BLUEPRINT-CREATE: Pressing **Ctrl+C** (REQ-UI-HOTKEYS) opens the modal **blueprint save dialog**, which pauses the simulation and dims the game window (REQ-UI-MODAL-DIM). It has effect only when at least one player-placeable building (i.e. a building with a button in the build button bar) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition, and pressing Ctrl+C with an empty selection or a selection of only non-player-placeable buildings does nothing (no dialog opens). A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). The dialog is drawn by the game like every other modal (REQ-UI-MODAL-CHROME): it prompts the player to enter a name under its own title and has Confirm and Cancel buttons. Clicking Cancel — or pressing Escape — closes it with no effect and does not open the blueprint selection dialog. It is not dismissed by Q or by a click outside it, a half-typed name being work in progress (REQ-UI-DIALOG-DISMISS). Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, appends it to the blueprint list, closes the save dialog, and immediately opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) showing the new blueprint among the others.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-DIALOG: The **blueprint selection dialog** is the only place saved blueprints are shown. It is opened by pressing **Ctrl+V** (REQ-UI-HOTKEYS) and by confirming a save (REQ-UI-BLUEPRINT-CREATE). It is modal, pauses the simulation, and dims the game window (REQ-UI-MODAL-DIM). The dialog has a fixed size and consists of:
|
- REQ-UI-BLUEPRINT-DIALOG: The **blueprint selection dialog** is the only place saved blueprints are shown. It is opened by pressing **Ctrl+V** (REQ-UI-HOTKEYS) and by confirming a save (REQ-UI-BLUEPRINT-CREATE). It is modal, pauses the simulation, and dims the game window (REQ-UI-MODAL-DIM). The dialog has a fixed size and consists of:
|
||||||
- A **title bar** reading `Blueprints`, followed by a small dimmed **hotkey badge** reading `Ctrl+V` — the same "learn the shortcut from the widget" device as the build button badges (REQ-UI-BUILD-COST) — and, at the far right, a **close ("×") button**.
|
- A **title bar** the dialog draws itself (REQ-UI-MODAL-CHROME) reading `Blueprints`, followed by a small dimmed **hotkey badge** reading `Ctrl+V` — the same "learn the shortcut from the widget" device as the build button badges (REQ-UI-BUILD-COST) — and, at the far right, a **close ("×") button**.
|
||||||
- Below it, a **scrollable two-column grid of blueprint cards** (REQ-UI-BLUEPRINT-CARD), one per saved blueprint, filling the grid left to right and top to bottom in creation order. The column count is fixed at two; the grid scrolls vertically when the cards do not fit, and does not scroll horizontally.
|
- Below it, a **scrollable two-column grid of blueprint cards** (REQ-UI-BLUEPRINT-CARD), one per saved blueprint, filling the grid left to right and top to bottom in creation order. The column count is fixed at two; the grid scrolls vertically when the cards do not fit, and does not scroll horizontally.
|
||||||
- When no blueprints are saved, the dialog still opens and shows an empty-state message in place of the grid, telling the player that blueprints are created with Ctrl+C from a selection of buildings.
|
- When no blueprints are saved, the dialog still opens and shows an empty-state message in place of the grid, telling the player that blueprints are created with Ctrl+C from a selection of buildings.
|
||||||
|
|
||||||
Clicking the close button, pressing Escape, or closing the dialog through the window manager closes it with no other effect: the current selection, build mode, and blueprint list are unchanged, and the simulation speed is restored to what it was before the dialog was opened. While the dialog is open, Escape closes it rather than opening the escape menu (REQ-UI-GAME-MENU).
|
Clicking the close button, pressing Escape, pressing Q, or clicking outside the dialog (REQ-UI-DIALOG-DISMISS) closes it with no other effect: the current selection, build mode, and blueprint list are unchanged, and the simulation speed is restored to what it was before the dialog was opened. While the dialog is open, Escape closes it rather than opening the escape menu (REQ-UI-GAME-MENU), and Q closes it rather than acting on the game world beneath — as does the click, which is spent on closing the dialog and does not reach what it landed on.
|
||||||
|
|
||||||
```
|
```
|
||||||
+------------------------------------------------------+
|
+------------------------------------------------------+
|
||||||
@@ -634,7 +805,7 @@ Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from
|
|||||||
+------------------------------------------------------+
|
+------------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-TEMP: Pressing the **C** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing C with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing at all, and in particular leaves any existing temporary blueprint in place. Entering this mode replaces any currently active build, blueprint placement, or deconstruct mode. The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode; unlike the mode, the temporary blueprint itself survives, so it can be entered again with V.
|
- REQ-UI-BLUEPRINT-TEMP: Pressing the **C** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing C with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing at all, and in particular leaves any existing temporary blueprint in place. Entering this mode replaces any currently active build, blueprint placement, or deconstruct mode, and clears the selection — the blueprint is captured from it first, so C loses nothing (REQ-UI-SELECTION-EXCLUSIVE). The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode; unlike the mode, the temporary blueprint itself survives, so it can be entered again with V.
|
||||||
|
|
||||||
Pressing the **V** key re-enters blueprint placement mode for the temporary blueprint, capturing nothing new: it is independent of the current selection, can be pressed any number of times, and yields exactly the mode described above. Like C it replaces any currently active build, blueprint placement, or deconstruct mode, and it does not test whether the player can currently afford the blueprint — cost is enforced at placement (REQ-UI-BLUEPRINT-PLACE), consistently with C. Pressing V when no temporary blueprint exists does nothing: no mode is entered and any currently active mode is left untouched.
|
Pressing the **V** key re-enters blueprint placement mode for the temporary blueprint, capturing nothing new: it is independent of the current selection, can be pressed any number of times, and yields exactly the mode described above. Like C it replaces any currently active build, blueprint placement, or deconstruct mode, and it does not test whether the player can currently afford the blueprint — cost is enforced at placement (REQ-UI-BLUEPRINT-PLACE), consistently with C. Pressing V when no temporary blueprint exists does nothing: no mode is entered and any currently active mode is left untouched.
|
||||||
|
|
||||||
@@ -645,7 +816,7 @@ Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from
|
|||||||
- REQ-UI-BLUEPRINT-CARD: Each blueprint in the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) is shown as a **card**. All cards share a uniform size. A card contains, from top to bottom:
|
- REQ-UI-BLUEPRINT-CARD: Each blueprint in the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) is shown as a **card**. All cards share a uniform size. A card contains, from top to bottom:
|
||||||
- The **blueprint name**.
|
- The **blueprint name**.
|
||||||
- A **contents line** summarizing what the blueprint holds: one `<building name> x <count>` entry per building type it contains, comma-separated, in descending count order with ties broken by the order the types appear in the build button bar (REQ-UI-BUILD-BAR). This is the same `x`-count notation as the building multi-selection summary (REQ-UI-MULTI-SELECTION). If the entries do not fit on the line, the line is elided at its end rather than wrapped or shrunk, so every card keeps the same height.
|
- A **contents line** summarizing what the blueprint holds: one `<building name> x <count>` entry per building type it contains, comma-separated, in descending count order with ties broken by the order the types appear in the build button bar (REQ-UI-BUILD-BAR). This is the same `x`-count notation as the building multi-selection summary (REQ-UI-MULTI-SELECTION). If the entries do not fit on the line, the line is elided at its end rather than wrapped or shrunk, so every card keeps the same height.
|
||||||
- The **total building block cost** of the blueprint (sum of the individual costs of all constituent buildings), shown with the `building_block` item icon to the right of the number in place of a trailing `Blocks` word, exactly as elsewhere in the UI (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST). When no icon file exists for `building_block`, the cost is shown as the bare number.
|
- The **total building block cost** of the blueprint (sum of the individual costs of all constituent buildings), shown with the `building_block` item icon to the right of the number in place of a trailing `Blocks` word, exactly as elsewhere in the UI (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST). When no icon file exists for `building_block`, the cost is shown as the bare number. The card carries no tooltip of its own, so the cost explains the item it names on hover, the click staying the gesture that takes the blueprint (REQ-UI-ITEM-VALUE-TOOLTIP).
|
||||||
- A **delete icon ("×")** in the card's bottom-right corner (REQ-UI-BLUEPRINT-DELETE).
|
- A **delete icon ("×")** in the card's bottom-right corner (REQ-UI-BLUEPRINT-DELETE).
|
||||||
|
|
||||||
A card shows no preview of the blueprint's layout.
|
A card shows no preview of the blueprint's layout.
|
||||||
@@ -686,7 +857,7 @@ Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from
|
|||||||
- After the transfer the game stays in blueprint placement mode, so further same-type buildings can be clicked in turn.
|
- After the transfer the game stays in blueprint placement mode, so further same-type buildings can be clicked in turn.
|
||||||
- A blueprint placement applies every transfer among its ghosts in the same click that places its new construction sites (REQ-UI-BLUEPRINT-PLACE); the placement is all-or-nothing, so if any ghost is invalid nothing is placed and nothing is transferred.
|
- A blueprint placement applies every transfer among its ghosts in the same click that places its new construction sites (REQ-UI-BLUEPRINT-PLACE); the placement is all-or-nothing, so if any ghost is invalid nothing is placed and nothing is transferred.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-DELETE: Clicking the delete icon ("×") on a blueprint card (REQ-UI-BLUEPRINT-CARD) immediately removes that blueprint from the list, without a confirmation prompt. The blueprint selection dialog stays open and its card grid reflows to close the gap. If the deleted blueprint was active in blueprint placement mode, that mode is exited.
|
- REQ-UI-BLUEPRINT-DELETE: Clicking the delete icon ("×") on a blueprint card (REQ-UI-BLUEPRINT-CARD) immediately removes that blueprint from the list, without a confirmation prompt. The icon carries a hover tooltip naming what it does, since a bare "×" on a card could as easily mean closing something (REQ-UI-TOOLTIP-TRIGGER, REQ-UI-TOOLTIP-DISMISS). The blueprint selection dialog stays open and its card grid reflows to close the gap. If the deleted blueprint was active in blueprint placement mode, that mode is exited.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-SAVE: On application shutdown, all current blueprints are serialized to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. Write errors are silently ignored on shutdown (no button, no dialog).
|
- REQ-UI-BLUEPRINT-SAVE: On application shutdown, all current blueprints are serialized to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. Write errors are silently ignored on shutdown (no button, no dialog).
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ set(TARGET_LIB_INCLUDE_DIRS
|
|||||||
"${CMAKE_CURRENT_SOURCE_DIR}/lib"
|
"${CMAKE_CURRENT_SOURCE_DIR}/lib"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/external"
|
"${CMAKE_CURRENT_SOURCE_DIR}/external"
|
||||||
)
|
)
|
||||||
set(TARGET_UI_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/ui")
|
set(TARGET_UI_INCLUDE_DIRS
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/ui"
|
||||||
|
# The balancing target compiles a few ui files into itself rather than linking the
|
||||||
|
# ui library, and the ship stats panel is built from the selection card's parts.
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/ui/selection"
|
||||||
|
)
|
||||||
set(TARGET_TEST_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/test")
|
set(TARGET_TEST_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/test")
|
||||||
set(TARGET_BALANCING_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/balancing")
|
set(TARGET_BALANCING_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/balancing")
|
||||||
|
|
||||||
@@ -78,6 +83,7 @@ unset(SRCS)
|
|||||||
|
|
||||||
set(HDRS)
|
set(HDRS)
|
||||||
set(SRCS)
|
set(SRCS)
|
||||||
|
set(UI_INCLUDE_PATH)
|
||||||
|
|
||||||
add_subdirectory(ui)
|
add_subdirectory(ui)
|
||||||
|
|
||||||
@@ -106,6 +112,7 @@ set_target_properties(${TARGET_UI_NAME} PROPERTIES
|
|||||||
)
|
)
|
||||||
target_include_directories(${TARGET_UI_NAME} PUBLIC
|
target_include_directories(${TARGET_UI_NAME} PUBLIC
|
||||||
"${TARGET_UI_INCLUDE_DIRS}"
|
"${TARGET_UI_INCLUDE_DIRS}"
|
||||||
|
"${UI_INCLUDE_PATH}"
|
||||||
"${TARGET_LIB_INCLUDE_DIRS}"
|
"${TARGET_LIB_INCLUDE_DIRS}"
|
||||||
"${LIB_INCLUDE_PATH}"
|
"${LIB_INCLUDE_PATH}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.h
|
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h
|
||||||
|
# The card parts the ship stats panel is built from. They are deliberately free of
|
||||||
|
# Simulation and GameConfig, which is what lets them come along here.
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/StatRow.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SelectionNames.h
|
||||||
|
# A stat row states a value with an inline item icon (REQ-UI-ITEM-ICON), which this
|
||||||
|
# composes; it needs nothing but Qt's painting.
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/IconCaption.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h
|
||||||
# Shared world-space shapes so the arena keeps looking like the game
|
# Shared world-space shapes so the arena keeps looking like the game
|
||||||
@@ -26,6 +35,11 @@ SET(SRCS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/StatRow.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SelectionNames.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/IconCaption.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.cpp
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
|
|||||||
@@ -32,15 +32,46 @@ std::vector<RecipeOutput> parseRecipeOutputs(const toml::array& arr,
|
|||||||
RecipeOutput out;
|
RecipeOutput out;
|
||||||
out.item = utility::requireString(mt["item"], file, elemPath + ".item");
|
out.item = utility::requireString(mt["item"], file, elemPath + ".item");
|
||||||
out.amount = static_cast<int>(utility::requireInt(mt["amount"], file, elemPath + ".amount"));
|
out.amount = static_cast<int>(utility::requireInt(mt["amount"], file, elemPath + ".amount"));
|
||||||
|
result.push_back(std::move(out));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The several-group form: one [[recipe.output_group]] entry per possible result, each
|
||||||
|
// carrying its weight and the items it yields together (REQ-MAT-OUTPUT-GROUP).
|
||||||
|
std::vector<RecipeOutputGroup> parseOutputGroups(const toml::array& arr,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
std::vector<RecipeOutputGroup> result;
|
||||||
|
result.reserve(arr.size());
|
||||||
|
for (std::size_t i = 0; i < arr.size(); ++i)
|
||||||
|
{
|
||||||
|
const std::string elemPath = path + "[" + std::to_string(i) + "]";
|
||||||
|
const toml::table* t = arr[i].as_table();
|
||||||
|
if (t == nullptr)
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath, "not a table");
|
||||||
|
}
|
||||||
|
toml::table& mt = const_cast<toml::table&>(*t);
|
||||||
|
|
||||||
|
RecipeOutputGroup group;
|
||||||
|
const toml::array& items =
|
||||||
|
utility::requireArray(mt["items"], file, elemPath + ".items");
|
||||||
|
group.items = parseRecipeOutputs(items, file, elemPath + ".items");
|
||||||
|
if (group.items.empty())
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath + ".items", "produces nothing");
|
||||||
|
}
|
||||||
if (const std::optional<double> p = mt["probability"].value<double>())
|
if (const std::optional<double> p = mt["probability"].value<double>())
|
||||||
{
|
{
|
||||||
out.probability = *p;
|
group.probability = *p;
|
||||||
}
|
}
|
||||||
else if (const std::optional<int64_t> p = mt["probability"].value<int64_t>())
|
else if (const std::optional<int64_t> p = mt["probability"].value<int64_t>())
|
||||||
{
|
{
|
||||||
out.probability = static_cast<double>(*p);
|
group.probability = static_cast<double>(*p);
|
||||||
}
|
}
|
||||||
result.push_back(std::move(out));
|
result.push_back(std::move(group));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -91,15 +122,33 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
|
|||||||
def.inputs = utility::parseIngredients(inputs, file, elemPath + ".inputs");
|
def.inputs = utility::parseIngredients(inputs, file, elemPath + ".inputs");
|
||||||
}
|
}
|
||||||
|
|
||||||
const toml::array& outputs = utility::requireArray(mt["outputs"], file, elemPath + ".outputs");
|
// Either form, never both (REQ-MAT-OUTPUT-GROUP): `outputs` is the single-group
|
||||||
def.outputs = parseRecipeOutputs(outputs, file, elemPath + ".outputs");
|
// shorthand that all but the reprocessing recipes use, `output_group` the several-
|
||||||
|
// group form. The shorthand carries no weight -- with one group nothing is picked.
|
||||||
// Optional icon item id (REQ-UI-RECIPE-ICON); defaults to the first output
|
const bool hasOutputs = mt.contains("outputs");
|
||||||
// in the UI when unset. Not validated against known items here — a missing
|
const bool hasGroups = mt.contains("output_group");
|
||||||
// icon is not an error (REQ-UI-ITEM-ICON).
|
if (hasOutputs && hasGroups)
|
||||||
if (mt.contains("icon"))
|
|
||||||
{
|
{
|
||||||
def.icon = utility::requireString(mt["icon"], file, elemPath + ".icon");
|
throw utility::makeError(file, elemPath,
|
||||||
|
"has both 'outputs' and 'output_group'; use one or the other");
|
||||||
|
}
|
||||||
|
if (hasGroups)
|
||||||
|
{
|
||||||
|
const toml::array& groups =
|
||||||
|
utility::requireArray(mt["output_group"], file, elemPath + ".output_group");
|
||||||
|
def.outputGroups = parseOutputGroups(groups, file, elemPath + ".output_group");
|
||||||
|
if (def.outputGroups.empty())
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath + ".output_group", "is empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const toml::array& outputs =
|
||||||
|
utility::requireArray(mt["outputs"], file, elemPath + ".outputs");
|
||||||
|
RecipeOutputGroup group;
|
||||||
|
group.items = parseRecipeOutputs(outputs, file, elemPath + ".outputs");
|
||||||
|
def.outputGroups.push_back(std::move(group));
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.recipes.push_back(std::move(def));
|
cfg.recipes.push_back(std::move(def));
|
||||||
|
|||||||
@@ -27,12 +27,6 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
|
|||||||
cfg.orbitFactor = utility::requireDouble(tbl["world"]["orbit_factor"], file, "world.orbit_factor");
|
cfg.orbitFactor = utility::requireDouble(tbl["world"]["orbit_factor"], file, "world.orbit_factor");
|
||||||
cfg.rallyOrbitRadius_tiles = utility::requireDouble(tbl["world"]["rally_orbit_radius_tiles"], file, "world.rally_orbit_radius_tiles");
|
cfg.rallyOrbitRadius_tiles = utility::requireDouble(tbl["world"]["rally_orbit_radius_tiles"], file, "world.rally_orbit_radius_tiles");
|
||||||
|
|
||||||
if (const std::optional<std::string> tip =
|
|
||||||
tbl["world"]["building_blocks_tooltip"].value<std::string>())
|
|
||||||
{
|
|
||||||
cfg.buildingBlocksTooltip = *tip;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (const std::optional<std::string> tip =
|
if (const std::optional<std::string> tip =
|
||||||
tbl["world"]["artifact_tooltip"].value<std::string>())
|
tbl["world"]["artifact_tooltip"].value<std::string>())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -14,14 +15,22 @@ struct RecipeIngredient
|
|||||||
int amount;
|
int amount;
|
||||||
};
|
};
|
||||||
|
|
||||||
// One entry in [[recipe]].outputs. For reprocessing_plant recipes, probability
|
// One item produced by an output group -- amount units of a named item
|
||||||
// is populated and outputs are rolled with weighted pick at cycle start
|
// (REQ-MAT-OUTPUT-GROUP).
|
||||||
// (REQ-BLD-REPROCESSING, REQ-MAT-CYCLE). For other buildings, probability is
|
|
||||||
// std::nullopt and all outputs are produced on every cycle.
|
|
||||||
struct RecipeOutput
|
struct RecipeOutput
|
||||||
{
|
{
|
||||||
std::string item;
|
std::string item;
|
||||||
int amount;
|
int amount;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One possible result of a production cycle: the items it yields, produced together, and
|
||||||
|
// the weight this group is picked with among the recipe's groups (REQ-MAT-OUTPUT-GROUP).
|
||||||
|
// A recipe with a single group always produces it, so the weight is meaningful only where
|
||||||
|
// there are several -- which is the only difference between what used to be called a
|
||||||
|
// deterministic and a probabilistic recipe.
|
||||||
|
struct RecipeOutputGroup
|
||||||
|
{
|
||||||
|
std::vector<RecipeOutput> items;
|
||||||
std::optional<double> probability;
|
std::optional<double> probability;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,12 +39,9 @@ struct RecipeDef
|
|||||||
std::string id; // Unique recipe id; used by UI for selection.
|
std::string id; // Unique recipe id; used by UI for selection.
|
||||||
BuildingType building; // Which BuildingType can run this recipe.
|
BuildingType building; // Which BuildingType can run this recipe.
|
||||||
std::vector<RecipeIngredient> inputs;
|
std::vector<RecipeIngredient> inputs;
|
||||||
std::vector<RecipeOutput> outputs;
|
// Never empty: one group is the ordinary recipe (REQ-MAT-OUTPUT-GROUP).
|
||||||
|
std::vector<RecipeOutputGroup> outputGroups;
|
||||||
double durationSeconds;
|
double durationSeconds;
|
||||||
// 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.
|
||||||
@@ -44,6 +50,38 @@ struct RecipeDef
|
|||||||
bool unlockedAtStart = false;
|
bool unlockedAtStart = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Every distinct item any group of this recipe can produce, in config order. Most callers
|
||||||
|
// only want to know what a recipe can make at all -- which items it has buffers for, which
|
||||||
|
// recipes produce an item -- and not which group yields what.
|
||||||
|
inline std::vector<std::string> getProducibleItems(const RecipeDef& recipe)
|
||||||
|
{
|
||||||
|
std::vector<std::string> items;
|
||||||
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
|
{
|
||||||
|
for (const RecipeOutput& out : group.items)
|
||||||
|
{
|
||||||
|
if (std::find(items.begin(), items.end(), out.item) == items.end())
|
||||||
|
{
|
||||||
|
items.push_back(out.item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when some group of this recipe yields the given item.
|
||||||
|
inline bool producesItem(const RecipeDef& recipe, const std::string& itemId)
|
||||||
|
{
|
||||||
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
|
{
|
||||||
|
for (const RecipeOutput& out : group.items)
|
||||||
|
{
|
||||||
|
if (out.item == itemId) { return true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
struct RecipesConfig
|
struct RecipesConfig
|
||||||
{
|
{
|
||||||
std::vector<RecipeDef> recipes;
|
std::vector<RecipeDef> recipes;
|
||||||
|
|||||||
@@ -63,4 +63,19 @@ struct ShipsConfig
|
|||||||
}
|
}
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The definition to configure a layout against, or nullptr when there is no layout
|
||||||
|
// to configure: no schematic is selected at all (the empty id the "(None)" option
|
||||||
|
// sets, REQ-UI-SELECT-OPTIONS), the id names no ship, or the ship defines no layout
|
||||||
|
// grid. Every entry to the layout configuration dialog asks this before opening it
|
||||||
|
// and the panel asks it before offering the button that opens it, so the dialog
|
||||||
|
// cannot appear over a grid with no cells (REQ-MOD-UI-DIALOG, REQ-MOD-UI-PREVIEW,
|
||||||
|
// REQ-MOD-UI-AUTO-DIALOG).
|
||||||
|
const ShipDef* findLayoutShipDef(const std::string& id) const
|
||||||
|
{
|
||||||
|
if (id.empty()) { return nullptr; }
|
||||||
|
const ShipDef* def = findShipDef(id);
|
||||||
|
if (!def || def->layout.empty()) { return nullptr; }
|
||||||
|
return def;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,10 +79,6 @@ struct WorldConfig
|
|||||||
double orbitFactor; // REQ-SHP-ORBIT (multiplies tool range for orbit radius)
|
double orbitFactor; // REQ-SHP-ORBIT (multiplies tool range for orbit radius)
|
||||||
double rallyOrbitRadius_tiles; // REQ-SHP-ORBIT (fixed orbit radius around the rally point)
|
double rallyOrbitRadius_tiles; // REQ-SHP-ORBIT (fixed orbit radius around the rally point)
|
||||||
|
|
||||||
// Optional hover-tooltip for the header building blocks stock display
|
|
||||||
// (REQ-UI-BLOCKS-TOOLTIP). Presentation-only; the simulation ignores it.
|
|
||||||
std::optional<std::string> buildingBlocksTooltip;
|
|
||||||
|
|
||||||
// Optional hover-tooltip for the header artifact count display
|
// Optional hover-tooltip for the header artifact count display
|
||||||
// (REQ-UI-ARTIFACTS-TOOLTIP). Presentation-only; the simulation ignores it.
|
// (REQ-UI-ARTIFACTS-TOOLTIP). Presentation-only; the simulation ignores it.
|
||||||
std::optional<std::string> artifactTooltip;
|
std::optional<std::string> artifactTooltip;
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ void BuildModeController::enterMode(BuildMode mode)
|
|||||||
std::make_shared<BuilderModeExitedEvent>());
|
std::make_shared<BuilderModeExitedEvent>());
|
||||||
break;
|
break;
|
||||||
case BuildMode::Blueprint:
|
case BuildMode::Blueprint:
|
||||||
|
m_hoveredGhostIsTransfer = false;
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
std::make_shared<BlueprintModeExitedEvent>());
|
std::make_shared<BlueprintModeExitedEvent>());
|
||||||
break;
|
break;
|
||||||
@@ -134,6 +135,17 @@ void BuildModeController::exitCurrentMode()
|
|||||||
enterMode(BuildMode::None);
|
enterMode(BuildMode::None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void BuildModeController::clearHover()
|
||||||
|
{
|
||||||
|
m_ghostTile.reset();
|
||||||
|
m_ghostValid = false;
|
||||||
|
m_tunnelGhostType = BuildingType::TunnelEntry;
|
||||||
|
m_tunnelPartnerTile.reset();
|
||||||
|
m_blueprintGhostTile.reset();
|
||||||
|
m_hoveredGhostIsTransfer = false;
|
||||||
|
m_deconstructHoverBuildingId.reset();
|
||||||
|
}
|
||||||
|
|
||||||
BuildingType BuildModeController::getBuilderType() const
|
BuildingType BuildModeController::getBuilderType() const
|
||||||
{
|
{
|
||||||
return m_builderType;
|
return m_builderType;
|
||||||
@@ -149,7 +161,7 @@ BuildingType BuildModeController::getEffectiveBuilderType() const
|
|||||||
return isTunnelMode() ? m_tunnelGhostType : m_builderType;
|
return isTunnelMode() ? m_tunnelGhostType : m_builderType;
|
||||||
}
|
}
|
||||||
|
|
||||||
QPoint BuildModeController::getGhostTile() const
|
const std::optional<QPoint>& BuildModeController::getGhostTile() const
|
||||||
{
|
{
|
||||||
return m_ghostTile;
|
return m_ghostTile;
|
||||||
}
|
}
|
||||||
@@ -239,7 +251,7 @@ Blueprint& BuildModeController::getMutableBlueprint()
|
|||||||
return m_blueprint;
|
return m_blueprint;
|
||||||
}
|
}
|
||||||
|
|
||||||
QPoint BuildModeController::getBlueprintGhostTile() const
|
const std::optional<QPoint>& BuildModeController::getBlueprintGhostTile() const
|
||||||
{
|
{
|
||||||
return m_blueprintGhostTile;
|
return m_blueprintGhostTile;
|
||||||
}
|
}
|
||||||
@@ -249,6 +261,16 @@ void BuildModeController::setBlueprintGhostTile(QPoint tile)
|
|||||||
m_blueprintGhostTile = tile;
|
m_blueprintGhostTile = tile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isHoveredGhostTransfer() const
|
||||||
|
{
|
||||||
|
return m_hoveredGhostIsTransfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setHoveredGhostTransfer(bool transfer)
|
||||||
|
{
|
||||||
|
m_hoveredGhostIsTransfer = transfer;
|
||||||
|
}
|
||||||
|
|
||||||
const std::optional<BuildingId>&
|
const std::optional<BuildingId>&
|
||||||
BuildModeController::getDeconstructHoverBuildingId() const
|
BuildModeController::getDeconstructHoverBuildingId() const
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ public:
|
|||||||
// Backs out of whichever mode is active, if any (the Q key and right-click).
|
// Backs out of whichever mode is active, if any (the Q key and right-click).
|
||||||
void exitCurrentMode();
|
void exitCurrentMode();
|
||||||
|
|
||||||
|
// --- hover ----------------------------------------------------------------
|
||||||
|
// Drops everything that follows from a cursor pointing at the world — both
|
||||||
|
// ghost tiles, placement validity, the resolved tunnel end, the transfer flag,
|
||||||
|
// the deconstruct hover — for a cursor that points at no tile at all, because it
|
||||||
|
// rests on a panel or has left the window (REQ-BLD-GHOST). The active mode is
|
||||||
|
// untouched: the player is still building, just not over anything. A belt drag's
|
||||||
|
// path is untouched too, since a drag keeps hovering while the button is held.
|
||||||
|
void clearHover();
|
||||||
|
|
||||||
// --- builder mode ---------------------------------------------------------
|
// --- builder mode ---------------------------------------------------------
|
||||||
// Only meaningful while isBuilderMode().
|
// Only meaningful while isBuilderMode().
|
||||||
BuildingType getBuilderType() const;
|
BuildingType getBuilderType() const;
|
||||||
@@ -64,7 +73,9 @@ public:
|
|||||||
// tunnel mode, the plain builder type otherwise.
|
// tunnel mode, the plain builder type otherwise.
|
||||||
BuildingType getEffectiveBuilderType() const;
|
BuildingType getEffectiveBuilderType() const;
|
||||||
|
|
||||||
QPoint getGhostTile() const;
|
// Unset while the cursor points at no tile (clearHover), which is the one case
|
||||||
|
// where builder mode draws no ghost at all.
|
||||||
|
const std::optional<QPoint>& getGhostTile() const;
|
||||||
Rotation getGhostRotation() const;
|
Rotation getGhostRotation() const;
|
||||||
bool isGhostValid() const;
|
bool isGhostValid() const;
|
||||||
void setGhostTile(QPoint tile);
|
void setGhostTile(QPoint tile);
|
||||||
@@ -92,9 +103,19 @@ public:
|
|||||||
// Mutable so the caller can rotate the layout in place; rotating a blueprint
|
// Mutable so the caller can rotate the layout in place; rotating a blueprint
|
||||||
// needs building footprints from the config, which does not belong here.
|
// needs building footprints from the config, which does not belong here.
|
||||||
Blueprint& getMutableBlueprint();
|
Blueprint& getMutableBlueprint();
|
||||||
QPoint getBlueprintGhostTile() const;
|
// Unset for a cursor pointing at no tile, as for the builder ghost above.
|
||||||
|
const std::optional<QPoint>& getBlueprintGhostTile() const;
|
||||||
void setBlueprintGhostTile(QPoint tile);
|
void setBlueprintGhostTile(QPoint tile);
|
||||||
|
|
||||||
|
// Whether the ghost under the cursor would hand its settings to the building
|
||||||
|
// already there rather than place anything (REQ-UI-BLUEPRINT-TRANSFER). Classifying
|
||||||
|
// it needs the factory state, so the caller resolves it and stores the answer here,
|
||||||
|
// as with setGhostValidity. Kept here rather than recomputed per reader so the
|
||||||
|
// click, the ghost's colour, and the controls panel cannot disagree about what the
|
||||||
|
// cursor is over.
|
||||||
|
bool isHoveredGhostTransfer() const;
|
||||||
|
void setHoveredGhostTransfer(bool transfer);
|
||||||
|
|
||||||
// --- deconstruct mode -----------------------------------------------------
|
// --- deconstruct mode -----------------------------------------------------
|
||||||
const std::optional<BuildingId>& getDeconstructHoverBuildingId() const;
|
const std::optional<BuildingId>& getDeconstructHoverBuildingId() const;
|
||||||
void setDeconstructHoverBuildingId(std::optional<BuildingId> id);
|
void setDeconstructHoverBuildingId(std::optional<BuildingId> id);
|
||||||
@@ -106,7 +127,7 @@ private:
|
|||||||
BuildMode m_mode = BuildMode::None;
|
BuildMode m_mode = BuildMode::None;
|
||||||
|
|
||||||
BuildingType m_builderType = BuildingType::Belt;
|
BuildingType m_builderType = BuildingType::Belt;
|
||||||
QPoint m_ghostTile;
|
std::optional<QPoint> m_ghostTile;
|
||||||
Rotation m_ghostRotation = Rotation::East;
|
Rotation m_ghostRotation = Rotation::East;
|
||||||
bool m_ghostValid = false;
|
bool m_ghostValid = false;
|
||||||
BuildingType m_tunnelGhostType = BuildingType::TunnelEntry;
|
BuildingType m_tunnelGhostType = BuildingType::TunnelEntry;
|
||||||
@@ -116,8 +137,9 @@ private:
|
|||||||
QPoint m_beltDragAnchor;
|
QPoint m_beltDragAnchor;
|
||||||
std::vector<BeltPathTile> m_beltDragPath;
|
std::vector<BeltPathTile> m_beltDragPath;
|
||||||
|
|
||||||
Blueprint m_blueprint;
|
Blueprint m_blueprint;
|
||||||
QPoint m_blueprintGhostTile;
|
std::optional<QPoint> m_blueprintGhostTile;
|
||||||
|
bool m_hoveredGhostIsTransfer = false;
|
||||||
|
|
||||||
std::optional<BuildingId> m_deconstructHoverBuildingId;
|
std::optional<BuildingId> m_deconstructHoverBuildingId;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
|
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
|
||||||
${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}/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
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,8 +34,10 @@ SET(SRCS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanelPlacement.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.cpp
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
281
src/lib/core/ControlAction.cpp
Normal file
281
src/lib/core/ControlAction.cpp
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
#include "ControlAction.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// A binding that a belt drag can take over. Availability is a property of the action,
|
||||||
|
// but a binding can be claimed by a different action while a gesture is in progress:
|
||||||
|
// right-click cancels the drag instead of leaving builder mode (REQ-BLD-BELT-DRAG), so
|
||||||
|
// ExitMode's right-click drops out and it is left with Q alone.
|
||||||
|
enum class BindingCondition
|
||||||
|
{
|
||||||
|
Always,
|
||||||
|
NotDraggingBelt
|
||||||
|
};
|
||||||
|
|
||||||
|
struct KeyBindingEntry
|
||||||
|
{
|
||||||
|
ControlAction action;
|
||||||
|
int key;
|
||||||
|
// Shown on the badge, and Ctrl additionally participates in matching. Every other
|
||||||
|
// modifier is display only -- see resolveKeyAction.
|
||||||
|
Qt::KeyboardModifiers modifiers;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct MouseBindingEntry
|
||||||
|
{
|
||||||
|
ControlAction action;
|
||||||
|
MouseBinding binding;
|
||||||
|
BindingCondition condition;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolution is first-match-wins over these tables, so an entry that must beat another
|
||||||
|
// on the same input is listed above it -- CancelBeltLine before ExitMode on the right
|
||||||
|
// mouse button. Everything else is disjoint by availability: Q carries three actions
|
||||||
|
// whose availability rules partition the situations between them, so their order here is
|
||||||
|
// the order the reader meets them and nothing more (REQ-UI-HOTKEYS).
|
||||||
|
const KeyBindingEntry KEY_BINDINGS[] = {
|
||||||
|
{ControlAction::Move, Qt::Key_A, Qt::NoModifier},
|
||||||
|
{ControlAction::Move, Qt::Key_D, Qt::NoModifier},
|
||||||
|
{ControlAction::GameSpeed, Qt::Key_W, Qt::NoModifier},
|
||||||
|
{ControlAction::GameSpeed, Qt::Key_S, Qt::NoModifier},
|
||||||
|
{ControlAction::TogglePause, Qt::Key_Space, Qt::NoModifier},
|
||||||
|
{ControlAction::CopyTemporary, Qt::Key_C, Qt::NoModifier},
|
||||||
|
{ControlAction::CreateBlueprint, Qt::Key_C, Qt::ControlModifier},
|
||||||
|
{ControlAction::PasteTemporary, Qt::Key_V, Qt::NoModifier},
|
||||||
|
{ControlAction::OpenBlueprints, Qt::Key_V, Qt::ControlModifier},
|
||||||
|
// One binding, two badges: Shift picks the rotation direction and is read by the
|
||||||
|
// handler, the way a build hotkey's digit is (REQ-BLD-ROTATE). Both entries match
|
||||||
|
// the same press, and both resolve to the same action, so listing them twice costs
|
||||||
|
// nothing and is what puts "R" and "Shift+R" on the row.
|
||||||
|
{ControlAction::Rotate, Qt::Key_R, Qt::NoModifier},
|
||||||
|
{ControlAction::Rotate, Qt::Key_R, Qt::ShiftModifier},
|
||||||
|
{ControlAction::ExitMode, Qt::Key_Q, Qt::NoModifier},
|
||||||
|
{ControlAction::ClearSelection, Qt::Key_Q, Qt::NoModifier},
|
||||||
|
{ControlAction::EnterDeconstruct, Qt::Key_Q, Qt::NoModifier},
|
||||||
|
{ControlAction::OpenMenu, Qt::Key_Escape, Qt::NoModifier},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MouseBindingEntry MOUSE_BINDINGS[] = {
|
||||||
|
{ControlAction::Select, MouseBinding::LeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::Place, MouseBinding::LeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::ApplySettings, MouseBinding::LeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::ToggleDeconstruct, MouseBinding::LeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::SelectArea, MouseBinding::LeftDrag, BindingCondition::Always},
|
||||||
|
{ControlAction::PlaceBeltLine, MouseBinding::LeftDrag, BindingCondition::Always},
|
||||||
|
{ControlAction::DeconstructArea, MouseBinding::LeftDrag, BindingCondition::Always},
|
||||||
|
{ControlAction::AddToSelection, MouseBinding::CtrlLeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::AddAreaToSelection, MouseBinding::CtrlLeftDrag, BindingCondition::Always},
|
||||||
|
{ControlAction::CancelBeltLine, MouseBinding::RightClick, BindingCondition::Always},
|
||||||
|
{ControlAction::ExitMode, MouseBinding::RightClick, BindingCondition::NotDraggingBelt},
|
||||||
|
};
|
||||||
|
|
||||||
|
bool isConditionMet(BindingCondition condition, const ControlContext& context)
|
||||||
|
{
|
||||||
|
if (condition == BindingCondition::NotDraggingBelt) { return !context.draggingBelt; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPlacementMode(const ControlContext& context)
|
||||||
|
{
|
||||||
|
return context.mode == BuildMode::Builder || context.mode == BuildMode::Blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlAction> filterAvailable(const std::vector<ControlAction>& actions,
|
||||||
|
const ControlContext& context)
|
||||||
|
{
|
||||||
|
std::vector<ControlAction> available;
|
||||||
|
for (ControlAction action : actions)
|
||||||
|
{
|
||||||
|
if (isControlActionAvailable(action, context)) { available.push_back(action); }
|
||||||
|
}
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool isControlActionAvailable(ControlAction action, const ControlContext& context)
|
||||||
|
{
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case ControlAction::None:
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case ControlAction::Move:
|
||||||
|
case ControlAction::GameSpeed:
|
||||||
|
case ControlAction::TogglePause:
|
||||||
|
case ControlAction::OpenBlueprints:
|
||||||
|
case ControlAction::OpenMenu:
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Does nothing until something has been captured with C, so it is not offered
|
||||||
|
// before then (REQ-UI-BLUEPRINT-TEMP, REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
case ControlAction::PasteTemporary:
|
||||||
|
return context.temporaryBlueprintExists;
|
||||||
|
|
||||||
|
case ControlAction::Select:
|
||||||
|
case ControlAction::SelectArea:
|
||||||
|
case ControlAction::AddToSelection:
|
||||||
|
case ControlAction::AddAreaToSelection:
|
||||||
|
return context.mode == BuildMode::None;
|
||||||
|
|
||||||
|
// The three cases of Q, in the order REQ-UI-HOTKEYS evaluates them: it leaves the
|
||||||
|
// active mode, else clears the selection, else enters deconstruct mode. A selection
|
||||||
|
// and a build mode never coexist (REQ-UI-SELECTION-EXCLUSIVE), so the first two are
|
||||||
|
// already disjoint; what the empty-selection condition below adds is the third step,
|
||||||
|
// which is why entering deconstruct mode by key takes two presses while something is
|
||||||
|
// selected -- the Deconstruct button still gets there in one click.
|
||||||
|
case ControlAction::ClearSelection:
|
||||||
|
return context.mode == BuildMode::None
|
||||||
|
&& context.selection != ControlSelection::None;
|
||||||
|
case ControlAction::EnterDeconstruct:
|
||||||
|
return context.mode == BuildMode::None
|
||||||
|
&& context.selection == ControlSelection::None;
|
||||||
|
|
||||||
|
// Both need something a blueprint can be made of; a selection of ships or debris
|
||||||
|
// leaves them inert (REQ-UI-HOTKEYS).
|
||||||
|
case ControlAction::CopyTemporary:
|
||||||
|
case ControlAction::CreateBlueprint:
|
||||||
|
return context.mode == BuildMode::None && context.placeableBuildingSelected;
|
||||||
|
|
||||||
|
// Place and ApplySettings are the same click; which one it is depends on whether
|
||||||
|
// the ghost under the cursor is a transfer target (REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
case ControlAction::Place:
|
||||||
|
return isPlacementMode(context) && !context.hoveredGhostIsTransfer;
|
||||||
|
case ControlAction::ApplySettings:
|
||||||
|
return context.mode == BuildMode::Blueprint && context.hoveredGhostIsTransfer;
|
||||||
|
|
||||||
|
// The only building type placed by dragging (REQ-BLD-BELT-DRAG).
|
||||||
|
case ControlAction::PlaceBeltLine:
|
||||||
|
return context.mode == BuildMode::Builder
|
||||||
|
&& context.builderType == BuildingType::Belt;
|
||||||
|
|
||||||
|
case ControlAction::Rotate:
|
||||||
|
return isPlacementMode(context);
|
||||||
|
case ControlAction::CancelBeltLine:
|
||||||
|
return context.draggingBelt;
|
||||||
|
case ControlAction::ExitMode:
|
||||||
|
return context.mode != BuildMode::None;
|
||||||
|
|
||||||
|
case ControlAction::ToggleDeconstruct:
|
||||||
|
case ControlAction::DeconstructArea:
|
||||||
|
return context.mode == BuildMode::Deconstruct;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlBinding> getControlActionBindings(ControlAction action,
|
||||||
|
const ControlContext& context)
|
||||||
|
{
|
||||||
|
std::vector<ControlBinding> bindings;
|
||||||
|
for (const MouseBindingEntry& entry : MOUSE_BINDINGS)
|
||||||
|
{
|
||||||
|
if (entry.action != action) { continue; }
|
||||||
|
if (!isConditionMet(entry.condition, context)) { continue; }
|
||||||
|
ControlBinding binding;
|
||||||
|
binding.isMouse = true;
|
||||||
|
binding.mouse = entry.binding;
|
||||||
|
bindings.push_back(binding);
|
||||||
|
}
|
||||||
|
for (const KeyBindingEntry& entry : KEY_BINDINGS)
|
||||||
|
{
|
||||||
|
if (entry.action != action) { continue; }
|
||||||
|
ControlBinding binding;
|
||||||
|
binding.key = entry.key;
|
||||||
|
binding.modifiers = entry.modifiers;
|
||||||
|
bindings.push_back(binding);
|
||||||
|
}
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlContextKind getControlContextKind(const ControlContext& context)
|
||||||
|
{
|
||||||
|
switch (context.mode)
|
||||||
|
{
|
||||||
|
case BuildMode::Builder: return ControlContextKind::Build;
|
||||||
|
case BuildMode::Blueprint: return ControlContextKind::Blueprint;
|
||||||
|
case BuildMode::Deconstruct: return ControlContextKind::Deconstruct;
|
||||||
|
case BuildMode::None: break;
|
||||||
|
}
|
||||||
|
return context.selection == ControlSelection::None ? ControlContextKind::General
|
||||||
|
: ControlContextKind::Selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlAction> getContextActions(const ControlContext& context)
|
||||||
|
{
|
||||||
|
// The candidates of each context, in the order REQ-UI-CONTROLS-CONTENT lists them,
|
||||||
|
// then filtered by availability.
|
||||||
|
//
|
||||||
|
// The General and Selection lists differ rather than one filtered list serving
|
||||||
|
// both, because the additive-selection rows are an omission and not an
|
||||||
|
// unavailability: Ctrl+click does work with nothing selected, it just picks the
|
||||||
|
// object like a plain click would. "Add / remove from selection" is a row that
|
||||||
|
// means nothing until there is a selection to add to, so it waits for one
|
||||||
|
// (REQ-UI-CONTROLS-ACCURACY permits omitting an available binding).
|
||||||
|
switch (getControlContextKind(context))
|
||||||
|
{
|
||||||
|
case ControlContextKind::Build:
|
||||||
|
case ControlContextKind::Blueprint:
|
||||||
|
return filterAvailable({ControlAction::Place, ControlAction::ApplySettings,
|
||||||
|
ControlAction::PlaceBeltLine, ControlAction::Rotate,
|
||||||
|
ControlAction::CancelBeltLine, ControlAction::ExitMode},
|
||||||
|
context);
|
||||||
|
case ControlContextKind::Deconstruct:
|
||||||
|
return filterAvailable({ControlAction::ToggleDeconstruct,
|
||||||
|
ControlAction::DeconstructArea, ControlAction::ExitMode},
|
||||||
|
context);
|
||||||
|
// ClearSelection is listed last for the same reason ExitMode is above: the row that
|
||||||
|
// backs the player out of the context sits at the bottom of the context's rows
|
||||||
|
// wherever there is one (REQ-UI-CONTROLS-CONTENT).
|
||||||
|
case ControlContextKind::Selection:
|
||||||
|
return filterAvailable({ControlAction::Select, ControlAction::SelectArea,
|
||||||
|
ControlAction::AddToSelection,
|
||||||
|
ControlAction::AddAreaToSelection,
|
||||||
|
ControlAction::CopyTemporary,
|
||||||
|
ControlAction::CreateBlueprint,
|
||||||
|
ControlAction::ClearSelection},
|
||||||
|
context);
|
||||||
|
case ControlContextKind::General:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return filterAvailable({ControlAction::Select, ControlAction::SelectArea,
|
||||||
|
ControlAction::EnterDeconstruct},
|
||||||
|
context);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlAction> getAlwaysAvailableActions(const ControlContext& context)
|
||||||
|
{
|
||||||
|
return filterAvailable({ControlAction::Move, ControlAction::GameSpeed,
|
||||||
|
ControlAction::TogglePause, ControlAction::PasteTemporary,
|
||||||
|
ControlAction::OpenBlueprints, ControlAction::OpenMenu},
|
||||||
|
context);
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlAction resolveKeyAction(int key, Qt::KeyboardModifiers modifiers,
|
||||||
|
const ControlContext& context)
|
||||||
|
{
|
||||||
|
// Ctrl distinguishes a chord from the bare key (Ctrl+C is not C); every other
|
||||||
|
// modifier is ignored, so Shift+A still pans and Shift+R still rotates. This is
|
||||||
|
// what the key handler has always done, and matching modifiers exactly instead
|
||||||
|
// would silently drop those presses.
|
||||||
|
const bool controlHeld = (modifiers & Qt::ControlModifier) != 0;
|
||||||
|
for (const KeyBindingEntry& entry : KEY_BINDINGS)
|
||||||
|
{
|
||||||
|
if (entry.key != key) { continue; }
|
||||||
|
const bool entryNeedsControl = (entry.modifiers & Qt::ControlModifier) != 0;
|
||||||
|
if (entryNeedsControl != controlHeld) { continue; }
|
||||||
|
if (isControlActionAvailable(entry.action, context)) { return entry.action; }
|
||||||
|
}
|
||||||
|
return ControlAction::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlAction resolveMouseAction(MouseBinding binding, const ControlContext& context)
|
||||||
|
{
|
||||||
|
for (const MouseBindingEntry& entry : MOUSE_BINDINGS)
|
||||||
|
{
|
||||||
|
if (entry.binding != binding) { continue; }
|
||||||
|
if (!isConditionMet(entry.condition, context)) { continue; }
|
||||||
|
if (isControlActionAvailable(entry.action, context)) { return entry.action; }
|
||||||
|
}
|
||||||
|
return ControlAction::None;
|
||||||
|
}
|
||||||
168
src/lib/core/ControlAction.h
Normal file
168
src/lib/core/ControlAction.h
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <Qt>
|
||||||
|
|
||||||
|
#include "BuildModeController.h"
|
||||||
|
#include "BuildingType.h"
|
||||||
|
|
||||||
|
// The single statement of what the player can do right now, and which input does it
|
||||||
|
// (REQ-UI-CONTROLS-CONTENT, REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
//
|
||||||
|
// This file declares actions; it never performs them and it never names them. It knows
|
||||||
|
// an action's bindings and the situations in which it does something -- nothing about
|
||||||
|
// the simulation, the widgets, the events an action ends up firing, or the words shown
|
||||||
|
// to the player. Display text lives in ui/ControlActionText.h, which formats the
|
||||||
|
// bindings this hands it, so a badge is derived from the real binding rather than
|
||||||
|
// typed beside it.
|
||||||
|
//
|
||||||
|
// Three readers, all of them consuming this and none of them extending it:
|
||||||
|
//
|
||||||
|
// * ControlsPanel calls getContextActions()/getAlwaysAvailableActions() and draws them.
|
||||||
|
// * InputMapper calls resolveKeyAction() and fires the event the action stands for.
|
||||||
|
// * GameWorldView calls resolveMouseAction() and runs the branch it already ran.
|
||||||
|
//
|
||||||
|
// Two bindings are deliberately absent. Build hotkeys (REQ-UI-HOTKEYS) are advertised
|
||||||
|
// on the build buttons instead of in the panel, and InputMapper::getBuildHotkeyLabel
|
||||||
|
// already derives their badges from the same table the handler switches on, so they
|
||||||
|
// have no drift to fix. F3/F4 are development controls and appear nowhere
|
||||||
|
// (REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
//
|
||||||
|
// When bindings become player-configurable, only the binding tables in the .cpp turn
|
||||||
|
// from hard-coded data into loaded data. The actions, the availability rules, the
|
||||||
|
// panel, and every handler are unaffected.
|
||||||
|
enum class ControlAction
|
||||||
|
{
|
||||||
|
None, // no action is bound to the queried input in the queried context
|
||||||
|
|
||||||
|
// Always available (REQ-UI-CONTROLS-CONTENT).
|
||||||
|
Move,
|
||||||
|
GameSpeed,
|
||||||
|
TogglePause,
|
||||||
|
PasteTemporary,
|
||||||
|
OpenBlueprints,
|
||||||
|
OpenMenu,
|
||||||
|
|
||||||
|
// No build mode active.
|
||||||
|
Select,
|
||||||
|
SelectArea,
|
||||||
|
AddToSelection,
|
||||||
|
AddAreaToSelection,
|
||||||
|
EnterDeconstruct,
|
||||||
|
|
||||||
|
// No build mode active, with something selected.
|
||||||
|
CopyTemporary,
|
||||||
|
CreateBlueprint,
|
||||||
|
ClearSelection,
|
||||||
|
|
||||||
|
// Builder and blueprint placement mode.
|
||||||
|
Place,
|
||||||
|
ApplySettings,
|
||||||
|
PlaceBeltLine,
|
||||||
|
Rotate,
|
||||||
|
CancelBeltLine,
|
||||||
|
ExitMode,
|
||||||
|
|
||||||
|
// Deconstruct mode.
|
||||||
|
ToggleDeconstruct,
|
||||||
|
DeconstructArea
|
||||||
|
};
|
||||||
|
|
||||||
|
// The mouse gestures that carry a binding. Each is a whole gesture rather than a raw
|
||||||
|
// event: a drag is one binding, not a press plus a release, because that is the unit
|
||||||
|
// the player and the panel both think in. Which events make up the gesture, and the
|
||||||
|
// state it runs on, stay with the widget that owns them.
|
||||||
|
enum class MouseBinding
|
||||||
|
{
|
||||||
|
LeftClick,
|
||||||
|
LeftDrag,
|
||||||
|
CtrlLeftClick,
|
||||||
|
CtrlLeftDrag,
|
||||||
|
RightClick
|
||||||
|
};
|
||||||
|
|
||||||
|
// Which selection category is held, mirroring REQ-UI-SELECTION-CATEGORIES without
|
||||||
|
// depending on SelectionController.
|
||||||
|
enum class ControlSelection
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Buildings,
|
||||||
|
FieldObjects
|
||||||
|
};
|
||||||
|
|
||||||
|
// Which card the panel is showing (REQ-UI-CONTROLS-CONTENT). Named rather than
|
||||||
|
// spelled, so the heading text stays a presentation concern.
|
||||||
|
enum class ControlContextKind
|
||||||
|
{
|
||||||
|
General,
|
||||||
|
Selection,
|
||||||
|
Build,
|
||||||
|
Blueprint,
|
||||||
|
Deconstruct
|
||||||
|
};
|
||||||
|
|
||||||
|
// Everything the availability rules are allowed to depend on, as a plain snapshot.
|
||||||
|
//
|
||||||
|
// Taking a snapshot rather than references to the live controllers is what keeps this
|
||||||
|
// testable without a world, and it is what stops an action from reaching into the
|
||||||
|
// simulation: if a rule needs a fact, the fact is named here and the caller supplies it.
|
||||||
|
struct ControlContext
|
||||||
|
{
|
||||||
|
BuildMode mode = BuildMode::None;
|
||||||
|
// While mode == Builder: the type a click would place at the current hover position,
|
||||||
|
// not the type the mode was entered with — tunnel mode resolves to either end
|
||||||
|
// (REQ-BLD-TUNNEL-MODE).
|
||||||
|
BuildingType builderType = BuildingType::Belt;
|
||||||
|
bool draggingBelt = false;
|
||||||
|
// A single-building blueprint whose ghost is over a configuration-transfer target,
|
||||||
|
// so clicking hands over settings rather than placing (REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
bool hoveredGhostIsTransfer = false;
|
||||||
|
ControlSelection selection = ControlSelection::None;
|
||||||
|
int selectionCount = 0;
|
||||||
|
// At least one selected building is player-placeable, the condition under which
|
||||||
|
// C and Ctrl+C do anything (REQ-UI-HOTKEYS).
|
||||||
|
bool placeableBuildingSelected = false;
|
||||||
|
bool temporaryBlueprintExists = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One input an action answers to, in structured form so the badge can be rendered from
|
||||||
|
// it. `modifiers` is what the badge shows; matching is looser than equality, see
|
||||||
|
// resolveKeyAction.
|
||||||
|
struct ControlBinding
|
||||||
|
{
|
||||||
|
bool isMouse = false;
|
||||||
|
MouseBinding mouse = MouseBinding::LeftClick;
|
||||||
|
int key = 0; // Qt::Key_*, when !isMouse
|
||||||
|
Qt::KeyboardModifiers modifiers = Qt::NoModifier;
|
||||||
|
};
|
||||||
|
|
||||||
|
// True when triggering the action in this context would do what its label says. The
|
||||||
|
// panel shows exactly the available actions, and the resolvers return only available
|
||||||
|
// ones, which is REQ-UI-CONTROLS-ACCURACY expressed as one function.
|
||||||
|
bool isControlActionAvailable(ControlAction action, const ControlContext& context);
|
||||||
|
|
||||||
|
// The inputs an action answers to, in the order the panel should badge them.
|
||||||
|
// Context-dependent because a binding can be taken over: while a belt drag is in
|
||||||
|
// progress the right mouse button cancels the drag, so ExitMode is left with its key
|
||||||
|
// binding alone (REQ-BLD-BELT-DRAG).
|
||||||
|
std::vector<ControlBinding> getControlActionBindings(ControlAction action,
|
||||||
|
const ControlContext& context);
|
||||||
|
|
||||||
|
// Which card is showing, and the rows it holds -- the context's own, then the block
|
||||||
|
// available everywhere (REQ-UI-CONTROLS-CARD). Both lists are already filtered to the
|
||||||
|
// available actions and ordered as REQ-UI-CONTROLS-CONTENT lists them.
|
||||||
|
ControlContextKind getControlContextKind(const ControlContext& context);
|
||||||
|
std::vector<ControlAction> getContextActions(const ControlContext& context);
|
||||||
|
std::vector<ControlAction> getAlwaysAvailableActions(const ControlContext& context);
|
||||||
|
|
||||||
|
// The action a key press or a mouse gesture triggers here, or None when the input is
|
||||||
|
// unbound in this context. Both return only actions that are available, so a caller can
|
||||||
|
// act on the result without re-checking the situation.
|
||||||
|
//
|
||||||
|
// Rotation direction is not part of the action: R and Shift+R are one Rotate, and the
|
||||||
|
// caller reads the modifier for the direction, exactly as the digit of a build hotkey
|
||||||
|
// is read from the key. An action with a parameter keeps the parameter at the handler.
|
||||||
|
ControlAction resolveKeyAction(int key, Qt::KeyboardModifiers modifiers,
|
||||||
|
const ControlContext& context);
|
||||||
|
ControlAction resolveMouseAction(MouseBinding binding, const ControlContext& context);
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
111
src/lib/core/FloatingPanelPlacement.cpp
Normal file
111
src/lib/core/FloatingPanelPlacement.cpp
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
#include "FloatingPanelPlacement.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// The part every placement shares, whatever put the panel where it wants to be: it is
|
||||||
|
// pushed inside band rather than hanging off it, shortened to what its own column has
|
||||||
|
// free, and lifted by however much of it hangs below that. wantedLeftPx and wantedTopPx
|
||||||
|
// are where the panel would stand if nothing were in the way.
|
||||||
|
QRect fitInBand(const QRect& band, int wantedLeftPx, int wantedTopPx, QSize wantedSize,
|
||||||
|
const std::vector<QRect>& occupiedRects, int marginPx)
|
||||||
|
{
|
||||||
|
const int widthPx = std::min(wantedSize.width(), band.width());
|
||||||
|
|
||||||
|
int leftPx = std::min(wantedLeftPx, band.right() - widthPx + 1);
|
||||||
|
leftPx = std::max(leftPx, band.left());
|
||||||
|
|
||||||
|
// Only the widgets its own column meets can shorten it.
|
||||||
|
const int bottomPx = getAvailableBottomPx(band, occupiedRects, leftPx,
|
||||||
|
leftPx + widthPx - 1, marginPx);
|
||||||
|
const int heightPx =
|
||||||
|
std::min(wantedSize.height(), std::max(0, bottomPx - band.top() + 1));
|
||||||
|
|
||||||
|
// Never above the band: a panel taller than the space left is capped instead, and
|
||||||
|
// scrolls.
|
||||||
|
int topPx = std::max(wantedTopPx, band.top());
|
||||||
|
topPx = std::min(topPx, bottomPx - heightPx + 1);
|
||||||
|
topPx = std::max(topPx, band.top());
|
||||||
|
|
||||||
|
return QRect(leftPx, topPx, widthPx, heightPx);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
int getAvailableBottomPx(const QRect& band, const std::vector<QRect>& occupiedRects,
|
||||||
|
int leftPx, int rightPx, int marginPx)
|
||||||
|
{
|
||||||
|
int bottomPx = band.bottom();
|
||||||
|
for (const QRect& occupied : occupiedRects)
|
||||||
|
{
|
||||||
|
if (occupied.isEmpty())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Only what is actually in the way counts: a widget entirely to one side of this
|
||||||
|
// span is not below it, however tall it is.
|
||||||
|
if (occupied.right() < leftPx || occupied.left() > rightPx)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bottomPx = std::min(bottomPx, occupied.top() - marginPx - 1);
|
||||||
|
}
|
||||||
|
return bottomPx;
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx,
|
||||||
|
int selectionGapPx)
|
||||||
|
{
|
||||||
|
// What each side offers: the room between the anchor and that edge of the band, less
|
||||||
|
// the gap the panel keeps from the anchor. The band's own inset from the view has
|
||||||
|
// already taken the edge margin off.
|
||||||
|
const int roomRightPx = band.right() - anchorRect.right() - selectionGapPx;
|
||||||
|
const int roomLeftPx = anchorRect.left() - band.left() - selectionGapPx;
|
||||||
|
|
||||||
|
if (roomRightPx >= widthPx)
|
||||||
|
{
|
||||||
|
return PanelSide::Right;
|
||||||
|
}
|
||||||
|
if (roomLeftPx >= widthPx)
|
||||||
|
{
|
||||||
|
return PanelSide::Left;
|
||||||
|
}
|
||||||
|
// Neither side can hold it without covering the selection, so it goes where it
|
||||||
|
// covers the least of it.
|
||||||
|
return (roomRightPx >= roomLeftPx) ? PanelSide::Right : PanelSide::Left;
|
||||||
|
}
|
||||||
|
|
||||||
|
QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side,
|
||||||
|
QSize wantedSize, const std::vector<QRect>& occupiedRects,
|
||||||
|
int selectionGapPx, int marginPx)
|
||||||
|
{
|
||||||
|
const int widthPx = std::min(wantedSize.width(), band.width());
|
||||||
|
|
||||||
|
// A gap from the anchor on the chosen side, growing away from it: the edge facing the
|
||||||
|
// selection is the one that stays put as the panel's content resizes. A panel that
|
||||||
|
// does not fit there is pushed back inside the view rather than hanging off it, which
|
||||||
|
// is what puts it over the selection when neither side had room.
|
||||||
|
const int wantedLeftPx = (side == PanelSide::Right)
|
||||||
|
? anchorRect.right() + selectionGapPx + 1
|
||||||
|
: anchorRect.left() - selectionGapPx - widthPx;
|
||||||
|
|
||||||
|
// Top-aligned with the anchor -- the gap separates the two horizontally and plays no
|
||||||
|
// part here -- then lifted by however much of the panel hangs below what is free.
|
||||||
|
return fitInBand(band, wantedLeftPx, anchorRect.top(), wantedSize, occupiedRects,
|
||||||
|
marginPx);
|
||||||
|
}
|
||||||
|
|
||||||
|
QRect placeAtDesiredTopLeft(const QRect& band, const QPoint& desiredTopLeftPx,
|
||||||
|
QSize wantedSize, const std::vector<QRect>& occupiedRects,
|
||||||
|
int marginPx)
|
||||||
|
{
|
||||||
|
// Where the player dropped it, resolved by the same rules as any other placement
|
||||||
|
// (REQ-UI-SELECTION-PANEL-DRAG). Nothing here is written back to the desired point:
|
||||||
|
// a panel lifted above the build button bar returns to where it was dropped as soon
|
||||||
|
// as the bar stops meeting its column.
|
||||||
|
return fitInBand(band, desiredTopLeftPx.x(), desiredTopLeftPx.y(), wantedSize,
|
||||||
|
occupiedRects, marginPx);
|
||||||
|
}
|
||||||
62
src/lib/core/FloatingPanelPlacement.h
Normal file
62
src/lib/core/FloatingPanelPlacement.h
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QRect>
|
||||||
|
#include <QSize>
|
||||||
|
|
||||||
|
// Geometry for the widgets floating over the game world view (REQ-UI-WORLD-SIZE). Their
|
||||||
|
// owner places them in one ordered pass, each into the space the earlier ones left free,
|
||||||
|
// and these are the rules they place themselves by. Pure geometry -- no widget is
|
||||||
|
// involved, which is what lets the rules be tested without a display.
|
||||||
|
//
|
||||||
|
// Two distances run through these rules and are deliberately different (see
|
||||||
|
// REQ-UI-SELECTION-PANEL). marginPx is the edge margin: what a widget keeps from the
|
||||||
|
// view's edges and from the widgets it steps around. selectionGapPx is the gap the
|
||||||
|
// selection panel keeps from the selection it describes -- half a tile, which is the
|
||||||
|
// wider of the two, so the panel stands clear of the objects rather than touching them.
|
||||||
|
|
||||||
|
// The lowest bottom edge available to a widget occupying the horizontal span
|
||||||
|
// [leftPx, rightPx] inside band: the band's own bottom, or marginPx above the topmost
|
||||||
|
// occupied rectangle whose horizontal extent meets that span. A rectangle beside the
|
||||||
|
// span is not in the way and does not shorten it (REQ-UI-SELECTION-PANEL,
|
||||||
|
// REQ-UI-CONTROLS-PANEL). The result is inclusive, as QRect::bottom() is.
|
||||||
|
int getAvailableBottomPx(const QRect& band, const std::vector<QRect>& occupiedRects,
|
||||||
|
int leftPx, int rightPx, int marginPx);
|
||||||
|
|
||||||
|
// Which side of the selection the panel stands on (REQ-UI-SELECTION-PANEL).
|
||||||
|
enum class PanelSide
|
||||||
|
{
|
||||||
|
Right,
|
||||||
|
Left
|
||||||
|
};
|
||||||
|
|
||||||
|
// The side a panel widthPx wide takes beside anchorRect: the right of it where it fits
|
||||||
|
// within band, otherwise the left, and where it fits on neither, whichever side leaves
|
||||||
|
// more room -- the one case in which the panel ends up over the selection
|
||||||
|
// (REQ-UI-SELECTION-PANEL). The room a side offers is what is left of it once the panel's
|
||||||
|
// gap from the selection is taken off. Decided once when the selection starts and kept for
|
||||||
|
// as long as it lasts, so a card that grows later never flips the panel across the object.
|
||||||
|
PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx,
|
||||||
|
int selectionGapPx);
|
||||||
|
|
||||||
|
// Where a panel of wantedSize stands beside anchorRect on the given side: separated from
|
||||||
|
// it by selectionGapPx and growing away from it, its top edge on the anchor's top edge,
|
||||||
|
// pushed inside band and above whatever occupies it. The gap is horizontal only -- the
|
||||||
|
// panel's top sits level with the anchor's, however wide the gap. The returned height is
|
||||||
|
// short of wantedSize's when there was not enough room, which is the caller's cue to
|
||||||
|
// scroll its content (REQ-UI-SELECTION-PANEL).
|
||||||
|
QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side,
|
||||||
|
QSize wantedSize, const std::vector<QRect>& occupiedRects,
|
||||||
|
int selectionGapPx, 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()));
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectionChangedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionChangedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionAnchorChangedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/GameResetEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/GameResetEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h
|
||||||
@@ -24,6 +25,7 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/SpeedStepRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SpeedStepRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/GhostRotationRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/GhostRotationRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ModeCancelRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ModeCancelRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionClearRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggleRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggleRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h
|
||||||
@@ -43,7 +45,9 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/FloatingLayoutInvalidatedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/PlayerCommandsAppliedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/PlayerCommandsAppliedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ViewScrolledEvent.h
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
|
|
||||||
// Fired when the current asteroid-expansion cost changes (REQ-EXP-COST): once at
|
// Fired when the current asteroid-expansion cost changes (REQ-EXP-COST): once at
|
||||||
// startup and again after each expansion is purchased. Carries no payload — the
|
// startup and again after each expansion is purchased. Carries no payload — the
|
||||||
// header Expand button re-reads Simulation::getCurrentExpansionCost() to update
|
// expansion button re-reads Simulation::getCurrentExpansionCost() to update its cost
|
||||||
// its caption and enabled state (REQ-UI-EXPAND-BUTTON).
|
// line and enabled state, and moves onto the next stretch of locked ground, the
|
||||||
|
// buildable edge having shifted with the purchase (REQ-UI-EXPAND-BUTTON).
|
||||||
class ExpansionCostChangedEvent : public Event
|
class ExpansionCostChangedEvent : public Event
|
||||||
{
|
{
|
||||||
};
|
};
|
||||||
|
|||||||
12
src/lib/eventsystem/event/FloatingLayoutInvalidatedEvent.h
Normal file
12
src/lib/eventsystem/event/FloatingLayoutInvalidatedEvent.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Asks the owner of the widgets floating over the game world view to re-run its placement
|
||||||
|
// pass (see ui/FloatingPanel.h). Published by a floating widget whose content or
|
||||||
|
// visibility changed: what space that widget may take depends on the ones placed before
|
||||||
|
// it, so it cannot re-place itself alone (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL,
|
||||||
|
// REQ-UI-SELECTION-PANEL). Carries no payload -- the pass re-reads every widget.
|
||||||
|
class FloatingLayoutInvalidatedEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
31
src/lib/eventsystem/event/SelectionAnchorChangedEvent.h
Normal file
31
src/lib/eventsystem/event/SelectionAnchorChangedEvent.h
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QRect>
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Where on the screen the selection that is about to be made sits: the bounds of the one
|
||||||
|
// object selected, or of all of them when the selection starts as a multi-selection
|
||||||
|
// (REQ-UI-SELECTION-PANEL). The rectangle is in the game world view's own widget
|
||||||
|
// coordinates.
|
||||||
|
//
|
||||||
|
// Published only when a selection *starts* -- a plain click or drag, or an additive one
|
||||||
|
// onto an empty selection -- and always immediately before the selection itself. Adding
|
||||||
|
// to a selection publishes nothing, which is what leaves the selection panel where it
|
||||||
|
// is while the selection grows; and because the rectangle is screen space frozen at that
|
||||||
|
// moment, scrolling the view or a selected ship flying off does not move the panel
|
||||||
|
// either.
|
||||||
|
//
|
||||||
|
// The gap the panel keeps from that rectangle travels with it, for the same reason: it is
|
||||||
|
// half a tile as the tile stood in this moment (REQ-UI-SELECTION-PANEL, REQ-GW-TILE-SIZE)
|
||||||
|
// and stays that for as long as the selection lasts, a rectangle frozen in one moment
|
||||||
|
// having no meaningful distance to a tile size measured in another.
|
||||||
|
class SelectionAnchorChangedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
SelectionAnchorChangedEvent(QRect rectPx, int selectionGapPx)
|
||||||
|
: rectPx(rectPx), selectionGapPx(selectionGapPx) {}
|
||||||
|
|
||||||
|
const QRect rectPx;
|
||||||
|
const int selectionGapPx;
|
||||||
|
};
|
||||||
11
src/lib/eventsystem/event/SelectionClearRequestedEvent.h
Normal file
11
src/lib/eventsystem/event/SelectionClearRequestedEvent.h
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// The player pressed the key that drops the current selection (REQ-UI-HOTKEYS). Separate
|
||||||
|
// from ModeCancelRequestedEvent although both are Q: which of the two a press means is
|
||||||
|
// settled by the action table, and an event that meant either would force the receiver to
|
||||||
|
// decide it a second time.
|
||||||
|
class SelectionClearRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
15
src/lib/eventsystem/event/ViewScrolledEvent.h
Normal file
15
src/lib/eventsystem/event/ViewScrolledEvent.h
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Fired when the game world view's scroll position actually moved (REQ-UI-SCROLL), once
|
||||||
|
// per frame at most and not at all while the view sits still. Carries no payload: what it
|
||||||
|
// says is that the world-to-widget transform has changed, and whoever cares re-reads it.
|
||||||
|
//
|
||||||
|
// It exists for the widgets that keep a place in the world rather than on the screen --
|
||||||
|
// the asteroid expansion button, which stands on the columns it would buy
|
||||||
|
// (REQ-UI-EXPAND-BUTTON). The floating panels need nothing of the sort: they are placed
|
||||||
|
// against the screen and stay put as the view moves under them (ui/FloatingPanel.h).
|
||||||
|
class ViewScrolledEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
@@ -27,11 +27,14 @@ struct InputBuffer
|
|||||||
std::map<ItemType, int> caps; // max items per material (2× per-cycle requirement)
|
std::map<ItemType, int> caps; // max items per material (2× per-cycle requirement)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Output buffer shared by all output materials for a production building.
|
// Per-material output buffer for a production building. The items are held in one
|
||||||
|
// production-ordered queue -- that is the order they leave at the output port
|
||||||
|
// (REQ-MAT-OUTPUT-EMERGE) -- while the capacity is per item type, so one item's backlog
|
||||||
|
// never occupies another's room (REQ-MAT-OUTPUT-BUFFER).
|
||||||
struct OutputBuffer
|
struct OutputBuffer
|
||||||
{
|
{
|
||||||
std::vector<Item> items;
|
std::vector<Item> items; // production order; feeds the output belt
|
||||||
int capacity = 0; // 2× per-cycle output; 1× for ReprocessingPlant
|
std::map<ItemType, int> caps; // max items per material (2x its per-cycle amount)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Active production cycle for a building.
|
// Active production cycle for a building.
|
||||||
@@ -96,6 +99,25 @@ struct Building
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The same over one material, which is what its own capacity is measured against
|
||||||
|
// (REQ-MAT-OUTPUT-BUFFER).
|
||||||
|
int getOutputItemCount(const ItemType& type) const
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
for (const Item& item : outputBuffer.items)
|
||||||
|
{
|
||||||
|
if (item.type == type) { ++count; }
|
||||||
|
}
|
||||||
|
for (const std::vector<BeltItemSlot>& lane : emergingItems)
|
||||||
|
{
|
||||||
|
for (const BeltItemSlot& slot : lane)
|
||||||
|
{
|
||||||
|
if (slot.item.type == type) { ++count; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
// Items currently travelling inward on each input port's virtual input belt
|
// Items currently travelling inward on each input port's virtual input belt
|
||||||
// (REQ-MAT-INPUT-INTAKE); one lane per input port, parallel to inputPorts. Each
|
// (REQ-MAT-INPUT-INTAKE); one lane per input port, parallel to inputPorts. Each
|
||||||
// lane holds slots at progress [0.0, 0.5], front (highest progress) first. An
|
// lane holds slots at progress [0.0, 0.5], front (highest progress) first. An
|
||||||
|
|||||||
@@ -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 cycle yields exactly one output group
|
||||||
|
// (REQ-MAT-OUTPUT-GROUP), so an item's per-cycle amount is the largest total any single
|
||||||
|
// group produces of it -- summed within a group, whose items come together, and taken at
|
||||||
|
// its maximum across groups, of which only one ever happens.
|
||||||
|
//
|
||||||
|
// Where a cap is already present the larger wins, which is how a cap unions across the
|
||||||
|
// recipes it could be sized over -- the same rule the input caps follow.
|
||||||
|
void addOutputCaps(std::map<ItemType, int>& caps, const RecipeDef& recipe)
|
||||||
|
{
|
||||||
|
std::map<ItemType, int> perCycle;
|
||||||
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
|
{
|
||||||
|
std::map<ItemType, int> inGroup;
|
||||||
|
for (const RecipeOutput& out : group.items)
|
||||||
|
{
|
||||||
|
inGroup[ItemType{out.item}] += out.amount;
|
||||||
|
}
|
||||||
|
for (const std::pair<const ItemType, int>& entry : inGroup)
|
||||||
|
{
|
||||||
|
perCycle[entry.first] = std::max(perCycle[entry.first], entry.second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 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);
|
||||||
|
|||||||
@@ -48,29 +48,62 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe)
|
namespace
|
||||||
{
|
{
|
||||||
std::vector<const RecipeOutput*> eligible;
|
// The items of one group, produced together (REQ-MAT-OUTPUT-GROUP).
|
||||||
std::vector<double> weights;
|
std::vector<Item> itemsOf(const RecipeOutputGroup& group)
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
{
|
||||||
|
std::vector<Item> result;
|
||||||
|
for (const RecipeOutput& out : group.items)
|
||||||
{
|
{
|
||||||
if (!m_isItemUnlocked(out.item)) { continue; }
|
Item item;
|
||||||
eligible.push_back(&out);
|
item.type.id = out.item;
|
||||||
weights.push_back(out.probability.value_or(1.0));
|
for (int i = 0; i < out.amount; ++i)
|
||||||
|
{
|
||||||
|
result.push_back(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<Item> BuildingSystem::rollOutputGroup(const RecipeDef& recipe)
|
||||||
|
{
|
||||||
|
// One group: nothing to choose, so no weight is read, no draw is made, and no
|
||||||
|
// eligibility is tested (REQ-MAT-OUTPUT-GROUP, REQ-LOCK-OUTPUT-POOL).
|
||||||
|
//
|
||||||
|
// Not drawing matters beyond speed. A draw here would consume entropy for every
|
||||||
|
// ordinary recipe, shifting every later random outcome and invalidating recorded
|
||||||
|
// replays. And eligibility must not apply either: implicit unlocking is derived from
|
||||||
|
// demand, so an ordinary recipe's output can be perfectly producible while nothing
|
||||||
|
// yet calls for it -- testing it here would stop the building producing at all.
|
||||||
|
if (recipe.outputGroups.size() == 1)
|
||||||
|
{
|
||||||
|
return itemsOf(recipe.outputGroups.front());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Several groups: only those whose items are all unlocked can be picked, and a group
|
||||||
|
// holding any locked item is dropped whole, since its items come together
|
||||||
|
// (REQ-LOCK-OUTPUT-POOL). Weights are renormalized over what is left by
|
||||||
|
// discrete_distribution.
|
||||||
|
std::vector<const RecipeOutputGroup*> eligible;
|
||||||
|
std::vector<double> weights;
|
||||||
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
|
{
|
||||||
|
bool allUnlocked = true;
|
||||||
|
for (const RecipeOutput& out : group.items)
|
||||||
|
{
|
||||||
|
if (!m_isItemUnlocked(out.item)) { allUnlocked = false; break; }
|
||||||
|
}
|
||||||
|
if (!allUnlocked) { continue; }
|
||||||
|
eligible.push_back(&group);
|
||||||
|
weights.push_back(group.probability.value_or(1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eligible.empty()) { return {}; }
|
if (eligible.empty()) { return {}; }
|
||||||
|
|
||||||
std::discrete_distribution<int> dist(weights.begin(), weights.end());
|
std::discrete_distribution<int> dist(weights.begin(), weights.end());
|
||||||
const RecipeOutput& chosen = *eligible[static_cast<std::size_t>(dist(m_rng))];
|
return itemsOf(*eligible[static_cast<std::size_t>(dist(m_rng))]);
|
||||||
std::vector<Item> result;
|
|
||||||
Item item;
|
|
||||||
item.type.id = chosen.item;
|
|
||||||
for (int i = 0; i < chosen.amount; ++i)
|
|
||||||
{
|
|
||||||
result.push_back(item);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -200,12 +233,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 +250,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 +261,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 +328,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 +428,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 +442,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 +521,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 +550,78 @@ 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).
|
|
||||||
std::vector<Item> chosen;
|
|
||||||
if (building.type == BuildingType::ReprocessingPlant)
|
|
||||||
{
|
|
||||||
chosen = rollReprocessingOutput(*recipe);
|
|
||||||
if (chosen.empty()) { continue; }
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
for (const RecipeOutput& out : recipe->outputs)
|
|
||||||
{
|
|
||||||
Item item;
|
|
||||||
item.type.id = out.item;
|
|
||||||
for (int i = 0; i < out.amount; ++i)
|
|
||||||
{
|
|
||||||
chosen.push_back(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Room for every output this cycle could produce -- checked before anything
|
||||||
|
// is rolled (REQ-MAT-CYCLE). The roll below is committed the moment the cycle
|
||||||
|
// 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. Settle what this cycle produces: its one output group, picked by weight only
|
||||||
|
// where the recipe has several (REQ-MAT-OUTPUT-GROUP). Empty means every group
|
||||||
|
// was ineligible, so there is nothing to run.
|
||||||
|
std::vector<Item> chosen = rollOutputGroup(*recipe);
|
||||||
|
if (chosen.empty()) { 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,26 +650,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 +983,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 +1018,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).
|
||||||
@@ -217,11 +224,11 @@ private:
|
|||||||
// (ignoring output-buffer space); drives the Starved/Blocked distinction of
|
// (ignoring output-buffer space); drives the Starved/Blocked distinction of
|
||||||
// the status light (REQ-UI-STATUS-LIGHT).
|
// the status light (REQ-UI-STATUS-LIGHT).
|
||||||
|
|
||||||
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input
|
// What one cycle of this recipe produces: the items of its one output group
|
||||||
// caps span the union of every recipe of the building's type; no player
|
// (REQ-MAT-OUTPUT-GROUP). Where the recipe has several, one is picked by weight from
|
||||||
// recipe is selected (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
|
// those currently eligible (REQ-LOCK-OUTPUT-POOL) and the result is empty if none is;
|
||||||
// Core input-edge scan shared by operational buildings and construction sites.
|
// where it has one, that group is returned with no draw and no eligibility test.
|
||||||
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
|
std::vector<Item> rollOutputGroup(const RecipeDef& recipe);
|
||||||
|
|
||||||
const GameConfig& m_config;
|
const GameConfig& m_config;
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
#include "Rotation.h"
|
#include "Rotation.h"
|
||||||
#include "ShipLayout.h"
|
#include "ShipLayout.h"
|
||||||
|
|
||||||
class GameConfig;
|
struct GameConfig;
|
||||||
|
|
||||||
// Player intent, resolved to domain ids / tile coordinates and serializable, that
|
// Player intent, resolved to domain ids / tile coordinates and serializable, that
|
||||||
// mutates the simulation. Every sim mutation during play flows through a Command
|
// mutates the simulation. Every sim mutation during play flows through a Command
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
@@ -45,9 +56,17 @@ bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe)
|
|||||||
}
|
}
|
||||||
std::map<std::string, int>
|
std::map<std::string, int>
|
||||||
computeShipyardRequiredMaterials(const GameConfig& config, const Building& b)
|
computeShipyardRequiredMaterials(const GameConfig& config, const Building& b)
|
||||||
|
{
|
||||||
|
return computeShipyardRequiredMaterials(config, b.recipeId, b.shipLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::map<std::string, int>
|
||||||
|
computeShipyardRequiredMaterials(const GameConfig& config,
|
||||||
|
const std::string& recipeId,
|
||||||
|
const std::optional<ShipLayoutConfig>& shipLayout)
|
||||||
{
|
{
|
||||||
std::map<std::string, int> requiredMaterials;
|
std::map<std::string, int> requiredMaterials;
|
||||||
const ShipDef* shipDef = config.ships.findShipDef(b.recipeId);
|
const ShipDef* shipDef = config.ships.findShipDef(recipeId);
|
||||||
if (!shipDef)
|
if (!shipDef)
|
||||||
{
|
{
|
||||||
return requiredMaterials;
|
return requiredMaterials;
|
||||||
@@ -56,9 +75,9 @@ computeShipyardRequiredMaterials(const GameConfig& config, const Building& b)
|
|||||||
{
|
{
|
||||||
requiredMaterials[ing.item] += ing.amount;
|
requiredMaterials[ing.item] += ing.amount;
|
||||||
}
|
}
|
||||||
if (b.shipLayout.has_value())
|
if (shipLayout.has_value())
|
||||||
{
|
{
|
||||||
for (const PlacedModule& pm : b.shipLayout->placedModules)
|
for (const PlacedModule& pm : shipLayout->placedModules)
|
||||||
{
|
{
|
||||||
const ModuleDef* modDef = config.modules.findModuleDef(pm.moduleId);
|
const ModuleDef* modDef = config.modules.findModuleDef(pm.moduleId);
|
||||||
if (!modDef)
|
if (!modDef)
|
||||||
@@ -73,6 +92,33 @@ computeShipyardRequiredMaterials(const GameConfig& config, const Building& b)
|
|||||||
}
|
}
|
||||||
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)
|
||||||
@@ -92,18 +138,56 @@ 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)
|
||||||
|
{
|
||||||
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
{
|
{
|
||||||
if (recipeInputsAvailable(b, *recipe))
|
// A group's items come together, so an item listed twice in one is produced in the
|
||||||
|
// sum of those amounts and judged once, as a sum.
|
||||||
|
std::map<ItemType, int> perCycle;
|
||||||
|
for (const RecipeOutput& out : group.items)
|
||||||
{
|
{
|
||||||
return true;
|
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)
|
||||||
{
|
{
|
||||||
@@ -122,9 +206,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;
|
||||||
}
|
}
|
||||||
@@ -134,9 +219,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);
|
||||||
@@ -38,9 +44,38 @@ bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe);
|
|||||||
std::map<std::string, int> computeShipyardRequiredMaterials(const GameConfig& config,
|
std::map<std::string, int> computeShipyardRequiredMaterials(const GameConfig& config,
|
||||||
const Building& b);
|
const Building& b);
|
||||||
|
|
||||||
|
// The same sum over a stored configuration rather than an operational building, so a
|
||||||
|
// construction site's schematic can be costed before it is built (REQ-BLD-SITE-CONFIG).
|
||||||
|
std::map<std::string, int> computeShipyardRequiredMaterials(
|
||||||
|
const GameConfig& config, const std::string& recipeId,
|
||||||
|
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 one of the recipe's output groups would fit -- the gate a cycle has to
|
||||||
|
// pass before it may start (REQ-MAT-CYCLE, REQ-MAT-OUTPUT-GROUP). With a single group that
|
||||||
|
// is simply that group. With several the pick is committed the moment the cycle starts, so
|
||||||
|
// every outcome must fit: testing all of them rather than the picked 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,
|
||||||
|
|||||||
@@ -97,35 +97,42 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
|
|||||||
// values are raw from config; we normalize them per-recipe below).
|
// values are raw from config; we normalize them per-recipe below).
|
||||||
std::map<std::string, std::vector<RecipeRef>> reprocessingRecipes;
|
std::map<std::string, std::vector<RecipeRef>> reprocessingRecipes;
|
||||||
|
|
||||||
|
// What decides which model an item's threat follows is the recipe's shape, not the
|
||||||
|
// building running it (REQ-MAT-OUTPUT-GROUP): a recipe with several groups yields one
|
||||||
|
// of them by chance, so its items cost the cycle divided by their odds; a recipe with
|
||||||
|
// one group yields it every cycle, so its items cost the cycle outright.
|
||||||
for (const RecipeDef& recipe : config.recipes.recipes)
|
for (const RecipeDef& recipe : config.recipes.recipes)
|
||||||
{
|
{
|
||||||
if (recipe.building == BuildingType::ReprocessingPlant)
|
if (recipe.outputGroups.size() > 1)
|
||||||
{
|
{
|
||||||
// Compute the total weight across all outputs of this reprocessing recipe
|
// Total weight across the groups, so each group's probability normalizes.
|
||||||
// so we can normalize each output's probability.
|
|
||||||
double totalWeight = 0.0;
|
double totalWeight = 0.0;
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
{
|
{
|
||||||
totalWeight += out.probability.value_or(1.0);
|
totalWeight += group.probability.value_or(1.0);
|
||||||
}
|
}
|
||||||
if (totalWeight <= 0.0)
|
if (totalWeight <= 0.0)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
{
|
{
|
||||||
RecipeRef ref;
|
const double probability = group.probability.value_or(1.0) / totalWeight;
|
||||||
ref.recipe = &recipe;
|
for (const RecipeOutput& out : group.items)
|
||||||
ref.outputItem = out.item;
|
{
|
||||||
ref.outputAmount = out.amount;
|
RecipeRef ref;
|
||||||
ref.probability = out.probability.value_or(1.0) / totalWeight;
|
ref.recipe = &recipe;
|
||||||
reprocessingRecipes[out.item].push_back(ref);
|
ref.outputItem = out.item;
|
||||||
|
ref.outputAmount = out.amount;
|
||||||
|
ref.probability = probability;
|
||||||
|
reprocessingRecipes[out.item].push_back(ref);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Check whether this non-reprocessing recipe consumes scrap.
|
// Check whether this single-group recipe consumes scrap.
|
||||||
bool consumesScrap = false;
|
bool consumesScrap = false;
|
||||||
for (const RecipeIngredient& input : recipe.inputs)
|
for (const RecipeIngredient& input : recipe.inputs)
|
||||||
{
|
{
|
||||||
@@ -136,7 +143,7 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
for (const RecipeOutput& out : recipe.outputGroups.front().items)
|
||||||
{
|
{
|
||||||
if (!consumesScrap)
|
if (!consumesScrap)
|
||||||
{
|
{
|
||||||
@@ -288,8 +295,13 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
|
|||||||
scrapPerCycle += input.amount;
|
scrapPerCycle += input.amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per unit: the cycle's cost, divided by the odds of getting this group
|
||||||
|
// at all and then by how many units that group yields (REQ-THREAT-ITEM).
|
||||||
|
const double perUnitDivisor =
|
||||||
|
ref.probability * static_cast<double>(ref.outputAmount);
|
||||||
|
if (perUnitDivisor <= 0.0) { continue; }
|
||||||
double threat = (table.scrapThreat * scrapPerCycle
|
double threat = (table.scrapThreat * scrapPerCycle
|
||||||
+ ref.recipe->durationSeconds) / ref.probability;
|
+ ref.recipe->durationSeconds) / perUnitDivisor;
|
||||||
|
|
||||||
std::map<std::string, double>::iterator existing = resolved.find(item);
|
std::map<std::string, double>::iterator existing = resolved.find(item);
|
||||||
if (existing == resolved.end() || threat > existing->second)
|
if (existing == resolved.end() || threat > existing->second)
|
||||||
|
|||||||
@@ -244,9 +244,9 @@ UnlockState::UnlockedSets UnlockState::computeUnlockedSets(
|
|||||||
if (def.building == BuildingType::Assembler
|
if (def.building == BuildingType::Assembler
|
||||||
&& (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0))
|
&& (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0))
|
||||||
{
|
{
|
||||||
for (const RecipeOutput& out : def.outputs)
|
for (const std::string& item : getProducibleItems(def))
|
||||||
{
|
{
|
||||||
result.itemIds.insert(out.item);
|
result.itemIds.insert(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,9 +272,9 @@ UnlockState::UnlockedSets UnlockState::computeUnlockedSets(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
bool producesUnlocked = false;
|
bool producesUnlocked = false;
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
for (const std::string& item : getProducibleItems(recipe))
|
||||||
{
|
{
|
||||||
if (result.itemIds.count(out.item) > 0)
|
if (result.itemIds.count(item) > 0)
|
||||||
{
|
{
|
||||||
producesUnlocked = true;
|
producesUnlocked = true;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -288,6 +288,66 @@ TEST_CASE("The effective builder type follows the resolved tunnel end", "[buildm
|
|||||||
REQUIRE(controller.getTunnelPartnerTile() == QPoint(5, 5));
|
REQUIRE(controller.getTunnelPartnerTile() == QPoint(5, 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Clearing the hover drops everything the cursor pointed at", "[buildmode]")
|
||||||
|
{
|
||||||
|
// A cursor that leaves the world hovers nothing, so the ghost and its resolved
|
||||||
|
// tunnel end go with it, while the mode itself stays active (REQ-BLD-GHOST).
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::TunnelEntry);
|
||||||
|
controller.setGhostTile(QPoint(7, 2));
|
||||||
|
controller.setGhostValidity(true);
|
||||||
|
controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 2));
|
||||||
|
|
||||||
|
controller.clearHover();
|
||||||
|
|
||||||
|
REQUIRE(controller.isBuilderMode());
|
||||||
|
REQUIRE_FALSE(controller.getGhostTile().has_value());
|
||||||
|
REQUIRE_FALSE(controller.isGhostValid());
|
||||||
|
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelEntry);
|
||||||
|
REQUIRE_FALSE(controller.getTunnelPartnerTile().has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Clearing the hover keeps a belt drag's path", "[buildmode]")
|
||||||
|
{
|
||||||
|
// A drag holds the button and goes on hovering wherever the cursor travels, so
|
||||||
|
// nothing clears it short of releasing or cancelling (REQ-BLD-BELT-DRAG).
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
controller.beginBeltDrag(QPoint(3, 4));
|
||||||
|
controller.setBeltDragPath({BeltPathTile{QPoint(3, 4), Rotation::East}});
|
||||||
|
|
||||||
|
controller.clearHover();
|
||||||
|
|
||||||
|
REQUIRE(controller.isDraggingBelt());
|
||||||
|
REQUIRE(controller.getBeltDragPath().size() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Clearing the hover drops the blueprint ghost and its transfer", "[buildmode]")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBlueprintMode(makeBlueprint());
|
||||||
|
controller.setBlueprintGhostTile(QPoint(9, 9));
|
||||||
|
controller.setHoveredGhostTransfer(true);
|
||||||
|
|
||||||
|
controller.clearHover();
|
||||||
|
|
||||||
|
REQUIRE(controller.isBlueprintMode());
|
||||||
|
REQUIRE_FALSE(controller.getBlueprintGhostTile().has_value());
|
||||||
|
REQUIRE_FALSE(controller.isHoveredGhostTransfer());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Clearing the hover drops the deconstruct hover", "[buildmode]")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.toggleDeconstructMode();
|
||||||
|
controller.setDeconstructHoverBuildingId(BuildingId(4));
|
||||||
|
|
||||||
|
controller.clearHover();
|
||||||
|
|
||||||
|
REQUIRE(controller.isDeconstructMode());
|
||||||
|
REQUIRE_FALSE(controller.getDeconstructHoverBuildingId().has_value());
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("A non-tunnel builder ignores any resolved tunnel end", "[buildmode]")
|
TEST_CASE("A non-tunnel builder ignores any resolved tunnel end", "[buildmode]")
|
||||||
{
|
{
|
||||||
BuildModeController controller;
|
BuildModeController controller;
|
||||||
|
|||||||
@@ -11,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"
|
||||||
@@ -102,14 +104,20 @@ struct PlacementFixture
|
|||||||
BuildingSystem bs;
|
BuildingSystem bs;
|
||||||
|
|
||||||
// Defaults to the configured belt speed; pass kFastBeltSpeed_tps where the test
|
// Defaults to the configured belt speed; pass kFastBeltSpeed_tps where the test
|
||||||
// needs items to arrive immediately.
|
// needs items to arrive immediately. Everything counts as unlocked unless the test
|
||||||
explicit PlacementFixture(std::optional<double> beltSpeed_tps = std::nullopt)
|
// says otherwise, which is what the output-group eligibility rule turns on
|
||||||
|
// (REQ-LOCK-OUTPUT-POOL).
|
||||||
|
explicit PlacementFixture(
|
||||||
|
std::optional<double> beltSpeed_tps = std::nullopt,
|
||||||
|
std::function<bool(const std::string&)> isItemUnlocked = nullptr)
|
||||||
: belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps))
|
: belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps))
|
||||||
, bs(cfg, belts,
|
, bs(cfg, belts,
|
||||||
[this]() { return nextBuildingId++; },
|
[this]() { return nextBuildingId++; },
|
||||||
[this](int n) { stock += n; },
|
[this](int n) { stock += n; },
|
||||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||||
[](const std::string&) -> bool { return true; },
|
isItemUnlocked ? std::move(isItemUnlocked)
|
||||||
|
: std::function<bool(const std::string&)>(
|
||||||
|
[](const std::string&) { return true; }),
|
||||||
rng)
|
rng)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -134,6 +142,26 @@ TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building
|
|||||||
REQUIRE_FALSE(isTileOccupied(f.state, QPoint(1, 1)));
|
REQUIRE_FALSE(isTileOccupied(f.state, QPoint(1, 1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("buildingsInBox covers a body cell the box only reaches into", "[building]")
|
||||||
|
{
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0),
|
||||||
|
Rotation::East, 0).value();
|
||||||
|
|
||||||
|
// Body at (0,0),(1,0),(0,1). The box is unsnapped and lies wholly within cell
|
||||||
|
// (1,0) without filling it, which is enough: a building is covered when the box
|
||||||
|
// overlaps any of its body cells (REQ-UI-MULTI-SELECT, Coverage).
|
||||||
|
const std::vector<BuildingId> grazed =
|
||||||
|
buildingsInBox(f.state, QRectF(1.6, 0.4, 0.2, 0.2));
|
||||||
|
REQUIRE(grazed.size() == 1);
|
||||||
|
REQUIRE(grazed.front() == id);
|
||||||
|
|
||||||
|
// (1,1) is the output-port tile, not a body cell, so a box inside it covers
|
||||||
|
// nothing even though it is surrounded by the miner's cells.
|
||||||
|
REQUIRE(buildingsInBox(f.state, QRectF(1.2, 1.2, 0.5, 0.5)).empty());
|
||||||
|
}
|
||||||
|
|
||||||
// -- World-bounds rejection (REQ-BLD-PLACE-VALID) ---------------------------
|
// -- World-bounds rejection (REQ-BLD-PLACE-VALID) ---------------------------
|
||||||
|
|
||||||
TEST_CASE("BuildingSystem: place rejects a building above the world (y < 0)", "[building]")
|
TEST_CASE("BuildingSystem: place rejects a building above the world (y < 0)", "[building]")
|
||||||
@@ -495,13 +523,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 +540,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 +656,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 +673,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 +784,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 +797,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 +807,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 +913,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 +951,379 @@ 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("ConfigLoader: the outputs shorthand and one output_group load alike",
|
||||||
|
"[config]")
|
||||||
|
{
|
||||||
|
// `outputs = [...]` is exactly one group holding those items (REQ-MAT-OUTPUT-GROUP),
|
||||||
|
// so a recipe written either way behaves identically.
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
const RecipeDef* shorthand =
|
||||||
|
f.cfg.recipes.findRecipeDef("iron_ingot", BuildingType::Smelter);
|
||||||
|
REQUIRE(shorthand != nullptr);
|
||||||
|
REQUIRE(shorthand->outputGroups.size() == 1);
|
||||||
|
REQUIRE_FALSE(shorthand->outputGroups.front().probability.has_value());
|
||||||
|
|
||||||
|
// Sizing and the cycle gate read it as one group like any other.
|
||||||
|
Building smelter; smelter.type = BuildingType::Smelter;
|
||||||
|
smelter.recipeId = shorthand->id;
|
||||||
|
initBuffers(smelter, *shorthand);
|
||||||
|
REQUIRE(smelter.outputBuffer.caps.at(ItemType{"iron_ingot"})
|
||||||
|
== 2 * shorthand->outputGroups.front().items.front().amount);
|
||||||
|
REQUIRE(recipeOutputsFit(smelter, *shorthand));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: a single-group recipe consumes no randomness", "[building]")
|
||||||
|
{
|
||||||
|
// Nothing is picked where there is one group, so no draw is made (REQ-MAT-OUTPUT-GROUP).
|
||||||
|
// Drawing here would consume entropy for every ordinary recipe and shift every later
|
||||||
|
// random outcome, which is what the two fixtures below would expose: they differ only
|
||||||
|
// in how far their generators have been advanced.
|
||||||
|
PlacementFixture quiet;
|
||||||
|
PlacementFixture advanced;
|
||||||
|
for (int i = 0; i < 50; ++i) { (void)advanced.rng(); }
|
||||||
|
|
||||||
|
Tick tickA = 0;
|
||||||
|
Tick tickB = 0;
|
||||||
|
const BuildingId a =
|
||||||
|
quiet.bs.place(quiet.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
|
||||||
|
const BuildingId b =
|
||||||
|
advanced.bs.place(advanced.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
|
||||||
|
quiet.bs.setRecipe(quiet.state, a, "mine_iron_ore");
|
||||||
|
advanced.bs.setRecipe(advanced.state, b, "mine_iron_ore");
|
||||||
|
|
||||||
|
const int ticks = static_cast<int>(secondsToTicks(10.0)) + 40;
|
||||||
|
runTicks(quiet.bs, quiet.cfg, quiet.state, quiet.belts, quiet.stock, ticks, tickA);
|
||||||
|
runTicks(advanced.bs, advanced.cfg, advanced.state, advanced.belts, advanced.stock,
|
||||||
|
ticks, tickB);
|
||||||
|
|
||||||
|
const Building* minerA = findBuilding(quiet.state, a);
|
||||||
|
const Building* minerB = findBuilding(advanced.state, b);
|
||||||
|
REQUIRE(minerA != nullptr);
|
||||||
|
REQUIRE(minerB != nullptr);
|
||||||
|
REQUIRE(minerA->getOutputItemCount() > 0);
|
||||||
|
REQUIRE(minerA->getOutputItemCount() == minerB->getOutputItemCount());
|
||||||
|
REQUIRE(minerA->production.has_value() == minerB->production.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: a group's items are sized and gated together", "[building]")
|
||||||
|
{
|
||||||
|
// A group yields all of its items at once (REQ-MAT-OUTPUT-GROUP), so each is buffered
|
||||||
|
// at twice its own amount and the cycle needs room for all of them at once. No config
|
||||||
|
// recipe has a multi-item group yet, so one is built here.
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
RecipeDef recipe;
|
||||||
|
recipe.id = "multi_item_group";
|
||||||
|
recipe.building = BuildingType::Assembler;
|
||||||
|
recipe.durationSeconds = 1.0;
|
||||||
|
recipe.inputs.push_back(RecipeIngredient{"iron_ore", 1});
|
||||||
|
RecipeOutputGroup group;
|
||||||
|
group.items.push_back(RecipeOutput{"iron_ingot", 2});
|
||||||
|
group.items.push_back(RecipeOutput{"silicon", 1});
|
||||||
|
recipe.outputGroups.push_back(group);
|
||||||
|
|
||||||
|
Building assembler; assembler.type = BuildingType::Assembler;
|
||||||
|
initBuffers(assembler, recipe);
|
||||||
|
REQUIRE(assembler.outputBuffer.caps.at(ItemType{"iron_ingot"}) == 4);
|
||||||
|
REQUIRE(assembler.outputBuffer.caps.at(ItemType{"silicon"}) == 2);
|
||||||
|
|
||||||
|
// Both fit while both have room.
|
||||||
|
REQUIRE(recipeOutputsFit(assembler, recipe));
|
||||||
|
|
||||||
|
// One item of the group short of room blocks the whole cycle, even though the other
|
||||||
|
// still has plenty: the group cannot be produced in halves.
|
||||||
|
assembler.outputBuffer.items.push_back(makeItem("silicon"));
|
||||||
|
assembler.outputBuffer.items.push_back(makeItem("silicon"));
|
||||||
|
REQUIRE(outputBufferHasRoom(assembler, ItemType{"iron_ingot"}, 2));
|
||||||
|
REQUIRE_FALSE(outputBufferHasRoom(assembler, ItemType{"silicon"}, 1));
|
||||||
|
REQUIRE_FALSE(recipeOutputsFit(assembler, recipe));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: a group with a locked item is never picked", "[building]")
|
||||||
|
{
|
||||||
|
// A group's items come together, so a group holding any locked item is dropped whole
|
||||||
|
// (REQ-LOCK-OUTPUT-POOL). Here only circuit_board is unlocked, so every cycle must
|
||||||
|
// yield that group however the weights are stacked -- iron_ingot's group carries the
|
||||||
|
// largest weight of the three and would dominate were the filter not applied.
|
||||||
|
PlacementFixture f(std::nullopt,
|
||||||
|
[](const std::string& id) { return id == "circuit_board"; });
|
||||||
|
|
||||||
|
Tick tick = 0;
|
||||||
|
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
|
||||||
|
QPoint(0, 0), Rotation::East, 0).value();
|
||||||
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
|
||||||
|
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
|
||||||
|
f.bs.setRecipe(f.state, id, "reprocessing_cycle");
|
||||||
|
|
||||||
|
// Run many cycles, refilling the scrap and draining the output each time so the plant
|
||||||
|
// never stalls. A broken filter would show iron_ingot within a few rounds.
|
||||||
|
int produced = 0;
|
||||||
|
for (int round = 0; round < 20; ++round)
|
||||||
|
{
|
||||||
|
f.bs.forEachBuilding(f.state, [](Building& building) {
|
||||||
|
if (building.type != BuildingType::ReprocessingPlant) { return; }
|
||||||
|
building.inputBuffer.counts[ItemType{"scrap"}] =
|
||||||
|
building.inputBuffer.caps.at(ItemType{"scrap"});
|
||||||
|
building.outputBuffer.items.clear();
|
||||||
|
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
|
||||||
|
});
|
||||||
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
|
||||||
|
static_cast<int>(secondsToTicks(3.0)) + 1, tick);
|
||||||
|
|
||||||
|
for (const Item& item : outputSideItems(*findBuilding(f.state, id)))
|
||||||
|
{
|
||||||
|
CHECK(item.type.id == "circuit_board");
|
||||||
|
++produced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
REQUIRE(produced > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possible roll",
|
||||||
"[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 +1334,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 +1513,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 +1568,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 +1737,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 +1956,194 @@ 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->outputGroups.front().items.front().item;
|
||||||
|
int cycleOutput = 0;
|
||||||
|
for (const RecipeOutput& out : assemblerRecipe->outputGroups.front().items)
|
||||||
|
{
|
||||||
|
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.outputGroups.front().items)
|
||||||
|
{
|
||||||
|
total += out.amount;
|
||||||
|
}
|
||||||
|
if (total >= 2) { multiOutputRecipe = &r; break; }
|
||||||
|
}
|
||||||
|
REQUIRE(multiOutputRecipe != nullptr);
|
||||||
|
|
||||||
|
int cycleOutput = 0;
|
||||||
|
for (const RecipeOutput& out : multiOutputRecipe->outputGroups.front().items)
|
||||||
|
{
|
||||||
|
cycleOutput += out.amount;
|
||||||
|
}
|
||||||
|
const std::string outputItemId =
|
||||||
|
multiOutputRecipe->outputGroups.front().items.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->outputGroups.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->outputGroups.front().items.front().item;
|
||||||
|
const std::string lastItemId =
|
||||||
|
reprocessingRecipe->outputGroups.back().items.front().item;
|
||||||
|
for (int i = 0; i < plant.outputBuffer.caps.at(ItemType{firstItemId}); ++i)
|
||||||
|
{
|
||||||
|
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 +2161,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") };
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ add_files(
|
|||||||
TunnelCompletionTest.cpp
|
TunnelCompletionTest.cpp
|
||||||
WorldCoordinatesTest.cpp
|
WorldCoordinatesTest.cpp
|
||||||
WorldCameraTest.cpp
|
WorldCameraTest.cpp
|
||||||
|
FloatingPanelPlacementTest.cpp
|
||||||
SelectionControllerTest.cpp
|
SelectionControllerTest.cpp
|
||||||
BuildModeControllerTest.cpp
|
BuildModeControllerTest.cpp
|
||||||
|
ControlActionTest.cpp
|
||||||
BuildingTest.cpp
|
BuildingTest.cpp
|
||||||
BuildingConfigTest.cpp
|
BuildingConfigTest.cpp
|
||||||
ShipTest.cpp
|
ShipTest.cpp
|
||||||
|
|||||||
@@ -84,11 +84,6 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
|
|||||||
REQUIRE(cfg.world.rallyOrbitRadius_tiles == Approx(5.0));
|
REQUIRE(cfg.world.rallyOrbitRadius_tiles == Approx(5.0));
|
||||||
REQUIRE(cfg.world.scrapPerThreat == Approx(1.0));
|
REQUIRE(cfg.world.scrapPerThreat == Approx(1.0));
|
||||||
|
|
||||||
// Optional header building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP).
|
|
||||||
REQUIRE(cfg.world.buildingBlocksTooltip.has_value());
|
|
||||||
REQUIRE(*cfg.world.buildingBlocksTooltip ==
|
|
||||||
"Spend building blocks to build; deliver them to the HQ to gain more.");
|
|
||||||
|
|
||||||
// Optional header artifact tooltip (REQ-UI-ARTIFACTS-TOOLTIP).
|
// Optional header artifact tooltip (REQ-UI-ARTIFACTS-TOOLTIP).
|
||||||
REQUIRE(cfg.world.artifactTooltip.has_value());
|
REQUIRE(cfg.world.artifactTooltip.has_value());
|
||||||
REQUIRE(*cfg.world.artifactTooltip ==
|
REQUIRE(*cfg.world.artifactTooltip ==
|
||||||
@@ -130,22 +125,26 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
|
|||||||
REQUIRE(*salvageBayIt->tooltip == "Drop-off point for salvage ships.");
|
REQUIRE(*salvageBayIt->tooltip == "Drop-off point for salvage ships.");
|
||||||
REQUIRE_FALSE(minerIt->tooltip.has_value());
|
REQUIRE_FALSE(minerIt->tooltip.has_value());
|
||||||
|
|
||||||
// recipes.toml — reprocessing cycle has three weighted outputs.
|
// recipes.toml -- the reprocessing cycle is written as three weighted output groups,
|
||||||
|
// each yielding one item (REQ-MAT-OUTPUT-GROUP).
|
||||||
const auto reproIt = std::find_if(
|
const auto reproIt = std::find_if(
|
||||||
cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
||||||
[](const RecipeDef& r) { return r.id == "reprocessing_cycle"; });
|
[](const RecipeDef& r) { return r.id == "reprocessing_cycle"; });
|
||||||
REQUIRE(reproIt != cfg.recipes.recipes.end());
|
REQUIRE(reproIt != cfg.recipes.recipes.end());
|
||||||
REQUIRE(reproIt->building == BuildingType::ReprocessingPlant);
|
REQUIRE(reproIt->building == BuildingType::ReprocessingPlant);
|
||||||
REQUIRE(reproIt->outputs.size() == 3);
|
REQUIRE(reproIt->outputGroups.size() == 3);
|
||||||
REQUIRE(reproIt->outputs[0].probability.has_value());
|
REQUIRE(reproIt->outputGroups[0].probability.has_value());
|
||||||
|
REQUIRE(reproIt->outputGroups[0].items.size() == 1);
|
||||||
|
|
||||||
// Non-reprocessing recipes don't carry probability.
|
// The `outputs = [...]` shorthand loads as one group carrying no weight: with a single
|
||||||
|
// group nothing is picked, so there is nothing to weigh.
|
||||||
const auto ironIngotIt = std::find_if(
|
const auto ironIngotIt = std::find_if(
|
||||||
cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
||||||
[](const RecipeDef& r) { return r.id == "iron_ingot"; });
|
[](const RecipeDef& r) { return r.id == "iron_ingot"; });
|
||||||
REQUIRE(ironIngotIt != cfg.recipes.recipes.end());
|
REQUIRE(ironIngotIt != cfg.recipes.recipes.end());
|
||||||
REQUIRE(ironIngotIt->outputs.size() == 1);
|
REQUIRE(ironIngotIt->outputGroups.size() == 1);
|
||||||
REQUIRE_FALSE(ironIngotIt->outputs[0].probability.has_value());
|
REQUIRE(ironIngotIt->outputGroups[0].items.size() == 1);
|
||||||
|
REQUIRE_FALSE(ironIngotIt->outputGroups[0].probability.has_value());
|
||||||
|
|
||||||
// ships.toml — combat ships have default_modules with a weapon; salvage ships don't.
|
// ships.toml — combat ships have default_modules with a weapon; salvage ships don't.
|
||||||
const auto interceptorIt = std::find_if(
|
const auto interceptorIt = std::find_if(
|
||||||
@@ -390,39 +389,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
|
||||||
|
|||||||
343
src/test/ControlActionTest.cpp
Normal file
343
src/test/ControlActionTest.cpp
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "BuildModeController.h"
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "ControlAction.h"
|
||||||
|
|
||||||
|
// REQ-UI-CONTROLS-ACCURACY. The panel and the input handling read one table, and these
|
||||||
|
// tests are what makes that pay: the round-trip case below fails the moment a row is
|
||||||
|
// shown whose binding resolves elsewhere, which is the drift the whole design exists to
|
||||||
|
// prevent. Everything here works on action ids -- the display strings live in the ui
|
||||||
|
// target and are not what can silently go wrong.
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
ControlContext generalContext()
|
||||||
|
{
|
||||||
|
return ControlContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlContext selectionContext(bool placeable = true)
|
||||||
|
{
|
||||||
|
ControlContext context;
|
||||||
|
context.selection = ControlSelection::Buildings;
|
||||||
|
context.selectionCount = 3;
|
||||||
|
context.placeableBuildingSelected = placeable;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlContext builderContext(BuildingType type)
|
||||||
|
{
|
||||||
|
ControlContext context;
|
||||||
|
context.mode = BuildMode::Builder;
|
||||||
|
context.builderType = type;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlContext blueprintContext(bool transfer = false)
|
||||||
|
{
|
||||||
|
ControlContext context;
|
||||||
|
context.mode = BuildMode::Blueprint;
|
||||||
|
context.hoveredGhostIsTransfer = transfer;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlContext deconstructContext()
|
||||||
|
{
|
||||||
|
ControlContext context;
|
||||||
|
context.mode = BuildMode::Deconstruct;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A spread wide enough that every availability rule and every binding condition is
|
||||||
|
// exercised by the whole-table properties below.
|
||||||
|
std::vector<ControlContext> allContexts()
|
||||||
|
{
|
||||||
|
ControlContext beltDragging = builderContext(BuildingType::Belt);
|
||||||
|
beltDragging.draggingBelt = true;
|
||||||
|
|
||||||
|
ControlContext generalWithBlueprint = generalContext();
|
||||||
|
generalWithBlueprint.temporaryBlueprintExists = true;
|
||||||
|
|
||||||
|
ControlContext fieldSelection = selectionContext(false);
|
||||||
|
fieldSelection.selection = ControlSelection::FieldObjects;
|
||||||
|
|
||||||
|
return {generalContext(),
|
||||||
|
generalWithBlueprint,
|
||||||
|
selectionContext(),
|
||||||
|
fieldSelection,
|
||||||
|
builderContext(BuildingType::Belt),
|
||||||
|
builderContext(BuildingType::Assembler),
|
||||||
|
beltDragging,
|
||||||
|
blueprintContext(false),
|
||||||
|
blueprintContext(true),
|
||||||
|
deconstructContext()};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlAction> shownActions(const ControlContext& context)
|
||||||
|
{
|
||||||
|
std::vector<ControlAction> actions = getContextActions(context);
|
||||||
|
const std::vector<ControlAction> always = getAlwaysAvailableActions(context);
|
||||||
|
actions.insert(actions.end(), always.begin(), always.end());
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool contains(const std::vector<ControlAction>& actions, ControlAction action)
|
||||||
|
{
|
||||||
|
return std::find(actions.begin(), actions.end(), action) != actions.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: every shown row is available", "[controls]")
|
||||||
|
{
|
||||||
|
for (const ControlContext& context : allContexts())
|
||||||
|
{
|
||||||
|
for (ControlAction action : shownActions(context))
|
||||||
|
{
|
||||||
|
REQUIRE(isControlActionAvailable(action, context));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: no row is shown twice", "[controls]")
|
||||||
|
{
|
||||||
|
for (const ControlContext& context : allContexts())
|
||||||
|
{
|
||||||
|
std::vector<ControlAction> actions = shownActions(context);
|
||||||
|
std::vector<ControlAction> unique = actions;
|
||||||
|
std::sort(unique.begin(), unique.end());
|
||||||
|
unique.erase(std::unique(unique.begin(), unique.end()), unique.end());
|
||||||
|
REQUIRE(unique.size() == actions.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The core anti-drift property: a row's badges are rendered from its bindings, so every
|
||||||
|
// binding a row advertises must actually trigger that row's action in that same context.
|
||||||
|
TEST_CASE("ControlAction: every advertised binding resolves back to its own action",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
for (const ControlContext& context : allContexts())
|
||||||
|
{
|
||||||
|
for (ControlAction action : shownActions(context))
|
||||||
|
{
|
||||||
|
const std::vector<ControlBinding> bindings =
|
||||||
|
getControlActionBindings(action, context);
|
||||||
|
REQUIRE_FALSE(bindings.empty());
|
||||||
|
|
||||||
|
for (const ControlBinding& binding : bindings)
|
||||||
|
{
|
||||||
|
if (binding.isMouse)
|
||||||
|
{
|
||||||
|
REQUIRE(resolveMouseAction(binding.mouse, context) == action);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
REQUIRE(resolveKeyAction(binding.key, binding.modifiers, context)
|
||||||
|
== action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other direction: an input that resolves to something must resolve to an action
|
||||||
|
// that is actually available there, so no input can trigger a no-op. This is weaker
|
||||||
|
// than "must be a shown row" on purpose -- a context may omit an available binding
|
||||||
|
// (Ctrl+click in the General context), which REQ-UI-CONTROLS-ACCURACY permits; what it
|
||||||
|
// may never do is act on an unavailable one.
|
||||||
|
TEST_CASE("ControlAction: every resolvable input is available", "[controls]")
|
||||||
|
{
|
||||||
|
const std::vector<MouseBinding> mouseBindings = {
|
||||||
|
MouseBinding::LeftClick, MouseBinding::LeftDrag, MouseBinding::CtrlLeftClick,
|
||||||
|
MouseBinding::CtrlLeftDrag, MouseBinding::RightClick};
|
||||||
|
|
||||||
|
for (const ControlContext& context : allContexts())
|
||||||
|
{
|
||||||
|
for (MouseBinding binding : mouseBindings)
|
||||||
|
{
|
||||||
|
const ControlAction action = resolveMouseAction(binding, context);
|
||||||
|
if (action != ControlAction::None)
|
||||||
|
{
|
||||||
|
REQUIRE(isControlActionAvailable(action, context));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<int> keys = {Qt::Key_A, Qt::Key_D, Qt::Key_W, Qt::Key_S,
|
||||||
|
Qt::Key_Space, Qt::Key_C, Qt::Key_V, Qt::Key_R,
|
||||||
|
Qt::Key_Q, Qt::Key_Escape};
|
||||||
|
for (int key : keys)
|
||||||
|
{
|
||||||
|
for (Qt::KeyboardModifiers modifiers :
|
||||||
|
{Qt::KeyboardModifiers(Qt::NoModifier),
|
||||||
|
Qt::KeyboardModifiers(Qt::ShiftModifier),
|
||||||
|
Qt::KeyboardModifiers(Qt::ControlModifier)})
|
||||||
|
{
|
||||||
|
const ControlAction action = resolveKeyAction(key, modifiers, context);
|
||||||
|
if (action != ControlAction::None)
|
||||||
|
{
|
||||||
|
REQUIRE(isControlActionAvailable(action, context));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: contexts are named by mode and selection", "[controls]")
|
||||||
|
{
|
||||||
|
REQUIRE(getControlContextKind(generalContext()) == ControlContextKind::General);
|
||||||
|
REQUIRE(getControlContextKind(selectionContext()) == ControlContextKind::Selection);
|
||||||
|
REQUIRE(getControlContextKind(builderContext(BuildingType::Belt))
|
||||||
|
== ControlContextKind::Build);
|
||||||
|
REQUIRE(getControlContextKind(blueprintContext()) == ControlContextKind::Blueprint);
|
||||||
|
REQUIRE(getControlContextKind(deconstructContext())
|
||||||
|
== ControlContextKind::Deconstruct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three cases of Q, in the order REQ-UI-HOTKEYS evaluates them.
|
||||||
|
TEST_CASE("ControlAction: Q leaves the active mode, else clears, else deconstructs",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, builderContext(BuildingType::Belt))
|
||||||
|
== ControlAction::ExitMode);
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, blueprintContext())
|
||||||
|
== ControlAction::ExitMode);
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, deconstructContext())
|
||||||
|
== ControlAction::ExitMode);
|
||||||
|
|
||||||
|
// Whatever the selection holds: clearing it is not a buildings-only action.
|
||||||
|
ControlContext fieldSelection = selectionContext(false);
|
||||||
|
fieldSelection.selection = ControlSelection::FieldObjects;
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, selectionContext())
|
||||||
|
== ControlAction::ClearSelection);
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, fieldSelection)
|
||||||
|
== ControlAction::ClearSelection);
|
||||||
|
|
||||||
|
// Only with nothing to clear and no mode to leave does Q enter deconstruct mode --
|
||||||
|
// which is what makes it two presses from a selection.
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, generalContext())
|
||||||
|
== ControlAction::EnterDeconstruct);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: Ctrl distinguishes the chords, other modifiers do not",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
const ControlContext context = selectionContext();
|
||||||
|
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::NoModifier, context)
|
||||||
|
== ControlAction::CopyTemporary);
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::ControlModifier, context)
|
||||||
|
== ControlAction::CreateBlueprint);
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::ControlModifier, context)
|
||||||
|
== ControlAction::OpenBlueprints);
|
||||||
|
|
||||||
|
// Shift+A must still pan, as it always has -- matching modifiers exactly instead of
|
||||||
|
// testing Ctrl alone would silently swallow the press.
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_A, Qt::ShiftModifier, context) == ControlAction::Move);
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_R, Qt::ShiftModifier, blueprintContext())
|
||||||
|
== ControlAction::Rotate);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: a transfer target turns the click into Apply settings",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
const ControlContext plain = blueprintContext(false);
|
||||||
|
const ControlContext transfer = blueprintContext(true);
|
||||||
|
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::LeftClick, plain) == ControlAction::Place);
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::LeftClick, transfer)
|
||||||
|
== ControlAction::ApplySettings);
|
||||||
|
|
||||||
|
REQUIRE(contains(getContextActions(plain), ControlAction::Place));
|
||||||
|
REQUIRE_FALSE(contains(getContextActions(plain), ControlAction::ApplySettings));
|
||||||
|
REQUIRE(contains(getContextActions(transfer), ControlAction::ApplySettings));
|
||||||
|
REQUIRE_FALSE(contains(getContextActions(transfer), ControlAction::Place));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: a belt drag takes the right mouse button from ExitMode",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
ControlContext dragging = builderContext(BuildingType::Belt);
|
||||||
|
dragging.draggingBelt = true;
|
||||||
|
const ControlContext idle = builderContext(BuildingType::Belt);
|
||||||
|
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::RightClick, idle) == ControlAction::ExitMode);
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::RightClick, dragging)
|
||||||
|
== ControlAction::CancelBeltLine);
|
||||||
|
|
||||||
|
// Q still leaves the mode outright while the drag runs, so ExitMode stays shown --
|
||||||
|
// with its right-click badge dropped, since the button now means something else.
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, dragging) == ControlAction::ExitMode);
|
||||||
|
|
||||||
|
const std::vector<ControlBinding> idleBindings =
|
||||||
|
getControlActionBindings(ControlAction::ExitMode, idle);
|
||||||
|
const std::vector<ControlBinding> dragBindings =
|
||||||
|
getControlActionBindings(ControlAction::ExitMode, dragging);
|
||||||
|
REQUIRE(idleBindings.size() == 2);
|
||||||
|
REQUIRE(dragBindings.size() == 1);
|
||||||
|
REQUIRE_FALSE(dragBindings.front().isMouse);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: dragging a line is offered for belts only", "[controls]")
|
||||||
|
{
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag, builderContext(BuildingType::Belt))
|
||||||
|
== ControlAction::PlaceBeltLine);
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag,
|
||||||
|
builderContext(BuildingType::Assembler))
|
||||||
|
== ControlAction::None);
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag, blueprintContext())
|
||||||
|
== ControlAction::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: blueprint keys are offered only where they do something",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
// V does nothing until C has captured something (REQ-UI-BLUEPRINT-TEMP).
|
||||||
|
ControlContext withBlueprint = generalContext();
|
||||||
|
withBlueprint.temporaryBlueprintExists = true;
|
||||||
|
REQUIRE_FALSE(contains(shownActions(generalContext()), ControlAction::PasteTemporary));
|
||||||
|
REQUIRE(contains(shownActions(withBlueprint), ControlAction::PasteTemporary));
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::NoModifier, generalContext())
|
||||||
|
== ControlAction::None);
|
||||||
|
|
||||||
|
// C and Ctrl+C need a selection holding something placeable (REQ-UI-HOTKEYS).
|
||||||
|
ControlContext fieldSelection = selectionContext(false);
|
||||||
|
fieldSelection.selection = ControlSelection::FieldObjects;
|
||||||
|
REQUIRE(contains(getContextActions(selectionContext()), ControlAction::CopyTemporary));
|
||||||
|
REQUIRE_FALSE(contains(getContextActions(fieldSelection), ControlAction::CopyTemporary));
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::NoModifier, fieldSelection)
|
||||||
|
== ControlAction::None);
|
||||||
|
|
||||||
|
// Ctrl+V opens the dialog whatever is going on (REQ-UI-HOTKEYS).
|
||||||
|
for (const ControlContext& context : allContexts())
|
||||||
|
{
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::ControlModifier, context)
|
||||||
|
== ControlAction::OpenBlueprints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: selection rows appear only once something is selected",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
const std::vector<ControlAction> general = getContextActions(generalContext());
|
||||||
|
const std::vector<ControlAction> selection = getContextActions(selectionContext());
|
||||||
|
|
||||||
|
REQUIRE(contains(general, ControlAction::Select));
|
||||||
|
REQUIRE(contains(general, ControlAction::EnterDeconstruct));
|
||||||
|
REQUIRE_FALSE(contains(general, ControlAction::AddToSelection));
|
||||||
|
REQUIRE_FALSE(contains(general, ControlAction::CreateBlueprint));
|
||||||
|
REQUIRE_FALSE(contains(general, ControlAction::ClearSelection));
|
||||||
|
|
||||||
|
REQUIRE(contains(selection, ControlAction::AddToSelection));
|
||||||
|
REQUIRE(contains(selection, ControlAction::AddAreaToSelection));
|
||||||
|
REQUIRE(contains(selection, ControlAction::CreateBlueprint));
|
||||||
|
// Q clears here instead of entering deconstruct mode, and its row sits last, as the
|
||||||
|
// row that hands the context back does in every context (REQ-UI-CONTROLS-CONTENT).
|
||||||
|
REQUIRE(contains(selection, ControlAction::ClearSelection));
|
||||||
|
REQUIRE_FALSE(contains(selection, ControlAction::EnterDeconstruct));
|
||||||
|
REQUIRE(selection.back() == ControlAction::ClearSelection);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
258
src/test/FloatingPanelPlacementTest.cpp
Normal file
258
src/test/FloatingPanelPlacementTest.cpp
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QRect>
|
||||||
|
|
||||||
|
#include "FloatingPanelPlacement.h"
|
||||||
|
|
||||||
|
// The band every case below places into: 1000x600, so a bottom edge of 599.
|
||||||
|
static QRect makeBand()
|
||||||
|
{
|
||||||
|
return QRect(0, 0, 1000, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two distances the rules run on, deliberately different (REQ-UI-SELECTION-PANEL):
|
||||||
|
// the edge margin the panel keeps from the view and from the widgets it steps around, and
|
||||||
|
// the wider gap it keeps from the selection -- half a tile, so 20 px at a 40 px tile.
|
||||||
|
static const int kMarginPx = 8;
|
||||||
|
static const int kSelectionGapPx = 20;
|
||||||
|
|
||||||
|
TEST_CASE("With nothing in the way a widget may use the whole band", "[layout]")
|
||||||
|
{
|
||||||
|
// REQ-UI-SELECTION-PANEL: the band's own bottom is the limit when no other floating
|
||||||
|
// widget has been placed yet.
|
||||||
|
REQUIRE(getAvailableBottomPx(makeBand(), {}, 0, 999, kMarginPx) == 599);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A widget in the same column pushes the bottom above it", "[layout]")
|
||||||
|
{
|
||||||
|
// The build button bar sitting at the bottom center leaves the space above it, less
|
||||||
|
// the margin kept between the two (REQ-UI-BUILD-BAR).
|
||||||
|
const std::vector<QRect> occupied = { QRect(400, 520, 200, 72) };
|
||||||
|
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 350, 650, kMarginPx) == 511);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A widget beside the column does not shorten it", "[layout]")
|
||||||
|
{
|
||||||
|
// The controls panel in the bottom-left corner is not in the way of a panel standing
|
||||||
|
// at the right edge, however tall it is (REQ-UI-CONTROLS-PANEL).
|
||||||
|
const std::vector<QRect> occupied = { QRect(0, 100, 200, 499) };
|
||||||
|
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 700, 999, kMarginPx) == 599);
|
||||||
|
|
||||||
|
// Touching columns do count as meeting: the panel starts exactly where the widget
|
||||||
|
// ends, which is an overlap of one pixel.
|
||||||
|
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 199, 999, kMarginPx) == 91);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The topmost widget in the column decides", "[layout]")
|
||||||
|
{
|
||||||
|
// Several widgets meet the column: the one that reaches highest is the binding one,
|
||||||
|
// whatever order they are given in.
|
||||||
|
const std::vector<QRect> occupied = { QRect(400, 520, 200, 72),
|
||||||
|
QRect(0, 300, 500, 299),
|
||||||
|
QRect(900, 560, 100, 40) };
|
||||||
|
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 450, 550, kMarginPx) == 291);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("An empty rectangle occupies nothing", "[layout]")
|
||||||
|
{
|
||||||
|
// A floating widget that is hidden contributes a null rect rather than being left
|
||||||
|
// out of the pass.
|
||||||
|
const std::vector<QRect> occupied = { QRect(), QRect(400, 520, 200, 0) };
|
||||||
|
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 0, 999, kMarginPx) == 599);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A widget filling the column leaves nothing", "[layout]")
|
||||||
|
{
|
||||||
|
// The caller is expected to notice that the space left is not positive rather than
|
||||||
|
// being handed a floor of its own.
|
||||||
|
const std::vector<QRect> occupied = { QRect(0, 0, 1000, 600) };
|
||||||
|
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 0, 999, kMarginPx) == -9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Which side of the selection the panel takes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("The panel stands to the right of the selection where it fits", "[layout]")
|
||||||
|
{
|
||||||
|
// REQ-UI-SELECTION-PANEL: right of the anchor is the first choice.
|
||||||
|
REQUIRE(chooseSide(makeBand(), QRect(100, 100, 60, 60), 300, kSelectionGapPx)
|
||||||
|
== PanelSide::Right);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The panel goes left when the right cannot hold it", "[layout]")
|
||||||
|
{
|
||||||
|
// A selection near the right edge leaves 80 px there once the gap it keeps from the
|
||||||
|
// selection is taken off, not enough for a 300 px panel, and the left is wide open.
|
||||||
|
REQUIRE(chooseSide(makeBand(), QRect(880, 100, 20, 60), 300, kSelectionGapPx)
|
||||||
|
== PanelSide::Left);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The room a side offers is measured less the gap", "[layout]")
|
||||||
|
{
|
||||||
|
// A 200 px panel beside a selection whose right edge leaves 214 px to the band's: it
|
||||||
|
// fits there on the margin alone, but not once the wider gap is taken off.
|
||||||
|
const QRect anchorRect(766, 100, 20, 60);
|
||||||
|
REQUIRE(chooseSide(makeBand(), anchorRect, 200, kMarginPx) == PanelSide::Right);
|
||||||
|
REQUIRE(chooseSide(makeBand(), anchorRect, 200, kSelectionGapPx) == PanelSide::Left);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Fitting on neither side, the panel takes the roomier one", "[layout]")
|
||||||
|
{
|
||||||
|
// A bounding box spanning most of the view: 180 px free on the left, 80 on the
|
||||||
|
// right, and a 300 px panel fits in neither. It covers as little as it can.
|
||||||
|
REQUIRE(chooseSide(makeBand(), QRect(200, 100, 700, 200), 300, kSelectionGapPx)
|
||||||
|
== PanelSide::Left);
|
||||||
|
REQUIRE(chooseSide(makeBand(), QRect(100, 100, 700, 200), 300, kSelectionGapPx)
|
||||||
|
== PanelSide::Right);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Where it then stands
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("The panel sits beside the anchor with its top edges aligned", "[layout]")
|
||||||
|
{
|
||||||
|
// REQ-UI-SELECTION-PANEL: separated by the gap it keeps from the selection, growing
|
||||||
|
// away from it, top edge on the anchor's top edge.
|
||||||
|
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 120, 60, 60),
|
||||||
|
PanelSide::Right, QSize(300, 200), {},
|
||||||
|
kSelectionGapPx, kMarginPx);
|
||||||
|
REQUIRE(placed == QRect(180, 120, 300, 200));
|
||||||
|
|
||||||
|
const QRect placedLeft = placeBesideAnchor(makeBand(), QRect(520, 120, 60, 60),
|
||||||
|
PanelSide::Left, QSize(300, 200), {},
|
||||||
|
kSelectionGapPx, kMarginPx);
|
||||||
|
REQUIRE(placedLeft == QRect(200, 120, 300, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The gap separates the panel horizontally only", "[layout]")
|
||||||
|
{
|
||||||
|
// REQ-UI-SELECTION-PANEL: widening the gap moves the panel further from the selection
|
||||||
|
// sideways and nowhere else -- its top stays level with the top of what it describes.
|
||||||
|
const QRect anchorRect(100, 120, 60, 60);
|
||||||
|
REQUIRE(placeBesideAnchor(makeBand(), anchorRect, PanelSide::Right, QSize(300, 200),
|
||||||
|
{}, 0, kMarginPx)
|
||||||
|
== QRect(160, 120, 300, 200));
|
||||||
|
REQUIRE(placeBesideAnchor(makeBand(), anchorRect, PanelSide::Right, QSize(300, 200),
|
||||||
|
{}, kSelectionGapPx, kMarginPx)
|
||||||
|
== QRect(180, 120, 300, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A panel that would hang below the view is lifted", "[layout]")
|
||||||
|
{
|
||||||
|
// Top-aligning with a selection low in the view would put the panel's bottom past
|
||||||
|
// the band, so it rises until it fits rather than overrunning it.
|
||||||
|
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 500, 60, 60),
|
||||||
|
PanelSide::Right, QSize(300, 200), {},
|
||||||
|
kSelectionGapPx, kMarginPx);
|
||||||
|
REQUIRE(placed == QRect(180, 400, 300, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A panel standing over another widget rises above it", "[layout]")
|
||||||
|
{
|
||||||
|
// The controls panel in the bottom-left is in the way of a panel placed to the left
|
||||||
|
// of a selection: it clears the top of it by the edge margin, the gap from the
|
||||||
|
// selection having settled its left edge (REQ-UI-CONTROLS-PANEL).
|
||||||
|
const std::vector<QRect> occupied = { QRect(0, 300, 260, 300) };
|
||||||
|
const QRect placed = placeBesideAnchor(makeBand(), QRect(500, 250, 60, 60),
|
||||||
|
PanelSide::Left, QSize(300, 200), occupied,
|
||||||
|
kSelectionGapPx, kMarginPx);
|
||||||
|
REQUIRE(placed == QRect(180, 92, 300, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A panel taller than the space left is capped", "[layout]")
|
||||||
|
{
|
||||||
|
// Capping is the caller's cue to scroll: it asked for 700 and got what there was.
|
||||||
|
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 100, 60, 60),
|
||||||
|
PanelSide::Right, QSize(300, 700), {},
|
||||||
|
kSelectionGapPx, kMarginPx);
|
||||||
|
REQUIRE(placed == QRect(180, 0, 300, 600));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A panel that fits on neither side is pushed inside the view", "[layout]")
|
||||||
|
{
|
||||||
|
// The one case where it covers part of the selection (REQ-UI-SELECTION-PANEL): it
|
||||||
|
// stands as far from the anchor as the band allows, not off the edge of it.
|
||||||
|
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 100, 700, 200),
|
||||||
|
PanelSide::Right, QSize(300, 200), {},
|
||||||
|
kSelectionGapPx, kMarginPx);
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -690,3 +690,31 @@ TEST_CASE("calculateShipStats: maneuvering_thrusters additive maneuvering_accele
|
|||||||
const float expected = (base_mpss + 10.0f) / tileSize;
|
const float expected = (base_mpss + 10.0f) / tileSize;
|
||||||
CHECK(stats.maneuveringAcceleration_tpss == Approx(expected));
|
CHECK(stats.maneuveringAcceleration_tpss == Approx(expected));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The precondition every entry to the layout configuration dialog checks
|
||||||
|
// (REQ-MOD-UI-DIALOG, REQ-MOD-UI-AUTO-DIALOG, REQ-MOD-UI-PREVIEW). The empty id is the
|
||||||
|
// one that used to slip through: "(None)" clears a shipyard and differs from whatever
|
||||||
|
// was set, which is not the same as naming a schematic to configure.
|
||||||
|
TEST_CASE("ShipsConfig: a layout is configurable only for a ship that has one",
|
||||||
|
"[modules][config]")
|
||||||
|
{
|
||||||
|
const GameConfig cfg = loadTestConfig();
|
||||||
|
|
||||||
|
const ShipDef* interceptor = cfg.ships.findLayoutShipDef("interceptor");
|
||||||
|
REQUIRE(interceptor != nullptr);
|
||||||
|
CHECK(interceptor->id == "interceptor");
|
||||||
|
CHECK_FALSE(interceptor->layout.empty());
|
||||||
|
|
||||||
|
// The "(None)" option's id, and an id naming no ship at all.
|
||||||
|
CHECK(cfg.ships.findLayoutShipDef("") == nullptr);
|
||||||
|
CHECK(cfg.ships.findLayoutShipDef("no_such_ship") == nullptr);
|
||||||
|
|
||||||
|
// A ship that exists but defines no grid has nothing to place modules on either,
|
||||||
|
// where plain findShipDef still finds it.
|
||||||
|
ShipsConfig gridless;
|
||||||
|
ShipDef def;
|
||||||
|
def.id = "hull_only";
|
||||||
|
gridless.ships.push_back(def);
|
||||||
|
CHECK(gridless.findShipDef("hull_only") != nullptr);
|
||||||
|
CHECK(gridless.findLayoutShipDef("hull_only") == nullptr);
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ bool BlueprintLibrary::getCanCaptureSelection() const
|
|||||||
return selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds);
|
return selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool BlueprintLibrary::getHasTemporaryBlueprint() const
|
||||||
|
{
|
||||||
|
return m_temporaryBlueprint.has_value();
|
||||||
|
}
|
||||||
|
|
||||||
void BlueprintLibrary::saveSelectionAs(const QString& name)
|
void BlueprintLibrary::saveSelectionAs(const QString& name)
|
||||||
{
|
{
|
||||||
Blueprint blueprint = createBlueprintFromSelection();
|
Blueprint blueprint = createBlueprintFromSelection();
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ public:
|
|||||||
// (REQ-UI-BLUEPRINT-CREATE).
|
// (REQ-UI-BLUEPRINT-CREATE).
|
||||||
bool getCanCaptureSelection() const;
|
bool getCanCaptureSelection() const;
|
||||||
|
|
||||||
|
// True once C has captured something -- the condition under which V does anything
|
||||||
|
// (REQ-UI-BLUEPRINT-TEMP), and so the condition under which the controls panel
|
||||||
|
// offers it (REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
bool getHasTemporaryBlueprint() const;
|
||||||
|
|
||||||
// Captures the current selection under the given name and appends it to the list.
|
// Captures the current selection under the given name and appends it to the list.
|
||||||
// Silently does nothing when nothing player-placeable is selected.
|
// Silently does nothing when nothing player-placeable is selected.
|
||||||
void saveSelectionAs(const QString& name);
|
void saveSelectionAs(const QString& name);
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
#include "BlueprintLibrary.h"
|
#include "BlueprintLibrary.h"
|
||||||
#include "IconCaption.h"
|
#include "IconCaption.h"
|
||||||
#include "ItemIconCache.h"
|
#include "ItemIconCache.h"
|
||||||
|
#include "ItemTooltip.h"
|
||||||
|
#include "TooltipTrigger.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -112,9 +114,10 @@ namespace
|
|||||||
return face;
|
return face;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A composed card face and the size to show it at; the button needs both, and only
|
// A composed card face, the size to show it at, and where in it the cost ended up:
|
||||||
// the composer knows the size it arrived at.
|
// the button needs the first two, and only the composer knows the third, which is
|
||||||
struct CardFace { QIcon icon; QSize size; };
|
// what the cost's tooltip is hung on (REQ-UI-ITEM-VALUE-TOOLTIP).
|
||||||
|
struct CardFace { QIcon icon; QSize size; QRect costRect; };
|
||||||
|
|
||||||
// The two-mode face of one card. The modes differ only in the text color, so an
|
// The two-mode face of one card. The modes differ only in the text color, so an
|
||||||
// unaffordable card greys itself when Qt swaps the pixmap on the disabled button
|
// unaffordable card greys itself when Qt swaps the pixmap on the disabled button
|
||||||
@@ -141,62 +144,42 @@ namespace
|
|||||||
textColor, dimColor, faceSize);
|
textColor, dimColor, faceSize);
|
||||||
result.icon.addPixmap(face, mode);
|
result.icon.addPixmap(face, mode);
|
||||||
result.size = result.size.expandedTo(getLogicalSize(face));
|
result.size = result.size.expandedTo(getLogicalSize(face));
|
||||||
|
// Where composeCardFace put it: bottom left, its own width. Both modes
|
||||||
|
// compose the same text, so both arrive at the same rect.
|
||||||
|
const QSize costSize = getLogicalSize(costPixmap);
|
||||||
|
result.costRect = QRect(0, faceSize.height() - costSize.height(),
|
||||||
|
costSize.width(), costSize.height());
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library,
|
BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library,
|
||||||
ItemIconCache* itemIcons,
|
const ItemTooltipContext& context,
|
||||||
QWidget* parent)
|
QWidget* parent)
|
||||||
: QDialog(parent)
|
: ModalDialog(parent)
|
||||||
, m_library(library)
|
, m_library(library)
|
||||||
, m_itemIcons(itemIcons)
|
, m_context(context)
|
||||||
|
, m_itemIcons(context.itemIcons)
|
||||||
{
|
{
|
||||||
setWindowTitle(tr("Blueprints"));
|
|
||||||
setModal(true);
|
|
||||||
// Frameless: the dialog draws its own header row, so an OS title bar would only
|
|
||||||
// repeat it (REQ-UI-BLUEPRINT-DIALOG). Square corners rather than rounded ones --
|
|
||||||
// rounding a top-level window needs a translucent background, which is unreliable
|
|
||||||
// on Windows. The border matches the build bar and the selection panel.
|
|
||||||
setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint);
|
|
||||||
setAttribute(Qt::WA_StyledBackground, true);
|
|
||||||
setStyleSheet(QStringLiteral(
|
|
||||||
"BlueprintSelectionDialog { background-color: palette(window);"
|
|
||||||
" border: 1px solid palette(mid); }"));
|
|
||||||
|
|
||||||
QVBoxLayout* mainLayout = new QVBoxLayout(this);
|
QVBoxLayout* mainLayout = new QVBoxLayout(this);
|
||||||
mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx);
|
mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx);
|
||||||
mainLayout->setSpacing(kSpacingPx);
|
mainLayout->setSpacing(kSpacingPx);
|
||||||
|
|
||||||
QHBoxLayout* headerLayout = new QHBoxLayout();
|
QHBoxLayout* headerLayout = addHeader(mainLayout, tr("Blueprints"), true);
|
||||||
headerLayout->setSpacing(kSpacingPx);
|
|
||||||
|
|
||||||
QLabel* titleLabel = new QLabel(tr("Blueprints"), this);
|
|
||||||
QFont headerFont = titleLabel->font();
|
|
||||||
headerFont.setBold(true);
|
|
||||||
titleLabel->setFont(headerFont);
|
|
||||||
headerLayout->addWidget(titleLabel);
|
|
||||||
|
|
||||||
// Dimmed and bold, the treatment the build button hotkey badges use, so the
|
// Dimmed and bold, the treatment the build button hotkey badges use, so the
|
||||||
// shortcut reads as a reminder rather than a second title (REQ-UI-BUILD-COST).
|
// shortcut reads as a reminder rather than a second title (REQ-UI-BUILD-COST).
|
||||||
|
// Inserted right after the title, left of the stretch the header ends with.
|
||||||
QLabel* hotkeyBadge = new QLabel(tr("Ctrl+V"), this);
|
QLabel* hotkeyBadge = new QLabel(tr("Ctrl+V"), this);
|
||||||
hotkeyBadge->setFont(headerFont);
|
QFont badgeFont = hotkeyBadge->font();
|
||||||
|
badgeFont.setBold(true);
|
||||||
|
hotkeyBadge->setFont(badgeFont);
|
||||||
QPalette badgePalette = hotkeyBadge->palette();
|
QPalette badgePalette = hotkeyBadge->palette();
|
||||||
badgePalette.setColor(hotkeyBadge->foregroundRole(),
|
badgePalette.setColor(hotkeyBadge->foregroundRole(),
|
||||||
palette().color(QPalette::Disabled, QPalette::WindowText));
|
palette().color(QPalette::Disabled, QPalette::WindowText));
|
||||||
hotkeyBadge->setPalette(badgePalette);
|
hotkeyBadge->setPalette(badgePalette);
|
||||||
headerLayout->addWidget(hotkeyBadge);
|
headerLayout->insertWidget(1, hotkeyBadge);
|
||||||
|
|
||||||
headerLayout->addStretch();
|
|
||||||
|
|
||||||
QPushButton* closeButton = new QPushButton(QString(kCrossGlyph), this);
|
|
||||||
closeButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
|
|
||||||
closeButton->setToolTip(tr("Close"));
|
|
||||||
connect(closeButton, &QPushButton::clicked, this, &QDialog::reject);
|
|
||||||
headerLayout->addWidget(closeButton);
|
|
||||||
|
|
||||||
mainLayout->addLayout(headerLayout);
|
|
||||||
|
|
||||||
QScrollArea* scrollArea = new QScrollArea(this);
|
QScrollArea* scrollArea = new QScrollArea(this);
|
||||||
scrollArea->setWidgetResizable(true);
|
scrollArea->setWidgetResizable(true);
|
||||||
@@ -222,13 +205,11 @@ BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library,
|
|||||||
setFixedSize(gridWidth + style()->pixelMetric(QStyle::PM_ScrollBarExtent)
|
setFixedSize(gridWidth + style()->pixelMetric(QStyle::PM_ScrollBarExtent)
|
||||||
+ 2 * kSpacingPx,
|
+ 2 * kSpacingPx,
|
||||||
gridHeight + kSmallButtonSizePx + 3 * kSpacingPx);
|
gridHeight + kSmallButtonSizePx + 3 * kSpacingPx);
|
||||||
|
}
|
||||||
|
|
||||||
// A frameless dialog does not get Qt's automatic centering on its parent.
|
bool BlueprintSelectionDialog::isDismissible() const
|
||||||
if (parent)
|
{
|
||||||
{
|
return true;
|
||||||
const QRect parentRect = parent->window()->geometry();
|
|
||||||
move(parentRect.center() - QPoint(width() / 2, height() / 2));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<int> BlueprintSelectionDialog::getChosenIndex() const
|
std::optional<int> BlueprintSelectionDialog::getChosenIndex() const
|
||||||
@@ -266,9 +247,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());
|
||||||
|
|
||||||
@@ -293,12 +272,28 @@ void BlueprintSelectionDialog::rebuildGrid()
|
|||||||
body->setEnabled(m_library->getCanAfford(i));
|
body->setEnabled(m_library->getCanAfford(i));
|
||||||
cardLayout->addWidget(body);
|
cardLayout->addWidget(body);
|
||||||
|
|
||||||
|
// The cost is painted into the face, so what explains the item it names is a
|
||||||
|
// child of its own laid over that band -- placed by hand like the delete icon
|
||||||
|
// below (REQ-UI-ITEM-VALUE-TOOLTIP). The face is centered in the button, which
|
||||||
|
// is what the offset accounts for. A child of the body rather than a sibling, so
|
||||||
|
// that the press it does not handle still reaches the button and picks the
|
||||||
|
// blueprint; hover only, for the same reason (REQ-UI-TOOLTIP-TRIGGER).
|
||||||
|
QWidget* costArea = new QWidget(body);
|
||||||
|
const QPoint faceOrigin((cardSize.width() - face.size.width()) / 2,
|
||||||
|
(cardSize.height() - face.size.height()) / 2);
|
||||||
|
costArea->setGeometry(QRect(faceOrigin + face.costRect.topLeft(),
|
||||||
|
face.costRect.size()));
|
||||||
|
ItemTooltip::attachTo(*costArea, m_context, kBlockItemId,
|
||||||
|
TooltipTrigger::Trigger::HoverOnly);
|
||||||
|
|
||||||
// A sibling of the body rather than one of its children, and outside the
|
// A sibling of the body rather than one of its children, and outside the
|
||||||
// layout: Qt disables a widget's children along with it, and the delete icon
|
// layout: Qt disables a widget's children along with it, and the delete icon
|
||||||
// must stay enabled on an unaffordable card (REQ-UI-BLUEPRINT-DELETE).
|
// must stay enabled on an unaffordable card (REQ-UI-BLUEPRINT-DELETE).
|
||||||
QPushButton* deleteButton = new QPushButton(QString(kCrossGlyph), card);
|
QPushButton* deleteButton = new QPushButton(QString(kCrossGlyph), card);
|
||||||
deleteButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
|
deleteButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
|
||||||
deleteButton->setToolTip(tr("Delete blueprint"));
|
// Hover only: the click deletes the blueprint (REQ-UI-TOOLTIP-TRIGGER).
|
||||||
|
TooltipTrigger::attachText(*deleteButton, tr("Delete blueprint"),
|
||||||
|
TooltipTrigger::Trigger::HoverOnly);
|
||||||
deleteButton->move(cardSize.width() - kSmallButtonSizePx - kCardPaddingPx / 2,
|
deleteButton->move(cardSize.width() - kSmallButtonSizePx - kCardPaddingPx / 2,
|
||||||
cardSize.height() - kSmallButtonSizePx - kCardPaddingPx / 2);
|
cardSize.height() - kSmallButtonSizePx - kCardPaddingPx / 2);
|
||||||
deleteButton->raise();
|
deleteButton->raise();
|
||||||
|
|||||||
@@ -2,39 +2,46 @@
|
|||||||
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
#include <QDialog>
|
#include "ItemTooltipContext.h"
|
||||||
|
#include "ModalDialog.h"
|
||||||
|
|
||||||
class BlueprintLibrary;
|
class BlueprintLibrary;
|
||||||
class ItemIconCache;
|
class ItemIconCache;
|
||||||
class QGridLayout;
|
class QGridLayout;
|
||||||
class QWidget;
|
class QWidget;
|
||||||
|
|
||||||
// The blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG): a frameless modal panel
|
// The blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG): a modal panel showing every
|
||||||
// showing every saved blueprint as a card in a scrolling two-column grid. The caller
|
// saved blueprint as a card in a scrolling two-column grid. The caller pauses the game
|
||||||
// pauses the game and raises the dim overlay while it is open.
|
// while it is open.
|
||||||
//
|
//
|
||||||
// Clicking a card accepts the dialog and reports that blueprint's index; the caller
|
// Clicking a card accepts the dialog and reports that blueprint's index; the caller
|
||||||
// enters placement mode afterwards, so the dialog is already closed by then
|
// enters placement mode afterwards, so the dialog is already closed by then
|
||||||
// (REQ-UI-BLUEPRINT-CARD). Deleting acts on the library immediately and leaves the
|
// (REQ-UI-BLUEPRINT-CARD). Deleting acts on the library immediately and leaves the
|
||||||
// dialog open (REQ-UI-BLUEPRINT-DELETE). Escape and the close button dismiss it with
|
// dialog open (REQ-UI-BLUEPRINT-DELETE). Escape, Q, the close button, and a click
|
||||||
// no other effect.
|
// outside dismiss it with no other effect (REQ-UI-DIALOG-DISMISS).
|
||||||
class BlueprintSelectionDialog : public QDialog
|
class BlueprintSelectionDialog : public ModalDialog
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// Neither the library nor the icon cache is owned; both outlive the dialog.
|
// The context's item cache draws the block icon beside each cost, and the rest of it
|
||||||
BlueprintSelectionDialog(BlueprintLibrary* library, ItemIconCache* itemIcons,
|
// lets that cost explain the item it names (REQ-UI-ITEM-VALUE-TOOLTIP). Neither the
|
||||||
|
// library nor anything in the context is owned; both outlive the dialog.
|
||||||
|
BlueprintSelectionDialog(BlueprintLibrary* library,
|
||||||
|
const ItemTooltipContext& context,
|
||||||
QWidget* parent = nullptr);
|
QWidget* parent = nullptr);
|
||||||
|
|
||||||
std::optional<int> getChosenIndex() const;
|
std::optional<int> getChosenIndex() const;
|
||||||
|
|
||||||
|
bool isDismissible() const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void rebuildGrid();
|
void rebuildGrid();
|
||||||
void onCardClicked(int index);
|
void onCardClicked(int index);
|
||||||
void onDeleteClicked(int index);
|
void onDeleteClicked(int index);
|
||||||
|
|
||||||
BlueprintLibrary* m_library;
|
BlueprintLibrary* m_library;
|
||||||
|
ItemTooltipContext m_context;
|
||||||
ItemIconCache* m_itemIcons;
|
ItemIconCache* m_itemIcons;
|
||||||
QWidget* m_gridContainer;
|
QWidget* m_gridContainer;
|
||||||
QGridLayout* m_grid;
|
QGridLayout* m_grid;
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include <QByteArray>
|
|
||||||
#include <QColor>
|
#include <QColor>
|
||||||
#include <QFile>
|
|
||||||
#include <QFont>
|
#include <QFont>
|
||||||
#include <QFontMetrics>
|
#include <QFontMetrics>
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
@@ -15,22 +13,23 @@
|
|||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QRect>
|
#include <QRect>
|
||||||
#include <QRegularExpression>
|
|
||||||
#include <QSignalMapper>
|
#include <QSignalMapper>
|
||||||
#include <QSize>
|
#include <QSize>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QSvgRenderer>
|
|
||||||
|
|
||||||
|
#include "BuildingIconCache.h"
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
#include "BuildingTypeSelectedEvent.h"
|
#include "BuildingTypeSelectedEvent.h"
|
||||||
#include "DeconstructModeToggleRequestedEvent.h"
|
#include "DeconstructModeToggleRequestedEvent.h"
|
||||||
#include "DisplayName.h"
|
#include "DisplayName.h"
|
||||||
#include "EventManager.h"
|
#include "EventManager.h"
|
||||||
#include "ExitBuilderModeRequestedEvent.h"
|
#include "ExitBuilderModeRequestedEvent.h"
|
||||||
|
#include "FloatingLayoutInvalidatedEvent.h"
|
||||||
#include "IconCaption.h"
|
#include "IconCaption.h"
|
||||||
#include "InputMapper.h"
|
#include "InputMapper.h"
|
||||||
#include "ItemIconCache.h"
|
#include "ItemIconCache.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
|
#include "TooltipTrigger.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -54,45 +53,15 @@ namespace
|
|||||||
// Gap between the chip icon and the cost line on a button face.
|
// Gap between the chip icon and the cost line on a button face.
|
||||||
const int kFaceGapPx = 2;
|
const int kFaceGapPx = 2;
|
||||||
|
|
||||||
// Rasterizes a chip SVG straight at its on-screen size times the device pixel
|
// Normal and grey-background chip pixmaps for one icon name. Empty pixmaps if the
|
||||||
// ratio, so it stays crisp without a downscale step.
|
// file cannot be read; the caller then falls back to a name caption
|
||||||
QPixmap renderChip(const QByteArray& svg)
|
|
||||||
{
|
|
||||||
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
|
|
||||||
QSvgRenderer renderer(svg);
|
|
||||||
QPixmap pixmap(static_cast<int>(kIconSize.width() * dpr),
|
|
||||||
static_cast<int>(kIconSize.height() * dpr));
|
|
||||||
pixmap.setDevicePixelRatio(dpr);
|
|
||||||
pixmap.fill(Qt::transparent);
|
|
||||||
QPainter painter(&pixmap);
|
|
||||||
renderer.render(&painter);
|
|
||||||
return pixmap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal and grey-background chip pixmaps for a "<id>.svg" file. Empty pixmaps if
|
|
||||||
// the file cannot be read; the caller then falls back to a name caption
|
|
||||||
// (REQ-UI-BUILD-ICON).
|
// (REQ-UI-BUILD-ICON).
|
||||||
struct ChipPixmaps { QPixmap normal; QPixmap grey; };
|
struct ChipPixmaps { QPixmap normal; QPixmap grey; };
|
||||||
ChipPixmaps loadChipPixmaps(const QString& path)
|
ChipPixmaps loadChipPixmaps(BuildingIconCache& icons, const std::string& iconName)
|
||||||
{
|
{
|
||||||
QFile file(path);
|
|
||||||
if (!file.open(QIODevice::ReadOnly)) { return {}; }
|
|
||||||
const QByteArray svg = file.readAll();
|
|
||||||
|
|
||||||
ChipPixmaps result;
|
ChipPixmaps result;
|
||||||
result.normal = renderChip(svg);
|
result.normal = icons.getChip(iconName, kIconSize.width());
|
||||||
|
result.grey = icons.getGreyChip(iconName, kIconSize.width());
|
||||||
// Recolor only the chip background: the first "#rrggbb" fill in the file is the
|
|
||||||
// rounded background rect; the white glyph uses fill="none" and is left alone.
|
|
||||||
QString greyed = QString::fromUtf8(svg);
|
|
||||||
static const QRegularExpression fillPattern(QStringLiteral("fill=\"#[0-9a-fA-F]{6}\""));
|
|
||||||
const QRegularExpressionMatch match = fillPattern.match(greyed);
|
|
||||||
if (match.hasMatch())
|
|
||||||
{
|
|
||||||
greyed.replace(match.capturedStart(), match.capturedLength(),
|
|
||||||
QStringLiteral("fill=\"#5f636e\""));
|
|
||||||
}
|
|
||||||
result.grey = renderChip(greyed.toUtf8());
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,12 +150,12 @@ namespace
|
|||||||
|
|
||||||
|
|
||||||
BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
|
BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
|
||||||
const std::string& iconDir,
|
BuildingIconCache* buildingIcons,
|
||||||
ItemIconCache* itemIcons, QWidget* parent)
|
ItemIconCache* itemIcons, QWidget* parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
, m_sim(sim)
|
, m_sim(sim)
|
||||||
, m_config(config)
|
, m_config(config)
|
||||||
, m_iconDir(iconDir)
|
, m_buildingIcons(buildingIcons)
|
||||||
, m_itemIcons(itemIcons)
|
, m_itemIcons(itemIcons)
|
||||||
{
|
{
|
||||||
// The bar floats over the rendered world rather than sitting in a panel, so it
|
// The bar floats over the rendered world rather than sitting in a panel, so it
|
||||||
@@ -207,9 +176,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)
|
||||||
{
|
{
|
||||||
@@ -231,13 +198,12 @@ BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
|
|||||||
const QString name = (def.type == BuildingType::TunnelEntry)
|
const QString name = (def.type == BuildingType::TunnelEntry)
|
||||||
? tr("Tunnel")
|
? tr("Tunnel")
|
||||||
: QString::fromStdString(toDisplayName(def.id));
|
: QString::fromStdString(toDisplayName(def.id));
|
||||||
|
|
||||||
// Icon file name matches the building id (REQ-UI-BUILD-ICON); Tunnel Entry's
|
// Icon file name matches the building id (REQ-UI-BUILD-ICON); Tunnel Entry's
|
||||||
// "tunnel_entry.svg" serves the shared Tunnel button.
|
// "tunnel_entry.svg" serves the shared Tunnel button.
|
||||||
const QString iconPath = QString::fromStdString(m_iconDir) + "/"
|
|
||||||
+ QString::fromStdString(def.id) + ".svg";
|
|
||||||
|
|
||||||
const ButtonFace face = buildButtonFace(
|
const ButtonFace face = buildButtonFace(
|
||||||
loadChipPixmaps(iconPath), InputMapper::getBuildHotkeyLabel(def.type), name,
|
loadChipPixmaps(*m_buildingIcons, def.id),
|
||||||
|
InputMapper::getBuildHotkeyLabel(def.type), name,
|
||||||
QString::number(def.cost), blockIcon, font(), palette());
|
QString::number(def.cost), blockIcon, font(), palette());
|
||||||
|
|
||||||
QPushButton* btn = new QPushButton(this);
|
QPushButton* btn = new QPushButton(this);
|
||||||
@@ -246,10 +212,12 @@ BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
|
|||||||
btn->setCheckable(true);
|
btn->setCheckable(true);
|
||||||
// The button face carries no name (REQ-UI-BUILD-COST), so the tooltip always
|
// The button face carries no name (REQ-UI-BUILD-COST), so the tooltip always
|
||||||
// leads with it and adds the config description when there is one
|
// leads with it and adds the config description when there is one
|
||||||
// (REQ-UI-BUILD-TOOLTIP).
|
// (REQ-UI-BUILD-TOOLTIP). Hover only: the click enters builder mode
|
||||||
btn->setToolTip(def.tooltip
|
// (REQ-UI-TOOLTIP-TRIGGER).
|
||||||
|
TooltipTrigger::attachText(*btn, def.tooltip
|
||||||
? QStringLiteral("%1\n%2").arg(name, QString::fromStdString(*def.tooltip))
|
? QStringLiteral("%1\n%2").arg(name, QString::fromStdString(*def.tooltip))
|
||||||
: name);
|
: name,
|
||||||
|
TooltipTrigger::Trigger::HoverOnly);
|
||||||
layout->addWidget(btn);
|
layout->addWidget(btn);
|
||||||
|
|
||||||
const int idx = static_cast<int>(m_buttons.size());
|
const int idx = static_cast<int>(m_buttons.size());
|
||||||
@@ -268,7 +236,7 @@ BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
|
|||||||
// Having no cost, it shows its name where the building buttons show theirs
|
// Having no cost, it shows its name where the building buttons show theirs
|
||||||
// (REQ-UI-DECONSTRUCT-BUTTON), and its Q toggle as the badge (REQ-UI-HOTKEYS).
|
// (REQ-UI-DECONSTRUCT-BUTTON), and its Q toggle as the badge (REQ-UI-HOTKEYS).
|
||||||
const ButtonFace deconstructFace = buildButtonFace(
|
const ButtonFace deconstructFace = buildButtonFace(
|
||||||
loadChipPixmaps(QString::fromStdString(m_iconDir) + "/deconstruct.svg"),
|
loadChipPixmaps(*m_buildingIcons, "deconstruct"),
|
||||||
QStringLiteral("Q"), tr("Deconstruct"), tr("Deconstruct"), QPixmap(),
|
QStringLiteral("Q"), tr("Deconstruct"), tr("Deconstruct"), QPixmap(),
|
||||||
font(), palette());
|
font(), palette());
|
||||||
|
|
||||||
@@ -288,7 +256,9 @@ BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
|
|||||||
"block cost once removed; a construction site removed before it is built "
|
"block cost once removed; a construction site removed before it is built "
|
||||||
"is refunded in full.")
|
"is refunded in full.")
|
||||||
.arg(refundPercentage);
|
.arg(refundPercentage);
|
||||||
m_deconstructButton->setToolTip(deconstructTooltip);
|
// Hover only: the click toggles the mode (REQ-UI-TOOLTIP-TRIGGER).
|
||||||
|
TooltipTrigger::attachText(*m_deconstructButton, deconstructTooltip,
|
||||||
|
TooltipTrigger::Trigger::HoverOnly);
|
||||||
layout->addWidget(m_deconstructButton);
|
layout->addWidget(m_deconstructButton);
|
||||||
connect(m_deconstructButton, &QPushButton::clicked, this, [this]() {
|
connect(m_deconstructButton, &QPushButton::clicked, this, [this]() {
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
@@ -304,15 +274,25 @@ BuildButtonBar::~BuildButtonBar()
|
|||||||
unregisterForEvents();
|
unregisterForEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
void BuildButtonBar::anchorTo(const QRect& worldViewRect)
|
void BuildButtonBar::placeIn(const QRect& viewRect,
|
||||||
|
const std::vector<QRect>& /*occupiedRects*/)
|
||||||
{
|
{
|
||||||
m_viewRect = worldViewRect;
|
if (viewRect.isNull())
|
||||||
recenter();
|
{
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
// The layout drops hidden buttons from its size hint, but only once it has been
|
||||||
|
// re-run: an unlock changes which buttons are shown, and Qt would not get around to
|
||||||
|
// it before the bar is measured here.
|
||||||
|
layout()->activate();
|
||||||
|
|
||||||
int BuildButtonBar::getStripHeightPx() const
|
const QSize barSize = sizeHint();
|
||||||
{
|
// Centered, except that a bar wider than the view stays flush with its left edge
|
||||||
return height() + kBottomMarginPx;
|
// rather than hanging off both sides.
|
||||||
|
const int x = qMax(viewRect.left(),
|
||||||
|
viewRect.left() + (viewRect.width() - barSize.width()) / 2);
|
||||||
|
const int y = viewRect.bottom() - kBottomMarginPx - barSize.height() + 1;
|
||||||
|
setGeometry(QRect(QPoint(x, y), barSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
void BuildButtonBar::clearActiveButton()
|
void BuildButtonBar::clearActiveButton()
|
||||||
@@ -362,29 +342,11 @@ void BuildButtonBar::updateVisibility()
|
|||||||
{
|
{
|
||||||
m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i]));
|
m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i]));
|
||||||
}
|
}
|
||||||
// A hidden button leaves the row, so the bar has to take up its new width and
|
// A hidden button leaves the row, so the bar takes up a new width and has to be
|
||||||
// re-center on it (REQ-UI-BUILD-BAR).
|
// re-centered on it -- and the widgets that keep clear of the bar have to be placed
|
||||||
recenter();
|
// against that new rect too, so the whole pass is re-run (REQ-UI-BUILD-BAR).
|
||||||
}
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<FloatingLayoutInvalidatedEvent>());
|
||||||
void BuildButtonBar::recenter()
|
|
||||||
{
|
|
||||||
if (m_viewRect.isNull())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The layout drops hidden buttons from its size hint, but only once it has been
|
|
||||||
// re-run: updateVisibility() calls this straight after setVisible(), before Qt
|
|
||||||
// would get around to it on its own.
|
|
||||||
layout()->activate();
|
|
||||||
|
|
||||||
const QSize barSize = sizeHint();
|
|
||||||
// Centered, except that a bar wider than the view stays flush with its left edge
|
|
||||||
// rather than hanging off both sides.
|
|
||||||
const int x = qMax(m_viewRect.left(),
|
|
||||||
m_viewRect.left() + (m_viewRect.width() - barSize.width()) / 2);
|
|
||||||
const int y = m_viewRect.bottom() - kBottomMarginPx - barSize.height() + 1;
|
|
||||||
setGeometry(QRect(QPoint(x, y), barSize));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void BuildButtonBar::onBuildButton(int index)
|
void BuildButtonBar::onBuildButton(int index)
|
||||||
|
|||||||
@@ -15,18 +15,22 @@
|
|||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
#include "DeconstructModeChangedEvent.h"
|
#include "DeconstructModeChangedEvent.h"
|
||||||
#include "EventHandler.h"
|
#include "EventHandler.h"
|
||||||
|
#include "FloatingPanel.h"
|
||||||
#include "GameConfig.h"
|
#include "GameConfig.h"
|
||||||
#include "UnlockedBuildingsChangedEvent.h"
|
#include "UnlockedBuildingsChangedEvent.h"
|
||||||
|
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class Simulation;
|
class Simulation;
|
||||||
|
class BuildingIconCache;
|
||||||
class ItemIconCache;
|
class ItemIconCache;
|
||||||
|
|
||||||
// The build menu: one horizontal row of build buttons floating over the game world
|
// The build menu: one horizontal row of build buttons floating over the game world
|
||||||
// view (REQ-UI-BUILD-BAR). The bar is sized to its buttons; its owner hands it the
|
// view (REQ-UI-BUILD-BAR). The bar is sized to its buttons and centers itself along the
|
||||||
// world view's rect through anchorTo() and it centers itself along that rect's
|
// bottom edge of the rect its owner places it in. It is placed first of the floating
|
||||||
// bottom edge.
|
// widgets and so takes the space it wants outright: nothing ever moves it aside, and the
|
||||||
|
// widgets placed after it keep out of its way instead.
|
||||||
class BuildButtonBar : public QWidget,
|
class BuildButtonBar : public QWidget,
|
||||||
|
public FloatingPanel,
|
||||||
public CombinedEventHandler<BuilderModeExitedEvent,
|
public CombinedEventHandler<BuilderModeExitedEvent,
|
||||||
DeconstructModeChangedEvent,
|
DeconstructModeChangedEvent,
|
||||||
BuildHotkeyPressedEvent,
|
BuildHotkeyPressedEvent,
|
||||||
@@ -36,25 +40,19 @@ class BuildButtonBar : public QWidget,
|
|||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// iconDir is the directory holding the per-building "<id>.svg" chip icons
|
// buildingIcons supplies each button's chip icon (REQ-UI-BUILD-ICON) and itemIcons
|
||||||
// (REQ-UI-BUILD-ICON), read from disk at runtime like the config files.
|
// the building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Both are
|
||||||
// itemIcons is the window-wide per-item icon cache and supplies the
|
// window-wide caches; neither is owned, and both must outlive this widget.
|
||||||
// building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Not
|
|
||||||
// owned; must outlive this widget.
|
|
||||||
BuildButtonBar(Simulation* sim, const GameConfig* config,
|
BuildButtonBar(Simulation* sim, const GameConfig* config,
|
||||||
const std::string& iconDir, ItemIconCache* itemIcons,
|
BuildingIconCache* buildingIcons, ItemIconCache* itemIcons,
|
||||||
QWidget* parent = nullptr);
|
QWidget* parent = nullptr);
|
||||||
~BuildButtonBar() override;
|
~BuildButtonBar() override;
|
||||||
|
|
||||||
// Centers the bar along the bottom edge of the game world view's rect, given in
|
// Centers the bar along the bottom edge of the game world view's rect, given in the
|
||||||
// the bar's parent coordinates (REQ-UI-BUILD-BAR). The rect is remembered, so a
|
// bar's parent coordinates (REQ-UI-BUILD-BAR). Nothing is occupied yet when the bar
|
||||||
// re-center later driven by an unlock needs no second call from the owner.
|
// is placed, so it ignores what it is handed there.
|
||||||
void anchorTo(const QRect& worldViewRect);
|
void placeIn(const QRect& viewRect,
|
||||||
|
const std::vector<QRect>& occupiedRects) override;
|
||||||
// Height of the strip the bar occupies along the bottom of the world view: its own
|
|
||||||
// height plus the margin below it. The selection panel keeps out of this strip, and
|
|
||||||
// the bar never moves for the panel in return (REQ-UI-BUILD-BAR).
|
|
||||||
int getStripHeightPx() const;
|
|
||||||
|
|
||||||
void clearActiveButton();
|
void clearActiveButton();
|
||||||
|
|
||||||
@@ -68,11 +66,6 @@ private:
|
|||||||
// unlock state (REQ-LOCK-BUILDING); a locked building type's button is hidden.
|
// unlock state (REQ-LOCK-BUILDING); a locked building type's button is hidden.
|
||||||
void updateVisibility();
|
void updateVisibility();
|
||||||
|
|
||||||
// Shrinks the bar to its currently shown buttons and re-centers it in the
|
|
||||||
// anchored rect (REQ-UI-BUILD-BAR). Does nothing until anchorTo() supplied that
|
|
||||||
// rect, so the construction-time call is harmless.
|
|
||||||
void recenter();
|
|
||||||
|
|
||||||
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override;
|
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override;
|
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override;
|
void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override;
|
||||||
@@ -85,12 +78,11 @@ private slots:
|
|||||||
private:
|
private:
|
||||||
Simulation* m_sim;
|
Simulation* m_sim;
|
||||||
const GameConfig* m_config;
|
const GameConfig* m_config;
|
||||||
std::string m_iconDir;
|
BuildingIconCache* m_buildingIcons; // Not owned; lives in MainWindow.
|
||||||
ItemIconCache* m_itemIcons; // Not owned; lives in MainWindow.
|
ItemIconCache* m_itemIcons; // Not owned; lives in MainWindow.
|
||||||
std::vector<BuildingType> m_types;
|
std::vector<BuildingType> m_types;
|
||||||
std::vector<QPushButton*> m_buttons;
|
std::vector<QPushButton*> m_buttons;
|
||||||
std::map<BuildingType, int> m_costs;
|
std::map<BuildingType, int> m_costs;
|
||||||
std::optional<std::size_t> m_activeIndex;
|
std::optional<std::size_t> m_activeIndex;
|
||||||
QPushButton* m_deconstructButton;
|
QPushButton* m_deconstructButton;
|
||||||
QRect m_viewRect;
|
|
||||||
};
|
};
|
||||||
|
|||||||
120
src/ui/BuildingIconCache.cpp
Normal file
120
src/ui/BuildingIconCache.cpp
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
#include "BuildingIconCache.h"
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QSvgRenderer>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// Chip background color for a build button the player cannot afford
|
||||||
|
// (REQ-UI-BUILD-DISABLED). Part of the icon rather than of any widget's palette, so it
|
||||||
|
// lives with the recoloring step.
|
||||||
|
const char* const kGreyFill = "fill=\"#5f636e\"";
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
BuildingIconCache::BuildingIconCache(const QString& iconDir)
|
||||||
|
: m_iconDir(iconDir)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
const QByteArray& BuildingIconCache::getSvg(const std::string& iconName)
|
||||||
|
{
|
||||||
|
const std::map<std::string, QByteArray>::const_iterator cached =
|
||||||
|
m_svgByName.find(iconName);
|
||||||
|
if (cached != m_svgByName.end())
|
||||||
|
{
|
||||||
|
return cached->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An absent or unreadable file caches an empty byte array so it is not retried;
|
||||||
|
// a missing icon is not an error (REQ-UI-BUILD-ICON).
|
||||||
|
QByteArray svg;
|
||||||
|
QFile file(m_iconDir + "/" + QString::fromStdString(iconName) + ".svg");
|
||||||
|
if (file.open(QIODevice::ReadOnly))
|
||||||
|
{
|
||||||
|
svg = file.readAll();
|
||||||
|
}
|
||||||
|
return m_svgByName.emplace(iconName, std::move(svg)).first->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QByteArray& BuildingIconCache::getGreySvg(const std::string& iconName)
|
||||||
|
{
|
||||||
|
const std::map<std::string, QByteArray>::const_iterator cached =
|
||||||
|
m_greySvgByName.find(iconName);
|
||||||
|
if (cached != m_greySvgByName.end())
|
||||||
|
{
|
||||||
|
return cached->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recolor only the chip background: the first "#rrggbb" fill in the file is the
|
||||||
|
// rounded background rect; the white glyph uses fill="none" and is left alone.
|
||||||
|
const QByteArray& svg = getSvg(iconName);
|
||||||
|
QByteArray greyed = svg;
|
||||||
|
if (!svg.isEmpty())
|
||||||
|
{
|
||||||
|
QString text = QString::fromUtf8(svg);
|
||||||
|
static const QRegularExpression fillPattern(
|
||||||
|
QStringLiteral("fill=\"#[0-9a-fA-F]{6}\""));
|
||||||
|
const QRegularExpressionMatch match = fillPattern.match(text);
|
||||||
|
if (match.hasMatch())
|
||||||
|
{
|
||||||
|
text.replace(match.capturedStart(), match.capturedLength(),
|
||||||
|
QLatin1String(kGreyFill));
|
||||||
|
}
|
||||||
|
greyed = text.toUtf8();
|
||||||
|
}
|
||||||
|
return m_greySvgByName.emplace(iconName, std::move(greyed)).first->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildingIconCache::hasIcon(const std::string& iconName)
|
||||||
|
{
|
||||||
|
return !getSvg(iconName).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
QPixmap BuildingIconCache::getChip(const std::string& iconName, int sizePx)
|
||||||
|
{
|
||||||
|
return getPixmap(iconName, getSvg(iconName), sizePx);
|
||||||
|
}
|
||||||
|
|
||||||
|
QPixmap BuildingIconCache::getGreyChip(const std::string& iconName, int sizePx)
|
||||||
|
{
|
||||||
|
return getPixmap("grey:" + iconName, getGreySvg(iconName), sizePx);
|
||||||
|
}
|
||||||
|
|
||||||
|
QPixmap BuildingIconCache::getPixmap(const std::string& cacheKey,
|
||||||
|
const QByteArray& svg, int sizePx)
|
||||||
|
{
|
||||||
|
if (sizePx <= 0)
|
||||||
|
{
|
||||||
|
return QPixmap();
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::pair<std::string, int> key(cacheKey, sizePx);
|
||||||
|
const std::map<std::pair<std::string, int>, QPixmap>::const_iterator cached =
|
||||||
|
m_pixmapCache.find(key);
|
||||||
|
if (cached != m_pixmapCache.end())
|
||||||
|
{
|
||||||
|
return cached->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPixmap pixmap;
|
||||||
|
if (!svg.isEmpty())
|
||||||
|
{
|
||||||
|
// Rasterized straight at its on-screen size times the device pixel ratio, so it
|
||||||
|
// stays crisp without a downscale step.
|
||||||
|
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
|
||||||
|
QSvgRenderer renderer(svg);
|
||||||
|
pixmap = QPixmap(static_cast<int>(sizePx * dpr), static_cast<int>(sizePx * dpr));
|
||||||
|
pixmap.setDevicePixelRatio(dpr);
|
||||||
|
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;
|
||||||
|
}
|
||||||
55
src/ui/BuildingIconCache.h
Normal file
55
src/ui/BuildingIconCache.h
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
// Rasterizes and caches the per-building chip SVGs (REQ-UI-BUILD-ICON). A chip is a
|
||||||
|
// rounded colored background bearing a white line glyph, loaded from a directory with
|
||||||
|
// one file per building named after the building's id (e.g. "belt.svg"). Shared by the
|
||||||
|
// build button bar and the selection panel's card header (REQ-UI-SELECTION-CARD) so the
|
||||||
|
// rasterization is not duplicated.
|
||||||
|
//
|
||||||
|
// A missing icon file is not an error: hasIcon() returns false for it and the caller
|
||||||
|
// falls back (a name caption on a build button, a bare title in the panel header).
|
||||||
|
class BuildingIconCache
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// iconDir is the directory holding the "<id>.svg" chip files (typically
|
||||||
|
// "<configDir>/../icons/buildings").
|
||||||
|
explicit BuildingIconCache(const QString& iconDir);
|
||||||
|
|
||||||
|
// True if a chip SVG file exists for the given icon name. Loads the file's bytes on
|
||||||
|
// first query and remembers the result (including absence) so repeated calls are
|
||||||
|
// cheap. The name is a building id for the building buttons, but need not be one --
|
||||||
|
// the Deconstruct button's "deconstruct" chip goes through the same path.
|
||||||
|
bool hasIcon(const std::string& iconName);
|
||||||
|
|
||||||
|
// The chip rasterized to a transparent sizePx*sizePx pixmap at the device pixel
|
||||||
|
// ratio, cached per (name, size). Returns a null pixmap when the file is missing.
|
||||||
|
QPixmap getChip(const std::string& iconName, int sizePx);
|
||||||
|
|
||||||
|
// As getChip(), but with the chip background recolored grey and the glyph left
|
||||||
|
// alone, for a build button the player cannot currently afford
|
||||||
|
// (REQ-UI-BUILD-DISABLED).
|
||||||
|
QPixmap getGreyChip(const std::string& iconName, int sizePx);
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Raw SVG bytes for an icon name, loading and caching them on first access. An
|
||||||
|
// absent file caches an empty QByteArray so it is not retried.
|
||||||
|
const QByteArray& getSvg(const std::string& iconName);
|
||||||
|
// The same SVG with its background fill replaced by grey, cached alongside.
|
||||||
|
const QByteArray& getGreySvg(const std::string& iconName);
|
||||||
|
// Shared rasterize-and-cache step. cacheKey distinguishes the normal and grey
|
||||||
|
// variants of one icon name within the single pixmap cache.
|
||||||
|
QPixmap getPixmap(const std::string& cacheKey, const QByteArray& svg, int sizePx);
|
||||||
|
|
||||||
|
QString m_iconDir;
|
||||||
|
std::map<std::string, QByteArray> m_svgByName;
|
||||||
|
std::map<std::string, QByteArray> m_greySvgByName;
|
||||||
|
std::map<std::pair<std::string, int>, QPixmap> m_pixmapCache;
|
||||||
|
};
|
||||||
@@ -1,18 +1,27 @@
|
|||||||
|
add_subdirectory(selection)
|
||||||
|
|
||||||
SET(HDRS
|
SET(HDRS
|
||||||
${HDRS}
|
${HDRS}
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h
|
${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h
|
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h
|
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
|
${CMAKE_CURRENT_SOURCE_DIR}/MessageDialog.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ModalDialog.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ModalLayer.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/NameInputDialog.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
|
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.h
|
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
|
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanel.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ExpandButton.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionBounds.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ControlsPanel.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ControlActionText.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
|
||||||
@@ -20,25 +29,43 @@ 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}/Tooltip.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemTooltip.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemTooltipContext.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/TooltipTrigger.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}/IconCaption.h
|
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.h
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
set(UI_INCLUDE_PATH
|
||||||
|
${UI_INCLUDE_PATH}
|
||||||
|
PARENT_SCOPE
|
||||||
|
)
|
||||||
|
|
||||||
SET(SRCS
|
SET(SRCS
|
||||||
${SRCS}
|
${SRCS}
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/MessageDialog.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ModalDialog.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ModalLayer.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/NameInputDialog.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ExpandButton.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionBounds.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ControlsPanel.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ControlActionText.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
|
||||||
@@ -46,8 +73,14 @@ 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}/Tooltip.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ItemTooltip.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/TooltipTrigger.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}/IconCaption.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.cpp
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|||||||
93
src/ui/ControlActionText.cpp
Normal file
93
src/ui/ControlActionText.cpp
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
#include "ControlActionText.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QKeySequence>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// tr() for a file of free functions. Q_DECLARE_TR_FUNCTIONS expands with access
|
||||||
|
// specifiers, so it needs a class rather than a namespace; this struct exists only to
|
||||||
|
// carry it and give the strings a single lupdate context.
|
||||||
|
struct Strings
|
||||||
|
{
|
||||||
|
Q_DECLARE_TR_FUNCTIONS(ControlActionText)
|
||||||
|
};
|
||||||
|
|
||||||
|
QString getMouseBadge(MouseBinding binding)
|
||||||
|
{
|
||||||
|
switch (binding)
|
||||||
|
{
|
||||||
|
case MouseBinding::LeftClick: return Strings::tr("LMB");
|
||||||
|
case MouseBinding::LeftDrag: return Strings::tr("LMB drag");
|
||||||
|
case MouseBinding::CtrlLeftClick: return Strings::tr("Ctrl+LMB");
|
||||||
|
case MouseBinding::CtrlLeftDrag: return Strings::tr("Ctrl+LMB drag");
|
||||||
|
case MouseBinding::RightClick: return Strings::tr("RMB");
|
||||||
|
}
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QString getControlBindingBadge(const ControlBinding& binding)
|
||||||
|
{
|
||||||
|
if (binding.isMouse) { return getMouseBadge(binding.mouse); }
|
||||||
|
|
||||||
|
// QKeySequence spells the modifiers and the key together and localizes them, which
|
||||||
|
// is what makes this stay correct once keys are rebindable: the chip is generated
|
||||||
|
// from the binding rather than typed next to it.
|
||||||
|
return QKeySequence(binding.key | static_cast<int>(binding.modifiers))
|
||||||
|
.toString(QKeySequence::NativeText);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString getControlActionLabel(ControlAction action, const ControlContext& context)
|
||||||
|
{
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case ControlAction::None: return QString();
|
||||||
|
case ControlAction::Move: return Strings::tr("Move");
|
||||||
|
case ControlAction::GameSpeed: return Strings::tr("Game speed");
|
||||||
|
case ControlAction::TogglePause: return Strings::tr("Toggle pause");
|
||||||
|
case ControlAction::PasteTemporary: return Strings::tr("Paste last");
|
||||||
|
case ControlAction::OpenBlueprints: return Strings::tr("Blueprints");
|
||||||
|
case ControlAction::OpenMenu: return Strings::tr("Menu");
|
||||||
|
// A click on empty space clears and a click on an object replaces; naming only the
|
||||||
|
// first would be a half-truth once something is selected.
|
||||||
|
case ControlAction::Select:
|
||||||
|
return context.selection == ControlSelection::None
|
||||||
|
? Strings::tr("Select")
|
||||||
|
: Strings::tr("Select / clear selection");
|
||||||
|
case ControlAction::SelectArea: return Strings::tr("Select area");
|
||||||
|
case ControlAction::AddToSelection: return Strings::tr("Add / remove from selection");
|
||||||
|
case ControlAction::AddAreaToSelection: return Strings::tr("Add area to selection");
|
||||||
|
case ControlAction::EnterDeconstruct: return Strings::tr("Deconstruct mode");
|
||||||
|
case ControlAction::CopyTemporary: return Strings::tr("Copy to temporary blueprint");
|
||||||
|
case ControlAction::CreateBlueprint: return Strings::tr("Create blueprint");
|
||||||
|
case ControlAction::ClearSelection: return Strings::tr("Clear selection");
|
||||||
|
case ControlAction::Place: return Strings::tr("Place");
|
||||||
|
case ControlAction::ApplySettings: return Strings::tr("Apply settings");
|
||||||
|
case ControlAction::PlaceBeltLine: return Strings::tr("Place belt line");
|
||||||
|
case ControlAction::Rotate: return Strings::tr("Rotate");
|
||||||
|
case ControlAction::CancelBeltLine: return Strings::tr("Cancel belt line");
|
||||||
|
case ControlAction::ExitMode:
|
||||||
|
return context.mode == BuildMode::Deconstruct
|
||||||
|
? Strings::tr("Exit deconstruct mode")
|
||||||
|
: Strings::tr("Exit placement");
|
||||||
|
case ControlAction::ToggleDeconstruct: return Strings::tr("Toggle deconstruct");
|
||||||
|
case ControlAction::DeconstructArea: return Strings::tr("Deconstruct area");
|
||||||
|
}
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString getControlContextName(ControlContextKind kind)
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ControlContextKind::General: return Strings::tr("GENERAL");
|
||||||
|
case ControlContextKind::Selection: return Strings::tr("SELECTION");
|
||||||
|
case ControlContextKind::Build: return Strings::tr("BUILD MODE");
|
||||||
|
case ControlContextKind::Blueprint: return Strings::tr("BLUEPRINT MODE");
|
||||||
|
case ControlContextKind::Deconstruct: return Strings::tr("DECONSTRUCT MODE");
|
||||||
|
}
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
26
src/ui/ControlActionText.h
Normal file
26
src/ui/ControlActionText.h
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "ControlAction.h"
|
||||||
|
|
||||||
|
// What the player is shown for the actions and bindings declared in ControlAction.h
|
||||||
|
// (REQ-UI-CONTROLS-CONTENT). Kept out of lib/core deliberately: that file decides what
|
||||||
|
// is available and what triggers it, this one decides what it is called, and only this
|
||||||
|
// half is presentation.
|
||||||
|
//
|
||||||
|
// A badge is rendered from the binding it belongs to rather than written beside it, so
|
||||||
|
// what the panel shows on a chip is what the resolver actually matches. When bindings
|
||||||
|
// become player-configurable, this is the only place that has to learn to spell a
|
||||||
|
// rebound key.
|
||||||
|
|
||||||
|
// The chip text for one binding: "Ctrl+V", "LMB drag", "RMB".
|
||||||
|
QString getControlBindingBadge(const ControlBinding& binding);
|
||||||
|
|
||||||
|
// What the action is called in this context. A few actions are named for their
|
||||||
|
// situation rather than their implementation -- one exit action reads "Exit placement"
|
||||||
|
// in a placement mode and "Exit deconstruct mode" in deconstruct mode.
|
||||||
|
QString getControlActionLabel(ControlAction action, const ControlContext& context);
|
||||||
|
|
||||||
|
// The heading, in the upper case the card shows it in (REQ-UI-CONTROLS-CARD).
|
||||||
|
QString getControlContextName(ControlContextKind kind);
|
||||||
343
src/ui/ControlsPanel.cpp
Normal file
343
src/ui/ControlsPanel.cpp
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
#include "ControlsPanel.h"
|
||||||
|
|
||||||
|
#include <QFont>
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QScrollBar>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "ControlActionText.h"
|
||||||
|
#include "EventManager.h"
|
||||||
|
#include "FloatingLayoutInvalidatedEvent.h"
|
||||||
|
#include "FloatingPanelPlacement.h"
|
||||||
|
#include "GameWorldView.h"
|
||||||
|
#include "selection/SelectionNames.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
const int kMarginPx = 8; // between the view's edge and the panel
|
||||||
|
const int kCardMarginPx = 8; // inside the panel, around its content
|
||||||
|
const int kHeadingGapPx = 4; // between the heading and the rows
|
||||||
|
const int kRefreshMs = 50; // see the class comment on why this polls
|
||||||
|
|
||||||
|
// The panel's border, from the stylesheet below. Spelled out because the stylesheet box
|
||||||
|
// is what sets it and asking the style for it before the first show is unreliable.
|
||||||
|
const int kBorderPx = 1;
|
||||||
|
|
||||||
|
// Separates the heading's name from its detail, e.g. "BUILD MODE * Assembler".
|
||||||
|
const QChar kHeadingSeparator(0x00B7); // U+00B7 MIDDLE DOT
|
||||||
|
|
||||||
|
// The upper-case heading and caption are tracked out a little so they read as labels
|
||||||
|
// rather than as words. Set on the font because Qt's stylesheets have no letter-spacing
|
||||||
|
// property -- writing one there is silently ignored apart from a warning per widget.
|
||||||
|
QFont makeSpacedFont(QFont font, bool bold, int pointSizeDelta)
|
||||||
|
{
|
||||||
|
font.setBold(bold);
|
||||||
|
if (pointSizeDelta != 0 && font.pointSize() > 0)
|
||||||
|
{
|
||||||
|
font.setPointSize(qMax(1, font.pointSize() + pointSizeDelta));
|
||||||
|
}
|
||||||
|
font.setLetterSpacing(QFont::AbsoluteSpacing, 1.0);
|
||||||
|
return font;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row: the chips for the bindings, then what the action is called. Built as a plain
|
||||||
|
// widget rather than a class of its own -- it holds no state and answers no questions.
|
||||||
|
QWidget* makeRow(ControlAction action, const ControlContext& context, QWidget* parent)
|
||||||
|
{
|
||||||
|
QWidget* row = new QWidget(parent);
|
||||||
|
QHBoxLayout* layout = new QHBoxLayout(row);
|
||||||
|
layout->setContentsMargins(0, 2, 0, 2);
|
||||||
|
layout->setSpacing(4);
|
||||||
|
|
||||||
|
// Every badge is rendered from the binding the resolver matches, so a chip cannot
|
||||||
|
// claim a key that does nothing (REQ-UI-CONTROLS-ACCURACY). The chips of the row
|
||||||
|
// that leaves the context are the ones marked, not its label (REQ-UI-CONTROLS-CARD):
|
||||||
|
// leaving a build mode and clearing the selection are the same gesture to the player,
|
||||||
|
// one key that hands the context back, so they are marked alike.
|
||||||
|
const bool backsOut = (action == ControlAction::ExitMode
|
||||||
|
|| action == ControlAction::ClearSelection);
|
||||||
|
const std::vector<ControlBinding> bindings = getControlActionBindings(action, context);
|
||||||
|
for (const ControlBinding& binding : bindings)
|
||||||
|
{
|
||||||
|
QLabel* badge = new QLabel(getControlBindingBadge(binding), row);
|
||||||
|
badge->setObjectName(backsOut ? QStringLiteral("controlBadgeExit")
|
||||||
|
: QStringLiteral("controlBadge"));
|
||||||
|
layout->addWidget(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
QLabel* label = new QLabel(getControlActionLabel(action, context), row);
|
||||||
|
label->setObjectName(QStringLiteral("controlLabel"));
|
||||||
|
layout->addSpacing(4);
|
||||||
|
layout->addWidget(label);
|
||||||
|
layout->addStretch(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a freshly built widget to the rows 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 -- it contributes nothing
|
||||||
|
// to the size hint until something shows it, which otherwise does not happen until the
|
||||||
|
// event loop next runs, long after the panel has measured itself.
|
||||||
|
void addAndShow(QVBoxLayout* layout, QWidget* widget)
|
||||||
|
{
|
||||||
|
layout->addWidget(widget);
|
||||||
|
widget->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ControlsPanel::ControlsPanel(const GameWorldView* view, QWidget* parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
, m_view(view)
|
||||||
|
{
|
||||||
|
// Floats over the rendered world, so it brings its own background to stay legible
|
||||||
|
// over any world content (REQ-UI-CONTROLS-PANEL). Palette colors match the build
|
||||||
|
// button bar's and the selection panel's chrome; like them this is widget chrome
|
||||||
|
// rather than world rendering, so it is deliberately not a visuals.toml color. The
|
||||||
|
// class scoped selector keeps the border on the panel rather than cascading onto
|
||||||
|
// its children.
|
||||||
|
setAttribute(Qt::WA_StyledBackground, true);
|
||||||
|
// Letter spacing is deliberately absent here: Qt's stylesheet syntax has no such
|
||||||
|
// property and warns on every widget it is applied to. The heading and the caption
|
||||||
|
// set it on their QFont instead.
|
||||||
|
setStyleSheet(QStringLiteral(
|
||||||
|
"ControlsPanel { background-color: palette(window);"
|
||||||
|
" border: 1px solid palette(mid); border-radius: 4px; }"
|
||||||
|
"QLabel#controlHeading { color: palette(text); }"
|
||||||
|
"QLabel#controlBadge { border: 1px solid palette(mid); border-radius: 3px;"
|
||||||
|
" padding: 1px 5px; font-family: monospace; color: palette(text); }"
|
||||||
|
"QLabel#controlLabel { color: palette(text); }"
|
||||||
|
// The row that leaves the mode is marked on its chips rather than its label
|
||||||
|
// (REQ-UI-CONTROLS-CARD): the label keeps the ordinary text color, so the row
|
||||||
|
// stays legible whatever the palette, and only the chips carry the warning.
|
||||||
|
//
|
||||||
|
// A literal red because there is no palette role for "destructive" -- the
|
||||||
|
// nearest, bright-text, is white by design, being meant for text over dark
|
||||||
|
// highlights, and was unreadable on this panel's chrome. This value reads on a
|
||||||
|
// light and a dark background alike. It is widget chrome, so like the rest of
|
||||||
|
// this stylesheet it is deliberately not a visuals.toml color.
|
||||||
|
"QLabel#controlBadgeExit { border: 1px solid #c0392b; border-radius: 3px;"
|
||||||
|
" padding: 1px 5px; font-family: monospace; color: #c0392b; }"
|
||||||
|
"QLabel#controlCaption { color: palette(mid); }"));
|
||||||
|
|
||||||
|
QVBoxLayout* outerLayout = new QVBoxLayout(this);
|
||||||
|
outerLayout->setContentsMargins(kCardMarginPx, kCardMarginPx,
|
||||||
|
kCardMarginPx, kCardMarginPx);
|
||||||
|
outerLayout->setSpacing(kHeadingGapPx);
|
||||||
|
|
||||||
|
m_heading = new QLabel(this);
|
||||||
|
m_heading->setObjectName(QStringLiteral("controlHeading"));
|
||||||
|
m_heading->setCursor(Qt::PointingHandCursor);
|
||||||
|
m_heading->setFont(makeSpacedFont(font(), /*bold*/ true, /*pointSizeDelta*/ 0));
|
||||||
|
outerLayout->addWidget(m_heading);
|
||||||
|
|
||||||
|
// Rows taller than the space available scroll rather than being cut off
|
||||||
|
// (REQ-UI-CONTROLS-PANEL). The viewport is transparent so the panel's own rounded
|
||||||
|
// chrome shows through, and horizontal scrolling is off because the width always
|
||||||
|
// follows the content.
|
||||||
|
m_rows = new QWidget(this);
|
||||||
|
m_rowsLayout = new QVBoxLayout(m_rows);
|
||||||
|
m_rowsLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
m_rowsLayout->setSpacing(0);
|
||||||
|
m_rows->setAutoFillBackground(false);
|
||||||
|
|
||||||
|
m_scrollArea = new QScrollArea(this);
|
||||||
|
m_scrollArea->setFrameShape(QFrame::NoFrame);
|
||||||
|
m_scrollArea->setWidgetResizable(true);
|
||||||
|
m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||||
|
m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
|
||||||
|
m_scrollArea->viewport()->setAutoFillBackground(false);
|
||||||
|
m_scrollArea->setWidget(m_rows);
|
||||||
|
outerLayout->addWidget(m_scrollArea);
|
||||||
|
|
||||||
|
// Polling rather than subscribing; see the class comment.
|
||||||
|
m_refreshTimer = new QTimer(this);
|
||||||
|
connect(m_refreshTimer, &QTimer::timeout, this, &ControlsPanel::refresh);
|
||||||
|
m_refreshTimer->start(kRefreshMs);
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ControlsPanel::invalidateLayout()
|
||||||
|
{
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<FloatingLayoutInvalidatedEvent>());
|
||||||
|
}
|
||||||
|
|
||||||
|
void ControlsPanel::mousePressEvent(QMouseEvent* event)
|
||||||
|
{
|
||||||
|
// Only the heading toggles; a click anywhere else is swallowed so it never reaches
|
||||||
|
// the world beneath (REQ-UI-CONTROLS-PANEL).
|
||||||
|
if (m_heading->geometry().contains(event->pos()))
|
||||||
|
{
|
||||||
|
m_collapsed = !m_collapsed;
|
||||||
|
m_scrollArea->setVisible(!m_collapsed);
|
||||||
|
invalidateLayout();
|
||||||
|
}
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ControlsPanel::refresh()
|
||||||
|
{
|
||||||
|
if (!m_view) { return; }
|
||||||
|
|
||||||
|
const ControlContext context = m_view->getControlContext();
|
||||||
|
|
||||||
|
const QString heading = getHeadingText(context);
|
||||||
|
const std::vector<ControlAction> contextActions = getContextActions(context);
|
||||||
|
const std::vector<ControlAction> alwaysActions = getAlwaysAvailableActions(context);
|
||||||
|
|
||||||
|
if (heading == m_shownHeading && contextActions == m_shownContextActions
|
||||||
|
&& alwaysActions == m_shownAlwaysActions)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_shownHeading = heading;
|
||||||
|
m_shownContextActions = contextActions;
|
||||||
|
m_shownAlwaysActions = alwaysActions;
|
||||||
|
rebuild(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ControlsPanel::rebuild(const ControlContext& context)
|
||||||
|
{
|
||||||
|
m_heading->setText(m_shownHeading);
|
||||||
|
|
||||||
|
// Rows are rebuilt wholesale rather than reconciled: a context change replaces
|
||||||
|
// nearly all of them, and the panel redraws only when something actually changed.
|
||||||
|
while (QLayoutItem* item = m_rowsLayout->takeAt(0))
|
||||||
|
{
|
||||||
|
delete item->widget();
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ControlAction action : m_shownContextActions)
|
||||||
|
{
|
||||||
|
addAndShow(m_rowsLayout, makeRow(action, context, m_rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The always-available block sits under a divider in every context, the General one
|
||||||
|
// included, so the card is read the same way wherever the player is
|
||||||
|
// (REQ-UI-CONTROLS-CARD).
|
||||||
|
if (!m_shownAlwaysActions.empty())
|
||||||
|
{
|
||||||
|
QFrame* divider = new QFrame(m_rows);
|
||||||
|
divider->setFrameShape(QFrame::HLine);
|
||||||
|
divider->setFrameShadow(QFrame::Plain);
|
||||||
|
m_rowsLayout->addSpacing(6);
|
||||||
|
addAndShow(m_rowsLayout, divider);
|
||||||
|
|
||||||
|
QLabel* caption = new QLabel(tr("ALWAYS AVAILABLE"), m_rows);
|
||||||
|
caption->setObjectName(QStringLiteral("controlCaption"));
|
||||||
|
caption->setFont(makeSpacedFont(font(), /*bold*/ false, /*pointSizeDelta*/ -1));
|
||||||
|
addAndShow(m_rowsLayout, caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ControlAction action : m_shownAlwaysActions)
|
||||||
|
{
|
||||||
|
addAndShow(m_rowsLayout, makeRow(action, context, m_rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
m_scrollArea->setVisible(!m_collapsed);
|
||||||
|
invalidateLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ControlsPanel::getHeadingText(const ControlContext& context) const
|
||||||
|
{
|
||||||
|
const ControlContextKind kind = getControlContextKind(context);
|
||||||
|
const QString name = getControlContextName(kind);
|
||||||
|
|
||||||
|
QString detail;
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ControlContextKind::Build:
|
||||||
|
detail = getBuildingTypeName(context.builderType);
|
||||||
|
break;
|
||||||
|
case ControlContextKind::Blueprint:
|
||||||
|
{
|
||||||
|
// The temporary blueprint is never named (REQ-UI-BLUEPRINT-TEMP), so it is
|
||||||
|
// labelled by what it is rather than left blank.
|
||||||
|
const QString blueprintName = m_view->getActiveBlueprintName();
|
||||||
|
detail = blueprintName.isEmpty() ? tr("Temporary") : blueprintName;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ControlContextKind::Selection:
|
||||||
|
detail = context.selection == ControlSelection::Buildings
|
||||||
|
? tr("%n building(s)", "", context.selectionCount)
|
||||||
|
: tr("%n object(s)", "", context.selectionCount);
|
||||||
|
break;
|
||||||
|
case ControlContextKind::General:
|
||||||
|
case ControlContextKind::Deconstruct:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.isEmpty()) { return name; }
|
||||||
|
return name + QStringLiteral(" ") + kHeadingSeparator + QStringLiteral(" ") + detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ControlsPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& occupiedRects)
|
||||||
|
{
|
||||||
|
if (viewRect.isNull()) { return; }
|
||||||
|
|
||||||
|
// Rows are torn down and rebuilt wholesale, and a widget added to a layout is only
|
||||||
|
// shown once that layout runs -- without this the new rows count for nothing and
|
||||||
|
// the card is measured for the context before it. The polish belongs to the same
|
||||||
|
// step: a freshly created label reports an unstyled size hint until the stylesheet
|
||||||
|
// has reached it, and the badge chips carry border and padding that change it.
|
||||||
|
m_rows->ensurePolished();
|
||||||
|
m_rowsLayout->invalidate();
|
||||||
|
m_rowsLayout->activate();
|
||||||
|
|
||||||
|
const QRect band = viewRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
|
||||||
|
if (band.width() <= 0 || band.height() <= 0) { return; }
|
||||||
|
|
||||||
|
// Measured from the heading and the rows directly rather than from the panel's own
|
||||||
|
// layout: the rows now sit in a scroll area, whose size hint describes a viewport
|
||||||
|
// and says nothing about how tall its contents are. Deliberately not activating the
|
||||||
|
// panel's own layout either -- that lays its children out inside the geometry left
|
||||||
|
// over from the previous context, which is the wrong frame of reference for
|
||||||
|
// choosing the new one. setGeometry below re-runs it.
|
||||||
|
const int chromePx = 2 * (kCardMarginPx + kBorderPx);
|
||||||
|
const QSize headingHint = m_heading->sizeHint();
|
||||||
|
const QSize rowsHint = m_rows->sizeHint();
|
||||||
|
|
||||||
|
int contentWidthPx = headingHint.width();
|
||||||
|
int contentHeightPx = headingHint.height();
|
||||||
|
if (!m_collapsed)
|
||||||
|
{
|
||||||
|
contentWidthPx = qMax(contentWidthPx, rowsHint.width());
|
||||||
|
contentHeightPx += kHeadingGapPx + rowsHint.height();
|
||||||
|
}
|
||||||
|
const int wantedHeightPx = contentHeightPx + chromePx;
|
||||||
|
|
||||||
|
// The bottom-left corner of the view, growing upward as rows are added
|
||||||
|
// (REQ-UI-CONTROLS-PANEL).
|
||||||
|
int widthPx = qMin(contentWidthPx + chromePx, band.width());
|
||||||
|
|
||||||
|
// The build button bar is centered and sized to its buttons, so it usually leaves
|
||||||
|
// this corner free and the panel can share the bottom edge with it. Only where the
|
||||||
|
// two would actually meet does the panel rise, clearing the bar's top by the same
|
||||||
|
// margin it keeps from the view's edges (REQ-UI-BUILD-BAR). The bar is the only
|
||||||
|
// widget placed before this one, so it is the only rect that can be in the way.
|
||||||
|
const int bottomPx = getAvailableBottomPx(band, occupiedRects, band.left(),
|
||||||
|
band.left() + widthPx - 1, kMarginPx);
|
||||||
|
int heightPx = qMin(wantedHeightPx, qMax(0, bottomPx - band.top() + 1));
|
||||||
|
int topPx = bottomPx - heightPx + 1;
|
||||||
|
|
||||||
|
// Whatever the rows lost to either cap, they scroll for. The scrollbar needs its
|
||||||
|
// own width, or it would appear over the labels.
|
||||||
|
if (heightPx < wantedHeightPx)
|
||||||
|
{
|
||||||
|
widthPx = qMin(widthPx + m_scrollArea->verticalScrollBar()->sizeHint().width(),
|
||||||
|
band.width());
|
||||||
|
}
|
||||||
|
|
||||||
|
setGeometry(band.left(), topPx, widthPx, heightPx);
|
||||||
|
}
|
||||||
87
src/ui/ControlsPanel.h
Normal file
87
src/ui/ControlsPanel.h
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QRect>
|
||||||
|
#include <QString>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include "ControlAction.h"
|
||||||
|
#include "FloatingPanel.h"
|
||||||
|
|
||||||
|
class GameWorldView;
|
||||||
|
class QLabel;
|
||||||
|
class QScrollArea;
|
||||||
|
class QTimer;
|
||||||
|
class QVBoxLayout;
|
||||||
|
|
||||||
|
// Shows the controls available in the player's current situation
|
||||||
|
// (REQ-UI-CONTROLS-PANEL). The panel decides nothing: which rows apply is
|
||||||
|
// ControlAction.h's answer, the same one the key handling and the world view's mouse
|
||||||
|
// dispatch act on, so what is shown and what happens cannot part company
|
||||||
|
// (REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
//
|
||||||
|
// It floats over the game world at the left edge, bottom-aligned within the band its
|
||||||
|
// owner hands it, and collapses to its heading when the heading is clicked.
|
||||||
|
//
|
||||||
|
// Refreshed on a timer rather than by subscribing to events: two of the things that
|
||||||
|
// change a row -- a belt drag starting, the ghost moving over a transfer target --
|
||||||
|
// happen on mouse movement and publish nothing, and they still have to be reflected
|
||||||
|
// while the game is paused, so there is no tick to hang it on either. The rebuild is
|
||||||
|
// skipped unless the resolved content actually differs, which is a vector of enums to
|
||||||
|
// compare.
|
||||||
|
class ControlsPanel : public QWidget, public FloatingPanel
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
// Neither pointer is owned; both must outlive this widget. The view is the source
|
||||||
|
// of the control context, being the widget that owns the build mode and the
|
||||||
|
// selection.
|
||||||
|
ControlsPanel(const GameWorldView* view, QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
// Places the panel in the bottom-left corner of the game world view. It shares the
|
||||||
|
// bottom edge with the build button bar rather than clearing its strip, the bar
|
||||||
|
// being centered and sized to its buttons and so usually leaving the left free; it
|
||||||
|
// rises above the bar only when the two would otherwise overlap
|
||||||
|
// (REQ-UI-CONTROLS-PANEL, REQ-UI-BUILD-BAR). The bar is the only thing placed before
|
||||||
|
// it, so it is the only rect it ever has to rise above.
|
||||||
|
void placeIn(const QRect& viewRect,
|
||||||
|
const std::vector<QRect>& occupiedRects) override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// Clicking the heading collapses and expands the panel (REQ-UI-CONTROLS-PANEL).
|
||||||
|
void mousePressEvent(QMouseEvent* event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Re-resolves the context and rebuilds only if the rows or the heading changed.
|
||||||
|
void refresh();
|
||||||
|
// Replaces the rows with those of the current context.
|
||||||
|
void rebuild(const ControlContext& context);
|
||||||
|
// The heading's "<name> * <detail>" text for the context, detail omitted when the
|
||||||
|
// context has none.
|
||||||
|
QString getHeadingText(const ControlContext& context) const;
|
||||||
|
// Asks for the placement pass to be re-run, the panel's own size having changed.
|
||||||
|
void invalidateLayout();
|
||||||
|
|
||||||
|
const GameWorldView* m_view;
|
||||||
|
|
||||||
|
QLabel* m_heading;
|
||||||
|
// Scrolls the rows once they outgrow the space the panel has (REQ-UI-CONTROLS-PANEL).
|
||||||
|
// The heading is deliberately outside it, so it stays put and stays clickable.
|
||||||
|
QScrollArea* m_scrollArea;
|
||||||
|
QWidget* m_rows;
|
||||||
|
QVBoxLayout* m_rowsLayout;
|
||||||
|
QTimer* m_refreshTimer;
|
||||||
|
|
||||||
|
// What is currently drawn, so a refresh that resolves to the same thing does
|
||||||
|
// nothing. The always-available block is kept separately because the divider
|
||||||
|
// between the two is part of what is drawn.
|
||||||
|
QString m_shownHeading;
|
||||||
|
std::vector<ControlAction> m_shownContextActions;
|
||||||
|
std::vector<ControlAction> m_shownAlwaysActions;
|
||||||
|
|
||||||
|
// Survives context changes and simulation restarts; presentation only, never a
|
||||||
|
// command (REQ-UI-CONTROLS-PANEL).
|
||||||
|
bool m_collapsed = false;
|
||||||
|
};
|
||||||
99
src/ui/ExpandButton.cpp
Normal file
99
src/ui/ExpandButton.cpp
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#include "ExpandButton.h"
|
||||||
|
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "Command.h"
|
||||||
|
#include "CommandRequestedEvent.h"
|
||||||
|
#include "EventManager.h"
|
||||||
|
#include "FloatingLayoutInvalidatedEvent.h"
|
||||||
|
#include "IconCaption.h"
|
||||||
|
#include "ItemIconCache.h"
|
||||||
|
#include "ItemTooltip.h"
|
||||||
|
#include "Simulation.h"
|
||||||
|
#include "TooltipTrigger.h"
|
||||||
|
|
||||||
|
ExpandButton::ExpandButton(const ItemTooltipContext& context, QWidget* parent)
|
||||||
|
: OptionButton(parent)
|
||||||
|
, m_context(context)
|
||||||
|
{
|
||||||
|
QVBoxLayout* face = new QVBoxLayout(this);
|
||||||
|
face->setContentsMargins(8, 4, 8, 4);
|
||||||
|
face->setSpacing(1);
|
||||||
|
|
||||||
|
// Names the action, not an item, so it says nothing of its own and stays out of the
|
||||||
|
// mouse's way -- a click anywhere on the face reaches the button beneath it.
|
||||||
|
QLabel* actionLabel = new QLabel(tr("Expand"), this);
|
||||||
|
actionLabel->setAlignment(Qt::AlignCenter);
|
||||||
|
actionLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
||||||
|
face->addWidget(actionLabel);
|
||||||
|
|
||||||
|
// The cost line, deliberately not transparent to the mouse: it names an item and has
|
||||||
|
// to be hoverable to explain it. Hover only, the click being the button's
|
||||||
|
// (REQ-UI-ITEM-VALUE-TOOLTIP). A label ignores presses, so they still reach the
|
||||||
|
// button.
|
||||||
|
m_costLabel = new QLabel(this);
|
||||||
|
m_costLabel->setAlignment(Qt::AlignCenter);
|
||||||
|
face->addWidget(m_costLabel);
|
||||||
|
ItemTooltip::attachTo(*m_costLabel, m_context, kBlockItemId,
|
||||||
|
TooltipTrigger::Trigger::HoverOnly);
|
||||||
|
|
||||||
|
// Carries no payload: the simulation derives the cost and the column count from its
|
||||||
|
// own expansion counter (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND).
|
||||||
|
connect(this, &QPushButton::clicked, this, []() {
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<CommandRequestedEvent>(
|
||||||
|
std::make_shared<ExpandAsteroidCommand>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
registerForEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
ExpandButton::~ExpandButton()
|
||||||
|
{
|
||||||
|
unregisterForEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ExpandButton::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> /*event*/)
|
||||||
|
{
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ExpandButton::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
|
||||||
|
{
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ExpandButton::refresh()
|
||||||
|
{
|
||||||
|
const int blocks = m_context.sim->getBuildingBlocksStock();
|
||||||
|
const int expansionCost = m_context.sim->getCurrentExpansionCost();
|
||||||
|
|
||||||
|
setEnabled(blocks >= expansionCost);
|
||||||
|
|
||||||
|
// The cost reads as it does everywhere else: the number, then the block icon in place
|
||||||
|
// of a trailing `Blocks` word (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON). With no icon
|
||||||
|
// file the word comes back, since nothing else on the line would name the item.
|
||||||
|
const QPixmap icon = m_context.itemIcons->getInlineIcon(kBlockItemId, font());
|
||||||
|
if (icon.isNull())
|
||||||
|
{
|
||||||
|
m_costLabel->setPixmap(QPixmap());
|
||||||
|
m_costLabel->setText(tr("%1 Blocks").arg(expansionCost));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
m_costLabel->setText(QString());
|
||||||
|
m_costLabel->setPixmap(renderCaptionWithIcon(
|
||||||
|
QString::number(expansionCost), icon, font(),
|
||||||
|
palette().color(isEnabled() ? QPalette::Active : QPalette::Disabled,
|
||||||
|
QPalette::ButtonText)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cost of a different width re-centers the button on its columns, which is the
|
||||||
|
// owner's to do (ui/FloatingPanel.h, MainWindow::placeExpandButton).
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<FloatingLayoutInvalidatedEvent>());
|
||||||
|
}
|
||||||
44
src/ui/ExpandButton.h
Normal file
44
src/ui/ExpandButton.h
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "BuildingBlocksChangedEvent.h"
|
||||||
|
#include "EventHandler.h"
|
||||||
|
#include "ExpansionCostChangedEvent.h"
|
||||||
|
#include "ItemTooltipContext.h"
|
||||||
|
#include "OptionButton.h"
|
||||||
|
|
||||||
|
class QLabel;
|
||||||
|
|
||||||
|
// The asteroid expansion button (REQ-UI-EXPAND-BUTTON): a button that stands in the game
|
||||||
|
// world on the columns the next purchase would unlock, rather than in the header bar.
|
||||||
|
//
|
||||||
|
// It knows what it says and what it costs; where it stands is MainWindow's to decide, the
|
||||||
|
// position being a place in the world and this button knowing nothing of the view's scroll
|
||||||
|
// (MainWindow::placeExpandButton).
|
||||||
|
//
|
||||||
|
// Its face is two lines, `Expand` over the cost, so that the cost is a display naming an
|
||||||
|
// item and can explain it the way every other item value does -- on hover only, the click
|
||||||
|
// belonging to the button (REQ-UI-ITEM-VALUE-TOOLTIP).
|
||||||
|
class ExpandButton : public OptionButton,
|
||||||
|
public CombinedEventHandler<ExpansionCostChangedEvent,
|
||||||
|
BuildingBlocksChangedEvent>
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
// Nothing in the context is owned; all of it must outlive this widget.
|
||||||
|
explicit ExpandButton(const ItemTooltipContext& context, QWidget* parent = nullptr);
|
||||||
|
~ExpandButton() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
|
||||||
|
|
||||||
|
// Re-states the cost and whether the player can pay it. Asks for a fresh placement
|
||||||
|
// pass afterwards, because a wider cost re-centers the button.
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
ItemTooltipContext m_context;
|
||||||
|
QLabel* m_costLabel;
|
||||||
|
};
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
#include "FieldSelectionPanel.h"
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <map>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include <QFont>
|
|
||||||
#include <QLabel>
|
|
||||||
#include <QStringList>
|
|
||||||
#include <QVBoxLayout>
|
|
||||||
|
|
||||||
#include "DebrisSystem.h"
|
|
||||||
#include "DisplayName.h"
|
|
||||||
#include "EntityAdmin.h"
|
|
||||||
#include "FactionComponent.h"
|
|
||||||
#include "GameConfig.h"
|
|
||||||
#include "HealthComponent.h"
|
|
||||||
#include "ModuleOwnerComponent.h"
|
|
||||||
#include "SelectedBehaviorComponent.h"
|
|
||||||
#include "ShipIdentityComponent.h"
|
|
||||||
#include "ShipStatsCalculator.h"
|
|
||||||
#include "ShipStatsPanel.h"
|
|
||||||
#include "Simulation.h"
|
|
||||||
#include "StationBodyComponent.h"
|
|
||||||
#include "ThreatCostCalculator.h"
|
|
||||||
#include "WeaponComponent.h"
|
|
||||||
|
|
||||||
|
|
||||||
FieldSelectionPanel::FieldSelectionPanel(Simulation* sim,
|
|
||||||
const GameConfig* config,
|
|
||||||
QWidget* parent)
|
|
||||||
: QWidget(parent)
|
|
||||||
, m_sim(sim)
|
|
||||||
, m_config(config)
|
|
||||||
{
|
|
||||||
// Zero margins and the same spacing as the enclosing SelectionPanel layout, so
|
|
||||||
// nesting the field widgets in this panel leaves their geometry unchanged.
|
|
||||||
m_layout = new QVBoxLayout(this);
|
|
||||||
m_layout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
m_layout->setSpacing(4);
|
|
||||||
m_layout->setAlignment(Qt::AlignTop);
|
|
||||||
|
|
||||||
m_entityTitleLabel = new QLabel(this);
|
|
||||||
QFont titleFont = m_entityTitleLabel->font();
|
|
||||||
titleFont.setBold(true);
|
|
||||||
m_entityTitleLabel->setFont(titleFont);
|
|
||||||
m_layout->addWidget(m_entityTitleLabel);
|
|
||||||
m_entityTitleLabel->hide();
|
|
||||||
|
|
||||||
m_entityStatsPanel = new ShipStatsPanel(config, this);
|
|
||||||
m_layout->addWidget(m_entityStatsPanel);
|
|
||||||
m_entityStatsPanel->hide();
|
|
||||||
|
|
||||||
m_stationStatsLabel = new QLabel(this);
|
|
||||||
m_stationStatsLabel->setWordWrap(true);
|
|
||||||
m_layout->addWidget(m_stationStatsLabel);
|
|
||||||
m_stationStatsLabel->hide();
|
|
||||||
|
|
||||||
m_entitySummaryLabel = new QLabel(this);
|
|
||||||
m_entitySummaryLabel->setWordWrap(true);
|
|
||||||
m_layout->addWidget(m_entitySummaryLabel);
|
|
||||||
m_entitySummaryLabel->hide();
|
|
||||||
|
|
||||||
m_scrapLabel = new QLabel(this);
|
|
||||||
m_layout->addWidget(m_scrapLabel);
|
|
||||||
m_scrapLabel->hide();
|
|
||||||
|
|
||||||
hide();
|
|
||||||
|
|
||||||
registerForEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
FieldSelectionPanel::~FieldSelectionPanel()
|
|
||||||
{
|
|
||||||
unregisterForEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::setSelectedEntities(const std::vector<entt::entity>& entities)
|
|
||||||
{
|
|
||||||
m_selectedEntities = entities;
|
|
||||||
rebuild();
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::setSelectedDebris(const std::vector<entt::entity>& debris)
|
|
||||||
{
|
|
||||||
m_selectedDebris = debris;
|
|
||||||
rebuild();
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::clearSelection()
|
|
||||||
{
|
|
||||||
m_selectedEntities.clear();
|
|
||||||
m_selectedDebris.clear();
|
|
||||||
rebuild();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool FieldSelectionPanel::hasSelection() const
|
|
||||||
{
|
|
||||||
return !m_selectedEntities.empty() || !m_selectedDebris.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::hideAllWidgets()
|
|
||||||
{
|
|
||||||
m_entityTitleLabel->hide();
|
|
||||||
m_entityStatsPanel->hide();
|
|
||||||
m_stationStatsLabel->hide();
|
|
||||||
m_entitySummaryLabel->hide();
|
|
||||||
m_scrapLabel->hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::rebuild()
|
|
||||||
{
|
|
||||||
if (!hasSelection())
|
|
||||||
{
|
|
||||||
// Nothing in the field category: take no space, leaving the panel to whatever
|
|
||||||
// the building category shows (REQ-UI-SELECTION-CATEGORIES).
|
|
||||||
hideAllWidgets();
|
|
||||||
hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
show();
|
|
||||||
|
|
||||||
EntityAdmin& admin = m_sim->getAdmin();
|
|
||||||
|
|
||||||
// A full single-object stats panel is shown only for a lone field object: one actor
|
|
||||||
// with no debris, or one piece of debris with no actors. As soon as the selection holds
|
|
||||||
// more than one object (multiple actors, multiple debris, or actors plus debris), the
|
|
||||||
// panel switches to the compact count summary (REQ-UI-FIELD-MULTI-SELECTION).
|
|
||||||
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
|
|
||||||
{
|
|
||||||
m_entitySummaryLabel->hide();
|
|
||||||
m_scrapLabel->hide();
|
|
||||||
const entt::entity entity = m_selectedEntities.front();
|
|
||||||
if (admin.isValid(entity) && admin.hasAll<ShipIdentityComponent>(entity))
|
|
||||||
{
|
|
||||||
buildEntityShip(entity);
|
|
||||||
}
|
|
||||||
else if (admin.isValid(entity) && admin.hasAll<StationBodyComponent>(entity))
|
|
||||||
{
|
|
||||||
buildEntityStation(entity);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
m_entityTitleLabel->hide();
|
|
||||||
m_entityStatsPanel->hide();
|
|
||||||
m_stationStatsLabel->hide();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
|
|
||||||
{
|
|
||||||
// Single piece of debris: a "Debris" heading plus a "Scrap" stat row, styled like
|
|
||||||
// the ship/station stats panels (REQ-UI-DEBRIS-PANEL).
|
|
||||||
m_entitySummaryLabel->hide();
|
|
||||||
m_entityStatsPanel->hide();
|
|
||||||
m_stationStatsLabel->hide();
|
|
||||||
buildDebrisSingle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// More than one field object: a compact count summary. buildEntitySummary() appends the
|
|
||||||
// "Debris x N" and "Scrap x N" lines when debris is part of the selection.
|
|
||||||
m_entityTitleLabel->hide();
|
|
||||||
m_entityStatsPanel->hide();
|
|
||||||
m_stationStatsLabel->hide();
|
|
||||||
m_scrapLabel->hide();
|
|
||||||
buildEntitySummary();
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::refreshDisplay()
|
|
||||||
{
|
|
||||||
if (!hasSelection()) { return; }
|
|
||||||
|
|
||||||
// Keep the live values current: the single-actor stats panel, the single-debris stats
|
|
||||||
// panel (whose Scrap row shrinks as it is collected), or the count summary (whose Scrap
|
|
||||||
// line shrinks likewise) — matching the layout chosen by rebuild()
|
|
||||||
// (REQ-UI-SHIP-STATS-PANEL, REQ-UI-DEBRIS-PANEL).
|
|
||||||
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
|
|
||||||
{
|
|
||||||
refreshEntityStats();
|
|
||||||
}
|
|
||||||
else if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
|
|
||||||
{
|
|
||||||
buildDebrisSingle();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
buildEntitySummary();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::buildDebrisSingle()
|
|
||||||
{
|
|
||||||
// "Debris" heading + a single "Scrap" stat row for the piece's remaining amount,
|
|
||||||
// mirroring the single-actor stats panels (REQ-UI-DEBRIS-PANEL).
|
|
||||||
m_entityTitleLabel->setText(tr("Debris"));
|
|
||||||
m_entityTitleLabel->show();
|
|
||||||
m_scrapLabel->setText(tr("Scrap: %1").arg(selectedDebrisScrapTotal()));
|
|
||||||
m_scrapLabel->show();
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::buildEntitySummary()
|
|
||||||
{
|
|
||||||
EntityAdmin& admin = m_sim->getAdmin();
|
|
||||||
|
|
||||||
// Group actors by faction + kind + ship schematic, preserving first-seen order
|
|
||||||
// (REQ-UI-FIELD-MULTI-SELECTION).
|
|
||||||
std::vector<QString> keys;
|
|
||||||
std::map<QString, int> counts;
|
|
||||||
std::map<QString, QString> labels;
|
|
||||||
|
|
||||||
for (entt::entity entity : m_selectedEntities)
|
|
||||||
{
|
|
||||||
if (!admin.isValid(entity)) { continue; }
|
|
||||||
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
|
|
||||||
&& admin.get<FactionComponent>(entity).isEnemy;
|
|
||||||
|
|
||||||
QString key;
|
|
||||||
QString label;
|
|
||||||
if (admin.hasAll<ShipIdentityComponent>(entity))
|
|
||||||
{
|
|
||||||
const std::string& id = admin.get<ShipIdentityComponent>(entity).schematicId;
|
|
||||||
const QString name = QString::fromStdString(toDisplayName(id));
|
|
||||||
key = (isEnemy ? QStringLiteral("ship:enemy:") : QStringLiteral("ship:player:"))
|
|
||||||
+ QString::fromStdString(id);
|
|
||||||
label = isEnemy ? tr("Enemy %1").arg(name) : name;
|
|
||||||
}
|
|
||||||
else if (admin.hasAll<StationBodyComponent>(entity))
|
|
||||||
{
|
|
||||||
key = isEnemy ? QStringLiteral("station:enemy") : QStringLiteral("station:player");
|
|
||||||
label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (counts.find(key) == counts.end())
|
|
||||||
{
|
|
||||||
keys.push_back(key);
|
|
||||||
labels[key] = label;
|
|
||||||
}
|
|
||||||
counts[key] += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One "<type> x <count>" line per group (matching the recipe tooltip and the building
|
|
||||||
// multi-selection). No total-count header, consistent with the building panel. When
|
|
||||||
// debris is part of the selection, a "Debris x <count>" line followed by a
|
|
||||||
// "Scrap x <total>" line are appended into the same label so the line spacing is
|
|
||||||
// uniform (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
|
|
||||||
QStringList lines;
|
|
||||||
for (const QString& key : keys)
|
|
||||||
{
|
|
||||||
lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]);
|
|
||||||
}
|
|
||||||
if (!m_selectedDebris.empty())
|
|
||||||
{
|
|
||||||
lines << tr("Debris x %1").arg(static_cast<int>(m_selectedDebris.size()));
|
|
||||||
lines << scrapTotalText();
|
|
||||||
}
|
|
||||||
m_entitySummaryLabel->setText(lines.join('\n'));
|
|
||||||
m_entitySummaryLabel->show();
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::buildEntityShip(entt::entity entity)
|
|
||||||
{
|
|
||||||
EntityAdmin& admin = m_sim->getAdmin();
|
|
||||||
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(entity);
|
|
||||||
const HealthComponent& health = admin.get<HealthComponent>(entity);
|
|
||||||
|
|
||||||
m_entityTitleLabel->setText(tr("Ship: %1")
|
|
||||||
.arg(QString::fromStdString(identity.schematicId)));
|
|
||||||
m_entityTitleLabel->show();
|
|
||||||
|
|
||||||
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
|
|
||||||
m_entityStatsPanel->refreshFromLive(stats, health.hp);
|
|
||||||
m_entityStatsPanel->setBehavior(
|
|
||||||
admin.get<SelectedBehaviorComponent>(entity).winner);
|
|
||||||
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
|
|
||||||
|
|
||||||
const ShipDef* schematicDef =
|
|
||||||
m_config->ships.findShipDef(identity.schematicId);
|
|
||||||
if (schematicDef)
|
|
||||||
{
|
|
||||||
const double threat = calculateShipThreatCost(
|
|
||||||
m_config->threatCosts, *m_config, schematicDef->id,
|
|
||||||
schematicDef->defaultModules);
|
|
||||||
m_entityStatsPanel->setThreatCost(threat);
|
|
||||||
}
|
|
||||||
|
|
||||||
m_entityStatsPanel->show();
|
|
||||||
|
|
||||||
m_stationStatsLabel->hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::buildEntityStation(entt::entity entity)
|
|
||||||
{
|
|
||||||
EntityAdmin& admin = m_sim->getAdmin();
|
|
||||||
const HealthComponent& health = admin.get<HealthComponent>(entity);
|
|
||||||
|
|
||||||
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
|
|
||||||
&& admin.get<FactionComponent>(entity).isEnemy;
|
|
||||||
m_entityTitleLabel->setText(isEnemy
|
|
||||||
? tr("Enemy Defence Station")
|
|
||||||
: tr("Player Defence Station"));
|
|
||||||
m_entityTitleLabel->show();
|
|
||||||
|
|
||||||
float totalDps = 0.0f;
|
|
||||||
float maxRange = 0.0f;
|
|
||||||
bool hasWeapons = false;
|
|
||||||
|
|
||||||
admin.forEach<ModuleOwnerComponent, WeaponComponent>(
|
|
||||||
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w)
|
|
||||||
{
|
|
||||||
if (owner.owner != entity) { return; }
|
|
||||||
hasWeapons = true;
|
|
||||||
totalDps += w.damage * w.fireRateHz;
|
|
||||||
if (w.range_tiles > maxRange) { maxRange = w.range_tiles; }
|
|
||||||
});
|
|
||||||
|
|
||||||
QString statsText = tr("HP: %1 / %2")
|
|
||||||
.arg(static_cast<int>(health.hp + 0.5f))
|
|
||||||
.arg(static_cast<int>(health.maxHp + 0.5f));
|
|
||||||
|
|
||||||
if (hasWeapons)
|
|
||||||
{
|
|
||||||
statsText += tr("\nDPS: %1").arg(QString::number(static_cast<double>(totalDps), 'f', 1));
|
|
||||||
statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast<double>(maxRange), 'f', 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
m_stationStatsLabel->setText(statsText);
|
|
||||||
m_stationStatsLabel->show();
|
|
||||||
|
|
||||||
m_entityStatsPanel->hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::refreshEntityStats()
|
|
||||||
{
|
|
||||||
// Only the single-actor stats panel needs a live refresh; the multi-actor summary is
|
|
||||||
// static counts, and GameWorldView prunes dead/despawned actors and re-emits the
|
|
||||||
// selection (REQ-UI-ENTITY-CLICK-SELECT), so the panel does not mutate it here.
|
|
||||||
if (m_selectedEntities.size() != 1) { return; }
|
|
||||||
|
|
||||||
EntityAdmin& admin = m_sim->getAdmin();
|
|
||||||
const entt::entity entity = m_selectedEntities.front();
|
|
||||||
|
|
||||||
if (!admin.isValid(entity) || !admin.hasAll<HealthComponent>(entity)) { return; }
|
|
||||||
const HealthComponent& health = admin.get<HealthComponent>(entity);
|
|
||||||
if (health.hp <= 0.0f) { return; }
|
|
||||||
|
|
||||||
if (admin.hasAll<ShipIdentityComponent>(entity))
|
|
||||||
{
|
|
||||||
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
|
|
||||||
m_entityStatsPanel->refreshFromLive(stats, health.hp);
|
|
||||||
m_entityStatsPanel->setBehavior(
|
|
||||||
admin.get<SelectedBehaviorComponent>(entity).winner);
|
|
||||||
}
|
|
||||||
else if (admin.hasAll<StationBodyComponent>(entity))
|
|
||||||
{
|
|
||||||
buildEntityStation(entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int FieldSelectionPanel::selectedDebrisScrapTotal() const
|
|
||||||
{
|
|
||||||
// Sum the remaining scrap across the still-living selected debris (REQ-UI-DEBRIS-PANEL).
|
|
||||||
int total = 0;
|
|
||||||
for (const DebrisInfo& info : getAllDebrisInfo(m_sim->getAdmin()))
|
|
||||||
{
|
|
||||||
if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity)
|
|
||||||
!= m_selectedDebris.end())
|
|
||||||
{
|
|
||||||
total += info.amount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString FieldSelectionPanel::scrapTotalText() const
|
|
||||||
{
|
|
||||||
return tr("Scrap x %1").arg(selectedDebrisScrapTotal());
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
|
|
||||||
{
|
|
||||||
refreshDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::handleEvent(
|
|
||||||
std::shared_ptr<const PlayerCommandsAppliedEvent> /*event*/)
|
|
||||||
{
|
|
||||||
// Player commands are applied by a queued drain, not synchronously. When the game is
|
|
||||||
// paused no tick advances, so TickAdvancedEvent never fires; refresh here too.
|
|
||||||
refreshDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
void FieldSelectionPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)
|
|
||||||
{
|
|
||||||
m_debugDraw = event->active;
|
|
||||||
m_entityStatsPanel->setDebugDrawEnabled(event->active);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include <QString>
|
|
||||||
#include <QWidget>
|
|
||||||
|
|
||||||
#include "entt/entity/entity.hpp"
|
|
||||||
|
|
||||||
#include "DebugDrawToggledEvent.h"
|
|
||||||
#include "EventHandler.h"
|
|
||||||
#include "PlayerCommandsAppliedEvent.h"
|
|
||||||
#include "TickAdvancedEvent.h"
|
|
||||||
|
|
||||||
struct GameConfig;
|
|
||||||
class Simulation;
|
|
||||||
class ShipStatsPanel;
|
|
||||||
class QLabel;
|
|
||||||
class QVBoxLayout;
|
|
||||||
|
|
||||||
// Renders the "field" selection category — ships, defence stations and debris — as either
|
|
||||||
// a single-object stats panel (ship, station, or debris) or a compact multi-object count
|
|
||||||
// summary (REQ-UI-SELECTION-CATEGORIES, REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
|
|
||||||
//
|
|
||||||
// The panel owns its own selection state and its own widgets, and nothing else. Which of
|
|
||||||
// the two selection categories owns the selection panel is arbitrated by the parent
|
|
||||||
// SelectionPanel: it feeds this panel through setSelectedEntities() /
|
|
||||||
// setSelectedDebris() / clearSelection() and asks it via hasSelection(). This panel hides
|
|
||||||
// itself whenever its selection is empty, so an inactive field category takes no space.
|
|
||||||
class FieldSelectionPanel : public QWidget,
|
|
||||||
public CombinedEventHandler<TickAdvancedEvent,
|
|
||||||
PlayerCommandsAppliedEvent,
|
|
||||||
DebugDrawToggledEvent>
|
|
||||||
{
|
|
||||||
Q_OBJECT
|
|
||||||
|
|
||||||
public:
|
|
||||||
FieldSelectionPanel(Simulation* sim, const GameConfig* config,
|
|
||||||
QWidget* parent = nullptr);
|
|
||||||
~FieldSelectionPanel() override;
|
|
||||||
|
|
||||||
// Replaces the selected actors (ships and defence stations); debris is left alone,
|
|
||||||
// the two coexist within the field category (REQ-UI-SELECTION-CATEGORIES).
|
|
||||||
void setSelectedEntities(const std::vector<entt::entity>& entities);
|
|
||||||
// Replaces the selected debris; the selected actors are left alone.
|
|
||||||
void setSelectedDebris(const std::vector<entt::entity>& debris);
|
|
||||||
// Drops the whole field selection — used when the building category takes over.
|
|
||||||
void clearSelection();
|
|
||||||
// True while the field category has anything selected, i.e. while this panel owns
|
|
||||||
// the selection panel's content.
|
|
||||||
bool hasSelection() const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
|
|
||||||
void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override;
|
|
||||||
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
|
|
||||||
|
|
||||||
// Picks the layout for the current selection and shows/hides this panel accordingly.
|
|
||||||
void rebuild();
|
|
||||||
// Keeps the live values of the layout chosen by rebuild() current.
|
|
||||||
void refreshDisplay();
|
|
||||||
void buildEntityShip(entt::entity entity);
|
|
||||||
void buildEntityStation(entt::entity entity);
|
|
||||||
void buildEntitySummary();
|
|
||||||
void buildDebrisSingle();
|
|
||||||
void refreshEntityStats();
|
|
||||||
void hideAllWidgets();
|
|
||||||
// Summed remaining scrap across the selected debris (REQ-UI-DEBRIS-PANEL).
|
|
||||||
int selectedDebrisScrapTotal() const;
|
|
||||||
// "Scrap x N" line for the multi-object summary (REQ-UI-FIELD-MULTI-SELECTION).
|
|
||||||
QString scrapTotalText() const;
|
|
||||||
|
|
||||||
Simulation* m_sim;
|
|
||||||
const GameConfig* m_config;
|
|
||||||
|
|
||||||
bool m_debugDraw = false;
|
|
||||||
|
|
||||||
// The selected ships/defence stations. Shares the "field" selection category with
|
|
||||||
// debris (m_selectedDebris): both can be non-empty at once (REQ-UI-SELECTION-CATEGORIES).
|
|
||||||
std::vector<entt::entity> m_selectedEntities;
|
|
||||||
std::vector<entt::entity> m_selectedDebris;
|
|
||||||
|
|
||||||
QVBoxLayout* m_layout;
|
|
||||||
QLabel* m_entityTitleLabel;
|
|
||||||
ShipStatsPanel* m_entityStatsPanel;
|
|
||||||
QLabel* m_stationStatsLabel;
|
|
||||||
QLabel* m_entitySummaryLabel;
|
|
||||||
// Shows the debris "Scrap" stat row (single selection) — the scrap total for the
|
|
||||||
// multi-object summary lives in m_entitySummaryLabel instead.
|
|
||||||
QLabel* m_scrapLabel;
|
|
||||||
};
|
|
||||||
27
src/ui/FloatingPanel.h
Normal file
27
src/ui/FloatingPanel.h
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QRect>
|
||||||
|
|
||||||
|
// A widget floating over the game world view (REQ-UI-WORLD-SIZE). MainWindow places all
|
||||||
|
// of them in one ordered pass -- build button bar, then controls panel, then selection
|
||||||
|
// panel -- and each places itself into the space the earlier ones have not taken. The
|
||||||
|
// order is the priority the requirements state: the bar never moves for anyone
|
||||||
|
// (REQ-UI-BUILD-BAR), the controls panel steps around the bar (REQ-UI-CONTROLS-PANEL),
|
||||||
|
// and keeping clear of both is the selection panel's job (REQ-UI-SELECTION-PANEL).
|
||||||
|
//
|
||||||
|
// A widget never re-places itself, because what it may take depends on the widgets placed
|
||||||
|
// before it. It instead publishes FloatingLayoutInvalidatedEvent whenever its content or
|
||||||
|
// its visibility changed, and the owner re-runs the whole pass.
|
||||||
|
class FloatingPanel
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
virtual ~FloatingPanel() = default;
|
||||||
|
|
||||||
|
// Sets this widget's own geometry within viewRect, keeping clear of occupiedRects --
|
||||||
|
// the geometry of every floating widget already placed in this pass, in the same
|
||||||
|
// coordinates. A widget with nothing to show hides itself and takes no space.
|
||||||
|
virtual void placeIn(const QRect& viewRect,
|
||||||
|
const std::vector<QRect>& occupiedRects) = 0;
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "GameWorldView.h"
|
#include "GameWorldView.h"
|
||||||
#include "PlacementRules.h"
|
#include "PlacementRules.h"
|
||||||
#include "FactoryQueries.h"
|
#include "FactoryQueries.h"
|
||||||
|
#include "BlueprintLibrary.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -46,6 +47,8 @@
|
|||||||
#include "ItemIconCache.h"
|
#include "ItemIconCache.h"
|
||||||
#include "PositionComponent.h"
|
#include "PositionComponent.h"
|
||||||
#include "DebrisSystem.h"
|
#include "DebrisSystem.h"
|
||||||
|
#include "SelectionAnchorChangedEvent.h"
|
||||||
|
#include "SelectionBounds.h"
|
||||||
#include "SelectionChangedEvent.h"
|
#include "SelectionChangedEvent.h"
|
||||||
#include "ShipIdentityComponent.h"
|
#include "ShipIdentityComponent.h"
|
||||||
#include "ShipSystem.h"
|
#include "ShipSystem.h"
|
||||||
@@ -65,6 +68,7 @@
|
|||||||
#include "BuildingBlocksChangedEvent.h"
|
#include "BuildingBlocksChangedEvent.h"
|
||||||
#include "UnlockedBuildingsChangedEvent.h"
|
#include "UnlockedBuildingsChangedEvent.h"
|
||||||
#include "ExpansionCostChangedEvent.h"
|
#include "ExpansionCostChangedEvent.h"
|
||||||
|
#include "ViewScrolledEvent.h"
|
||||||
#include "GameSpeedChangedEvent.h"
|
#include "GameSpeedChangedEvent.h"
|
||||||
#include "SchematicChoicesAvailableEvent.h"
|
#include "SchematicChoicesAvailableEvent.h"
|
||||||
#include "PlayerCommandsAppliedEvent.h"
|
#include "PlayerCommandsAppliedEvent.h"
|
||||||
@@ -73,6 +77,23 @@
|
|||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
|
||||||
|
// The action a left-button gesture triggers, with the Ctrl variant falling back to the
|
||||||
|
// plain one wherever nothing is bound to it. That fallback is what keeps Ctrl+click
|
||||||
|
// placing a building in builder mode and Ctrl+drag deconstructing an area: the modifier
|
||||||
|
// only means something where an action claims it (REQ-UI-CONTROLS-CONTENT).
|
||||||
|
ControlAction resolveLeftGesture(bool controlHeld, bool isDrag,
|
||||||
|
const ControlContext& context)
|
||||||
|
{
|
||||||
|
if (controlHeld)
|
||||||
|
{
|
||||||
|
const ControlAction action = resolveMouseAction(
|
||||||
|
isDrag ? MouseBinding::CtrlLeftDrag : MouseBinding::CtrlLeftClick, context);
|
||||||
|
if (action != ControlAction::None) { return action; }
|
||||||
|
}
|
||||||
|
return resolveMouseAction(isDrag ? MouseBinding::LeftDrag : MouseBinding::LeftClick,
|
||||||
|
context);
|
||||||
|
}
|
||||||
|
|
||||||
// Keep only the filter entries whose item type is currently unlocked
|
// Keep only the filter entries whose item type is currently unlocked
|
||||||
// (REQ-LOCK-UI-BLUEPRINT). An empty result means "accept all".
|
// (REQ-LOCK-UI-BLUEPRINT). An empty result means "accept all".
|
||||||
std::vector<ItemType> filterUnlockedItems(const std::vector<ItemType>& filter,
|
std::vector<ItemType> filterUnlockedItems(const std::vector<ItemType>& filter,
|
||||||
@@ -128,6 +149,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)
|
||||||
{
|
{
|
||||||
@@ -274,12 +296,21 @@ void GameWorldView::onFrame()
|
|||||||
const bool viewMoved =
|
const bool viewMoved =
|
||||||
m_camera.advance(m_panDirection, elapsed, getScrollBounds());
|
m_camera.advance(m_panDirection, elapsed, getScrollBounds());
|
||||||
|
|
||||||
// While the view scrolls, the tile under a stationary cursor changes,
|
// Two things no mouse move reports: the world position under a stationary
|
||||||
// so refresh the box-select rectangle even though no mouse move fires.
|
// cursor changing as the view scrolls, and the cursor crossing onto a panel
|
||||||
if (m_boxSelecting && viewMoved)
|
// or out of the window, which ends the hover (REQ-BLD-GHOST).
|
||||||
|
if (viewMoved || isHoverLive() != m_hoverLive)
|
||||||
{
|
{
|
||||||
m_boxCurrentTile =
|
refreshHover();
|
||||||
getCoordinates().widgetToTile(mapFromGlobal(QCursor::pos()));
|
}
|
||||||
|
|
||||||
|
// The world-to-widget transform has changed, which no other event reports. What
|
||||||
|
// stands in the world rather than on the screen re-places itself on this
|
||||||
|
// (REQ-UI-EXPAND-BUTTON).
|
||||||
|
if (viewMoved)
|
||||||
|
{
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<ViewScrolledEvent>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,8 +431,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};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -723,8 +760,10 @@ void GameWorldView::transferConfigTo(BuildingId id, const BlueprintBuilding& sou
|
|||||||
void GameWorldView::updateTunnelGhost()
|
void GameWorldView::updateTunnelGhost()
|
||||||
{
|
{
|
||||||
// The connection preview and entry/exit switch only apply at a valid placement
|
// The connection preview and entry/exit switch only apply at a valid placement
|
||||||
// (REQ-BLD-TUNNEL-MODE); at an invalid position the ghost stays a plain entry.
|
// (REQ-BLD-TUNNEL-MODE); at an invalid position, and where the cursor points at
|
||||||
if (!m_buildMode.isGhostValid())
|
// no tile at all, the ghost stays a plain entry.
|
||||||
|
const std::optional<QPoint>& ghostTile = m_buildMode.getGhostTile();
|
||||||
|
if (!ghostTile.has_value() || !m_buildMode.isGhostValid())
|
||||||
{
|
{
|
||||||
m_buildMode.setTunnelGhost(BuildingType::TunnelEntry, std::nullopt);
|
m_buildMode.setTunnelGhost(BuildingType::TunnelEntry, std::nullopt);
|
||||||
return;
|
return;
|
||||||
@@ -734,7 +773,7 @@ void GameWorldView::updateTunnelGhost()
|
|||||||
const TunnelLookup lookup = makeTunnelLookup(tunnels);
|
const TunnelLookup lookup = makeTunnelLookup(tunnels);
|
||||||
|
|
||||||
const TunnelCompletion completion =
|
const TunnelCompletion completion =
|
||||||
resolveTunnelCompletion(lookup, m_buildMode.getGhostTile(),
|
resolveTunnelCompletion(lookup, *ghostTile,
|
||||||
m_buildMode.getGhostRotation(),
|
m_buildMode.getGhostRotation(),
|
||||||
m_config->world.tunnelMaxDistance_tiles, m_cursorWorldPos);
|
m_config->world.tunnelMaxDistance_tiles, m_cursorWorldPos);
|
||||||
m_buildMode.setTunnelGhost(completion.resolvedType, completion.partnerTile);
|
m_buildMode.setTunnelGhost(completion.resolvedType, completion.partnerTile);
|
||||||
@@ -1075,7 +1114,7 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
|
|||||||
// Keys are turned into actions and published by the input mapper
|
// Keys are turned into actions and published by the input mapper
|
||||||
// (REQ-UI-HOTKEYS); this widget reacts to those as an ordinary subscriber, so
|
// (REQ-UI-HOTKEYS); this widget reacts to those as an ordinary subscriber, so
|
||||||
// nothing is handled here directly.
|
// nothing is handled here directly.
|
||||||
if (m_inputMapper.handleKeyPress(event)) { return; }
|
if (m_inputMapper.handleKeyPress(event, getControlContext())) { return; }
|
||||||
|
|
||||||
QOpenGLWidget::keyPressEvent(event);
|
QOpenGLWidget::keyPressEvent(event);
|
||||||
}
|
}
|
||||||
@@ -1087,7 +1126,7 @@ void GameWorldView::keyReleaseEvent(QKeyEvent* event)
|
|||||||
QOpenGLWidget::keyReleaseEvent(event);
|
QOpenGLWidget::keyReleaseEvent(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (m_inputMapper.handleKeyRelease(event)) { return; }
|
if (m_inputMapper.handleKeyRelease(event, getControlContext())) { return; }
|
||||||
QOpenGLWidget::keyReleaseEvent(event);
|
QOpenGLWidget::keyReleaseEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,36 +1140,98 @@ void GameWorldView::focusOutEvent(QFocusEvent* event)
|
|||||||
QOpenGLWidget::focusOutEvent(event);
|
QOpenGLWidget::focusOutEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GameWorldView::setBlueprintLibrary(const BlueprintLibrary* library)
|
||||||
|
{
|
||||||
|
m_blueprintLibrary = library;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlContext GameWorldView::getControlContext() const
|
||||||
|
{
|
||||||
|
ControlContext context;
|
||||||
|
context.mode = m_buildMode.getMode();
|
||||||
|
context.draggingBelt = m_buildMode.isDraggingBelt();
|
||||||
|
context.hoveredGhostIsTransfer = m_buildMode.isHoveredGhostTransfer();
|
||||||
|
// The type a click would actually place, so the panel agrees with the ghost when
|
||||||
|
// tunnel mode resolves to an exit (REQ-BLD-TUNNEL-MODE).
|
||||||
|
if (m_buildMode.isBuilderMode())
|
||||||
|
{
|
||||||
|
context.builderType = m_buildMode.getEffectiveBuilderType();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buildings win over field objects, so the two are never both non-empty
|
||||||
|
// (REQ-UI-SELECTION-CATEGORIES).
|
||||||
|
const std::vector<BuildingId>& buildings = m_selection.getSelectedBuildings();
|
||||||
|
const std::vector<entt::entity>& actors = m_selection.getSelectedActors();
|
||||||
|
const std::vector<entt::entity>& debris = m_selection.getSelectedDebris();
|
||||||
|
if (!buildings.empty())
|
||||||
|
{
|
||||||
|
context.selection = ControlSelection::Buildings;
|
||||||
|
context.selectionCount = static_cast<int>(buildings.size());
|
||||||
|
}
|
||||||
|
else if (!actors.empty() || !debris.empty())
|
||||||
|
{
|
||||||
|
context.selection = ControlSelection::FieldObjects;
|
||||||
|
context.selectionCount = static_cast<int>(actors.size() + debris.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_blueprintLibrary)
|
||||||
|
{
|
||||||
|
context.placeableBuildingSelected = m_blueprintLibrary->getCanCaptureSelection();
|
||||||
|
context.temporaryBlueprintExists = m_blueprintLibrary->getHasTemporaryBlueprint();
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString GameWorldView::getActiveBlueprintName() const
|
||||||
|
{
|
||||||
|
if (!m_buildMode.isBlueprintMode()) { return QString(); }
|
||||||
|
return m_buildMode.getBlueprint().name;
|
||||||
|
}
|
||||||
|
|
||||||
void GameWorldView::mousePressEvent(QMouseEvent* event)
|
void GameWorldView::mousePressEvent(QMouseEvent* event)
|
||||||
{
|
{
|
||||||
const WorldCoordinates coordinates = getCoordinates();
|
const WorldCoordinates coordinates = getCoordinates();
|
||||||
|
const ControlContext context = getControlContext();
|
||||||
|
|
||||||
if (event->button() != Qt::LeftButton)
|
if (event->button() != Qt::LeftButton)
|
||||||
{
|
{
|
||||||
if (event->button() == Qt::RightButton)
|
if (event->button() == Qt::RightButton)
|
||||||
{
|
{
|
||||||
if (m_buildMode.isBuilderMode() && m_buildMode.isDraggingBelt())
|
switch (resolveMouseAction(MouseBinding::RightClick, context))
|
||||||
{
|
{
|
||||||
// Cancel the in-progress belt drag without placing anything;
|
case ControlAction::CancelBeltLine:
|
||||||
// stay in belt builder mode (REQ-BLD-BELT-DRAG).
|
// Drop the in-progress path without placing anything; belt builder
|
||||||
|
// mode stays active (REQ-BLD-BELT-DRAG).
|
||||||
m_buildMode.cancelBeltDrag();
|
m_buildMode.cancelBeltDrag();
|
||||||
}
|
break;
|
||||||
else if (m_buildMode.getMode() != BuildMode::None)
|
case ControlAction::ExitMode:
|
||||||
{
|
|
||||||
m_buildMode.exitCurrentMode();
|
m_buildMode.exitCurrentMode();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QPoint tile = coordinates.widgetToTile(event->pos());
|
const QPoint tile = coordinates.widgetToTile(event->pos());
|
||||||
|
const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0;
|
||||||
|
|
||||||
if (m_buildMode.isBuilderMode())
|
// A press begins the click gesture; whether it turns out to be a drag is settled on
|
||||||
|
// release, where the drag binding is resolved instead.
|
||||||
|
switch (resolveLeftGesture(controlHeld, /*isDrag*/ false, context))
|
||||||
{
|
{
|
||||||
if (m_buildMode.getBuilderType() == BuildingType::Belt)
|
case ControlAction::Place:
|
||||||
|
case ControlAction::ApplySettings:
|
||||||
|
if (m_buildMode.isBlueprintMode())
|
||||||
{
|
{
|
||||||
// Deferred placement: start the drag and show the path ghost; nothing
|
placeBlueprintAtTile(tile);
|
||||||
// is placed until release (REQ-BLD-BELT-DRAG).
|
}
|
||||||
|
else if (m_buildMode.getBuilderType() == BuildingType::Belt)
|
||||||
|
{
|
||||||
|
// Belts place by dragging, so the press only anchors the path and shows its
|
||||||
|
// ghost; nothing is placed until release, and a plain click is the one-tile
|
||||||
|
// case (REQ-BLD-BELT-DRAG).
|
||||||
m_buildMode.beginBeltDrag(tile);
|
m_buildMode.beginBeltDrag(tile);
|
||||||
m_cursorWorldPos = coordinates.widgetToWorld(event->pos());
|
m_cursorWorldPos = coordinates.widgetToWorld(event->pos());
|
||||||
recomputeBeltDragPath(tile);
|
recomputeBeltDragPath(tile);
|
||||||
@@ -1139,34 +1240,35 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
|||||||
{
|
{
|
||||||
placeAtTile(tile);
|
placeAtTile(tile);
|
||||||
}
|
}
|
||||||
}
|
break;
|
||||||
else if (m_buildMode.isBlueprintMode())
|
|
||||||
{
|
case ControlAction::ToggleDeconstruct:
|
||||||
placeBlueprintAtTile(tile);
|
|
||||||
}
|
|
||||||
else if (m_buildMode.isDeconstructMode())
|
|
||||||
{
|
|
||||||
// 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;
|
||||||
else
|
break;
|
||||||
{
|
|
||||||
const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0;
|
|
||||||
|
|
||||||
|
case ControlAction::Select:
|
||||||
|
case ControlAction::AddToSelection:
|
||||||
// Only a click that hit nothing starts a box drag. Starting one on a hit
|
// Only a click that hit nothing starts a box drag. Starting one on a hit
|
||||||
// would re-resolve the same object as a 1x1 box on release and undo the
|
// would re-resolve the same object as a 1x1 box on release and undo the
|
||||||
// click: a Ctrl+click would toggle the building off, then straight back on.
|
// click: a Ctrl+click would toggle the building off, then straight back on.
|
||||||
if (!selectAtPoint(tile, coordinates.widgetToWorld(event->pos()), ctrl))
|
if (!selectAtPoint(tile, coordinates.widgetToWorld(event->pos()), controlHeld))
|
||||||
{
|
{
|
||||||
// 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;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1181,6 +1283,7 @@ bool GameWorldView::selectAtPoint(QPoint tile, QVector2D worldPos, bool additive
|
|||||||
if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); }
|
if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); }
|
||||||
if (buildingHit.has_value())
|
if (buildingHit.has_value())
|
||||||
{
|
{
|
||||||
|
publishSelectionAnchor(mode, {*buildingHit}, {}, {});
|
||||||
m_selection.selectBuildings({*buildingHit}, mode);
|
m_selection.selectBuildings({*buildingHit}, mode);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1188,6 +1291,7 @@ bool GameWorldView::selectAtPoint(QPoint tile, QVector2D worldPos, bool additive
|
|||||||
const entt::entity actorHit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
|
const entt::entity actorHit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
|
||||||
if (actorHit != entt::null)
|
if (actorHit != entt::null)
|
||||||
{
|
{
|
||||||
|
publishSelectionAnchor(mode, {}, {actorHit}, {});
|
||||||
m_selection.selectFieldObjects({actorHit}, {}, mode);
|
m_selection.selectFieldObjects({actorHit}, {}, mode);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1195,6 +1299,7 @@ bool GameWorldView::selectAtPoint(QPoint tile, QVector2D worldPos, bool additive
|
|||||||
const entt::entity debrisHit = debrisAtWorldPos(m_sim->getAdmin(), worldPos);
|
const entt::entity debrisHit = debrisAtWorldPos(m_sim->getAdmin(), worldPos);
|
||||||
if (debrisHit != entt::null)
|
if (debrisHit != entt::null)
|
||||||
{
|
{
|
||||||
|
publishSelectionAnchor(mode, {}, {}, {debrisHit});
|
||||||
m_selection.selectFieldObjects({}, {debrisHit}, mode);
|
m_selection.selectFieldObjects({}, {debrisHit}, mode);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1211,20 +1316,22 @@ 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, {}, {});
|
||||||
m_selection.selectBuildings(boxIds, mode);
|
m_selection.selectBuildings(boxIds, mode);
|
||||||
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);
|
||||||
m_selection.selectFieldObjects(boxActors, boxDebris, mode);
|
m_selection.selectFieldObjects(boxActors, boxDebris, mode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1233,11 +1340,43 @@ void GameWorldView::selectInBox(bool additive)
|
|||||||
if (!additive) { m_selection.clearAll(); }
|
if (!additive) { m_selection.clearAll(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameWorldView::mouseMoveEvent(QMouseEvent* event)
|
void GameWorldView::clearSelectionForBuildMode()
|
||||||
{
|
{
|
||||||
|
m_selection.clearAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameWorldView::isHoverLive() const
|
||||||
|
{
|
||||||
|
// underMouse() is false while the cursor sits on one of the floating panels,
|
||||||
|
// which are siblings of this widget rather than children, and while it is
|
||||||
|
// outside the window. A drag holding the button is the exception: it tracks the
|
||||||
|
// cursor wherever it goes until the button comes back up (REQ-UI-MULTI-SELECT,
|
||||||
|
// REQ-BLD-BELT-DRAG).
|
||||||
|
return underMouse() || m_boxSelecting || m_buildMode.isDraggingBelt();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameWorldView::refreshHover()
|
||||||
|
{
|
||||||
|
if (isHoverLive())
|
||||||
|
{
|
||||||
|
updateHoverAt(mapFromGlobal(QCursor::pos()));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
m_buildMode.clearHover();
|
||||||
|
m_hoverLive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameWorldView::updateHoverAt(QPoint cursorWidgetPos)
|
||||||
|
{
|
||||||
|
// Reached either from a mouse move, which only this widget receives, or from a
|
||||||
|
// hover refresh that has already established the cursor is on the world.
|
||||||
|
m_hoverLive = true;
|
||||||
|
|
||||||
const WorldCoordinates coordinates = getCoordinates();
|
const WorldCoordinates coordinates = getCoordinates();
|
||||||
const QPoint tile = coordinates.widgetToTile(event->pos());
|
const QPoint tile = coordinates.widgetToTile(cursorWidgetPos);
|
||||||
m_cursorWorldPos = coordinates.widgetToWorld(event->pos());
|
m_cursorWorldPos = coordinates.widgetToWorld(cursorWidgetPos);
|
||||||
|
|
||||||
if (m_buildMode.isBuilderMode())
|
if (m_buildMode.isBuilderMode())
|
||||||
{
|
{
|
||||||
@@ -1263,18 +1402,101 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event)
|
|||||||
else if (m_buildMode.isBlueprintMode())
|
else if (m_buildMode.isBlueprintMode())
|
||||||
{
|
{
|
||||||
m_buildMode.setBlueprintGhostTile(tile);
|
m_buildMode.setBlueprintGhostTile(tile);
|
||||||
|
|
||||||
|
// Resolved here, once, through the same classifier the click and the ghost's
|
||||||
|
// colour use, and stored on the mode: the controls panel says "Apply settings"
|
||||||
|
// exactly when clicking would transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a
|
||||||
|
// single-building blueprint hit-tests the cursor, so only it can be a hovered
|
||||||
|
// transfer target.
|
||||||
|
const std::vector<BlueprintBuilding>& buildings =
|
||||||
|
m_buildMode.getBlueprint().buildings;
|
||||||
|
bool transfer = false;
|
||||||
|
if (buildings.size() == 1)
|
||||||
|
{
|
||||||
|
transfer = resolveBlueprintGhostHere(buildings.front(), tile).action
|
||||||
|
== BlueprintGhostAction::Transfer;
|
||||||
|
}
|
||||||
|
m_buildMode.setHoveredGhostTransfer(transfer);
|
||||||
}
|
}
|
||||||
else if (m_buildMode.isDeconstructMode())
|
else if (m_buildMode.isDeconstructMode())
|
||||||
{
|
{
|
||||||
m_buildMode.setDeconstructHoverBuildingId(buildingAtTile(tile));
|
m_buildMode.setDeconstructHoverBuildingId(buildingAtTile(tile));
|
||||||
if (m_boxSelecting) { m_boxCurrentTile = tile; }
|
if (m_boxSelecting) { updateBoxDrag(cursorWidgetPos); }
|
||||||
}
|
}
|
||||||
else if (m_boxSelecting)
|
else if (m_boxSelecting)
|
||||||
{
|
{
|
||||||
m_boxCurrentTile = tile;
|
updateBoxDrag(cursorWidgetPos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GameWorldView::updateBoxDrag(QPoint cursorWidgetPos)
|
||||||
|
{
|
||||||
|
const WorldCoordinates coordinates = getCoordinates();
|
||||||
|
m_boxCurrentWorld = coordinates.widgetToWorld(cursorWidgetPos);
|
||||||
|
|
||||||
|
// Measured against where the anchor sits on screen right now, not against where
|
||||||
|
// the button went down: a view that scrolls under a held button moves the anchor
|
||||||
|
// away from a motionless cursor, and that is a drag as much as moving the mouse
|
||||||
|
// is (REQ-UI-MULTI-SELECT).
|
||||||
|
const QPointF anchorWidgetPos = coordinates.worldToWidget(m_boxStartWorld);
|
||||||
|
const qreal travel_px = std::abs(cursorWidgetPos.x() - anchorWidgetPos.x())
|
||||||
|
+ std::abs(cursorWidgetPos.y() - anchorWidgetPos.y());
|
||||||
|
if (travel_px >= kBoxDragThresholdPixels) { m_boxDragMoved = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
QRectF GameWorldView::getBoxWorldRect() const
|
||||||
|
{
|
||||||
|
if (!m_boxDragMoved)
|
||||||
|
{
|
||||||
|
// Still a click: the rectangle it spans has no area and would cover nothing,
|
||||||
|
// so the box is the whole tile the button went down on instead — what the
|
||||||
|
// click points at (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-CLICK).
|
||||||
|
return QRectF(std::floor(m_boxStartWorld.x()), std::floor(m_boxStartWorld.y()),
|
||||||
|
1.0, 1.0);
|
||||||
|
}
|
||||||
|
return QRectF(QPointF(m_boxStartWorld.x(), m_boxStartWorld.y()),
|
||||||
|
QPointF(m_boxCurrentWorld.x(), m_boxCurrentWorld.y())).normalized();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameWorldView::publishSelectionAnchor(SelectionMode mode,
|
||||||
|
const std::vector<BuildingId>& buildings,
|
||||||
|
const std::vector<entt::entity>& actors,
|
||||||
|
const std::vector<entt::entity>& debris)
|
||||||
|
{
|
||||||
|
// An additive gesture onto something already selected is growing that selection, not
|
||||||
|
// starting one, and the panel stays where it was put (REQ-UI-SELECTION-PANEL).
|
||||||
|
const bool startsSelection =
|
||||||
|
(mode == SelectionMode::Replace) || (m_selection.getSelectedBuildings().empty()
|
||||||
|
&& m_selection.getSelectedActors().empty()
|
||||||
|
&& m_selection.getSelectedDebris().empty());
|
||||||
|
if (!startsSelection)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen space, frozen here: the panel is placed against where the selection is at
|
||||||
|
// this moment and stays there, however far the view scrolls or the objects move
|
||||||
|
// afterwards.
|
||||||
|
const QRect anchorRect = getSelectionWidgetRect(*m_sim, getCoordinates(),
|
||||||
|
buildings, actors, debris);
|
||||||
|
if (anchorRect.isNull())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The gap the panel keeps from that rectangle is half a tile (REQ-UI-SELECTION-PANEL),
|
||||||
|
// and this is where the tile size is known. It is sampled in the same moment as the
|
||||||
|
// rectangle and travels with it, so both describe the view as it stood when the
|
||||||
|
// selection started.
|
||||||
|
const int selectionGapPx = qRound(getCoordinates().getTilePx() / 2.0f);
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<SelectionAnchorChangedEvent>(anchorRect, selectionGapPx));
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameWorldView::mouseMoveEvent(QMouseEvent* event)
|
||||||
|
{
|
||||||
|
updateHoverAt(event->pos());
|
||||||
|
}
|
||||||
|
|
||||||
void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||||||
{
|
{
|
||||||
if (event->button() != Qt::LeftButton) { return; }
|
if (event->button() != Qt::LeftButton) { return; }
|
||||||
@@ -1292,9 +1514,13 @@ 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());
|
||||||
|
|
||||||
if (m_buildMode.isDeconstructMode())
|
const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0;
|
||||||
|
const ControlAction dragAction =
|
||||||
|
resolveLeftGesture(controlHeld, /*isDrag*/ true, getControlContext());
|
||||||
|
|
||||||
|
if (dragAction == ControlAction::DeconstructArea)
|
||||||
{
|
{
|
||||||
const FactoryState& factory = m_sim->getFactoryState();
|
const FactoryState& factory = m_sim->getFactoryState();
|
||||||
|
|
||||||
@@ -1356,7 +1582,9 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectInBox((event->modifiers() & Qt::ControlModifier) != 0);
|
// A Ctrl box adds and never deselects, where a plain one replaces
|
||||||
|
// (REQ-UI-MULTI-SELECT).
|
||||||
|
selectInBox(dragAction == ControlAction::AddAreaToSelection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1369,8 +1597,14 @@ void GameWorldView::rotateGhost(bool clockwise)
|
|||||||
if (m_buildMode.isBuilderMode())
|
if (m_buildMode.isBuilderMode())
|
||||||
{
|
{
|
||||||
m_buildMode.rotateGhost(clockwise);
|
m_buildMode.rotateGhost(clockwise);
|
||||||
|
|
||||||
|
// The new facing is kept whatever the cursor is over; what it means for the
|
||||||
|
// world is only re-resolved while the cursor points at a tile (REQ-BLD-GHOST).
|
||||||
|
const std::optional<QPoint>& ghostTile = m_buildMode.getGhostTile();
|
||||||
|
if (!ghostTile.has_value()) { return; }
|
||||||
|
|
||||||
m_buildMode.setGhostValidity(
|
m_buildMode.setGhostValidity(
|
||||||
canPlaceBuildingHere(m_buildMode.getBuilderType(), m_buildMode.getGhostTile(),
|
canPlaceBuildingHere(m_buildMode.getBuilderType(), *ghostTile,
|
||||||
m_buildMode.getGhostRotation()));
|
m_buildMode.getGhostRotation()));
|
||||||
// A new facing changes which tunnels the ghost could complete (REQ-BLD-TUNNEL-MODE).
|
// A new facing changes which tunnels the ghost could complete (REQ-BLD-TUNNEL-MODE).
|
||||||
if (m_buildMode.isTunnelMode()) { updateTunnelGhost(); }
|
if (m_buildMode.isTunnelMode()) { updateTunnelGhost(); }
|
||||||
@@ -1378,7 +1612,7 @@ void GameWorldView::rotateGhost(bool clockwise)
|
|||||||
// without waiting for the next mouse move (REQ-BLD-BELT-DRAG).
|
// without waiting for the next mouse move (REQ-BLD-BELT-DRAG).
|
||||||
if (m_buildMode.isDraggingBelt())
|
if (m_buildMode.isDraggingBelt())
|
||||||
{
|
{
|
||||||
recomputeBeltDragPath(m_buildMode.getGhostTile());
|
recomputeBeltDragPath(*ghostTile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (m_buildMode.isBlueprintMode())
|
else if (m_buildMode.isBlueprintMode())
|
||||||
@@ -1507,7 +1741,12 @@ void GameWorldView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
|
|||||||
|
|
||||||
void GameWorldView::handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event)
|
void GameWorldView::handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event)
|
||||||
{
|
{
|
||||||
|
clearSelectionForBuildMode();
|
||||||
m_buildMode.enterBuilderMode(event->type);
|
m_buildMode.enterBuilderMode(event->type);
|
||||||
|
// A mode entered by hotkey usually leaves the cursor exactly where it was, and no
|
||||||
|
// mouse move follows to place the ghost; entered from a build button it leaves the
|
||||||
|
// cursor on the bar, where there is nothing to hover (REQ-BLD-GHOST).
|
||||||
|
refreshHover();
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> /*event*/)
|
void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> /*event*/)
|
||||||
@@ -1517,12 +1756,18 @@ void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEv
|
|||||||
|
|
||||||
void GameWorldView::handleEvent(std::shared_ptr<const DeconstructModeToggleRequestedEvent> /*event*/)
|
void GameWorldView::handleEvent(std::shared_ptr<const DeconstructModeToggleRequestedEvent> /*event*/)
|
||||||
{
|
{
|
||||||
|
clearSelectionForBuildMode();
|
||||||
m_buildMode.toggleDeconstructMode();
|
m_buildMode.toggleDeconstructMode();
|
||||||
|
refreshHover();
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameWorldView::handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event)
|
void GameWorldView::handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event)
|
||||||
{
|
{
|
||||||
|
// The blueprint arrives already built from the selection this drops
|
||||||
|
// (REQ-UI-BLUEPRINT-TEMP, REQ-UI-SELECTION-EXCLUSIVE).
|
||||||
|
clearSelectionForBuildMode();
|
||||||
m_buildMode.enterBlueprintMode(event->blueprint);
|
m_buildMode.enterBlueprintMode(event->blueprint);
|
||||||
|
refreshHover();
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameWorldView::handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> /*event*/)
|
void GameWorldView::handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> /*event*/)
|
||||||
@@ -1565,14 +1810,19 @@ void GameWorldView::handleEvent(std::shared_ptr<const GhostRotationRequestedEven
|
|||||||
|
|
||||||
void GameWorldView::handleEvent(std::shared_ptr<const ModeCancelRequestedEvent> /*event*/)
|
void GameWorldView::handleEvent(std::shared_ptr<const ModeCancelRequestedEvent> /*event*/)
|
||||||
{
|
{
|
||||||
// One key backs out of whichever mode is active, and enters deconstruct mode
|
// One key backs out of whichever mode is active, and enters deconstruct mode when
|
||||||
// when none is (REQ-UI-HOTKEYS).
|
// none is (REQ-UI-HOTKEYS). The selection case of that key never reaches here: it
|
||||||
// One key backs out of whichever mode is active, and enters deconstruct mode
|
// resolves to its own event, so there is nothing left to clear by the time the
|
||||||
// when none is (REQ-UI-HOTKEYS).
|
// fallthrough enters deconstruct mode (REQ-UI-SELECTION-EXCLUSIVE).
|
||||||
if (m_buildMode.getMode() == BuildMode::None) { m_buildMode.toggleDeconstructMode(); }
|
if (m_buildMode.getMode() == BuildMode::None) { m_buildMode.toggleDeconstructMode(); }
|
||||||
else { m_buildMode.exitCurrentMode(); }
|
else { m_buildMode.exitCurrentMode(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GameWorldView::handleEvent(std::shared_ptr<const SelectionClearRequestedEvent> /*event*/)
|
||||||
|
{
|
||||||
|
m_selection.clearAll();
|
||||||
|
}
|
||||||
|
|
||||||
void GameWorldView::handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEvent> /*event*/)
|
void GameWorldView::handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEvent> /*event*/)
|
||||||
{
|
{
|
||||||
m_debugDraw = !m_debugDraw;
|
m_debugDraw = !m_debugDraw;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
#include "ModeCancelRequestedEvent.h"
|
#include "ModeCancelRequestedEvent.h"
|
||||||
#include "PanDirectionChangedEvent.h"
|
#include "PanDirectionChangedEvent.h"
|
||||||
#include "PauseToggleRequestedEvent.h"
|
#include "PauseToggleRequestedEvent.h"
|
||||||
|
#include "SelectionClearRequestedEvent.h"
|
||||||
#include "SpeedStepRequestedEvent.h"
|
#include "SpeedStepRequestedEvent.h"
|
||||||
#include "DebugDrawToggledEvent.h"
|
#include "DebugDrawToggledEvent.h"
|
||||||
#include "ArtifactCountChangedEvent.h"
|
#include "ArtifactCountChangedEvent.h"
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
|
|
||||||
struct Command;
|
struct Command;
|
||||||
struct ParsedReplay;
|
struct ParsedReplay;
|
||||||
|
class BlueprintLibrary;
|
||||||
class ItemIconCache;
|
class ItemIconCache;
|
||||||
class ReplayPlayer;
|
class ReplayPlayer;
|
||||||
class Simulation;
|
class Simulation;
|
||||||
@@ -82,6 +84,7 @@ class GameWorldView : public QOpenGLWidget,
|
|||||||
SpeedStepRequestedEvent,
|
SpeedStepRequestedEvent,
|
||||||
GhostRotationRequestedEvent,
|
GhostRotationRequestedEvent,
|
||||||
ModeCancelRequestedEvent,
|
ModeCancelRequestedEvent,
|
||||||
|
SelectionClearRequestedEvent,
|
||||||
DebugDrawToggleRequestedEvent,
|
DebugDrawToggleRequestedEvent,
|
||||||
CommandRequestedEvent>
|
CommandRequestedEvent>
|
||||||
{
|
{
|
||||||
@@ -102,6 +105,28 @@ public:
|
|||||||
void setGameSpeed(double multiplier);
|
void setGameSpeed(double multiplier);
|
||||||
void resetForNewGame();
|
void resetForNewGame();
|
||||||
|
|
||||||
|
// The blueprint library is constructed after this widget, so it arrives by setter.
|
||||||
|
// Not owned; supplies the two facts about blueprints that the control context needs
|
||||||
|
// (REQ-UI-CONTROLS-CONTENT).
|
||||||
|
void setBlueprintLibrary(const BlueprintLibrary* library);
|
||||||
|
|
||||||
|
// The player's current situation, as the one snapshot every reader of the control
|
||||||
|
// table works from: this widget's own mouse dispatch, the input mapper, and the
|
||||||
|
// controls panel (REQ-UI-CONTROLS-CONTENT). Built here because this widget owns the
|
||||||
|
// build mode and the selection.
|
||||||
|
ControlContext getControlContext() const;
|
||||||
|
|
||||||
|
// Name of the blueprint being placed, for the controls panel's heading. Empty while
|
||||||
|
// no blueprint is active and for the unnamed temporary one (REQ-UI-BLUEPRINT-TEMP).
|
||||||
|
QString getActiveBlueprintName() const;
|
||||||
|
|
||||||
|
// The world <-> widget transform for the current viewport size and scroll
|
||||||
|
// position. Cheap to build and deliberately not cached: it is a snapshot that
|
||||||
|
// a resize or a scroll invalidates, so every user takes a fresh one. Public because
|
||||||
|
// a widget standing in the world needs it to find its place (REQ-UI-EXPAND-BUTTON);
|
||||||
|
// a ViewScrolledEvent says when it has gone stale.
|
||||||
|
WorldCoordinates getCoordinates() const;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void initializeGL() override;
|
void initializeGL() override;
|
||||||
void paintGL() override;
|
void paintGL() override;
|
||||||
@@ -133,6 +158,7 @@ private:
|
|||||||
void handleEvent(std::shared_ptr<const SpeedStepRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const SpeedStepRequestedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const GhostRotationRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const GhostRotationRequestedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const ModeCancelRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const ModeCancelRequestedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const SelectionClearRequestedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const CommandRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const CommandRequestedEvent> event) override;
|
||||||
|
|
||||||
@@ -170,11 +196,6 @@ private:
|
|||||||
// Gathers the interaction state the renderer needs for this frame.
|
// Gathers the interaction state the renderer needs for this frame.
|
||||||
WorldRenderFrame makeRenderFrame() const;
|
WorldRenderFrame makeRenderFrame() const;
|
||||||
|
|
||||||
// The world <-> widget transform for the current viewport size and scroll
|
|
||||||
// position. Cheap to build and deliberately not cached: it is a snapshot that
|
|
||||||
// a resize or a scroll invalidates, so every user takes a fresh one.
|
|
||||||
WorldCoordinates getCoordinates() const;
|
|
||||||
|
|
||||||
float getAsteroidLeftEdge() const;
|
float getAsteroidLeftEdge() const;
|
||||||
float getEnemyStationRightEdge() const;
|
float getEnemyStationRightEdge() const;
|
||||||
// The camera's current pan limits, read fresh from the simulation each frame:
|
// The camera's current pan limits, read fresh from the simulation each frame:
|
||||||
@@ -210,6 +231,47 @@ private:
|
|||||||
// which is the only case that goes on to start a box drag.
|
// which is the only case that goes on to start a box drag.
|
||||||
bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive);
|
bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive);
|
||||||
void selectInBox(bool additive);
|
void selectInBox(bool additive);
|
||||||
|
// A build mode and a selection are mutually exclusive, so entering any mode drops
|
||||||
|
// the selection (REQ-UI-SELECTION-EXCLUSIVE). Called from each of the three events
|
||||||
|
// that enter a mode -- the only ways in, whichever button or key the player used.
|
||||||
|
// Clearing an empty selection publishes nothing, so the exiting half of the
|
||||||
|
// deconstruct toggle costs nothing.
|
||||||
|
void clearSelectionForBuildMode();
|
||||||
|
// Whether the cursor points at the game world at all: it does while it is over
|
||||||
|
// this widget, and while a belt or box drag holds the button, which goes on
|
||||||
|
// following the cursor onto the floating panels and past the window edge.
|
||||||
|
bool isHoverLive() const;
|
||||||
|
// Re-derives the hover from wherever the cursor is now, or drops it when the
|
||||||
|
// cursor points at nothing (REQ-BLD-GHOST). The entry point for everything a
|
||||||
|
// mouse move does not cover: a scrolling view, a cursor crossing onto a panel or
|
||||||
|
// out of the window, and a mode just entered under a cursor that has not moved.
|
||||||
|
void refreshHover();
|
||||||
|
// Re-resolves everything that follows from where the cursor points into the world:
|
||||||
|
// the ghost tile and its validity, the tunnel ends, a running belt or box drag, the
|
||||||
|
// deconstruct hover. Called for every mouse move, and once per frame while the view
|
||||||
|
// scrolls under a cursor that has not moved, since that changes the world position
|
||||||
|
// the cursor points at just as moving the mouse does (REQ-BLD-GHOST).
|
||||||
|
void updateHoverAt(QPoint cursorWidgetPos);
|
||||||
|
// Moves the running box drag's far corner to the world position under
|
||||||
|
// `cursorWidgetPos` and, once the cursor sits far enough from where the anchor is
|
||||||
|
// drawn, promotes the gesture from a click to a drag. Every corner update goes
|
||||||
|
// through here, including the ones a scrolling view causes under a cursor that
|
||||||
|
// has not moved (REQ-UI-MULTI-SELECT).
|
||||||
|
void updateBoxDrag(QPoint cursorWidgetPos);
|
||||||
|
// The box the drag currently spans, in world coordinates and normalized: the
|
||||||
|
// rectangle between its two corners once it reads as a drag, and the whole tile
|
||||||
|
// the button went down on before that (REQ-UI-MULTI-SELECT). Both what is drawn
|
||||||
|
// and what is selected come from here, so they can never disagree.
|
||||||
|
QRectF getBoxWorldRect() const;
|
||||||
|
// Publishes where on the screen the selection about to be made sits, so the
|
||||||
|
// 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
|
||||||
|
// nothing unless that selection is starting rather than growing.
|
||||||
|
void publishSelectionAnchor(
|
||||||
|
SelectionMode mode,
|
||||||
|
const std::vector<BuildingId>& buildings,
|
||||||
|
const std::vector<entt::entity>& actors,
|
||||||
|
const std::vector<entt::entity>& debris);
|
||||||
void stepSpeed(int delta);
|
void stepSpeed(int delta);
|
||||||
void placeAtTile(QPoint tile);
|
void placeAtTile(QPoint tile);
|
||||||
|
|
||||||
@@ -235,6 +297,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;
|
||||||
@@ -273,6 +340,11 @@ private:
|
|||||||
// end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG)
|
// end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG)
|
||||||
// and to resolve the tunnel ghost sub-tile (REQ-BLD-TUNNEL-MODE).
|
// and to resolve the tunnel ghost sub-tile (REQ-BLD-TUNNEL-MODE).
|
||||||
QVector2D m_cursorWorldPos;
|
QVector2D m_cursorWorldPos;
|
||||||
|
// Whether the hover state currently stands for a cursor pointing at the world,
|
||||||
|
// so that losing it is noticed once rather than every frame. Kept here rather
|
||||||
|
// than asked of Qt per reader: it has to agree with what was last written to the
|
||||||
|
// build mode controller, not with where the cursor happens to be mid-frame.
|
||||||
|
bool m_hoverLive = false;
|
||||||
|
|
||||||
bool m_debugDraw;
|
bool m_debugDraw;
|
||||||
|
|
||||||
@@ -280,9 +352,20 @@ private:
|
|||||||
// between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change
|
// between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change
|
||||||
// events. This widget only resolves what was hit.
|
// events. This widget only resolves what was hit.
|
||||||
SelectionController m_selection;
|
SelectionController m_selection;
|
||||||
|
// Not owned; set after construction, so null until MainWindow has built it.
|
||||||
|
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,64 +3,55 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include <QFontMetrics>
|
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QIcon>
|
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QPalette>
|
#include <QPalette>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QSignalMapper>
|
#include <QSignalMapper>
|
||||||
#include <QSize>
|
|
||||||
|
|
||||||
#include "Command.h"
|
|
||||||
#include "CommandRequestedEvent.h"
|
|
||||||
#include "EventManager.h"
|
#include "EventManager.h"
|
||||||
#include "IconCaption.h"
|
#include "IconCaption.h"
|
||||||
#include "ItemIconCache.h"
|
#include "ItemIconCache.h"
|
||||||
|
#include "ItemTooltip.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
#include "SpeedChangeRequestedEvent.h"
|
#include "SpeedChangeRequestedEvent.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
|
#include "TooltipTrigger.h"
|
||||||
|
|
||||||
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
|
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
|
||||||
const int HeaderBar::kSpeedCount = 5;
|
const int HeaderBar::kSpeedCount = 5;
|
||||||
|
|
||||||
HeaderBar::HeaderBar(const Simulation* sim, const GameConfig* config,
|
HeaderBar::HeaderBar(const ItemTooltipContext& context, QWidget* parent)
|
||||||
ItemIconCache* itemIcons, QWidget* parent)
|
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
, m_itemIcons(itemIcons)
|
, m_context(context)
|
||||||
, m_sim(sim)
|
|
||||||
{
|
{
|
||||||
QHBoxLayout* layout = new QHBoxLayout(this);
|
QHBoxLayout* layout = new QHBoxLayout(this);
|
||||||
layout->setContentsMargins(8, 4, 8, 4);
|
layout->setContentsMargins(8, 4, 8, 4);
|
||||||
layout->setSpacing(8);
|
layout->setSpacing(8);
|
||||||
|
|
||||||
m_timeLabel = new QLabel("00:00", this);
|
m_timeLabel = new QLabel("00:00", this);
|
||||||
|
// Both displays state a value and do nothing when clicked, so a click brings the
|
||||||
|
// tooltip up at once rather than waiting the hover out (REQ-UI-TOOLTIP-TRIGGER).
|
||||||
|
// The stock states an amount of an item, so what it shows is that item's production
|
||||||
|
// tooltip, as every other display naming an item does
|
||||||
|
// (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-VALUE-TOOLTIP).
|
||||||
m_blocksLabel = new QLabel(this);
|
m_blocksLabel = new QLabel(this);
|
||||||
if (config->world.buildingBlocksTooltip)
|
ItemTooltip::attachTo(*m_blocksLabel, m_context, kBlockItemId,
|
||||||
{
|
TooltipTrigger::Trigger::HoverAndClick);
|
||||||
m_blocksLabel->setToolTip(
|
|
||||||
QString::fromStdString(*config->world.buildingBlocksTooltip));
|
|
||||||
}
|
|
||||||
updateBlocksLabel();
|
updateBlocksLabel();
|
||||||
|
// An artifact count is no item amount, so it has no production path to show and
|
||||||
|
// states configured text instead (REQ-UI-ARTIFACTS-TOOLTIP).
|
||||||
m_artifactsLabel = new QLabel(tr("Artifacts: 0/?"), this);
|
m_artifactsLabel = new QLabel(tr("Artifacts: 0/?"), this);
|
||||||
if (config->world.artifactTooltip)
|
if (m_context.config->world.artifactTooltip)
|
||||||
{
|
{
|
||||||
m_artifactsLabel->setToolTip(
|
TooltipTrigger::attachText(
|
||||||
QString::fromStdString(*config->world.artifactTooltip));
|
*m_artifactsLabel,
|
||||||
|
QString::fromStdString(*m_context.config->world.artifactTooltip),
|
||||||
|
TooltipTrigger::Trigger::HoverAndClick);
|
||||||
}
|
}
|
||||||
m_bossWaveLabel = new QLabel(tr("Boss Wave #1"), this);
|
m_bossWaveLabel = new QLabel(tr("Boss Wave #1"), this);
|
||||||
m_nextBossLabel = new QLabel(tr("Next boss: 5:00"), this);
|
m_nextBossLabel = new QLabel(tr("Next boss: 5:00"), this);
|
||||||
|
|
||||||
// Asteroid expansion button, to the left of the speed buttons (REQ-UI-HEADER,
|
|
||||||
// REQ-UI-EXPAND-BUTTON). Caption/enabled state are set on the first
|
|
||||||
// ExpansionCostChangedEvent; clicking requests an ExpandAsteroidCommand.
|
|
||||||
m_expandButton = new QPushButton(tr("Expand"), this);
|
|
||||||
connect(m_expandButton, &QPushButton::clicked, this, []() {
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<CommandRequestedEvent>(
|
|
||||||
std::make_shared<ExpandAsteroidCommand>()));
|
|
||||||
});
|
|
||||||
|
|
||||||
const int itemSpacing = 20;
|
const int itemSpacing = 20;
|
||||||
|
|
||||||
layout->addWidget(m_timeLabel);
|
layout->addWidget(m_timeLabel);
|
||||||
@@ -73,8 +64,6 @@ HeaderBar::HeaderBar(const Simulation* sim, const GameConfig* config,
|
|||||||
layout->addSpacing(itemSpacing);
|
layout->addSpacing(itemSpacing);
|
||||||
layout->addWidget(m_nextBossLabel);
|
layout->addWidget(m_nextBossLabel);
|
||||||
layout->addSpacing(itemSpacing);
|
layout->addSpacing(itemSpacing);
|
||||||
layout->addWidget(m_expandButton);
|
|
||||||
layout->addSpacing(itemSpacing);
|
|
||||||
|
|
||||||
const char* labels[] = { "0x", "0.5x", "1x", "2x", "10x" };
|
const char* labels[] = { "0x", "0.5x", "1x", "2x", "10x" };
|
||||||
QSignalMapper* mapper = new QSignalMapper(this);
|
QSignalMapper* mapper = new QSignalMapper(this);
|
||||||
@@ -102,7 +91,7 @@ HeaderBar::~HeaderBar()
|
|||||||
|
|
||||||
void HeaderBar::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
|
void HeaderBar::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
|
||||||
{
|
{
|
||||||
const int totalSeconds = static_cast<int>(ticksToSeconds(m_sim->getCurrentTick()));
|
const int totalSeconds = static_cast<int>(ticksToSeconds(m_context.sim->getCurrentTick()));
|
||||||
m_timeLabel->setText(
|
m_timeLabel->setText(
|
||||||
QString("%1:%2")
|
QString("%1:%2")
|
||||||
.arg(totalSeconds / 60, 2, 10, QChar('0'))
|
.arg(totalSeconds / 60, 2, 10, QChar('0'))
|
||||||
@@ -112,27 +101,13 @@ void HeaderBar::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
|
|||||||
void HeaderBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
|
void HeaderBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
|
||||||
{
|
{
|
||||||
updateBlocksLabel();
|
updateBlocksLabel();
|
||||||
updateExpandButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
void HeaderBar::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> /*event*/)
|
|
||||||
{
|
|
||||||
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_context.sim->getBuildingBlocksStock();
|
||||||
|
|
||||||
const QPixmap icon = blockIcon();
|
const QPixmap icon = m_context.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).
|
||||||
@@ -144,41 +119,6 @@ void HeaderBar::updateBlocksLabel()
|
|||||||
m_blocksLabel->palette().color(QPalette::WindowText)));
|
m_blocksLabel->palette().color(QPalette::WindowText)));
|
||||||
}
|
}
|
||||||
|
|
||||||
void HeaderBar::updateExpandButton()
|
|
||||||
{
|
|
||||||
const int blocks = m_sim->getBuildingBlocksStock();
|
|
||||||
const int expansionCost = m_sim->getCurrentExpansionCost();
|
|
||||||
|
|
||||||
m_expandButton->setEnabled(blocks >= expansionCost);
|
|
||||||
|
|
||||||
const QPixmap icon = blockIcon();
|
|
||||||
if (icon.isNull())
|
|
||||||
{
|
|
||||||
// Fallback text form when no building_block icon exists (REQ-UI-EXPAND-BUTTON).
|
|
||||||
m_expandButton->setIcon(QIcon());
|
|
||||||
m_expandButton->setText(tr("Expand: %1 Blocks").arg(expansionCost));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QString text = tr("Expand: %1").arg(expansionCost);
|
|
||||||
const QPalette& pal = m_expandButton->palette();
|
|
||||||
const QPixmap normal = renderCaptionWithIcon(
|
|
||||||
text, icon, m_expandButton->font(), pal.color(QPalette::ButtonText));
|
|
||||||
const QPixmap greyed = renderCaptionWithIcon(
|
|
||||||
text, icon, m_expandButton->font(),
|
|
||||||
pal.color(QPalette::Disabled, QPalette::ButtonText));
|
|
||||||
|
|
||||||
QIcon buttonIcon;
|
|
||||||
buttonIcon.addPixmap(normal, QIcon::Normal);
|
|
||||||
buttonIcon.addPixmap(greyed, QIcon::Disabled);
|
|
||||||
m_expandButton->setText(QString());
|
|
||||||
m_expandButton->setIcon(buttonIcon);
|
|
||||||
const qreal dpr = normal.devicePixelRatio();
|
|
||||||
m_expandButton->setIconSize(QSize(
|
|
||||||
static_cast<int>(normal.width() / dpr),
|
|
||||||
static_cast<int>(normal.height() / dpr)));
|
|
||||||
}
|
|
||||||
|
|
||||||
void HeaderBar::handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event)
|
void HeaderBar::handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < kSpeedCount; ++i)
|
for (int i = 0; i < kSpeedCount; ++i)
|
||||||
@@ -190,12 +130,12 @@ void HeaderBar::handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event)
|
|||||||
|
|
||||||
void HeaderBar::handleEvent(std::shared_ptr<const BossWaveUpdatedEvent> /*event*/)
|
void HeaderBar::handleEvent(std::shared_ptr<const BossWaveUpdatedEvent> /*event*/)
|
||||||
{
|
{
|
||||||
const Tick countdownTicks = m_sim->getBossCountdownTicks();
|
const Tick countdownTicks = m_context.sim->getBossCountdownTicks();
|
||||||
const int bossSeconds = static_cast<int>(
|
const int bossSeconds = static_cast<int>(
|
||||||
ticksToSeconds(countdownTicks > 0 ? countdownTicks : 0));
|
ticksToSeconds(countdownTicks > 0 ? countdownTicks : 0));
|
||||||
m_bossWaveLabel->setText(
|
m_bossWaveLabel->setText(
|
||||||
tr("Boss Wave #%1")
|
tr("Boss Wave #%1")
|
||||||
.arg(m_sim->getBossWaveCounter()));
|
.arg(m_context.sim->getBossWaveCounter()));
|
||||||
m_nextBossLabel->setText(
|
m_nextBossLabel->setText(
|
||||||
tr("Next boss: %1:%2")
|
tr("Next boss: %1:%2")
|
||||||
.arg(bossSeconds / 60)
|
.arg(bossSeconds / 60)
|
||||||
@@ -206,8 +146,8 @@ void HeaderBar::handleEvent(std::shared_ptr<const ArtifactCountChangedEvent> /*e
|
|||||||
{
|
{
|
||||||
m_artifactsLabel->setText(
|
m_artifactsLabel->setText(
|
||||||
tr("Artifacts: %1/%2")
|
tr("Artifacts: %1/%2")
|
||||||
.arg(m_sim->getArtifactCount())
|
.arg(m_context.sim->getArtifactCount())
|
||||||
.arg(m_sim->getConfig().world.artifacts.artifactWinCount));
|
.arg(m_context.sim->getConfig().world.artifacts.artifactWinCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
void HeaderBar::onSpeedButton(int index)
|
void HeaderBar::onSpeedButton(int index)
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
#include "BossWaveUpdatedEvent.h"
|
#include "BossWaveUpdatedEvent.h"
|
||||||
#include "BuildingBlocksChangedEvent.h"
|
#include "BuildingBlocksChangedEvent.h"
|
||||||
#include "EventHandler.h"
|
#include "EventHandler.h"
|
||||||
#include "ExpansionCostChangedEvent.h"
|
|
||||||
#include "GameConfig.h"
|
#include "GameConfig.h"
|
||||||
#include "GameSpeedChangedEvent.h"
|
#include "GameSpeedChangedEvent.h"
|
||||||
|
#include "ItemTooltipContext.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "TickAdvancedEvent.h"
|
#include "TickAdvancedEvent.h"
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ class Simulation;
|
|||||||
class HeaderBar : public QWidget,
|
class HeaderBar : public QWidget,
|
||||||
public CombinedEventHandler<TickAdvancedEvent,
|
public CombinedEventHandler<TickAdvancedEvent,
|
||||||
BuildingBlocksChangedEvent,
|
BuildingBlocksChangedEvent,
|
||||||
ExpansionCostChangedEvent,
|
|
||||||
GameSpeedChangedEvent,
|
GameSpeedChangedEvent,
|
||||||
BossWaveUpdatedEvent,
|
BossWaveUpdatedEvent,
|
||||||
ArtifactCountChangedEvent>
|
ArtifactCountChangedEvent>
|
||||||
@@ -33,11 +32,12 @@ class HeaderBar : public QWidget,
|
|||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); used to
|
// The context carries the simulation the bar reads, the config, and the icon caches
|
||||||
// show the building_block icon in the stock display and expand button
|
// -- the item cache draws the building_block icon in the stock display
|
||||||
// (REQ-UI-BLOCKS-ICON, REQ-UI-EXPAND-BUTTON). Not owned; must outlive this widget.
|
// (REQ-UI-BLOCKS-ICON), and the stock display explains that item with the cache's
|
||||||
HeaderBar(const Simulation* sim, const GameConfig* config,
|
// help (REQ-UI-ITEM-VALUE-TOOLTIP). Nothing in it is owned; all of it must outlive
|
||||||
ItemIconCache* itemIcons, QWidget* parent = nullptr);
|
// this widget.
|
||||||
|
explicit HeaderBar(const ItemTooltipContext& context, QWidget* parent = nullptr);
|
||||||
~HeaderBar() override;
|
~HeaderBar() override;
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
@@ -46,38 +46,27 @@ private slots:
|
|||||||
private:
|
private:
|
||||||
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
|
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
|
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> event) override;
|
|
||||||
void handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event) override;
|
void handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const BossWaveUpdatedEvent> event) override;
|
void handleEvent(std::shared_ptr<const BossWaveUpdatedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const ArtifactCountChangedEvent> event) override;
|
void handleEvent(std::shared_ptr<const ArtifactCountChangedEvent> event) override;
|
||||||
|
|
||||||
// Refreshes the Expand button caption and enabled state from the current
|
|
||||||
// expansion cost and building block stock (REQ-UI-EXPAND-BUTTON).
|
|
||||||
void updateExpandButton();
|
|
||||||
|
|
||||||
// Refreshes the building blocks stock display from the simulation:
|
// Refreshes the building blocks stock display from the simulation:
|
||||||
// `Stock: <n>` with the building_block icon after it, or the
|
// `Stock: <n>` with the building_block icon after it, or the
|
||||||
// `Stock: <n> Blocks` text fallback when no icon file exists
|
// `Stock: <n> Blocks` text fallback when no icon file exists
|
||||||
// (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;
|
||||||
QLabel* m_bossWaveLabel;
|
QLabel* m_bossWaveLabel;
|
||||||
QLabel* m_nextBossLabel;
|
QLabel* m_nextBossLabel;
|
||||||
QPushButton* m_expandButton;
|
|
||||||
std::vector<QPushButton*> m_speedButtons;
|
std::vector<QPushButton*> m_speedButtons;
|
||||||
|
|
||||||
ItemIconCache* m_itemIcons; // Not owned; lives in MainWindow.
|
// Not owned; all of it lives in MainWindow. The simulation it names is the single
|
||||||
|
// source of truth for everything the header displays; the change events are only
|
||||||
// The simulation is the single source of truth for everything the header
|
// refresh signals.
|
||||||
// displays; the change events are only refresh signals.
|
ItemTooltipContext m_context;
|
||||||
const Simulation* m_sim;
|
|
||||||
|
|
||||||
static const double kSpeeds[];
|
static const double kSpeeds[];
|
||||||
static const int kSpeedCount;
|
static const int kSpeedCount;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "BlueprintSelectionRequestedEvent.h"
|
#include "BlueprintSelectionRequestedEvent.h"
|
||||||
#include "BuildHotkeyPressedEvent.h"
|
#include "BuildHotkeyPressedEvent.h"
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
|
#include "ControlAction.h"
|
||||||
#include "DebugDrawToggleRequestedEvent.h"
|
#include "DebugDrawToggleRequestedEvent.h"
|
||||||
#include "EscapeMenuRequestedEvent.h"
|
#include "EscapeMenuRequestedEvent.h"
|
||||||
#include "EventManager.h"
|
#include "EventManager.h"
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
#include "ModeCancelRequestedEvent.h"
|
#include "ModeCancelRequestedEvent.h"
|
||||||
#include "PanDirectionChangedEvent.h"
|
#include "PanDirectionChangedEvent.h"
|
||||||
#include "PauseToggleRequestedEvent.h"
|
#include "PauseToggleRequestedEvent.h"
|
||||||
|
#include "SelectionClearRequestedEvent.h"
|
||||||
#include "SpeedStepRequestedEvent.h"
|
#include "SpeedStepRequestedEvent.h"
|
||||||
#include "TemporaryBlueprintCaptureRequestedEvent.h"
|
#include "TemporaryBlueprintCaptureRequestedEvent.h"
|
||||||
#include "TemporaryBlueprintPlaceRequestedEvent.h"
|
#include "TemporaryBlueprintPlaceRequestedEvent.h"
|
||||||
@@ -77,7 +79,7 @@ QString InputMapper::getBuildHotkeyLabel(BuildingType type)
|
|||||||
return QString();
|
return QString();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool InputMapper::handleKeyPress(QKeyEvent* event)
|
bool InputMapper::handleKeyPress(QKeyEvent* event, const ControlContext& context)
|
||||||
{
|
{
|
||||||
// Auto-repeat says nothing new about which keys are down, and a held action is
|
// Auto-repeat says nothing new about which keys are down, and a held action is
|
||||||
// already held.
|
// already held.
|
||||||
@@ -86,6 +88,9 @@ bool InputMapper::handleKeyPress(QKeyEvent* event)
|
|||||||
// Number-key build-mode hotkeys (REQ-UI-HOTKEYS). nativeVirtualKey gives the
|
// Number-key build-mode hotkeys (REQ-UI-HOTKEYS). nativeVirtualKey gives the
|
||||||
// physical digit independent of keyboard layout and Shift (with Shift held, key()
|
// physical digit independent of keyboard layout and Shift (with Shift held, key()
|
||||||
// for the number row can arrive as Key_Exclam etc.). VK_1..VK_9 = 0x31..0x39.
|
// for the number row can arrive as Key_Exclam etc.). VK_1..VK_9 = 0x31..0x39.
|
||||||
|
// Not part of the ControlAction table: these are advertised on the build buttons
|
||||||
|
// rather than in the controls panel, and getBuildHotkeyLabel already reads the
|
||||||
|
// same binding table this does (REQ-UI-CONTROLS-ACCURACY).
|
||||||
const quint32 virtualKey = event->nativeVirtualKey();
|
const quint32 virtualKey = event->nativeVirtualKey();
|
||||||
if (virtualKey >= 0x31 && virtualKey <= 0x39)
|
if (virtualKey >= 0x31 && virtualKey <= 0x39)
|
||||||
{
|
{
|
||||||
@@ -100,107 +105,106 @@ bool InputMapper::handleKeyPress(QKeyEvent* event)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blueprint chords (REQ-UI-HOTKEYS). Checked ahead of the plain-key switch below,
|
// Development controls, deliberately outside the table so they are never offered
|
||||||
// which binds bare A/D/W/S/R/Q/C/V and must not fire on a Ctrl chord -- bare C and V
|
// to the player (REQ-UI-CONTROLS-ACCURACY).
|
||||||
// are the temporary-blueprint counterparts of these two (REQ-UI-BLUEPRINT-TEMP). Both
|
|
||||||
// requests are decided by MainWindow, the only widget that can pause the game and dim
|
|
||||||
// the window for a modal.
|
|
||||||
if ((event->modifiers() & Qt::ControlModifier) != 0)
|
|
||||||
{
|
|
||||||
switch (event->key())
|
|
||||||
{
|
|
||||||
case Qt::Key_C:
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<BlueprintSaveRequestedEvent>());
|
|
||||||
return true;
|
|
||||||
case Qt::Key_V:
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<BlueprintSelectionRequestedEvent>());
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event->key())
|
switch (event->key())
|
||||||
{
|
{
|
||||||
case Qt::Key_A:
|
|
||||||
m_panLeftHeld = true;
|
|
||||||
updatePanDirection();
|
|
||||||
return true;
|
|
||||||
case Qt::Key_D:
|
|
||||||
m_panRightHeld = true;
|
|
||||||
updatePanDirection();
|
|
||||||
return true;
|
|
||||||
case Qt::Key_Space:
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<PauseToggleRequestedEvent>());
|
|
||||||
return true;
|
|
||||||
case Qt::Key_W:
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<SpeedStepRequestedEvent>(+1));
|
|
||||||
return true;
|
|
||||||
case Qt::Key_S:
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<SpeedStepRequestedEvent>(-1));
|
|
||||||
return true;
|
|
||||||
case Qt::Key_R:
|
|
||||||
// Shift reverses the rotation direction (REQ-BLD-ROTATE).
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<GhostRotationRequestedEvent>(
|
|
||||||
(event->modifiers() & Qt::ShiftModifier) != 0));
|
|
||||||
return true;
|
|
||||||
case Qt::Key_Q:
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<ModeCancelRequestedEvent>());
|
|
||||||
return true;
|
|
||||||
case Qt::Key_C:
|
|
||||||
// Capture a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP).
|
|
||||||
// The BlueprintLibrary owns the selection and blueprint-capture logic; it decides
|
|
||||||
// whether anything placeable is selected and drives placement mode from there.
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<TemporaryBlueprintCaptureRequestedEvent>());
|
|
||||||
return true;
|
|
||||||
case Qt::Key_V:
|
|
||||||
// Re-enter placement mode for the temporary blueprint captured with C, if there is
|
|
||||||
// one (REQ-UI-BLUEPRINT-TEMP). The library holds it; nothing is captured here.
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<TemporaryBlueprintPlaceRequestedEvent>());
|
|
||||||
return true;
|
|
||||||
case Qt::Key_F3:
|
case Qt::Key_F3:
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
std::make_shared<DebugDrawToggleRequestedEvent>());
|
std::make_shared<DebugDrawToggleRequestedEvent>());
|
||||||
return true;
|
return true;
|
||||||
case Qt::Key_Escape:
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<EscapeMenuRequestedEvent>());
|
|
||||||
return true;
|
|
||||||
case Qt::Key_F4:
|
case Qt::Key_F4:
|
||||||
EventManager::getInstance()->addEvent(
|
EventManager::getInstance()->addEvent(
|
||||||
std::make_shared<TracePrintRequestedEvent>());
|
std::make_shared<TracePrintRequestedEvent>());
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else: the key names an action, the action names an event. Which key
|
||||||
|
// is bound to what, and whether it does anything in this situation, are both the
|
||||||
|
// table's business -- this switch only knows what each action means
|
||||||
|
// (REQ-UI-HOTKEYS, REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
switch (resolveKeyAction(event->key(), event->modifiers(), context))
|
||||||
|
{
|
||||||
|
case ControlAction::Move:
|
||||||
|
// A parameter of the action rather than an action of its own: the table binds
|
||||||
|
// both keys to Move and the direction is read off the key here, as the rotation
|
||||||
|
// direction and the build hotkey's digit are.
|
||||||
|
if (event->key() == Qt::Key_A) { m_panLeftHeld = true; }
|
||||||
|
else { m_panRightHeld = true; }
|
||||||
|
updatePanDirection();
|
||||||
|
return true;
|
||||||
|
case ControlAction::GameSpeed:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<SpeedStepRequestedEvent>(event->key() == Qt::Key_W ? +1 : -1));
|
||||||
|
return true;
|
||||||
|
case ControlAction::TogglePause:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<PauseToggleRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case ControlAction::Rotate:
|
||||||
|
// Shift reverses the rotation direction (REQ-BLD-ROTATE).
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<GhostRotationRequestedEvent>(
|
||||||
|
(event->modifiers() & Qt::ShiftModifier) != 0));
|
||||||
|
return true;
|
||||||
|
case ControlAction::EnterDeconstruct:
|
||||||
|
case ControlAction::ExitMode:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<ModeCancelRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case ControlAction::ClearSelection:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<SelectionClearRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case ControlAction::CopyTemporary:
|
||||||
|
// The BlueprintLibrary owns the selection and blueprint-capture logic; it drives
|
||||||
|
// placement mode from there (REQ-UI-BLUEPRINT-TEMP).
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<TemporaryBlueprintCaptureRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case ControlAction::PasteTemporary:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<TemporaryBlueprintPlaceRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case ControlAction::CreateBlueprint:
|
||||||
|
// Decided by MainWindow, the only widget that can pause the game and dim the
|
||||||
|
// window for a modal.
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<BlueprintSaveRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case ControlAction::OpenBlueprints:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<BlueprintSelectionRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case ControlAction::OpenMenu:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<EscapeMenuRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
// Either nothing is bound to the key here, or what is bound is a mouse gesture
|
||||||
|
// the view handles. Unconsumed, so ordinary Qt shortcuts keep working.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool InputMapper::handleKeyRelease(QKeyEvent* event)
|
bool InputMapper::handleKeyRelease(QKeyEvent* event, const ControlContext& context)
|
||||||
{
|
{
|
||||||
if (event->isAutoRepeat()) { return false; }
|
if (event->isAutoRepeat()) { return false; }
|
||||||
|
|
||||||
switch (event->key())
|
// Only held actions have a release worth acting on. Resolved through the table
|
||||||
|
// rather than matched against A and D directly, so the keys stay rebindable in one
|
||||||
|
// place rather than two.
|
||||||
|
if (resolveKeyAction(event->key(), event->modifiers(), context) != ControlAction::Move)
|
||||||
{
|
{
|
||||||
case Qt::Key_A:
|
|
||||||
m_panLeftHeld = false;
|
|
||||||
updatePanDirection();
|
|
||||||
return true;
|
|
||||||
case Qt::Key_D:
|
|
||||||
m_panRightHeld = false;
|
|
||||||
updatePanDirection();
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event->key() == Qt::Key_A) { m_panLeftHeld = false; }
|
||||||
|
else { m_panRightHeld = false; }
|
||||||
|
updatePanDirection();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void InputMapper::releaseAll()
|
void InputMapper::releaseAll()
|
||||||
|
|||||||
@@ -3,14 +3,19 @@
|
|||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
|
#include "ControlAction.h"
|
||||||
#include "WorldCamera.h"
|
#include "WorldCamera.h"
|
||||||
|
|
||||||
class QKeyEvent;
|
class QKeyEvent;
|
||||||
|
|
||||||
// Turns raw key events into the game's semantic actions and publishes them
|
// Turns raw key events into the game's semantic actions and publishes them
|
||||||
// (REQ-UI-HOTKEYS). Widgets react to the action, never to the key, so the two can
|
// (REQ-UI-HOTKEYS). Widgets react to the action, never to the key, so the two can
|
||||||
// be rebound independently later; the bindings themselves are still hard-coded
|
// be rebound independently later.
|
||||||
// here for now.
|
//
|
||||||
|
// Which key means what is not decided here: the caller hands in a ControlContext and
|
||||||
|
// ControlAction.h resolves the press against it, so this file only knows what each
|
||||||
|
// action means once resolved. That is what keeps the controls panel and the key
|
||||||
|
// handling from drifting apart -- both read the one table (REQ-UI-CONTROLS-ACCURACY).
|
||||||
//
|
//
|
||||||
// Two output shapes, chosen by the nature of the action rather than by taste:
|
// Two output shapes, chosen by the nature of the action rather than by taste:
|
||||||
//
|
//
|
||||||
@@ -34,9 +39,10 @@ public:
|
|||||||
static QString getBuildHotkeyLabel(BuildingType type);
|
static QString getBuildHotkeyLabel(BuildingType type);
|
||||||
|
|
||||||
// Both return true when the key was consumed; the caller passes anything else
|
// Both return true when the key was consumed; the caller passes anything else
|
||||||
// on to its base class so unrelated shortcuts keep working.
|
// on to its base class so unrelated shortcuts keep working. `context` is the
|
||||||
bool handleKeyPress(QKeyEvent* event);
|
// player's current situation, which decides what a key does (REQ-UI-CONTROLS-CONTENT).
|
||||||
bool handleKeyRelease(QKeyEvent* event);
|
bool handleKeyPress(QKeyEvent* event, const ControlContext& context);
|
||||||
|
bool handleKeyRelease(QKeyEvent* event, const ControlContext& context);
|
||||||
|
|
||||||
// Drops all held-key state, publishing the resulting change. Call when the
|
// Drops all held-key state, publishing the resulting change. Call when the
|
||||||
// receiving widget can no longer expect key-up events.
|
// receiving widget can no longer expect key-up events.
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
48
src/ui/ItemProducers.cpp
Normal file
48
src/ui/ItemProducers.cpp
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#include "ItemProducers.h"
|
||||||
|
|
||||||
|
#include "GameConfig.h"
|
||||||
|
#include "RecipesConfig.h"
|
||||||
|
#include "Simulation.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
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);
|
||||||
102
src/ui/ItemTooltip.cpp
Normal file
102
src/ui/ItemTooltip.cpp
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#include "ItemTooltip.h"
|
||||||
|
|
||||||
|
#include <QFont>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "DisplayName.h"
|
||||||
|
#include "GameConfig.h"
|
||||||
|
#include "ItemProducers.h"
|
||||||
|
#include "RecipeLineRow.h"
|
||||||
|
#include "RecipesConfig.h"
|
||||||
|
#include "Simulation.h"
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
void ItemTooltip::attachTo(QWidget& target, const ItemTooltipContext& context,
|
||||||
|
const std::string& itemId, TooltipTrigger::Trigger trigger)
|
||||||
|
{
|
||||||
|
ItemTooltip* tooltip = new ItemTooltip(context, itemId, &target);
|
||||||
|
TooltipTrigger::attach(target, *tooltip, trigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemTooltip::ItemTooltip(const ItemTooltipContext& context, const std::string& itemId,
|
||||||
|
QWidget* parent)
|
||||||
|
: Tooltip(parent)
|
||||||
|
, m_context(context)
|
||||||
|
, m_itemId(itemId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void ItemTooltip::refreshContent()
|
||||||
|
{
|
||||||
|
clearContent();
|
||||||
|
QVBoxLayout* layout = getContentLayout();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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.outputGroups = RecipeLineRow::toOutputGroups(*recipe);
|
||||||
|
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). The items it
|
||||||
|
// names carry no tooltip of their own: this is already one
|
||||||
|
// (REQ-UI-ITEM-VALUE-TOOLTIP).
|
||||||
|
RecipeLineRow* line =
|
||||||
|
new RecipeLineRow(m_context.itemIcons, m_context.buildingIcons, this);
|
||||||
|
line->setCardChrome(true);
|
||||||
|
layout->addWidget(line);
|
||||||
|
line->setLine(spec);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/ui/ItemTooltip.h
Normal file
36
src/ui/ItemTooltip.h
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "ItemTooltipContext.h"
|
||||||
|
#include "Tooltip.h"
|
||||||
|
#include "TooltipTrigger.h"
|
||||||
|
|
||||||
|
// The tooltip an item's name carries: the item, and every way the player can currently
|
||||||
|
// produce it, each drawn as a recipe line (REQ-UI-ITEM-TOOLTIP).
|
||||||
|
//
|
||||||
|
// Its content is drawn -- item squares, building chips and all -- rather than written,
|
||||||
|
// which is what it adds to the plain text tooltip it derives from. Rebuilt on every show,
|
||||||
|
// because what produces an item changes as the player unlocks recipes
|
||||||
|
// (REQ-LOCK-UI-RECIPE).
|
||||||
|
class ItemTooltip : public Tooltip
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
// Gives one display the tooltip of the item it names (REQ-UI-ITEM-VALUE-TOOLTIP).
|
||||||
|
// The tooltip is parented to the target and dies with it. HoverOnly wherever the
|
||||||
|
// target sits on something the player clicks, whose click is not free to explain.
|
||||||
|
static void attachTo(QWidget& target, const ItemTooltipContext& context,
|
||||||
|
const std::string& itemId, TooltipTrigger::Trigger trigger);
|
||||||
|
|
||||||
|
ItemTooltip(const ItemTooltipContext& context, const std::string& itemId,
|
||||||
|
QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void refreshContent() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
ItemTooltipContext m_context;
|
||||||
|
std::string m_itemId;
|
||||||
|
};
|
||||||
27
src/ui/ItemTooltipContext.h
Normal file
27
src/ui/ItemTooltipContext.h
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
struct GameConfig;
|
||||||
|
class BuildingIconCache;
|
||||||
|
class ItemIconCache;
|
||||||
|
class Simulation;
|
||||||
|
|
||||||
|
// Everything an item production tooltip reads (REQ-UI-ITEM-TOOLTIP): the simulation it
|
||||||
|
// asks which recipes the player has unlocked, the immutable config holding the recipes
|
||||||
|
// themselves, and the two icon caches its recipe lines are drawn from.
|
||||||
|
//
|
||||||
|
// Its own bundle rather than the selection panel's (SelectionContext), because an item is
|
||||||
|
// named all over the UI -- the header bar, the dialogs, the panel -- and every one of
|
||||||
|
// those places has to be able to explain it (REQ-UI-ITEM-VALUE-TOOLTIP). The panel's
|
||||||
|
// context carries visuals and the debug flag besides, which no tooltip reads.
|
||||||
|
//
|
||||||
|
// Nothing here is owned: a view onto objects living in MainWindow, which it must not
|
||||||
|
// outlive.
|
||||||
|
struct ItemTooltipContext
|
||||||
|
{
|
||||||
|
// Const because a tooltip only ever reads: what produces an item is a question, not
|
||||||
|
// a command.
|
||||||
|
const Simulation* sim = nullptr;
|
||||||
|
const GameConfig* config = nullptr;
|
||||||
|
ItemIconCache* itemIcons = nullptr;
|
||||||
|
BuildingIconCache* buildingIcons = nullptr;
|
||||||
|
};
|
||||||
@@ -7,14 +7,14 @@
|
|||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QCloseEvent>
|
#include <QCloseEvent>
|
||||||
|
#include <QDialog>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QInputDialog>
|
|
||||||
#include <QLineEdit>
|
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QPushButton>
|
#include <QPoint>
|
||||||
#include <QResizeEvent>
|
#include <QResizeEvent>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
#include "BlueprintLibrary.h"
|
#include "BlueprintLibrary.h"
|
||||||
#include "BlueprintSelectionDialog.h"
|
#include "BlueprintSelectionDialog.h"
|
||||||
@@ -29,10 +29,16 @@
|
|||||||
#include "SchematicChoiceDialog.h"
|
#include "SchematicChoiceDialog.h"
|
||||||
#include "HeaderBar.h"
|
#include "HeaderBar.h"
|
||||||
#include "SelectionPanel.h"
|
#include "SelectionPanel.h"
|
||||||
|
#include "ControlsPanel.h"
|
||||||
|
#include "ExpandButton.h"
|
||||||
#include "ShipLayoutBlueprintSerializer.h"
|
#include "ShipLayoutBlueprintSerializer.h"
|
||||||
#include "ShipLayoutDialog.h"
|
#include "ShipLayoutDialog.h"
|
||||||
|
#include "BuildingIconCache.h"
|
||||||
#include "ItemIconCache.h"
|
#include "ItemIconCache.h"
|
||||||
|
#include "MessageDialog.h"
|
||||||
|
#include "ModalLayer.h"
|
||||||
#include "ModalPauseScope.h"
|
#include "ModalPauseScope.h"
|
||||||
|
#include "NameInputDialog.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "VisualsLoader.h"
|
#include "VisualsLoader.h"
|
||||||
@@ -48,53 +54,75 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
|
|||||||
setWindowTitle(tr("Dota Factory"));
|
setWindowTitle(tr("Dota Factory"));
|
||||||
resize(1280, 768);
|
resize(1280, 768);
|
||||||
|
|
||||||
// Item icons live alongside the config (a sibling of the config dir), read from
|
// Item and building icons live alongside the config (siblings of the config dir),
|
||||||
// disk at runtime like the building icons and visuals.toml (REQ-UI-ITEM-ICON).
|
// read from disk at runtime the same way visuals.toml is (REQ-UI-ITEM-ICON,
|
||||||
const std::string itemsIconDir = QDir::cleanPath(
|
// REQ-UI-BUILD-ICON).
|
||||||
QString::fromStdString(m_configDir) + "/../icons/items").toStdString();
|
const QString configDirPath = QString::fromStdString(m_configDir);
|
||||||
|
|
||||||
m_itemIcons = std::make_unique<ItemIconCache>(
|
m_itemIcons = std::make_unique<ItemIconCache>(
|
||||||
QString::fromStdString(itemsIconDir));
|
QDir::cleanPath(configDirPath + "/../icons/items"), &m_visuals);
|
||||||
|
m_buildingIcons = std::make_unique<BuildingIconCache>(
|
||||||
|
QDir::cleanPath(configDirPath + "/../icons/buildings"));
|
||||||
|
|
||||||
m_headerBar = new HeaderBar(sim, &sim->getConfig(), m_itemIcons.get(), this);
|
m_headerBar = new HeaderBar(getItemTooltipContext(), this);
|
||||||
|
|
||||||
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
|
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
|
||||||
m_itemIcons.get(), m_replay.get(), this);
|
m_itemIcons.get(), m_replay.get(), this);
|
||||||
|
|
||||||
// Building icons live alongside the config (a sibling of the config dir), read
|
|
||||||
// from disk at runtime the same way visuals.toml is.
|
|
||||||
const std::string iconDir = QDir::cleanPath(
|
|
||||||
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
|
|
||||||
|
|
||||||
// Floats over the game world at its bottom center, sized to its buttons
|
// Floats over the game world at its bottom center, sized to its buttons
|
||||||
// (REQ-UI-BUILD-BAR). Creation order is the stacking order for siblings, so
|
// (REQ-UI-BUILD-BAR). Creation order is the stacking order for siblings, so
|
||||||
// building it after the world view puts it above the world and its vignettes,
|
// building it after the world view puts it above the world and its vignettes,
|
||||||
// and before the dim overlay keeps modals dimming it too (REQ-UI-MODAL-DIM).
|
// and before the dim overlay keeps modals dimming it too (REQ-UI-MODAL-DIM).
|
||||||
// Its geometry comes from layoutPanels().
|
// Its geometry comes from layoutPanels().
|
||||||
m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(), iconDir,
|
m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(),
|
||||||
m_itemIcons.get(), this);
|
m_buildingIcons.get(), m_itemIcons.get(),
|
||||||
|
this);
|
||||||
|
|
||||||
|
// Stands in the world rather than on the screen, on the columns the next expansion
|
||||||
|
// unlocks (REQ-UI-EXPAND-BUTTON). A sibling of the world view like the panels, which
|
||||||
|
// is what keeps the builder-mode ghost off the tile beneath it and the click off the
|
||||||
|
// world (REQ-BLD-GHOST): the view's hover follows underMouse(), false while the
|
||||||
|
// cursor rests on a sibling.
|
||||||
|
m_expandButton = new ExpandButton(getItemTooltipContext(), this);
|
||||||
|
|
||||||
// The blueprints have no widget of their own: they are saved with Ctrl+C and picked
|
// The blueprints have no widget of their own: they are saved with Ctrl+C and picked
|
||||||
// from a modal dialog (REQ-UI-BLUEPRINT-DIALOG), both driven from this window
|
// from a modal dialog (REQ-UI-BLUEPRINT-DIALOG), both driven from this window
|
||||||
// because only it can pause the game and raise the dim overlay. Built after the
|
// because only it can pause the game and raise the dim overlay. Built after the
|
||||||
// world view because loading blueprints.toml may put a message box on screen.
|
// world view because loading blueprints.toml may put a message box on screen.
|
||||||
m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this);
|
m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this);
|
||||||
|
// Two facts about blueprints decide what the world view offers the player
|
||||||
|
// (REQ-UI-CONTROLS-CONTENT); the library is built after the view, so it is handed
|
||||||
|
// over here rather than passed to the constructor.
|
||||||
|
m_gameWorldView->setBlueprintLibrary(m_blueprintLibrary.get());
|
||||||
|
|
||||||
// Floats over the game world at its right edge rather than occupying a column of
|
// Floats over the game world at its right edge rather than occupying a column of
|
||||||
// its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like
|
// its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like
|
||||||
// the build button bar it is a sibling of the world view built after it, which is
|
// the build button bar it is a sibling of the world view built after it, which is
|
||||||
// what puts it above the world and its vignettes and below the dim overlay. It
|
// what puts it above the world and its vignettes and below the dim overlay. It
|
||||||
// brings its own chrome; its geometry comes from layoutPanels().
|
// brings its own chrome; its geometry comes from layoutPanels().
|
||||||
m_selectionPanel = new SelectionPanel(sim, &sim->getConfig(), this);
|
m_selectionPanel = new SelectionPanel(sim, &sim->getConfig(), &m_visuals,
|
||||||
|
m_itemIcons.get(), m_buildingIcons.get(),
|
||||||
|
this);
|
||||||
|
|
||||||
// Created last so it stacks above the other children; covers the whole window and
|
// Floats at the world view's opposite edge from the selection panel and reads the
|
||||||
// dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM).
|
// world view for the player's current situation (REQ-UI-CONTROLS-PANEL). Built
|
||||||
m_dimOverlay = new ModalDimOverlay(m_visuals.overlays.modalDim, this);
|
// after the view for the same stacking reason as the panels above it.
|
||||||
|
m_controlsPanel = new ControlsPanel(m_gameWorldView, this);
|
||||||
|
|
||||||
|
// Created last so it stacks above the other children; covers the whole window,
|
||||||
|
// dims the game behind every modal, and is what those modals are drawn on
|
||||||
|
// (REQ-UI-MODAL-DIM, REQ-UI-MODAL-CHROME).
|
||||||
|
m_modalLayer = new ModalLayer(m_visuals.overlays.modalDim, this);
|
||||||
|
|
||||||
m_gameWorldView->setFocus();
|
m_gameWorldView->setFocus();
|
||||||
|
|
||||||
connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* newWidget) {
|
connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* newWidget) {
|
||||||
if (newWidget && newWidget != m_gameWorldView && !QApplication::activeModalWidget())
|
// A modal holds the focus while it is open, whether it is one of ours on the
|
||||||
|
// layer or a system message box (REQ-UI-MODAL-CHROME). A null widget -- the focus
|
||||||
|
// going nowhere at all -- is caught too: the world view is what answers keys when
|
||||||
|
// no modal is up, so leaving the window with no focus widget would leave the
|
||||||
|
// hotkeys dead (REQ-UI-HOTKEYS).
|
||||||
|
if (newWidget != m_gameWorldView && !QApplication::activeModalWidget()
|
||||||
|
&& !m_modalLayer->isActive())
|
||||||
{
|
{
|
||||||
m_gameWorldView->setFocus();
|
m_gameWorldView->setFocus();
|
||||||
}
|
}
|
||||||
@@ -134,6 +162,15 @@ void MainWindow::resizeEvent(QResizeEvent* event)
|
|||||||
|
|
||||||
void MainWindow::closeEvent(QCloseEvent* event)
|
void MainWindow::closeEvent(QCloseEvent* event)
|
||||||
{
|
{
|
||||||
|
// A modal on the layer runs a nested event loop over widgets this window owns, so
|
||||||
|
// the window must outlive it. A modal window used to block the close outright; this
|
||||||
|
// does the same for a modal that is only a widget (REQ-UI-MODAL-CHROME).
|
||||||
|
if (m_modalLayer->isActive())
|
||||||
|
{
|
||||||
|
event->ignore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const QString path = QCoreApplication::applicationDirPath() + "/ship_layouts.toml";
|
const QString path = QCoreApplication::applicationDirPath() + "/ship_layouts.toml";
|
||||||
QFile file(path);
|
QFile file(path);
|
||||||
if (file.open(QIODevice::WriteOnly | QIODevice::Text))
|
if (file.open(QIODevice::WriteOnly | QIODevice::Text))
|
||||||
@@ -162,24 +199,70 @@ void MainWindow::layoutPanels()
|
|||||||
const QRect worldRect(0, headerH, totalW, totalH - headerH);
|
const QRect worldRect(0, headerH, totalW, totalH - headerH);
|
||||||
m_headerBar->setGeometry(0, 0, totalW, headerH);
|
m_headerBar->setGeometry(0, 0, totalW, headerH);
|
||||||
m_gameWorldView->setGeometry(worldRect);
|
m_gameWorldView->setGeometry(worldRect);
|
||||||
// Sizes itself to its buttons and centers along the bottom of the world view
|
m_modalLayer->setGeometry(0, 0, totalW, totalH);
|
||||||
// (REQ-UI-BUILD-BAR).
|
|
||||||
m_buildButtonBar->anchorTo(worldRect);
|
// The floating widgets are placed in one ordered pass, each into the space the
|
||||||
// The panel confines itself to what the bar leaves free, so the bar never has to
|
// earlier ones have not taken (FloatingPanel.h). The order is the priority the
|
||||||
// move for it (REQ-UI-SELECTION-PANEL, REQ-UI-BUILD-BAR).
|
// requirements state: the build button bar takes what it wants and never moves for
|
||||||
m_selectionPanel->anchorTo(
|
// anyone (REQ-UI-BUILD-BAR), the controls panel steps around the bar
|
||||||
worldRect.adjusted(0, 0, 0, -m_buildButtonBar->getStripHeightPx()));
|
// (REQ-UI-CONTROLS-PANEL), and the selection panel keeps clear of both
|
||||||
m_dimOverlay->setGeometry(0, 0, totalW, totalH);
|
// (REQ-UI-SELECTION-PANEL). A widget with nothing to show hides itself in placeIn()
|
||||||
|
// and takes no space.
|
||||||
|
//
|
||||||
|
// Re-entry is refused rather than queued: setGeometry() on a widget in the pass can
|
||||||
|
// reach code that asks for another pass, and the one already running is about to
|
||||||
|
// produce the same answer.
|
||||||
|
if (m_layingOut)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_layingOut = true;
|
||||||
|
|
||||||
|
const std::vector<QWidget*> floatingWidgets = {
|
||||||
|
m_buildButtonBar, m_controlsPanel, m_selectionPanel };
|
||||||
|
const std::vector<FloatingPanel*> floatingPanels = {
|
||||||
|
m_buildButtonBar, m_controlsPanel, m_selectionPanel };
|
||||||
|
|
||||||
|
std::vector<QRect> occupiedRects;
|
||||||
|
for (std::size_t i = 0; i < floatingPanels.size(); ++i)
|
||||||
|
{
|
||||||
|
floatingPanels[i]->placeIn(worldRect, occupiedRects);
|
||||||
|
if (floatingWidgets[i]->isVisible())
|
||||||
|
{
|
||||||
|
occupiedRects.push_back(floatingWidgets[i]->geometry());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last, and never added to occupiedRects: it marks a place in the world, so the
|
||||||
|
// panels do not step around it and it does not step around them
|
||||||
|
// (REQ-UI-EXPAND-BUTTON).
|
||||||
|
placeExpandButton();
|
||||||
|
|
||||||
|
m_layingOut = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::handleEvent(
|
||||||
|
std::shared_ptr<const FloatingLayoutInvalidatedEvent> /*event*/)
|
||||||
|
{
|
||||||
|
// One of the floating widgets changed size or visibility. What each of them may take
|
||||||
|
// depends on the ones placed before it, so the answer is the whole pass rather than
|
||||||
|
// that one widget re-placing itself.
|
||||||
|
layoutPanels();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)
|
void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)
|
||||||
{
|
{
|
||||||
ModalPauseScope pause(*m_gameWorldView);
|
ModalPauseScope pause(*m_gameWorldView);
|
||||||
|
|
||||||
ModalDimScope dim(*m_dimOverlay);
|
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes,
|
||||||
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, this);
|
getItemTooltipContext(), m_modalLayer);
|
||||||
dialog.exec();
|
m_modalLayer->execute(dialog);
|
||||||
|
|
||||||
|
// The command goes out unconditionally because the dialog cannot be dismissed: it
|
||||||
|
// returns only once an option was clicked, so the index always names that option
|
||||||
|
// (REQ-DEF-SCHEMATIC-DROP). It is also the only thing that resolves the drop -- the
|
||||||
|
// poll that opened this dialog will not open it again while the choices stay pending
|
||||||
|
// (GameWorldView::onFrame) -- so a path that skipped the command would strand it.
|
||||||
std::shared_ptr<ApplySchematicChoiceCommand> command =
|
std::shared_ptr<ApplySchematicChoiceCommand> command =
|
||||||
std::make_shared<ApplySchematicChoiceCommand>();
|
std::make_shared<ApplySchematicChoiceCommand>();
|
||||||
command->choiceIndex = dialog.getChosenIndex();
|
command->choiceIndex = dialog.getChosenIndex();
|
||||||
@@ -191,17 +274,17 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
|
|||||||
{
|
{
|
||||||
ModalPauseScope pause(*m_gameWorldView);
|
ModalPauseScope pause(*m_gameWorldView);
|
||||||
|
|
||||||
ModalDimScope dim(*m_dimOverlay);
|
MessageDialog box(tr("Paused"), QString(), m_modalLayer);
|
||||||
QMessageBox box(this);
|
const int continueIndex = box.addButton(tr("Continue"));
|
||||||
box.setWindowTitle(tr("Paused"));
|
const int restartIndex = box.addButton(tr("Restart"));
|
||||||
QPushButton* continueBtn = box.addButton(tr("Continue"), QMessageBox::AcceptRole);
|
const int quitIndex = box.addButton(tr("Quit"));
|
||||||
QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::ResetRole);
|
// Escape stands for Continue, as it did when this was a system box
|
||||||
QPushButton* quitBtn = box.addButton(tr("Quit"), QMessageBox::DestructiveRole);
|
// (REQ-UI-GAME-MENU).
|
||||||
box.setEscapeButton(continueBtn);
|
box.setEscapeButtonIndex(continueIndex);
|
||||||
box.exec();
|
m_modalLayer->execute(box);
|
||||||
|
|
||||||
QAbstractButton* clicked = box.clickedButton();
|
const std::optional<int> clicked = box.getClickedButtonIndex();
|
||||||
if (clicked == restartBtn)
|
if (clicked == restartIndex)
|
||||||
{
|
{
|
||||||
std::optional<GameConfig> newConfig = reloadConfig();
|
std::optional<GameConfig> newConfig = reloadConfig();
|
||||||
if (!newConfig.has_value())
|
if (!newConfig.has_value())
|
||||||
@@ -219,7 +302,7 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
|
|||||||
EventManager::getInstance()->sendEventImmediately(
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
std::make_shared<CommandRequestedEvent>(command));
|
std::make_shared<CommandRequestedEvent>(command));
|
||||||
}
|
}
|
||||||
else if (clicked == quitBtn)
|
else if (clicked == quitIndex)
|
||||||
{
|
{
|
||||||
pause.release();
|
pause.release();
|
||||||
close();
|
close();
|
||||||
@@ -236,7 +319,10 @@ std::optional<GameConfig> MainWindow::reloadConfig()
|
|||||||
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
|
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
|
||||||
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
||||||
m_visuals = std::move(newVisuals);
|
m_visuals = std::move(newVisuals);
|
||||||
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
|
m_modalLayer->setDimColor(m_visuals.overlays.modalDim);
|
||||||
|
// The composed item squares carry the colors they were painted with, so they
|
||||||
|
// 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)
|
||||||
@@ -262,13 +348,15 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ModalDimScope dim(*m_dimOverlay);
|
ShipLayoutDialog dialog(schematicId, currentLayout,
|
||||||
ShipLayoutDialog dialog(&m_sim->getConfig(), schematicId, currentLayout,
|
|
||||||
m_layoutBlueprints,
|
m_layoutBlueprints,
|
||||||
std::move(unlockedModuleIds),
|
std::move(unlockedModuleIds),
|
||||||
m_gameWorldView->isDebugDrawEnabled(),
|
m_gameWorldView->isDebugDrawEnabled(),
|
||||||
this);
|
getItemTooltipContext(), m_modalLayer);
|
||||||
if (dialog.exec() == QDialog::Accepted && dialog.getResult().has_value())
|
// 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.
|
||||||
|
if (m_modalLayer->execute(dialog, getSelectionPanelAnchor()) == QDialog::Accepted
|
||||||
|
&& dialog.getResult().has_value())
|
||||||
{
|
{
|
||||||
std::shared_ptr<SetShipLayoutCommand> command =
|
std::shared_ptr<SetShipLayoutCommand> command =
|
||||||
std::make_shared<SetShipLayoutCommand>();
|
std::make_shared<SetShipLayoutCommand>();
|
||||||
@@ -279,6 +367,20 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QRect MainWindow::getSelectionPanelAnchor() 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 the layer's own centering
|
||||||
|
// stands (REQ-UI-PANEL-MODAL).
|
||||||
|
if (!m_selectionPanel->isVisible()) { return QRect(); }
|
||||||
|
|
||||||
|
// The panel's live geometry, so a panel the player has dragged
|
||||||
|
// (REQ-UI-SELECTION-PANEL-DRAG) carries the modal with it. Panel and layer are both
|
||||||
|
// children of this window, so the panel's geometry needs no mapping.
|
||||||
|
return m_selectionPanel->geometry();
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -292,6 +394,15 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
|
|||||||
}
|
}
|
||||||
|
|
||||||
const std::string& schematicId = b ? b->recipeId : s->recipeId;
|
const std::string& schematicId = b ? b->recipeId : s->recipeId;
|
||||||
|
// Nothing to configure without a schematic and a grid to place modules on. The
|
||||||
|
// Configure button that publishes this is already disabled then (REQ-MOD-UI-PREVIEW),
|
||||||
|
// so this guards the event rather than the button: the dialog would otherwise open
|
||||||
|
// over a grid of no cells.
|
||||||
|
if (!m_sim->getConfig().ships.findLayoutShipDef(schematicId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const std::optional<ShipLayoutConfig>& layoutOpt =
|
const std::optional<ShipLayoutConfig>& layoutOpt =
|
||||||
b ? b->shipLayout : s->shipLayout;
|
b ? b->shipLayout : s->shipLayout;
|
||||||
|
|
||||||
@@ -320,7 +431,7 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
|
|||||||
|
|
||||||
// Held across both the selection dialog and any auto-opened layout dialog so the
|
// Held across both the selection dialog and any auto-opened layout dialog so the
|
||||||
// dim stays continuously visible through that sequence (REQ-UI-MODAL-DIM).
|
// dim stays continuously visible through that sequence (REQ-UI-MODAL-DIM).
|
||||||
ModalDimScope dim(*m_dimOverlay);
|
ModalLayerHold dim(*m_modalLayer);
|
||||||
|
|
||||||
const BuildingType type = b ? b->type : s->type;
|
const BuildingType type = b ? b->type : s->type;
|
||||||
// Captured as a copy: a queued command may drain during the modal dialog's
|
// Captured as a copy: a queued command may drain during the modal dialog's
|
||||||
@@ -335,8 +446,10 @@ 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, getItemTooltipContext(),
|
||||||
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
|
m_modalLayer);
|
||||||
|
if (m_modalLayer->execute(dialog, getSelectionPanelAnchor()) == QDialog::Accepted
|
||||||
|
&& dialog.getChosenId().has_value())
|
||||||
{
|
{
|
||||||
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
|
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
|
||||||
command->id = event->buildingId;
|
command->id = event->buildingId;
|
||||||
@@ -345,8 +458,12 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
|
|||||||
std::make_shared<CommandRequestedEvent>(command));
|
std::make_shared<CommandRequestedEvent>(command));
|
||||||
|
|
||||||
// REQ-MOD-UI-AUTO-DIALOG: picking a new schematic for a shipyard opens the
|
// REQ-MOD-UI-AUTO-DIALOG: picking a new schematic for a shipyard opens the
|
||||||
// layout configuration dialog immediately. Only on an actual change.
|
// layout configuration dialog immediately. Only on an actual change, and only
|
||||||
if (type == BuildingType::Shipyard && *dialog.getChosenId() != oldSchematic)
|
// for an actual schematic: "(None)" clears the shipyard and carries the empty
|
||||||
|
// id (REQ-UI-SELECT-OPTIONS), which differs from whatever was set but is not a
|
||||||
|
// schematic to configure.
|
||||||
|
if (type == BuildingType::Shipyard && *dialog.getChosenId() != oldSchematic
|
||||||
|
&& m_sim->getConfig().ships.findLayoutShipDef(*dialog.getChosenId()))
|
||||||
{
|
{
|
||||||
autoOpenLayout = true;
|
autoOpenLayout = true;
|
||||||
chosenSchematic = *dialog.getChosenId();
|
chosenSchematic = *dialog.getChosenId();
|
||||||
@@ -374,30 +491,78 @@ void MainWindow::handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent>
|
|||||||
// over to, so the dim never blinks off and the simulation is not resumed in between
|
// over to, so the dim never blinks off and the simulation is not resumed in between
|
||||||
// (REQ-UI-MODAL-DIM).
|
// (REQ-UI-MODAL-DIM).
|
||||||
ModalPauseScope pause(*m_gameWorldView);
|
ModalPauseScope pause(*m_gameWorldView);
|
||||||
ModalDimScope dim(*m_dimOverlay);
|
ModalLayerHold dim(*m_modalLayer);
|
||||||
|
|
||||||
bool ok = false;
|
NameInputDialog nameDialog(tr("Create Blueprint"), tr("Blueprint name:"),
|
||||||
const QString name = QInputDialog::getText(
|
m_modalLayer);
|
||||||
this, tr("Create Blueprint"), tr("Blueprint name:"), QLineEdit::Normal,
|
const int result = m_modalLayer->execute(nameDialog);
|
||||||
QString(), &ok);
|
|
||||||
// Cancel, Escape, or an empty name: no blueprint, and no selection dialog.
|
// Cancel, Escape, or an empty name: no blueprint, and no selection dialog.
|
||||||
if (!ok || name.trimmed().isEmpty()) { return; }
|
if (result != QDialog::Accepted || nameDialog.getName().isEmpty()) { return; }
|
||||||
|
|
||||||
m_blueprintLibrary->saveSelectionAs(name.trimmed());
|
m_blueprintLibrary->saveSelectionAs(nameDialog.getName());
|
||||||
showBlueprintSelectionDialog();
|
showBlueprintSelectionDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> /*event*/)
|
void MainWindow::handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> /*event*/)
|
||||||
{
|
{
|
||||||
ModalPauseScope pause(*m_gameWorldView);
|
ModalPauseScope pause(*m_gameWorldView);
|
||||||
ModalDimScope dim(*m_dimOverlay);
|
|
||||||
showBlueprintSelectionDialog();
|
showBlueprintSelectionDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::placeExpandButton()
|
||||||
|
{
|
||||||
|
// Null while the button is still being built: refreshing its cost asks for a
|
||||||
|
// placement pass, and the first refresh happens inside its own constructor.
|
||||||
|
if (m_expandButton == nullptr)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The columns the next purchase unlocks: the ones immediately left of the buildable
|
||||||
|
// edge (REQ-GW-ASTEROID-EXPAND, REQ-UI-LOCKED-ASTEROID). Their middle, and the
|
||||||
|
// world's middle vertically, is where the button goes (REQ-UI-EXPAND-BUTTON).
|
||||||
|
const int asteroidWidth_tiles = m_sim->getCurrentAsteroidWidth_tiles();
|
||||||
|
const float columns_tiles = static_cast<float>(
|
||||||
|
m_sim->getConfig().world.expansion.columnsPerExpansion_tiles);
|
||||||
|
const QVector2D center_tiles(
|
||||||
|
-static_cast<float>(asteroidWidth_tiles) - columns_tiles / 2.0f,
|
||||||
|
static_cast<float>(m_sim->getConfig().world.heightTiles) / 2.0f);
|
||||||
|
|
||||||
|
// The transform is the view's, so the point it yields is in the view's coordinates;
|
||||||
|
// the button is a sibling of the view, so it is lifted into this window's.
|
||||||
|
const QPointF centerInView_px =
|
||||||
|
m_gameWorldView->getCoordinates().worldToWidget(center_tiles);
|
||||||
|
const QPoint center_px = m_gameWorldView->geometry().topLeft()
|
||||||
|
+ QPoint(static_cast<int>(centerInView_px.x()),
|
||||||
|
static_cast<int>(centerInView_px.y()));
|
||||||
|
|
||||||
|
// Neither clamped nor hidden: the button belongs to that ground and leaves the view
|
||||||
|
// with it, which the window's own edge takes care of (REQ-UI-EXPAND-BUTTON).
|
||||||
|
const QSize buttonSize = m_expandButton->sizeHint();
|
||||||
|
m_expandButton->setGeometry(QRect(
|
||||||
|
center_px - QPoint(buttonSize.width() / 2, buttonSize.height() / 2),
|
||||||
|
buttonSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::handleEvent(std::shared_ptr<const ViewScrolledEvent> /*event*/)
|
||||||
|
{
|
||||||
|
// The ground moved under the button; nothing else about the layout changed, so this
|
||||||
|
// is the one widget to re-place (REQ-UI-EXPAND-BUTTON).
|
||||||
|
placeExpandButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemTooltipContext MainWindow::getItemTooltipContext() const
|
||||||
|
{
|
||||||
|
return ItemTooltipContext{ m_sim, &m_sim->getConfig(), m_itemIcons.get(),
|
||||||
|
m_buildingIcons.get() };
|
||||||
|
}
|
||||||
|
|
||||||
void MainWindow::showBlueprintSelectionDialog()
|
void MainWindow::showBlueprintSelectionDialog()
|
||||||
{
|
{
|
||||||
BlueprintSelectionDialog dialog(m_blueprintLibrary.get(), m_itemIcons.get(), this);
|
BlueprintSelectionDialog dialog(m_blueprintLibrary.get(), getItemTooltipContext(),
|
||||||
if (dialog.exec() == QDialog::Accepted && dialog.getChosenIndex().has_value())
|
m_modalLayer);
|
||||||
|
if (m_modalLayer->execute(dialog) == QDialog::Accepted
|
||||||
|
&& dialog.getChosenIndex().has_value())
|
||||||
{
|
{
|
||||||
// Entered after the dialog has closed, which is the order REQ-UI-BLUEPRINT-CARD
|
// Entered after the dialog has closed, which is the order REQ-UI-BLUEPRINT-CARD
|
||||||
// describes: clicking a card closes the dialog and enters placement mode.
|
// describes: clicking a card closes the dialog and enters placement mode.
|
||||||
@@ -412,17 +577,18 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
|
|||||||
const int minutes = totalSeconds / 60;
|
const int minutes = totalSeconds / 60;
|
||||||
const int seconds = totalSeconds % 60;
|
const int seconds = totalSeconds % 60;
|
||||||
|
|
||||||
ModalDimScope dim(*m_dimOverlay);
|
MessageDialog box(tr("Game Over"),
|
||||||
QMessageBox box(this);
|
tr("HQ destroyed!\nSurvival time: %1:%2")
|
||||||
box.setWindowTitle(tr("Game Over"));
|
.arg(minutes, 2, 10, QChar('0'))
|
||||||
box.setText(tr("HQ destroyed!\nSurvival time: %1:%2")
|
.arg(seconds, 2, 10, QChar('0')),
|
||||||
.arg(minutes, 2, 10, QChar('0'))
|
m_modalLayer);
|
||||||
.arg(seconds, 2, 10, QChar('0')));
|
const int restartIndex = box.addButton(tr("Restart"));
|
||||||
QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::AcceptRole);
|
const int quitIndex = box.addButton(tr("Quit"));
|
||||||
box.addButton(tr("Quit"), QMessageBox::RejectRole);
|
// Escape quits, which is where the system box's reject role sent it.
|
||||||
box.exec();
|
box.setEscapeButtonIndex(quitIndex);
|
||||||
|
m_modalLayer->execute(box);
|
||||||
|
|
||||||
if (box.clickedButton() == restartBtn)
|
if (box.getClickedButtonIndex() == restartIndex)
|
||||||
{
|
{
|
||||||
std::optional<GameConfig> newConfig = reloadConfig();
|
std::optional<GameConfig> newConfig = reloadConfig();
|
||||||
if (!newConfig.has_value())
|
if (!newConfig.has_value())
|
||||||
@@ -449,17 +615,18 @@ void MainWindow::handleEvent(std::shared_ptr<const WinEvent> /*event*/)
|
|||||||
const int minutes = totalSeconds / 60;
|
const int minutes = totalSeconds / 60;
|
||||||
const int seconds = totalSeconds % 60;
|
const int seconds = totalSeconds % 60;
|
||||||
|
|
||||||
ModalDimScope dim(*m_dimOverlay);
|
MessageDialog box(tr("Won!"),
|
||||||
QMessageBox box(this);
|
tr("You collected all artifacts!\nSurvival time: %1:%2")
|
||||||
box.setWindowTitle(tr("Won!"));
|
.arg(minutes, 2, 10, QChar('0'))
|
||||||
box.setText(tr("You collected all artifacts!\nSurvival time: %1:%2")
|
.arg(seconds, 2, 10, QChar('0')),
|
||||||
.arg(minutes, 2, 10, QChar('0'))
|
m_modalLayer);
|
||||||
.arg(seconds, 2, 10, QChar('0')));
|
const int restartIndex = box.addButton(tr("Restart"));
|
||||||
QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::AcceptRole);
|
const int quitIndex = box.addButton(tr("Quit"));
|
||||||
box.addButton(tr("Quit"), QMessageBox::RejectRole);
|
// Escape quits, which is where the system box's reject role sent it.
|
||||||
box.exec();
|
box.setEscapeButtonIndex(quitIndex);
|
||||||
|
m_modalLayer->execute(box);
|
||||||
|
|
||||||
if (box.clickedButton() == restartBtn)
|
if (box.getClickedButtonIndex() == restartIndex)
|
||||||
{
|
{
|
||||||
std::optional<GameConfig> newConfig = reloadConfig();
|
std::optional<GameConfig> newConfig = reloadConfig();
|
||||||
if (!newConfig.has_value())
|
if (!newConfig.has_value())
|
||||||
|
|||||||
@@ -12,14 +12,17 @@
|
|||||||
#include "BuildingId.h"
|
#include "BuildingId.h"
|
||||||
#include "EscapeMenuRequestedEvent.h"
|
#include "EscapeMenuRequestedEvent.h"
|
||||||
#include "EventHandler.h"
|
#include "EventHandler.h"
|
||||||
|
#include "FloatingLayoutInvalidatedEvent.h"
|
||||||
#include "GameConfig.h"
|
#include "GameConfig.h"
|
||||||
#include "GameOverEvent.h"
|
#include "GameOverEvent.h"
|
||||||
|
#include "ItemTooltipContext.h"
|
||||||
#include "LayoutDialogRequestedEvent.h"
|
#include "LayoutDialogRequestedEvent.h"
|
||||||
#include "ModalDimOverlay.h"
|
#include "ModalLayer.h"
|
||||||
#include "WinEvent.h"
|
#include "WinEvent.h"
|
||||||
#include "RecipeSelectionRequestedEvent.h"
|
#include "RecipeSelectionRequestedEvent.h"
|
||||||
#include "SchematicChoicesAvailableEvent.h"
|
#include "SchematicChoicesAvailableEvent.h"
|
||||||
#include "ShipLayout.h"
|
#include "ShipLayout.h"
|
||||||
|
#include "ViewScrolledEvent.h"
|
||||||
#include "ShipLayoutBlueprint.h"
|
#include "ShipLayoutBlueprint.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "VisualsConfig.h"
|
#include "VisualsConfig.h"
|
||||||
@@ -29,8 +32,11 @@ class Simulation;
|
|||||||
class GameWorldView;
|
class GameWorldView;
|
||||||
class HeaderBar;
|
class HeaderBar;
|
||||||
class SelectionPanel;
|
class SelectionPanel;
|
||||||
|
class ControlsPanel;
|
||||||
class BuildButtonBar;
|
class BuildButtonBar;
|
||||||
class BlueprintLibrary;
|
class BlueprintLibrary;
|
||||||
|
class BuildingIconCache;
|
||||||
|
class ExpandButton;
|
||||||
class ItemIconCache;
|
class ItemIconCache;
|
||||||
class QCloseEvent;
|
class QCloseEvent;
|
||||||
class QResizeEvent;
|
class QResizeEvent;
|
||||||
@@ -43,7 +49,9 @@ class MainWindow : public QWidget,
|
|||||||
LayoutDialogRequestedEvent,
|
LayoutDialogRequestedEvent,
|
||||||
RecipeSelectionRequestedEvent,
|
RecipeSelectionRequestedEvent,
|
||||||
BlueprintSaveRequestedEvent,
|
BlueprintSaveRequestedEvent,
|
||||||
BlueprintSelectionRequestedEvent>
|
BlueprintSelectionRequestedEvent,
|
||||||
|
FloatingLayoutInvalidatedEvent,
|
||||||
|
ViewScrolledEvent>
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
@@ -65,6 +73,8 @@ private:
|
|||||||
void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const FloatingLayoutInvalidatedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const ViewScrolledEvent> event) override;
|
||||||
|
|
||||||
// Reloads the game config and visuals.toml from disk (REQ-CFG-RELOAD), shared
|
// Reloads the game config and visuals.toml from disk (REQ-CFG-RELOAD), shared
|
||||||
// by every restart path. On success the reloaded visuals are applied to this
|
// by every restart path. On success the reloaded visuals are applied to this
|
||||||
@@ -78,11 +88,25 @@ private:
|
|||||||
const std::string& schematicId,
|
const std::string& schematicId,
|
||||||
const ShipLayoutConfig& currentLayout);
|
const ShipLayoutConfig& currentLayout);
|
||||||
|
|
||||||
|
// The rectangle a modal opened from the selection panel is centered on, in this
|
||||||
|
// window's coordinates, or a null rect when the panel is not up (REQ-UI-PANEL-MODAL).
|
||||||
|
QRect getSelectionPanelAnchor() const;
|
||||||
|
|
||||||
// Runs the blueprint selection dialog and enters placement mode for whatever the
|
// Runs the blueprint selection dialog and enters placement mode for whatever the
|
||||||
// player picked (REQ-UI-BLUEPRINT-DIALOG). Holds no pause or dim scope of its own:
|
// player picked (REQ-UI-BLUEPRINT-DIALOG). Holds no pause scope of its own: both
|
||||||
// both callers already hold theirs, which is what keeps the dim continuous when a
|
// callers already hold theirs, and the save path also holds the layer, which is what
|
||||||
// confirmed save hands straight over to this dialog (REQ-UI-MODAL-DIM).
|
// keeps the dim continuous when a confirmed save hands straight over to this dialog
|
||||||
|
// (REQ-UI-MODAL-DIM).
|
||||||
void showBlueprintSelectionDialog();
|
void showBlueprintSelectionDialog();
|
||||||
|
// What every display naming an item needs to explain it (REQ-UI-ITEM-VALUE-TOOLTIP).
|
||||||
|
// Assembled here because this is where the simulation and the icon caches live.
|
||||||
|
ItemTooltipContext getItemTooltipContext() const;
|
||||||
|
// Centers the expansion button on the columns the next purchase unlocks
|
||||||
|
// (REQ-UI-EXPAND-BUTTON). Its place is in the world, so it is re-derived whenever the
|
||||||
|
// view scrolls or resizes rather than once per selection like the panels.
|
||||||
|
void placeExpandButton();
|
||||||
|
// Places the widgets floating over the game world view, in one ordered pass
|
||||||
|
// (FloatingPanel.h). Runs on a resize and on every FloatingLayoutInvalidatedEvent.
|
||||||
void layoutPanels();
|
void layoutPanels();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -92,15 +116,26 @@ private:
|
|||||||
// One per-item icon cache for the whole window (REQ-UI-ITEM-ICON): the header,
|
// One per-item icon cache for the whole window (REQ-UI-ITEM-ICON): the header,
|
||||||
// build bar, world view, and recipe dialog all rasterize the same SVGs.
|
// build bar, world view, and recipe dialog all rasterize the same SVGs.
|
||||||
std::unique_ptr<ItemIconCache> m_itemIcons;
|
std::unique_ptr<ItemIconCache> m_itemIcons;
|
||||||
|
// Likewise one per-building chip cache (REQ-UI-BUILD-ICON), shared by the build bar
|
||||||
|
// and the selection panel's card headers (REQ-UI-SELECTION-CARD).
|
||||||
|
std::unique_ptr<BuildingIconCache> m_buildingIcons;
|
||||||
GameWorldView* m_gameWorldView;
|
GameWorldView* m_gameWorldView;
|
||||||
HeaderBar* m_headerBar;
|
HeaderBar* m_headerBar;
|
||||||
SelectionPanel* m_selectionPanel;
|
SelectionPanel* m_selectionPanel;
|
||||||
|
ControlsPanel* m_controlsPanel;
|
||||||
BuildButtonBar* m_buildButtonBar;
|
BuildButtonBar* m_buildButtonBar;
|
||||||
|
// Stands in the world on the ground it would buy, so it takes no part in the panels'
|
||||||
|
// placement pass (REQ-UI-EXPAND-BUTTON).
|
||||||
|
ExpandButton* m_expandButton = nullptr;
|
||||||
// The saved blueprints themselves; they have no widget of their own any more and
|
// The saved blueprints themselves; they have no widget of their own any more and
|
||||||
// are reached through the two modal dialogs (REQ-UI-BLUEPRINT-DIALOG).
|
// are reached through the two modal dialogs (REQ-UI-BLUEPRINT-DIALOG).
|
||||||
std::unique_ptr<BlueprintLibrary> m_blueprintLibrary;
|
std::unique_ptr<BlueprintLibrary> m_blueprintLibrary;
|
||||||
ModalDimOverlay* m_dimOverlay = nullptr;
|
ModalLayer* m_modalLayer = nullptr;
|
||||||
|
|
||||||
std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
|
std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
|
||||||
std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback
|
std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback
|
||||||
|
|
||||||
|
// Set while the placement pass runs, so a widget placed in it cannot start a second
|
||||||
|
// pass from inside the first.
|
||||||
|
bool m_layingOut = false;
|
||||||
};
|
};
|
||||||
|
|||||||
75
src/ui/MessageDialog.cpp
Normal file
75
src/ui/MessageDialog.cpp
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#include "MessageDialog.h"
|
||||||
|
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
const int kSpacingPx = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageDialog::MessageDialog(const QString& title, const QString& text, QWidget* parent)
|
||||||
|
: ModalDialog(parent)
|
||||||
|
, m_buttonLayout(nullptr)
|
||||||
|
, m_buttonCount(0)
|
||||||
|
{
|
||||||
|
QVBoxLayout* mainLayout = new QVBoxLayout(this);
|
||||||
|
mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx);
|
||||||
|
mainLayout->setSpacing(kSpacingPx);
|
||||||
|
|
||||||
|
if (!text.isEmpty())
|
||||||
|
{
|
||||||
|
QLabel* textLabel = new QLabel(text, this);
|
||||||
|
mainLayout->addWidget(textLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The buttons sit at the right of their row, the side a dialog is confirmed from.
|
||||||
|
m_buttonLayout = new QHBoxLayout();
|
||||||
|
m_buttonLayout->setSpacing(kSpacingPx);
|
||||||
|
m_buttonLayout->addStretch();
|
||||||
|
mainLayout->addLayout(m_buttonLayout);
|
||||||
|
|
||||||
|
// Last, so it becomes the first row (REQ-UI-MODAL-CHROME). No close button: closing
|
||||||
|
// is one of the buttons here, or nothing at all.
|
||||||
|
addHeader(mainLayout, title, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
int MessageDialog::addButton(const QString& caption)
|
||||||
|
{
|
||||||
|
const int index = m_buttonCount++;
|
||||||
|
QPushButton* button = new QPushButton(caption, this);
|
||||||
|
m_buttonLayout->addWidget(button);
|
||||||
|
connect(button, &QPushButton::clicked, this, [this, index]() {
|
||||||
|
onButtonClicked(index);
|
||||||
|
});
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageDialog::setEscapeButtonIndex(int index)
|
||||||
|
{
|
||||||
|
m_escapeButtonIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<int> MessageDialog::getClickedButtonIndex() const
|
||||||
|
{
|
||||||
|
return m_clickedButtonIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageDialog::reject()
|
||||||
|
{
|
||||||
|
// Escape stands for a button rather than for a dismissal of its own, so the caller
|
||||||
|
// reads one answer whichever way the player gave it (REQ-UI-GAME-MENU).
|
||||||
|
if (!m_escapeButtonIndex.has_value())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onButtonClicked(*m_escapeButtonIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageDialog::onButtonClicked(int index)
|
||||||
|
{
|
||||||
|
m_clickedButtonIndex = index;
|
||||||
|
accept();
|
||||||
|
}
|
||||||
50
src/ui/MessageDialog.h
Normal file
50
src/ui/MessageDialog.h
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "ModalDialog.h"
|
||||||
|
|
||||||
|
class QHBoxLayout;
|
||||||
|
class QLabel;
|
||||||
|
|
||||||
|
// The game's own message box (REQ-UI-MODAL-CHROME): a title, an optional line or two of
|
||||||
|
// text, and a row of buttons the caller names. It stands in for QMessageBox at the three
|
||||||
|
// places the player is asked to decide something -- the escape menu (REQ-UI-GAME-MENU)
|
||||||
|
// and the game-over and win screens (REQ-HQ-GAME-OVER, REQ-WIN-SCREEN) -- so that those
|
||||||
|
// read as part of the game rather than as system alerts.
|
||||||
|
//
|
||||||
|
// The caller identifies buttons by the index addButton() hands back, and asks
|
||||||
|
// getClickedButtonIndex() afterwards; the QDialog result code says only whether a button
|
||||||
|
// was clicked at all. Q and a click outside do not dismiss it: every button here is a
|
||||||
|
// decision, and there is no "no change" among them to fall back on.
|
||||||
|
class MessageDialog : public ModalDialog
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
// text may be empty, for a dialog that is a question its buttons already state.
|
||||||
|
MessageDialog(const QString& title, const QString& text, QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
// Appends a button and returns its index, left to right.
|
||||||
|
int addButton(const QString& caption);
|
||||||
|
|
||||||
|
// Which button Escape stands for. Without one, Escape does nothing -- a dialog whose
|
||||||
|
// buttons all commit to something has no dismissal to offer.
|
||||||
|
void setEscapeButtonIndex(int index);
|
||||||
|
|
||||||
|
std::optional<int> getClickedButtonIndex() const;
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void reject() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void onButtonClicked(int index);
|
||||||
|
|
||||||
|
QHBoxLayout* m_buttonLayout;
|
||||||
|
int m_buttonCount;
|
||||||
|
std::optional<int> m_escapeButtonIndex;
|
||||||
|
std::optional<int> m_clickedButtonIndex;
|
||||||
|
};
|
||||||
101
src/ui/ModalDialog.cpp
Normal file
101
src/ui/ModalDialog.cpp
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#include "ModalDialog.h"
|
||||||
|
|
||||||
|
#include <QBoxLayout>
|
||||||
|
#include <QChar>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QKeyEvent>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// The header's own metrics, matching the row the blueprint selection dialog drew
|
||||||
|
// before this class existed (REQ-UI-BLUEPRINT-DIALOG).
|
||||||
|
const int kSpacingPx = 8;
|
||||||
|
const int kSmallButtonSizePx = 22;
|
||||||
|
const QChar kCrossGlyph(0x00D7); // U+00D7 MULTIPLICATION SIGN
|
||||||
|
|
||||||
|
// Q dismisses an open dialog, beside the Escape that QDialog already handles
|
||||||
|
// (REQ-UI-DIALOG-DISMISS). Ctrl must not be held, matching how the game world's
|
||||||
|
// table separates a chord from the bare key (resolveKeyAction in
|
||||||
|
// lib/core/ControlAction.cpp). This deliberately stays out of that table: the table
|
||||||
|
// answers what an input does in the player's current situation, and a dialog has no
|
||||||
|
// situation -- it holds focus and takes the key whatever the world beneath it is
|
||||||
|
// doing.
|
||||||
|
bool isDismissKey(const QKeyEvent& event)
|
||||||
|
{
|
||||||
|
return event.key() == Qt::Key_Q
|
||||||
|
&& (event.modifiers() & Qt::ControlModifier) == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalDialog::ModalDialog(QWidget* parent)
|
||||||
|
: QDialog(parent)
|
||||||
|
{
|
||||||
|
// An ordinary child widget rather than a window: the layer places it and the dim
|
||||||
|
// behind it is the same widget's paint (REQ-UI-MODAL-CHROME). Square corners rather
|
||||||
|
// than rounded ones -- rounding needs a translucent background, which is unreliable
|
||||||
|
// on Windows. The border matches the build bar and the selection panel.
|
||||||
|
setWindowFlags(Qt::Widget);
|
||||||
|
setAttribute(Qt::WA_StyledBackground, true);
|
||||||
|
// A type selector, so it reaches every subclass but none of the child widgets
|
||||||
|
// inside them, which draw themselves.
|
||||||
|
setStyleSheet(QStringLiteral(
|
||||||
|
"ModalDialog { background-color: palette(window);"
|
||||||
|
" border: 1px solid palette(mid); }"));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ModalDialog::isDismissible() const
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QWidget* ModalDialog::getInitialFocusWidget() const
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModalDialog::requestDismiss()
|
||||||
|
{
|
||||||
|
reject();
|
||||||
|
}
|
||||||
|
|
||||||
|
QHBoxLayout* ModalDialog::addHeader(QBoxLayout* mainLayout, const QString& title,
|
||||||
|
bool withCloseButton)
|
||||||
|
{
|
||||||
|
QHBoxLayout* headerLayout = new QHBoxLayout();
|
||||||
|
headerLayout->setSpacing(kSpacingPx);
|
||||||
|
|
||||||
|
QLabel* titleLabel = new QLabel(title, this);
|
||||||
|
QFont headerFont = titleLabel->font();
|
||||||
|
headerFont.setBold(true);
|
||||||
|
titleLabel->setFont(headerFont);
|
||||||
|
headerLayout->addWidget(titleLabel);
|
||||||
|
|
||||||
|
// The stretch is added before the close button so a subclass inserting after the
|
||||||
|
// title lands left of it, where the blueprint dialog's hotkey badge belongs.
|
||||||
|
headerLayout->addStretch();
|
||||||
|
|
||||||
|
if (withCloseButton)
|
||||||
|
{
|
||||||
|
QPushButton* closeButton = new QPushButton(QString(kCrossGlyph), this);
|
||||||
|
closeButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
|
||||||
|
connect(closeButton, &QPushButton::clicked, this, &QDialog::reject);
|
||||||
|
headerLayout->addWidget(closeButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLayout->insertLayout(0, headerLayout);
|
||||||
|
return headerLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModalDialog::keyPressEvent(QKeyEvent* event)
|
||||||
|
{
|
||||||
|
if (isDismissible() && isDismissKey(*event))
|
||||||
|
{
|
||||||
|
requestDismiss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QDialog::keyPressEvent(event);
|
||||||
|
}
|
||||||
54
src/ui/ModalDialog.h
Normal file
54
src/ui/ModalDialog.h
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
|
||||||
|
class QBoxLayout;
|
||||||
|
class QHBoxLayout;
|
||||||
|
class QKeyEvent;
|
||||||
|
class QString;
|
||||||
|
|
||||||
|
// Base of every modal the player meets while playing (REQ-UI-MODAL-CHROME). A modal is
|
||||||
|
// not an operating system window here: it is an ordinary child widget, hosted and placed
|
||||||
|
// by the ModalLayer that also paints the dim it sits on, so it has no title bar, no
|
||||||
|
// window border, and no window-manager close. What a window used to supply, this class
|
||||||
|
// supplies instead -- the panel background, the drawn header, and the dismissal gestures.
|
||||||
|
//
|
||||||
|
// It stays a QDialog for accept()/reject()/result() and the Escape handling built into
|
||||||
|
// them; only the window-ness is dropped (Qt::Widget flags). Nothing calls exec() on it:
|
||||||
|
// ModalLayer::execute() runs the modal loop, so that the layer knows what is open.
|
||||||
|
class ModalDialog : public QDialog
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ModalDialog(QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
// Whether Q and a click outside this dialog dismiss it (REQ-UI-DIALOG-DISMISS). One
|
||||||
|
// predicate for both gestures because they reach exactly the same dialogs. The
|
||||||
|
// default refuses both, so a dialog takes the gestures only by saying so: the two
|
||||||
|
// name dialogs must let Q through as a character, and the schematic choice dialog
|
||||||
|
// has no way out but choosing (REQ-DEF-SCHEMATIC-DROP).
|
||||||
|
virtual bool isDismissible() const;
|
||||||
|
|
||||||
|
// The widget that should hold the keyboard once the modal is open -- a name prompt's
|
||||||
|
// line edit -- or nullptr to leave the focus on the modal itself. Asked for by
|
||||||
|
// ModalLayer rather than read off focusWidget(), because hosting the modal reparents
|
||||||
|
// it and a reparent clears the focus its constructor set.
|
||||||
|
virtual QWidget* getInitialFocusWidget() const;
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
// What a dismissal does, whichever gesture asked for it. Cancelling is the default;
|
||||||
|
// a dialog with modes of its own overrides this to back out of them one at a time
|
||||||
|
// (ShipLayoutDialog, REQ-UI-DIALOG-DISMISS).
|
||||||
|
virtual void requestDismiss();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// Adds the drawn header row -- the title, and a close button at the far right when
|
||||||
|
// asked for -- as the first row of mainLayout, and returns it so a subclass can
|
||||||
|
// insert its own widgets after the title (REQ-UI-MODAL-CHROME). The close button
|
||||||
|
// rejects the dialog, which is what the window-manager close used to do.
|
||||||
|
QHBoxLayout* addHeader(QBoxLayout* mainLayout, const QString& title,
|
||||||
|
bool withCloseButton);
|
||||||
|
|
||||||
|
void keyPressEvent(QKeyEvent* event) override;
|
||||||
|
};
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
#include "ModalDimOverlay.h"
|
|
||||||
|
|
||||||
#include <QPainter>
|
|
||||||
|
|
||||||
ModalDimOverlay::ModalDimOverlay(const QColor& dimColor, QWidget* parent)
|
|
||||||
: QWidget(parent)
|
|
||||||
, m_dimColor(dimColor)
|
|
||||||
{
|
|
||||||
setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
|
||||||
hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
void ModalDimOverlay::pushModal()
|
|
||||||
{
|
|
||||||
if (m_modalDepth++ == 0)
|
|
||||||
{
|
|
||||||
raise();
|
|
||||||
show();
|
|
||||||
// Force an immediate synchronous paint so the scrim is visible before the
|
|
||||||
// caller enters a blocking dialog exec() (no undimmed frame flashes through).
|
|
||||||
repaint();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ModalDimOverlay::popModal()
|
|
||||||
{
|
|
||||||
if (m_modalDepth > 0 && --m_modalDepth == 0)
|
|
||||||
{
|
|
||||||
hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ModalDimOverlay::setDimColor(const QColor& dimColor)
|
|
||||||
{
|
|
||||||
m_dimColor = dimColor;
|
|
||||||
if (isVisible())
|
|
||||||
{
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ModalDimOverlay::paintEvent(QPaintEvent* /*event*/)
|
|
||||||
{
|
|
||||||
QPainter painter(this);
|
|
||||||
painter.fillRect(rect(), m_dimColor);
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QColor>
|
|
||||||
#include <QWidget>
|
|
||||||
|
|
||||||
class QPaintEvent;
|
|
||||||
|
|
||||||
// A window-wide, semi-transparent scrim drawn over the entire game window while a
|
|
||||||
// modal dialog or menu is open, behind that modal (REQ-UI-MODAL-DIM). It is a child
|
|
||||||
// of the main window covering its full rect and is transparent to mouse events, so it
|
|
||||||
// only dims the game presentation and never intercepts input.
|
|
||||||
//
|
|
||||||
// Visibility is reference-counted via pushModal()/popModal() so that a single dim is
|
|
||||||
// shown across nested or back-to-back modals (e.g. the recipe selection dialog that
|
|
||||||
// immediately opens the layout dialog) rather than flickering or stacking overlays.
|
|
||||||
class ModalDimOverlay : public QWidget
|
|
||||||
{
|
|
||||||
Q_OBJECT
|
|
||||||
|
|
||||||
public:
|
|
||||||
ModalDimOverlay(const QColor& dimColor, QWidget* parent);
|
|
||||||
|
|
||||||
// Raise + show on the first active modal; hide when the last one closes.
|
|
||||||
void pushModal();
|
|
||||||
void popModal();
|
|
||||||
|
|
||||||
// Update the dim color (e.g. after a config reload on Restart, REQ-CFG-RELOAD).
|
|
||||||
void setDimColor(const QColor& dimColor);
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void paintEvent(QPaintEvent* event) override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
QColor m_dimColor;
|
|
||||||
int m_modalDepth = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// RAII guard: shows the dim overlay for the duration of a scope (typically around a
|
|
||||||
// blocking dialog exec()) and hides it (via reference count) on scope exit.
|
|
||||||
class ModalDimScope
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
explicit ModalDimScope(ModalDimOverlay& overlay)
|
|
||||||
: m_overlay(overlay)
|
|
||||||
{
|
|
||||||
m_overlay.pushModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
~ModalDimScope()
|
|
||||||
{
|
|
||||||
m_overlay.popModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
ModalDimScope(const ModalDimScope&) = delete;
|
|
||||||
ModalDimScope& operator=(const ModalDimScope&) = delete;
|
|
||||||
|
|
||||||
private:
|
|
||||||
ModalDimOverlay& m_overlay;
|
|
||||||
};
|
|
||||||
241
src/ui/ModalLayer.cpp
Normal file
241
src/ui/ModalLayer.cpp
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
#include "ModalLayer.h"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QEventLoop>
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QMetaObject>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QSize>
|
||||||
|
|
||||||
|
#include "ModalDialog.h"
|
||||||
|
|
||||||
|
ModalLayer* ModalLayer::findFor(const QWidget& widget)
|
||||||
|
{
|
||||||
|
for (QWidget* candidate = widget.parentWidget(); candidate != nullptr;
|
||||||
|
candidate = candidate->parentWidget())
|
||||||
|
{
|
||||||
|
ModalLayer* layer = qobject_cast<ModalLayer*>(candidate);
|
||||||
|
if (layer != nullptr)
|
||||||
|
{
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalLayer::ModalLayer(const QColor& dimColor, QWidget* parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
, m_dimColor(dimColor)
|
||||||
|
{
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
int ModalLayer::execute(ModalDialog& content, const QRect& anchorRect)
|
||||||
|
{
|
||||||
|
// Hosted in a scroll area so a modal larger than the window is reached by scrolling
|
||||||
|
// instead of being cut off by the window edge (REQ-UI-MODAL-CHROME). When the
|
||||||
|
// content fits -- which is the normal case -- the host is exactly its size and no
|
||||||
|
// scroll bar appears, so the player sees the modal alone.
|
||||||
|
// Noted before anything here touches the focus, and given back when this modal
|
||||||
|
// closes: the game world beneath for the first modal of a stack, and the modal it was
|
||||||
|
// opened from for one above that.
|
||||||
|
QWidget* const focusBefore = QApplication::focusWidget();
|
||||||
|
|
||||||
|
QScrollArea* host = new QScrollArea(this);
|
||||||
|
host->setFrameShape(QFrame::NoFrame);
|
||||||
|
host->setWidgetResizable(false);
|
||||||
|
host->setWidget(&content);
|
||||||
|
|
||||||
|
// Resolved before the new modal joins the stack, so a null rect means the modal it
|
||||||
|
// was opened from -- the Create Blueprint dialog opens on the layout dialog beneath
|
||||||
|
// it (REQ-UI-PANEL-MODAL).
|
||||||
|
const QRect anchor = !anchorRect.isNull() ? anchorRect
|
||||||
|
: (m_stack.empty() ? rect()
|
||||||
|
: m_stack.back().host->geometry());
|
||||||
|
|
||||||
|
m_stack.push_back(HostedModal{ &content, host, focusBefore });
|
||||||
|
|
||||||
|
// Placed and shown before the layer itself is, so that the frame the layer first
|
||||||
|
// paints already carries the modal. Showing the layer first put the dim on screen a
|
||||||
|
// frame before anything stood on it, which read as a black flash
|
||||||
|
// (REQ-UI-MODAL-DIM). Placing works while the layer is hidden: its geometry is the
|
||||||
|
// window's whether or not it is shown, and a child shown under a hidden parent
|
||||||
|
// appears with it.
|
||||||
|
raise();
|
||||||
|
place(*host, content, anchor);
|
||||||
|
host->show();
|
||||||
|
content.show();
|
||||||
|
updateVisibility();
|
||||||
|
|
||||||
|
// The widget the modal names where it names one -- a name dialog puts the caret in
|
||||||
|
// its line edit -- and the modal itself otherwise, so keys reach it and not the game
|
||||||
|
// world behind (REQ-UI-MODAL-CHROME).
|
||||||
|
QWidget* focusTarget = content.getInitialFocusWidget();
|
||||||
|
if (focusTarget == nullptr)
|
||||||
|
{
|
||||||
|
focusTarget = &content;
|
||||||
|
}
|
||||||
|
focusTarget->setFocus();
|
||||||
|
|
||||||
|
// The dialog's own loop, run here rather than by QDialog::exec(), so the layer knows
|
||||||
|
// what is open and can place it, dim behind it, and take the clicks beside it.
|
||||||
|
QEventLoop loop;
|
||||||
|
const QMetaObject::Connection connection =
|
||||||
|
connect(&content, &QDialog::finished, &loop, &QEventLoop::quit);
|
||||||
|
loop.exec();
|
||||||
|
disconnect(connection);
|
||||||
|
|
||||||
|
// takeWidget() before the host goes: the scroll area owns what it is given, and
|
||||||
|
// every modal here is a local of its caller. It hands the content back parentless.
|
||||||
|
host->takeWidget();
|
||||||
|
content.hide();
|
||||||
|
delete host;
|
||||||
|
|
||||||
|
QPointer<QWidget> focusAfter;
|
||||||
|
for (std::size_t i = m_stack.size(); i > 0; --i)
|
||||||
|
{
|
||||||
|
if (m_stack[i - 1].content == &content)
|
||||||
|
{
|
||||||
|
focusAfter = m_stack[i - 1].focusBefore;
|
||||||
|
m_stack.erase(m_stack.begin() + static_cast<std::ptrdiff_t>(i - 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateVisibility();
|
||||||
|
|
||||||
|
// After the layer is hidden, not before: hiding a widget takes the focus off
|
||||||
|
// everything on it, which would undo this again.
|
||||||
|
if (!focusAfter.isNull())
|
||||||
|
{
|
||||||
|
focusAfter->setFocus();
|
||||||
|
}
|
||||||
|
return content.result();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ModalLayer::isActive() const
|
||||||
|
{
|
||||||
|
return !m_stack.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModalLayer::addHold()
|
||||||
|
{
|
||||||
|
++m_holdCount;
|
||||||
|
updateVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModalLayer::removeHold()
|
||||||
|
{
|
||||||
|
if (m_holdCount > 0)
|
||||||
|
{
|
||||||
|
--m_holdCount;
|
||||||
|
updateVisibility();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModalLayer::setDimColor(const QColor& dimColor)
|
||||||
|
{
|
||||||
|
m_dimColor = dimColor;
|
||||||
|
if (isVisible())
|
||||||
|
{
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModalLayer::paintEvent(QPaintEvent* /*event*/)
|
||||||
|
{
|
||||||
|
// One dim however many modals are stacked (REQ-UI-MODAL-DIM): the modals above it
|
||||||
|
// draw their own opaque background, so nesting darkens nothing twice.
|
||||||
|
QPainter painter(this);
|
||||||
|
painter.fillRect(rect(), m_dimColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModalLayer::mousePressEvent(QMouseEvent* event)
|
||||||
|
{
|
||||||
|
m_pressedOutside = event->button() == Qt::LeftButton
|
||||||
|
&& isOutsideOpenModal(event->pos());
|
||||||
|
|
||||||
|
// Taken whether or not it dismisses anything: the window behind a modal receives no
|
||||||
|
// input, and closing the modal does not turn this click into one for what lies under
|
||||||
|
// it (REQ-UI-DIALOG-DISMISS).
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModalLayer::mouseReleaseEvent(QMouseEvent* event)
|
||||||
|
{
|
||||||
|
const bool pressedOutside = m_pressedOutside;
|
||||||
|
m_pressedOutside = false;
|
||||||
|
event->accept();
|
||||||
|
|
||||||
|
if (event->button() != Qt::LeftButton || !pressedOutside
|
||||||
|
|| !isOutsideOpenModal(event->pos()) || m_stack.empty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A drag that began inside the modal never gets here -- the widget it began on keeps
|
||||||
|
// the release -- so no gesture ends by discarding the modal it was made in.
|
||||||
|
ModalDialog* openModal = m_stack.back().content;
|
||||||
|
if (openModal->isDismissible())
|
||||||
|
{
|
||||||
|
openModal->requestDismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ModalLayer::isOutsideOpenModal(const QPoint& position) const
|
||||||
|
{
|
||||||
|
if (m_stack.empty())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !m_stack.back().host->geometry().contains(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModalLayer::place(QScrollArea& host, ModalDialog& content,
|
||||||
|
const QRect& anchorRect) const
|
||||||
|
{
|
||||||
|
// The content has never been shown, so it is still at its default size until its
|
||||||
|
// layout has run; sizing it before that would use the wrong extent.
|
||||||
|
content.adjustSize();
|
||||||
|
|
||||||
|
const QSize hostSize = content.size().boundedTo(size());
|
||||||
|
host.resize(hostSize);
|
||||||
|
|
||||||
|
QPoint topLeft(anchorRect.center().x() - hostSize.width() / 2,
|
||||||
|
anchorRect.center().y() - hostSize.height() / 2);
|
||||||
|
|
||||||
|
// Pushed back inside the layer, which is the game window (REQ-UI-PANEL-MODAL). The
|
||||||
|
// far edge is clamped first and the near edge second, which is what aligns a modal
|
||||||
|
// as large as the window with the window's top-left corner rather than pushing it
|
||||||
|
// off the opposite edge.
|
||||||
|
topLeft.setX(qMax(0, qMin(topLeft.x(), width() - hostSize.width())));
|
||||||
|
topLeft.setY(qMax(0, qMin(topLeft.y(), height() - hostSize.height())));
|
||||||
|
host.move(topLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModalLayer::updateVisibility()
|
||||||
|
{
|
||||||
|
const bool shouldShow = !m_stack.empty() || m_holdCount > 0;
|
||||||
|
if (shouldShow == isVisible())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldShow)
|
||||||
|
{
|
||||||
|
raise();
|
||||||
|
show();
|
||||||
|
// Painted at once rather than at the next paint event, so no undimmed frame of
|
||||||
|
// the world flashes through in between. The modal standing on the layer is
|
||||||
|
// already placed and shown by now, so this one paint puts up both (execute()).
|
||||||
|
repaint();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/ui/ModalLayer.h
Normal file
117
src/ui/ModalLayer.h
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QRect>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
class ModalDialog;
|
||||||
|
class QMouseEvent;
|
||||||
|
class QPaintEvent;
|
||||||
|
class QScrollArea;
|
||||||
|
|
||||||
|
// The one surface every modal is shown on (REQ-UI-MODAL-CHROME, REQ-UI-MODAL-DIM): a
|
||||||
|
// child of the main window covering its whole rect, which paints the dim, hosts the
|
||||||
|
// open modal, and runs its modal loop. Because it is a widget of the window rather than
|
||||||
|
// a window of its own, it receives the clicks that land beside the modal -- which a
|
||||||
|
// blocked window would never see -- and that is what makes the click dismissal possible
|
||||||
|
// (REQ-UI-DIALOG-DISMISS).
|
||||||
|
//
|
||||||
|
// Modals nest: the layer keeps a stack, shows one dim for all of them, and hides itself
|
||||||
|
// when the last one closes. It is shown while the stack is non-empty or a hold is taken.
|
||||||
|
class ModalLayer : public QWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
// The layer a widget is shown on, found by walking up its parents, or nullptr when
|
||||||
|
// it is not on one. How a widget deep inside a modal reaches the layer to open a
|
||||||
|
// second modal on it, without every widget between them having to carry a pointer.
|
||||||
|
static ModalLayer* findFor(const QWidget& widget);
|
||||||
|
|
||||||
|
ModalLayer(const QColor& dimColor, QWidget* parent);
|
||||||
|
|
||||||
|
// Shows content on this layer and runs it until it accepts or rejects, returning its
|
||||||
|
// QDialog result code. anchorRect (in this layer's coordinates, which are the main
|
||||||
|
// window's) is what the content is centered on. A null rect centers it on the modal
|
||||||
|
// it was opened from, and on the layer itself when it is the first one open
|
||||||
|
// (REQ-UI-PANEL-MODAL).
|
||||||
|
int execute(ModalDialog& content, const QRect& anchorRect = QRect());
|
||||||
|
|
||||||
|
// Whether a modal is open. The main window asks before handing focus back to the
|
||||||
|
// game world, which it must not do while a modal holds it.
|
||||||
|
bool isActive() const;
|
||||||
|
|
||||||
|
// Keeps the layer shown with nothing on it, so that one modal handing straight over
|
||||||
|
// to another does not blink the dim off in between (REQ-UI-MODAL-DIM).
|
||||||
|
void addHold();
|
||||||
|
void removeHold();
|
||||||
|
|
||||||
|
// Update the dim color (e.g. after a config reload on Restart, REQ-CFG-RELOAD).
|
||||||
|
void setDimColor(const QColor& dimColor);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void paintEvent(QPaintEvent* event) override;
|
||||||
|
void mousePressEvent(QMouseEvent* event) override;
|
||||||
|
void mouseReleaseEvent(QMouseEvent* event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// A modal and the scroll area it is shown in. The host is what the layer places and
|
||||||
|
// what the player sees the edges of, so it is also the rectangle "outside the modal"
|
||||||
|
// is measured against.
|
||||||
|
struct HostedModal
|
||||||
|
{
|
||||||
|
ModalDialog* content;
|
||||||
|
QScrollArea* host;
|
||||||
|
// What held the keyboard when this modal opened, given back when it closes.
|
||||||
|
// Hiding the layer, or closing a modal a second one was opened from, leaves the
|
||||||
|
// window with no focus widget at all, and a window with none answers no keys --
|
||||||
|
// Escape included, which is what opens the menu (REQ-UI-GAME-MENU). A QPointer
|
||||||
|
// because a modal may outlive what it took the focus from.
|
||||||
|
QPointer<QWidget> focusBefore;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sizes content to what it asks for, capped at the layer, and centers it on
|
||||||
|
// anchorRect (REQ-UI-PANEL-MODAL).
|
||||||
|
void place(QScrollArea& host, ModalDialog& content, const QRect& anchorRect) const;
|
||||||
|
|
||||||
|
void updateVisibility();
|
||||||
|
|
||||||
|
// Whether a point in this layer's coordinates lies beyond the open modal. A click on
|
||||||
|
// an inert part of the modal -- a label, the space between two controls -- arrives
|
||||||
|
// here too, propagated by the widget that ignored it, so where the click landed is
|
||||||
|
// what decides, never that the layer received it.
|
||||||
|
bool isOutsideOpenModal(const QPoint& position) const;
|
||||||
|
|
||||||
|
QColor m_dimColor;
|
||||||
|
// Set by a left press that landed outside the open modal, so that only a press and a
|
||||||
|
// release both outside dismiss it (REQ-UI-DIALOG-DISMISS).
|
||||||
|
bool m_pressedOutside = false;
|
||||||
|
std::vector<HostedModal> m_stack; // bottom-most first; back() is the open one
|
||||||
|
int m_holdCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// RAII guard for addHold()/removeHold().
|
||||||
|
class ModalLayerHold
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit ModalLayerHold(ModalLayer& layer)
|
||||||
|
: m_layer(layer)
|
||||||
|
{
|
||||||
|
m_layer.addHold();
|
||||||
|
}
|
||||||
|
|
||||||
|
~ModalLayerHold()
|
||||||
|
{
|
||||||
|
m_layer.removeHold();
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalLayerHold(const ModalLayerHold&) = delete;
|
||||||
|
ModalLayerHold& operator=(const ModalLayerHold&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
ModalLayer& m_layer;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user