Compare commits
5 Commits
3ef32ee6ba
...
34c6dea505
| Author | SHA1 | Date | |
|---|---|---|---|
| 34c6dea505 | |||
| d397b9969a | |||
| d08bf5d37b | |||
| b59e392461 | |||
| c0b6f8f778 |
51
bin/app/data/config/modules.toml
Normal file
51
bin/app/data/config/modules.toml
Normal file
@@ -0,0 +1,51 @@
|
||||
[[module]]
|
||||
id = "armor_plate"
|
||||
surface_mask = ["OO", "OO"]
|
||||
materials = [{item = "iron_ingot", amount = 2}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 3
|
||||
threat_cost = 2.0
|
||||
fill_color = "#808080"
|
||||
glyph = "A"
|
||||
|
||||
[module.health]
|
||||
multiplied_hp_formula = "1.0 + 0.2 * x"
|
||||
|
||||
[[module]]
|
||||
id = "sensor_booster"
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "circuit_board", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 2
|
||||
threat_cost = 1.0
|
||||
fill_color = "#40A0FF"
|
||||
glyph = "S"
|
||||
|
||||
[module.sensor]
|
||||
added_sensor_range_formula = "2 + x"
|
||||
|
||||
[[module]]
|
||||
id = "weapon_upgrade"
|
||||
surface_mask = ["OO"]
|
||||
materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 4
|
||||
threat_cost = 3.0
|
||||
fill_color = "#FF4040"
|
||||
glyph = "W"
|
||||
|
||||
[module.combat]
|
||||
multiplied_damage_formula = "1.0 + 0.15 * x"
|
||||
|
||||
[[module]]
|
||||
id = "engine_booster"
|
||||
surface_mask = ["O", "O"]
|
||||
materials = [{item = "iron_ingot", amount = 2}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 3
|
||||
threat_cost = 1.5
|
||||
fill_color = "#40FF80"
|
||||
glyph = "E"
|
||||
|
||||
[module.movement]
|
||||
added_speed_formula = "0.5 * x"
|
||||
@@ -1,6 +1,7 @@
|
||||
[[ship]]
|
||||
id = "interceptor"
|
||||
id = "fighter"
|
||||
available_from_start = true
|
||||
layout = ["XOX", "OOO", "XOX"]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}]
|
||||
@@ -11,56 +12,104 @@ production_time_seconds = 10
|
||||
cost_formula = "10"
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "4"
|
||||
hp_formula = "3"
|
||||
|
||||
[ship.movement]
|
||||
speed_formula = "4"
|
||||
speed_formula = "4"
|
||||
main_acceleration_formula = "8"
|
||||
maneuvering_acceleration_formula = "4"
|
||||
angular_acceleration_formula = "12.56"
|
||||
max_rotation_speed_formula = "6.28"
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_formula = "15"
|
||||
|
||||
[ship.combat]
|
||||
damage_formula = "1"
|
||||
attack_range_formula = "10"
|
||||
damage_formula = "2"
|
||||
attack_range_formula = "5"
|
||||
attack_rate_formula = "2.0"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 2
|
||||
|
||||
|
||||
|
||||
[[ship]]
|
||||
id = "destroyer"
|
||||
id = "sniper"
|
||||
available_from_start = true
|
||||
layout = ["XOOX", "OOOO", "XOOX"]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 2}]
|
||||
player_production_level = 5
|
||||
production_time_seconds = 20
|
||||
materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 10
|
||||
|
||||
[ship.threat]
|
||||
cost_formula = "0"
|
||||
cost_formula = "10"
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "120 + 15*x"
|
||||
hp_formula = "8"
|
||||
|
||||
[ship.movement]
|
||||
speed_formula = "120"
|
||||
speed_formula = "1"
|
||||
main_acceleration_formula = "1.5"
|
||||
maneuvering_acceleration_formula = "0.5"
|
||||
angular_acceleration_formula = "9.42"
|
||||
max_rotation_speed_formula = "3.14"
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_formula = "300"
|
||||
sensor_range_formula = "25"
|
||||
|
||||
[ship.combat]
|
||||
damage_formula = "12 + 2*x"
|
||||
attack_range_formula = "250"
|
||||
attack_rate_formula = "1.0"
|
||||
damage_formula = "10"
|
||||
attack_range_formula = "20"
|
||||
attack_rate_formula = "0.5"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 4
|
||||
scrap_drop = 2
|
||||
|
||||
|
||||
|
||||
[[ship]]
|
||||
id = "gunship"
|
||||
available_from_start = true
|
||||
layout = ["XOOOX", "OOOOO", "OOOOO", "XOOOX"]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 10
|
||||
|
||||
[ship.threat]
|
||||
cost_formula = "10"
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "12"
|
||||
|
||||
[ship.movement]
|
||||
speed_formula = "1"
|
||||
main_acceleration_formula = "1.5"
|
||||
maneuvering_acceleration_formula = "0.5"
|
||||
angular_acceleration_formula = "15.7"
|
||||
max_rotation_speed_formula = "3.14"
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_formula = "20"
|
||||
|
||||
[ship.combat]
|
||||
damage_formula = "1"
|
||||
attack_range_formula = "10"
|
||||
attack_rate_formula = "5"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 2
|
||||
|
||||
|
||||
|
||||
[[ship]]
|
||||
id = "salvage_ship"
|
||||
available_from_start = true
|
||||
layout = ["OOO", "OOO"]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ingot", amount = 4}]
|
||||
@@ -74,7 +123,11 @@ cost_formula = "0"
|
||||
hp_formula = "40 + 4*x"
|
||||
|
||||
[ship.movement]
|
||||
speed_formula = "110"
|
||||
speed_formula = "110"
|
||||
main_acceleration_formula = "220"
|
||||
maneuvering_acceleration_formula = "110"
|
||||
angular_acceleration_formula = "12.56"
|
||||
max_rotation_speed_formula = "6.28"
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_formula = "250"
|
||||
@@ -90,6 +143,7 @@ scrap_drop = 2
|
||||
[[ship]]
|
||||
id = "repair_ship"
|
||||
available_from_start = false
|
||||
layout = ["XOX", "OOO", "XOX"]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}]
|
||||
@@ -103,7 +157,11 @@ cost_formula = "0"
|
||||
hp_formula = "60 + 5*x"
|
||||
|
||||
[ship.movement]
|
||||
speed_formula = "130"
|
||||
speed_formula = "130"
|
||||
main_acceleration_formula = "260"
|
||||
maneuvering_acceleration_formula = "130"
|
||||
angular_acceleration_formula = "12.56"
|
||||
max_rotation_speed_formula = "6.28"
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_formula = "250"
|
||||
|
||||
@@ -1,45 +1,93 @@
|
||||
[[arena]]
|
||||
name = "Interceptors vs Interceptors"
|
||||
height_tiles = 60
|
||||
player_buffer_width = 10
|
||||
contest_zone_width = 40
|
||||
enemy_buffer_width = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Alpha"
|
||||
[[arena.team.ship]]
|
||||
schematic = "interceptor"
|
||||
level = 1
|
||||
count = 5
|
||||
|
||||
[[arena.team]]
|
||||
name = "Beta"
|
||||
[[arena.team.ship]]
|
||||
schematic = "interceptor"
|
||||
level = 1
|
||||
count = 5
|
||||
|
||||
|
||||
[[arena]]
|
||||
name = "Few Destroyers vs Many Interceptors"
|
||||
height_tiles = 60
|
||||
name = "Fighters vs Sniper"
|
||||
height_tiles = 20
|
||||
player_buffer_width = 10
|
||||
contest_zone_width = 60
|
||||
enemy_buffer_width = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Destroyers"
|
||||
name = "Alpha"
|
||||
[[arena.team.ship]]
|
||||
schematic = "destroyer"
|
||||
level = 5
|
||||
count = 2
|
||||
schematic = "fighter"
|
||||
level = 1
|
||||
count = 5
|
||||
modules = [
|
||||
{type = "weapon_upgrade", x = 0, y = 1, rotation = "east"},
|
||||
{type = "sensor_booster", x = 1, y = 0, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Interceptors"
|
||||
name = "Beta"
|
||||
[[arena.team.ship]]
|
||||
schematic = "interceptor"
|
||||
schematic = "sniper"
|
||||
level = 1
|
||||
count = 10
|
||||
count = 1
|
||||
modules = [
|
||||
{type = "armor_plate", x = 1, y = 0, rotation = "east"},
|
||||
{type = "weapon_upgrade", x = 1, y = 2, rotation = "east"},
|
||||
]
|
||||
|
||||
|
||||
[[arena]]
|
||||
name = "Sniper vs Gunship"
|
||||
height_tiles = 20
|
||||
player_buffer_width = 10
|
||||
contest_zone_width = 60
|
||||
enemy_buffer_width = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Alpha"
|
||||
[[arena.team.ship]]
|
||||
schematic = "sniper"
|
||||
level = 1
|
||||
count = 1
|
||||
modules = [
|
||||
{type = "armor_plate", x = 1, y = 0, rotation = "east"},
|
||||
{type = "sensor_booster", x = 0, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Beta"
|
||||
[[arena.team.ship]]
|
||||
schematic = "gunship"
|
||||
level = 1
|
||||
count = 1
|
||||
modules = [
|
||||
{type = "armor_plate", x = 1, y = 0, rotation = "east"},
|
||||
{type = "weapon_upgrade", x = 3, y = 1, rotation = "east"},
|
||||
{type = "engine_booster", x = 0, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
|
||||
[[arena]]
|
||||
name = "Gunship vs Fighters"
|
||||
height_tiles = 20
|
||||
player_buffer_width = 10
|
||||
contest_zone_width = 60
|
||||
enemy_buffer_width = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Alpha"
|
||||
[[arena.team.ship]]
|
||||
schematic = "gunship"
|
||||
level = 1
|
||||
count = 1
|
||||
modules = [
|
||||
{type = "armor_plate", x = 1, y = 0, rotation = "east"},
|
||||
{type = "weapon_upgrade", x = 3, y = 2, rotation = "east"},
|
||||
{type = "engine_booster", x = 0, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Beta"
|
||||
[[arena.team.ship]]
|
||||
schematic = "fighter"
|
||||
level = 1
|
||||
count = 5
|
||||
modules = [
|
||||
{type = "engine_booster", x = 1, y = 0, rotation = "east"},
|
||||
{type = "sensor_booster", x = 2, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
|
||||
[[arena]]
|
||||
@@ -52,9 +100,13 @@ enemy_buffer_width = 15
|
||||
[[arena.team]]
|
||||
name = "Fortified"
|
||||
[[arena.team.ship]]
|
||||
schematic = "interceptor"
|
||||
schematic = "fighter"
|
||||
level = 1
|
||||
count = 3
|
||||
modules = [
|
||||
{type = "weapon_upgrade", x = 1, y = 1, rotation = "east"},
|
||||
{type = "sensor_booster", x = 1, y = 0, rotation = "east"},
|
||||
]
|
||||
[[arena.team.station]]
|
||||
type = "player_station"
|
||||
x = 8
|
||||
@@ -69,6 +121,9 @@ enemy_buffer_width = 15
|
||||
[[arena.team]]
|
||||
name = "Swarm"
|
||||
[[arena.team.ship]]
|
||||
schematic = "interceptor"
|
||||
schematic = "fighter"
|
||||
level = 1
|
||||
count = 8
|
||||
modules = [
|
||||
{type = "engine_booster", x = 1, y = 0, rotation = "east"},
|
||||
]
|
||||
|
||||
38
bin/test/data/config/modules.toml
Normal file
38
bin/test/data/config/modules.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
[[module]]
|
||||
id = "armor_plate"
|
||||
surface_mask = ["OO"]
|
||||
materials = [{item = "iron_ingot", amount = 2}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 3
|
||||
threat_cost = 2.0
|
||||
fill_color = "#808080"
|
||||
glyph = "A"
|
||||
|
||||
[module.health]
|
||||
multiplied_hp_formula = "1.5"
|
||||
|
||||
[[module]]
|
||||
id = "sensor_booster"
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "circuit_board", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 2
|
||||
threat_cost = 1.0
|
||||
fill_color = "#40A0FF"
|
||||
glyph = "S"
|
||||
|
||||
[module.sensor]
|
||||
added_sensor_range_formula = "10"
|
||||
|
||||
[[module]]
|
||||
id = "weapon_upgrade"
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 4
|
||||
threat_cost = 3.0
|
||||
fill_color = "#FF4040"
|
||||
glyph = "W"
|
||||
|
||||
[module.combat]
|
||||
multiplied_damage_formula = "1.2"
|
||||
@@ -1,6 +1,7 @@
|
||||
[[ship]]
|
||||
id = "interceptor"
|
||||
available_from_start = true
|
||||
layout = ["XOX", "OOO", "XOX"]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}]
|
||||
@@ -14,7 +15,11 @@ cost_formula = "5 + 1*x"
|
||||
hp_formula = "40 + 5*x"
|
||||
|
||||
[ship.movement]
|
||||
speed_formula = "200 + 5*x"
|
||||
speed_formula = "200 + 5*x"
|
||||
main_acceleration_formula = "100000"
|
||||
maneuvering_acceleration_formula = "100000"
|
||||
angular_acceleration_formula = "100000"
|
||||
max_rotation_speed_formula = "100000"
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_formula = "200"
|
||||
@@ -31,6 +36,7 @@ scrap_drop = 2
|
||||
[[ship]]
|
||||
id = "destroyer"
|
||||
available_from_start = true
|
||||
layout = ["XOOX", "OOOO", "XOOX"]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 2}]
|
||||
@@ -44,7 +50,11 @@ cost_formula = "10 + 2*x"
|
||||
hp_formula = "120 + 15*x"
|
||||
|
||||
[ship.movement]
|
||||
speed_formula = "120"
|
||||
speed_formula = "120"
|
||||
main_acceleration_formula = "100000"
|
||||
maneuvering_acceleration_formula = "100000"
|
||||
angular_acceleration_formula = "100000"
|
||||
max_rotation_speed_formula = "100000"
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_formula = "300"
|
||||
@@ -61,6 +71,7 @@ scrap_drop = 4
|
||||
[[ship]]
|
||||
id = "salvage_ship"
|
||||
available_from_start = true
|
||||
layout = ["OOO", "OOO"]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ingot", amount = 4}]
|
||||
@@ -74,7 +85,11 @@ cost_formula = "0"
|
||||
hp_formula = "40 + 4*x"
|
||||
|
||||
[ship.movement]
|
||||
speed_formula = "110"
|
||||
speed_formula = "110"
|
||||
main_acceleration_formula = "100000"
|
||||
maneuvering_acceleration_formula = "100000"
|
||||
angular_acceleration_formula = "100000"
|
||||
max_rotation_speed_formula = "100000"
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_formula = "250"
|
||||
@@ -90,6 +105,7 @@ scrap_drop = 2
|
||||
[[ship]]
|
||||
id = "repair_ship"
|
||||
available_from_start = false
|
||||
layout = ["XOX", "OOO", "XOX"]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}]
|
||||
@@ -103,7 +119,11 @@ cost_formula = "0"
|
||||
hp_formula = "60 + 5*x"
|
||||
|
||||
[ship.movement]
|
||||
speed_formula = "130"
|
||||
speed_formula = "130"
|
||||
main_acceleration_formula = "100000"
|
||||
maneuvering_acceleration_formula = "100000"
|
||||
angular_acceleration_formula = "100000"
|
||||
max_rotation_speed_formula = "100000"
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_formula = "250"
|
||||
|
||||
@@ -7,9 +7,11 @@ Config files use the TOML format. The following config files drive game paramete
|
||||
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, enemy ship level formula, belt speed, starting building blocks, departure interval.
|
||||
- **buildings.toml** — building block cost and construction time per building type.
|
||||
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities.
|
||||
- **ships.toml** — per schematic: a human-readable display name (used in toasts and UI), ship stats (HP, speed, damage, attack range, attack rate, sensor range) as formulas of ship level, required build materials, threat cost formula, player production level, and whether the schematic is available from game start.
|
||||
- **ships.toml** — per schematic: a human-readable display name (used in toasts and UI), ship stats (HP, max linear speed, damage, attack range, attack rate, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as formulas of ship level, required build materials, threat cost formula, player production level, whether the schematic is available from game start, and a layout grid defining the ship's module slots.
|
||||
- **modules.toml** — per module type: id, surface mask, materials list, player production level, production time, threat cost, fill color, glyph, and stat modifier formulas (additive and/or multiplicative per stat).
|
||||
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
|
||||
- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship role, and station type; beam color and width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it.
|
||||
- **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it.
|
||||
|
||||
- REQ-CFG-RELOAD: When the player triggers a Restart (REQ-UI-GAME-MENU), all config files are reloaded from disk before the simulation is reset to its initial state. Formula strings are recompiled at that point. This allows config edits made while the application is running to take effect without a full application restart.
|
||||
|
||||
@@ -27,6 +29,42 @@ Buildings in buildings.toml define a `surface_mask` — a list of strings that d
|
||||
|
||||
Output port indicators are not building tiles themselves. A building may have more than one output port (e.g. a splitter uses `<A>` to declare both a left and a right output on the same tile).
|
||||
|
||||
### Ship Layout Format
|
||||
|
||||
Ships in `ships.toml` define a `layout` — a list of strings that describes the ship's module grid. Each character occupies one cell:
|
||||
|
||||
- `O` — buildable cell; modules can be placed here.
|
||||
- `X` — non-buildable cell; not part of the ship's interior.
|
||||
|
||||
The layout grid determines which cells are available for module placement in the layout configuration dialog.
|
||||
|
||||
### Layout Blueprint TOML Format
|
||||
|
||||
Each entry in `ship_layouts.toml` represents a named layout blueprint:
|
||||
|
||||
```toml
|
||||
[[blueprint]]
|
||||
name = "Heavy Shields" # display name; must be unique within a ship type
|
||||
ship_type = "fighter" # matches a schematic id in ships.toml
|
||||
modules = [
|
||||
{type = "shield_module", x = 0, y = 0, rotation = 0},
|
||||
{type = "cannon_module", x = 2, y = 1, rotation = 90},
|
||||
]
|
||||
```
|
||||
|
||||
- `name` — human-readable display name. Must be unique within a ship type; need not be globally unique.
|
||||
- `ship_type` — the schematic id this blueprint belongs to. Must match a schematic defined in `ships.toml`.
|
||||
- `modules` — array of placed module instances. Each entry: `type` (module id from `modules.toml`), `x` and `y` (zero-based column/row in the ship's layout grid), `rotation` (0, 90, 180, or 270 degrees clockwise). If `modules` is absent or empty, the blueprint represents an empty layout.
|
||||
|
||||
The `modules` array format is reused verbatim in `balancing.toml` ship entries (see REQ-BAL-TEAM).
|
||||
|
||||
### Module Surface Mask Format
|
||||
|
||||
Modules in `modules.toml` define a `surface_mask` — a list of strings that describes the module's tile footprint within the ship layout grid. Each character occupies one cell:
|
||||
|
||||
- `O` — module cell: must be placed on an unoccupied buildable cell (`O`) of the ship's layout.
|
||||
- `X` — ignored cell: may overlap any cell (non-buildable, unoccupied buildable, or occupied buildable) or extend outside the layout grid entirely.
|
||||
|
||||
## 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.
|
||||
@@ -73,7 +111,7 @@ Output port indicators are not building tiles themselves. A building may have mo
|
||||
- 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-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"`.
|
||||
- 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 output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING.
|
||||
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials (`[ship.schematic].materials`) are present in its input buffer, the shipyard consumes them and begins a production cycle lasting `[ship.schematic].production_time_seconds` seconds (read from `ships.toml`). One ship of that type is spawned at `ships.toml [ship.schematic].player_production_level` (initial value 5, incremented by duplicate schematic drops per REQ-DEF-SCHEMATIC-DROP) when the cycle completes. The shipyard cannot start a new cycle while one is in progress.
|
||||
- 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 at `ships.toml [ship.schematic].player_production_level` (initial value 5, incremented by duplicate schematic drops per REQ-DEF-SCHEMATIC-DROP) with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration.
|
||||
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. Scrap delivered here is placed 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).
|
||||
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Each output can optionally have a filter (a list of item types), configurable via the selected building panel. Routing rules:
|
||||
@@ -111,10 +149,10 @@ Output port indicators are not building tiles themselves. A building may have mo
|
||||
## Ships
|
||||
|
||||
- REQ-SHP-AUTONOMOUS: Ships are produced by shipyards and are fully autonomous once produced.
|
||||
- REQ-SHP-STATS: All ship stats are defined as formulas of ship level in `ships.toml`: HP (`[ship.health].hp_formula`), speed (`[ship.movement].speed_formula`), damage (`[ship.combat].damage_formula`), attack range (`[ship.combat].attack_range_formula`), attack rate (`[ship.combat].attack_rate_formula`), sensor range (`[ship.sensors].range_formula`). Required build materials (`[ship.schematic].materials`) and availability from game start (`[[ship]].available_from_start`) are also defined there.
|
||||
- REQ-SHP-STATS: Base ship stats are defined as formulas of ship level in `ships.toml`: HP (`[ship.health].hp_formula`), max linear speed (`[ship.movement].speed_formula`), damage (`[ship.combat].damage_formula`), attack range (`[ship.combat].attack_range_formula`), attack rate (`[ship.combat].attack_rate_formula`), sensor range (`[ship.sensors].range_formula`), main acceleration (`[ship.movement].main_acceleration_formula`, tiles/s²), maneuvering acceleration (`[ship.movement].maneuvering_acceleration_formula`, tiles/s²), angular acceleration (`[ship.movement].angular_acceleration_formula`, rad/s²), max rotation speed (`[ship.movement].max_rotation_speed_formula`, rad/s). Required build materials (`[ship.schematic].materials`) and availability from game start (`[[ship]].available_from_start`) are also defined there. Final ship stats incorporate module modifiers per REQ-MOD-STAT-CALC.
|
||||
- REQ-SHP-SPAWN-PLAYER: A ship produced by a shipyard spawns centered on the shipyard's output port tile.
|
||||
- REQ-SHP-SPAWN-ENEMY: Enemy ships spawn at a uniformly random position within the current enemy buffer zone — random X across the buffer's width and random Y across the world height.
|
||||
- REQ-SHP-MOVEMENT: Ships move in straight lines toward their current destination at the speed defined by their speed formula. Ship position refers to the ship's center for all range, sensor, and attack checks.
|
||||
- REQ-SHP-MOVEMENT: Ships move using a physics-based model. Each ship has a velocity and a facing direction, both updated each tick. The main acceleration (`main_acceleration_formula`) is applied along the ship's current facing direction only. The maneuvering acceleration (`maneuvering_acceleration_formula`) can be applied in any direction independently of the facing direction, enabling lateral or braking movement without rotating. The angular acceleration (`angular_acceleration_formula`) controls how quickly the ship rotates. Linear speed is capped at the ship's `speed_formula` value; rotation rate is capped at the ship's `max_rotation_speed_formula` value. Ship position refers to the ship's center for all range, sensor, and attack checks.
|
||||
- REQ-SHP-NO-COLLISION: Ships do not collide with each other or with defence stations; they may visually overlap.
|
||||
- REQ-SHP-SENSOR: A ship perceives only entities within its sensor range. Behavior is driven by what is in sensor range; entities outside sensor range are ignored.
|
||||
- REQ-SHP-FIRING: All weapons — on ships and on defence stations — fire when off cooldown and the target is within attack range. Firing emits a fire event and starts a 0.15-second damage delay (half the beam duration). When that delay expires, damage is applied to the target — unless the target has already been destroyed, in which case the damage is silently dropped. If the shooter is destroyed before the delay expires, damage is still applied when the delay expires. There is no projectile entity and no intervening collision. The weapon's cooldown begins at the moment of firing, not at damage application.
|
||||
@@ -129,6 +167,66 @@ Output port indicators are not building tiles themselves. A building may have mo
|
||||
- REQ-SHP-ENEMY-AI: **Enemy ships** — engage the closest valid target (player defence station, HQ, or player ship) within their sensor range. If no target is in sensor range, they move toward the asteroid (leftward in world coordinates).
|
||||
- REQ-SHP-SCHEMATICS: The player selects a schematic per shipyard by clicking it. New schematics are unlocked automatically when an enemy defence station set is destroyed (REQ-DEF-SCHEMATIC-DROP) — there is no physical loot to collect.
|
||||
|
||||
## Ship Modules
|
||||
|
||||
### Module Configuration
|
||||
|
||||
- REQ-MOD-CONFIG: Module types are defined in `modules.toml`. Each module entry specifies:
|
||||
- `id` — unique identifier, also used as the display name in the UI.
|
||||
- `surface_mask` — footprint within the ship layout grid (see Module Surface Mask Format).
|
||||
- `materials` — list of materials required per instance (added to the ship's build cost).
|
||||
- `player_production_level` — fixed level for this module type; used as `x` in its stat modifier formulas.
|
||||
- `production_time_seconds` — time added to the ship's production cycle per instance.
|
||||
- `threat_cost` — threat cost added to the ship's threat cost per instance.
|
||||
- `fill_color` — fill color used to render this module's cells in the layout grid.
|
||||
- `glyph` — single character rendered on this module's cells in the layout grid and preview widget.
|
||||
- Zero or more stat modifier formulas (see REQ-MOD-STAT-CALC).
|
||||
|
||||
- REQ-MOD-LAYOUT: Each ship in `ships.toml` defines a `layout` — a list of strings representing the ship's module grid (see Ship Layout Format). All ships define a layout.
|
||||
|
||||
### 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-ROTATION: While in module placement mode, pressing Q rotates the module ghost 90° counter-clockwise and E 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.
|
||||
|
||||
### Module Effects
|
||||
|
||||
- REQ-MOD-MATERIALS: The total materials required to build a ship are the union of the ship's base `[ship.schematic].materials` and the `materials` of every module instance in the configured layout. Quantities of the same item type are summed.
|
||||
- REQ-MOD-PRODUCTION-TIME: The total production time is the ship's base `[ship.schematic].production_time_seconds` plus the sum of `production_time_seconds` for every module instance in the configured layout.
|
||||
- REQ-MOD-THREAT: The total threat cost of a ship is the ship's base `[ship.threat].cost_formula` evaluated at the ship's level, plus the sum of `threat_cost` for every module instance in the configured layout.
|
||||
- REQ-MOD-STAT-CALC: For each ship stat, the final value is computed as: `final = base × total_multiplier + total_additive`, where:
|
||||
- `base` is the ship's base stat formula evaluated at the ship's production level.
|
||||
- `total_multiplier` = 1 + sum of (m_i − 1) for each multiplicative modifier m_i from all module instances. Each m_i is evaluated from the module's multiplicative formula at the module's `player_production_level`.
|
||||
- `total_additive` = sum of all additive modifier values from all module instances. Each additive value is evaluated from the module's additive formula at the module's `player_production_level`.
|
||||
|
||||
Module stat modifier formulas follow the naming convention: for a ship stat `ship.<category>.<stat>_formula`, a module may define `added_<stat>_formula` (additive) and/or `multiplied_<stat>_formula` (multiplicative) under `[module.<category>]`. Example: for `ship.sensor.sensor_range_formula`, a module may define `module.sensor.added_sensor_range_formula` and/or `module.sensor.multiplied_sensor_range_formula`.
|
||||
|
||||
### Module UI
|
||||
|
||||
- REQ-MOD-UI-PREVIEW: When a schematic is selected in a shipyard's selected building panel, a small non-interactive **ship layout preview** widget is shown below the schematic dropdown. 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. Below the preview, a "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.
|
||||
|
||||
The dialog contains:
|
||||
- **Left**: 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.
|
||||
- **Center**: A grid of module selection buttons (one per module type defined in `modules.toml`) plus a "Remove" button. Each module button shows the module id and its glyph.
|
||||
- **Right**: The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD).
|
||||
- **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selected building panel are recalculated, and the ship layout preview is refreshed.
|
||||
|
||||
- REQ-MOD-UI-LAYOUT-SIZE: Ship layouts are small enough to display in the layout configuration dialog without scrolling (maximum grid size fits within the dialog).
|
||||
|
||||
### Layout Blueprints
|
||||
|
||||
- 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-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, 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-STARTUP: At application startup, layout blueprints are loaded from `ship_layouts.toml` in the same directory as the application executable. Blueprint entries missing required fields (`name` or `ship_type`) are silently skipped. If the file does not exist, the blueprint list starts empty with no error. If the file exists but cannot be parsed (malformed TOML), a modal error dialog describes the failure and the blueprint list starts empty.
|
||||
|
||||
- REQ-MOD-UI-BLUEPRINT-SHUTDOWN: On application shutdown, all layout blueprints (across all ship types) are written to `ship_layouts.toml` in the same directory as the application executable. The TOML structure matches the Layout Blueprint TOML Format. Write errors are silently ignored on shutdown.
|
||||
|
||||
## Defence Stations
|
||||
|
||||
- REQ-DEF-PLAYER-PLACEMENT: 2 player defence stations are pre-placed in space at the start. Their positions are determined by `world.toml [regions].asteroid_width` and `player_buffer_width`.
|
||||
@@ -146,6 +244,7 @@ Output port indicators are not building tiles themselves. A building may have mo
|
||||
- REQ-WAV-GAP: At game start and immediately after each wave is triggered, a random inter-wave gap is drawn uniformly from [`world.toml [waves].gap_min_seconds`, `gap_max_seconds`].
|
||||
- REQ-WAV-TRIGGER: When the gap expires, a wave is triggered. Ships are selected one at a time: from all schematics whose `threat.cost_formula` evaluates to > 0 at the current enemy ship level, uniformly randomly pick one whose cost fits the remaining threat budget. Repeat until no eligible schematic fits. Any remaining threat carries over to the next wave. A longer gap results in a larger wave. Because enemy ship level increases with time (REQ-WAV-SHIP-LEVEL), threat cost per ship rises naturally over the course of the game.
|
||||
- REQ-WAV-SHIP-LEVEL: Each wave's enemy ships are assigned a level determined by `world.toml [waves].ship_level_formula` where x is elapsed game time in seconds. This is the sole mechanism by which individual enemy ships become stronger over time. Wave *size* grows separately via threat accumulation (REQ-WAV-THREAT-RATE) and push scaling (REQ-PSH-ACCUMULATION). Per-ship stats and threat cost are computed from the ship level via the formulas in `ships.toml` (see REQ-SHP-STATS).
|
||||
- REQ-WAV-NO-MODULES: Enemy ships spawned by waves have no modules. Their stats are computed from the base ship formulas only, with no module modifiers applied.
|
||||
- REQ-WAV-SPAWN-DURATION: Ships in a wave are spawned one at a time over `world.toml [waves].spawn_duration_seconds`.
|
||||
|
||||
## Push Scaling
|
||||
@@ -222,7 +321,7 @@ The screen is divided into three vertical sections:
|
||||
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown.
|
||||
- REQ-UI-MULTI-SELECT: The player selects multiple buildings by box-drag or by Ctrl+clicking individual buildings to add or remove them from the selection.
|
||||
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown.
|
||||
- REQ-UI-CONFIG-INLINE: Recipe, schematic, ship stance, and target priority configuration for a selected building is shown and changed inline within this panel.
|
||||
- REQ-UI-CONFIG-INLINE: Recipe, schematic, ship stance, and target priority configuration for a selected building is shown and changed inline within this panel. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic dropdown (REQ-MOD-UI-PREVIEW).
|
||||
- REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels.
|
||||
|
||||
### Build Button Grid
|
||||
@@ -269,7 +368,7 @@ A separate executable target (`balancing`) that links against `lib` but contains
|
||||
- **World height** (in tiles).
|
||||
- Exactly **two teams**, each with a human-readable **team name**.
|
||||
- REQ-BAL-TEAM: Each team defines:
|
||||
- A list of **ship entries**, each specifying: ship schematic (type), level, and count.
|
||||
- A list of **ship entries**, each specifying: ship schematic (type), level, count, and an optional `modules` array defining the module layout applied to every ship of that entry. The `modules` array format is identical to that used in `ship_layouts.toml` (see Layout Blueprint TOML Format). If `modules` is omitted, ships of that entry have no modules. Invalid module instances (unknown type, position outside the grid, position on a non-buildable cell, or overlapping another module in the same entry) are silently skipped during loading.
|
||||
- An optional list of **defence station entries**, each specifying: station type (`player_station` or `enemy_station` from `stations.toml`), level, and tile position (x, y).
|
||||
- REQ-BAL-HQ: Each team has an HQ placed automatically at the vertical center of the arena at the far end of that team's buffer zone. HQ stats are read from `stations.toml [hq]` at level 1. Team 1's HQ is at the left edge; team 2's HQ is at the right edge.
|
||||
- REQ-BAL-SPAWN: Team 1's ships spawn in team 1's buffer zone (left side); team 2's ships spawn in team 2's buffer zone (right side). Spawn positions are uniformly random within the respective buffer zone.
|
||||
|
||||
@@ -36,7 +36,7 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
|
||||
m_beltSystem,
|
||||
[this]() { return allocateId(); },
|
||||
[](int) {},
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
m_rng);
|
||||
|
||||
m_shipSystem = std::make_unique<ShipSystem>(
|
||||
@@ -198,7 +198,8 @@ void ArenaSimulation::spawnShips()
|
||||
for (int i = 0; i < entry.count; ++i)
|
||||
{
|
||||
const QVector2D pos(xDist(m_rng), yDist(m_rng));
|
||||
m_shipSystem->spawn(entry.schematicId, entry.level, pos, false);
|
||||
m_shipSystem->spawn(entry.schematicId, entry.level, pos, false,
|
||||
entry.layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +215,8 @@ void ArenaSimulation::spawnShips()
|
||||
for (int i = 0; i < entry.count; ++i)
|
||||
{
|
||||
const QVector2D pos(xDist(m_rng), yDist(m_rng));
|
||||
m_shipSystem->spawn(entry.schematicId, entry.level, pos, true);
|
||||
m_shipSystem->spawn(entry.schematicId, entry.level, pos, true,
|
||||
entry.layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
#include "toml.hpp"
|
||||
|
||||
#include "Rotation.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
@@ -35,6 +37,14 @@ std::string requireString(NodeView node, const std::string& path)
|
||||
return *value;
|
||||
}
|
||||
|
||||
Rotation parseRotation(const std::string& s)
|
||||
{
|
||||
if (s == "east") { return Rotation::East; }
|
||||
if (s == "south") { return Rotation::South; }
|
||||
if (s == "west") { return Rotation::West; }
|
||||
return Rotation::North;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
BalancingConfig loadBalancingConfig(const std::string& path)
|
||||
@@ -119,6 +129,36 @@ BalancingConfig loadBalancingConfig(const std::string& path)
|
||||
requireInt((*shipTbl)["level"], sPrefix + ".level"));
|
||||
entry.count = static_cast<int>(
|
||||
requireInt((*shipTbl)["count"], sPrefix + ".count"));
|
||||
|
||||
const toml::array* modArray = (*shipTbl)["modules"].as_array();
|
||||
if (modArray && !modArray->empty())
|
||||
{
|
||||
ShipLayoutConfig layout;
|
||||
for (std::size_t mi = 0; mi < modArray->size(); ++mi)
|
||||
{
|
||||
const toml::table* modTbl = (*modArray)[mi].as_table();
|
||||
if (!modTbl) { continue; }
|
||||
|
||||
const std::optional<std::string> type =
|
||||
(*modTbl)["type"].value<std::string>();
|
||||
const std::optional<int64_t> x =
|
||||
(*modTbl)["x"].value<int64_t>();
|
||||
const std::optional<int64_t> y =
|
||||
(*modTbl)["y"].value<int64_t>();
|
||||
const std::optional<std::string> rotStr =
|
||||
(*modTbl)["rotation"].value<std::string>();
|
||||
if (!type || !x || !y || !rotStr) { continue; }
|
||||
|
||||
PlacedModule pm;
|
||||
pm.moduleId = *type;
|
||||
pm.position = QPoint(static_cast<int>(*x),
|
||||
static_cast<int>(*y));
|
||||
pm.rotation = parseRotation(*rotStr);
|
||||
layout.placedModules.push_back(std::move(pm));
|
||||
}
|
||||
entry.layout = std::move(layout);
|
||||
}
|
||||
|
||||
team.ships.push_back(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#include "ShipLayout.h"
|
||||
|
||||
struct ArenaStationEntry
|
||||
{
|
||||
std::string stationType; // "player_station" or "enemy_station"
|
||||
@@ -17,6 +20,7 @@ struct ArenaShipEntry
|
||||
std::string schematicId;
|
||||
int level;
|
||||
int count;
|
||||
std::optional<ShipLayoutConfig> layout;
|
||||
};
|
||||
|
||||
struct ArenaTeamConfig
|
||||
|
||||
@@ -7,9 +7,11 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipsConfig.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/StationsConfig.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameConfig.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ModulesConfig.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprintSerializer.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -19,6 +21,7 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprintSerializer.cpp
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
|
||||
@@ -358,6 +358,7 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
|
||||
ShipDef def;
|
||||
def.id = requireString(mt["id"], file, elemPath + ".id");
|
||||
def.availableFromStart = requireBool(mt["available_from_start"], file, elemPath + ".available_from_start");
|
||||
def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout");
|
||||
|
||||
// Schematic
|
||||
{
|
||||
@@ -394,7 +395,11 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
|
||||
const std::string mPath = elemPath + ".movement";
|
||||
const toml::table& mTable = requireTable(mt["movement"], file, mPath);
|
||||
toml::table& mMt = const_cast<toml::table&>(mTable);
|
||||
def.movement.speedFormula = requireFormula(mMt["speed_formula"], file, mPath + ".speed_formula");
|
||||
def.movement.speedFormula = requireFormula(mMt["speed_formula"], file, mPath + ".speed_formula");
|
||||
def.movement.mainAccelerationFormula = requireFormula(mMt["main_acceleration_formula"], file, mPath + ".main_acceleration_formula");
|
||||
def.movement.maneuveringAccelerationFormula = requireFormula(mMt["maneuvering_acceleration_formula"], file, mPath + ".maneuvering_acceleration_formula");
|
||||
def.movement.angularAccelerationFormula = requireFormula(mMt["angular_acceleration_formula"], file, mPath + ".angular_acceleration_formula");
|
||||
def.movement.maxRotationSpeedFormula = requireFormula(mMt["max_rotation_speed_formula"], file, mPath + ".max_rotation_speed_formula");
|
||||
}
|
||||
|
||||
// Sensor
|
||||
@@ -498,6 +503,106 @@ StationsConfig ConfigLoader::loadStations(const std::string& path)
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// Known category→stat mappings for module stat modifier discovery.
|
||||
struct StatEntry
|
||||
{
|
||||
const char* category;
|
||||
const char* stat;
|
||||
};
|
||||
|
||||
static const StatEntry kKnownStats[] = {
|
||||
{"health", "hp"},
|
||||
{"movement", "speed"},
|
||||
{"sensor", "sensor_range"},
|
||||
{"combat", "damage"},
|
||||
{"combat", "attack_range"},
|
||||
{"combat", "attack_rate"},
|
||||
{"repair", "repair_rate"},
|
||||
{"repair", "repair_range"},
|
||||
};
|
||||
|
||||
ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
||||
{
|
||||
const std::string file = "modules.toml";
|
||||
toml::table tbl = parseFile(path, file);
|
||||
|
||||
ModulesConfig cfg;
|
||||
|
||||
if (!tbl.contains("module"))
|
||||
{
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const toml::array& arr = requireArray(tbl["module"], file, "module");
|
||||
|
||||
for (std::size_t i = 0; i < arr.size(); ++i)
|
||||
{
|
||||
const std::string elemPath = "module[" + std::to_string(i) + "]";
|
||||
const toml::table* st = arr[i].as_table();
|
||||
if (st == nullptr)
|
||||
{
|
||||
throw makeError(file, elemPath, "not a table");
|
||||
}
|
||||
toml::table& mt = const_cast<toml::table&>(*st);
|
||||
|
||||
ModuleDef def;
|
||||
def.id = requireString(mt["id"], file, elemPath + ".id");
|
||||
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
|
||||
def.playerProductionLevel = static_cast<int>(requireInt(
|
||||
mt["player_production_level"], file, elemPath + ".player_production_level"));
|
||||
def.productionTimeSeconds = requireDouble(
|
||||
mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
|
||||
def.threatCost = requireDouble(mt["threat_cost"], file, elemPath + ".threat_cost");
|
||||
def.fillColor = requireString(mt["fill_color"], file, elemPath + ".fill_color");
|
||||
def.glyph = requireString(mt["glyph"], file, elemPath + ".glyph");
|
||||
|
||||
// Materials
|
||||
{
|
||||
const toml::array& materials = requireArray(mt["materials"], file, elemPath + ".materials");
|
||||
def.materials = parseIngredients(materials, file, elemPath + ".materials");
|
||||
}
|
||||
|
||||
// Stat modifiers from [module.<category>] sub-tables
|
||||
for (const StatEntry& se : kKnownStats)
|
||||
{
|
||||
if (!mt.contains(se.category))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const toml::table& catTable = requireTable(mt[se.category], file,
|
||||
elemPath + "." + se.category);
|
||||
toml::table& catMt = const_cast<toml::table&>(catTable);
|
||||
|
||||
const std::string addedKey = std::string("added_") + se.stat + "_formula";
|
||||
const std::string multipliedKey = std::string("multiplied_") + se.stat + "_formula";
|
||||
|
||||
if (catMt.contains(addedKey))
|
||||
{
|
||||
ModuleStatModifier mod;
|
||||
mod.stat = se.stat;
|
||||
mod.modifierType = "additive";
|
||||
mod.formula = requireFormula(catMt[addedKey], file,
|
||||
elemPath + "." + se.category + "." + addedKey);
|
||||
def.statModifiers.push_back(std::move(mod));
|
||||
}
|
||||
|
||||
if (catMt.contains(multipliedKey))
|
||||
{
|
||||
ModuleStatModifier mod;
|
||||
mod.stat = se.stat;
|
||||
mod.modifierType = "multiplicative";
|
||||
mod.formula = requireFormula(catMt[multipliedKey], file,
|
||||
elemPath + "." + se.category + "." + multipliedKey);
|
||||
def.statModifiers.push_back(std::move(mod));
|
||||
}
|
||||
}
|
||||
|
||||
cfg.modules.push_back(std::move(def));
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir)
|
||||
{
|
||||
GameConfig cfg;
|
||||
@@ -506,5 +611,6 @@ GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir)
|
||||
cfg.recipes = loadRecipes(configDir + "/recipes.toml");
|
||||
cfg.ships = loadShips(configDir + "/ships.toml");
|
||||
cfg.stations = loadStations(configDir + "/stations.toml");
|
||||
cfg.modules = loadModules(configDir + "/modules.toml");
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
#include "GameConfig.h"
|
||||
|
||||
// Parses the five simulation TOML files from a directory and returns a fully
|
||||
// Parses all simulation TOML files from a directory and returns a fully
|
||||
// populated, immutable GameConfig. Throws std::runtime_error on any parse or
|
||||
// validation failure; the exception message identifies the offending file,
|
||||
// field, or formula (see architecture.md "Config Loading").
|
||||
@@ -21,4 +21,5 @@ public:
|
||||
static RecipesConfig loadRecipes(const std::string& path);
|
||||
static ShipsConfig loadShips(const std::string& path);
|
||||
static StationsConfig loadStations(const std::string& path);
|
||||
static ModulesConfig loadModules(const std::string& path);
|
||||
};
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
#include "RecipesConfig.h"
|
||||
#include "ShipsConfig.h"
|
||||
#include "StationsConfig.h"
|
||||
#include "ModulesConfig.h"
|
||||
|
||||
// Aggregate of all five simulation config files. Loaded at startup and reloaded
|
||||
// Aggregate of all simulation config files. Loaded at startup and reloaded
|
||||
// from disk on each game restart (REQ-CFG-RELOAD). See architecture.md "Config Loading".
|
||||
struct GameConfig
|
||||
{
|
||||
@@ -15,4 +16,5 @@ struct GameConfig
|
||||
RecipesConfig recipes;
|
||||
ShipsConfig ships;
|
||||
StationsConfig stations;
|
||||
ModulesConfig modules;
|
||||
};
|
||||
|
||||
34
src/lib/config/ModulesConfig.h
Normal file
34
src/lib/config/ModulesConfig.h
Normal file
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Formula.h"
|
||||
#include "RecipesConfig.h"
|
||||
|
||||
// A single stat modifier contributed by a module instance.
|
||||
// REQ-MOD-STAT-CALC: final = base * (1 + sum(m_i - 1)) + sum(additives).
|
||||
struct ModuleStatModifier
|
||||
{
|
||||
std::string stat; // e.g. "hp", "speed", "sensor_range"
|
||||
std::string modifierType; // "additive" or "multiplicative"
|
||||
Formula formula;
|
||||
};
|
||||
|
||||
struct ModuleDef
|
||||
{
|
||||
std::string id;
|
||||
std::vector<std::string> surfaceMask;
|
||||
std::vector<RecipeIngredient> materials;
|
||||
int playerProductionLevel;
|
||||
double productionTimeSeconds;
|
||||
double threatCost;
|
||||
std::string fillColor;
|
||||
std::string glyph;
|
||||
std::vector<ModuleStatModifier> statModifiers;
|
||||
};
|
||||
|
||||
struct ModulesConfig
|
||||
{
|
||||
std::vector<ModuleDef> modules;
|
||||
};
|
||||
129
src/lib/config/ShipLayoutBlueprintSerializer.cpp
Normal file
129
src/lib/config/ShipLayoutBlueprintSerializer.cpp
Normal file
@@ -0,0 +1,129 @@
|
||||
#include "ShipLayoutBlueprintSerializer.h"
|
||||
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
#include "toml.hpp"
|
||||
|
||||
#include "Rotation.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
std::string rotationToString(Rotation r)
|
||||
{
|
||||
switch (r)
|
||||
{
|
||||
case Rotation::North: return "north";
|
||||
case Rotation::East: return "east";
|
||||
case Rotation::South: return "south";
|
||||
case Rotation::West: return "west";
|
||||
}
|
||||
return "north";
|
||||
}
|
||||
|
||||
Rotation parseRotation(const std::string& s)
|
||||
{
|
||||
if (s == "east") { return Rotation::East; }
|
||||
if (s == "south") { return Rotation::South; }
|
||||
if (s == "west") { return Rotation::West; }
|
||||
return Rotation::North;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace ShipLayoutBlueprintSerializer
|
||||
{
|
||||
|
||||
std::string serialize(const std::vector<ShipLayoutBlueprint>& blueprints)
|
||||
{
|
||||
toml::array bpArr;
|
||||
for (const ShipLayoutBlueprint& bp : blueprints)
|
||||
{
|
||||
toml::array modArr;
|
||||
for (const PlacedModule& pm : bp.modules)
|
||||
{
|
||||
toml::table modTbl;
|
||||
modTbl.insert("type", pm.moduleId);
|
||||
modTbl.insert("x", static_cast<int64_t>(pm.position.x()));
|
||||
modTbl.insert("y", static_cast<int64_t>(pm.position.y()));
|
||||
modTbl.insert("rotation", rotationToString(pm.rotation));
|
||||
modArr.push_back(std::move(modTbl));
|
||||
}
|
||||
|
||||
toml::table bpTbl;
|
||||
bpTbl.insert("name", bp.name.toStdString());
|
||||
bpTbl.insert("ship_type", bp.shipType);
|
||||
bpTbl.insert("modules", std::move(modArr));
|
||||
bpArr.push_back(std::move(bpTbl));
|
||||
}
|
||||
|
||||
toml::table root;
|
||||
root.insert("blueprint", std::move(bpArr));
|
||||
|
||||
std::ostringstream oss;
|
||||
oss << root;
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
std::vector<ShipLayoutBlueprint> deserialize(const std::string& tomlContent)
|
||||
{
|
||||
toml::table root;
|
||||
try
|
||||
{
|
||||
root = toml::parse(tomlContent);
|
||||
}
|
||||
catch (const toml::parse_error& e)
|
||||
{
|
||||
std::ostringstream msg;
|
||||
msg << "TOML parse error: " << e.description() << " at " << e.source().begin;
|
||||
throw std::runtime_error(msg.str());
|
||||
}
|
||||
|
||||
std::vector<ShipLayoutBlueprint> result;
|
||||
|
||||
const toml::array* bpArr = root["blueprint"].as_array();
|
||||
if (!bpArr) { return result; }
|
||||
|
||||
for (std::size_t i = 0; i < bpArr->size(); ++i)
|
||||
{
|
||||
const toml::table* bpTbl = (*bpArr)[i].as_table();
|
||||
if (!bpTbl) { continue; }
|
||||
|
||||
const std::optional<std::string> name = (*bpTbl)["name"].value<std::string>();
|
||||
const std::optional<std::string> shipType = (*bpTbl)["ship_type"].value<std::string>();
|
||||
if (!name || name->empty() || !shipType || shipType->empty()) { continue; }
|
||||
|
||||
ShipLayoutBlueprint bp;
|
||||
bp.name = QString::fromStdString(*name);
|
||||
bp.shipType = *shipType;
|
||||
|
||||
const toml::array* modArr = (*bpTbl)["modules"].as_array();
|
||||
if (modArr)
|
||||
{
|
||||
for (std::size_t j = 0; j < modArr->size(); ++j)
|
||||
{
|
||||
const toml::table* modTbl = (*modArr)[j].as_table();
|
||||
if (!modTbl) { continue; }
|
||||
|
||||
const std::optional<std::string> type = (*modTbl)["type"].value<std::string>();
|
||||
const std::optional<int64_t> x = (*modTbl)["x"].value<int64_t>();
|
||||
const std::optional<int64_t> y = (*modTbl)["y"].value<int64_t>();
|
||||
const std::optional<std::string> rotStr = (*modTbl)["rotation"].value<std::string>();
|
||||
if (!type || !x || !y || !rotStr) { continue; }
|
||||
|
||||
PlacedModule pm;
|
||||
pm.moduleId = *type;
|
||||
pm.position = QPoint(static_cast<int>(*x), static_cast<int>(*y));
|
||||
pm.rotation = parseRotation(*rotStr);
|
||||
bp.modules.push_back(std::move(pm));
|
||||
}
|
||||
}
|
||||
|
||||
result.push_back(std::move(bp));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace ShipLayoutBlueprintSerializer
|
||||
14
src/lib/config/ShipLayoutBlueprintSerializer.h
Normal file
14
src/lib/config/ShipLayoutBlueprintSerializer.h
Normal file
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "ShipLayoutBlueprint.h"
|
||||
|
||||
namespace ShipLayoutBlueprintSerializer
|
||||
{
|
||||
|
||||
std::string serialize(const std::vector<ShipLayoutBlueprint>& blueprints);
|
||||
std::vector<ShipLayoutBlueprint> deserialize(const std::string& tomlContent);
|
||||
|
||||
} // namespace ShipLayoutBlueprintSerializer
|
||||
@@ -30,7 +30,11 @@ struct ShipHealth
|
||||
|
||||
struct ShipMovement
|
||||
{
|
||||
Formula speedFormula; // REQ-SHP-STATS, REQ-SHP-MOVEMENT
|
||||
Formula speedFormula; // max linear speed cap, tiles/s (REQ-SHP-STATS, REQ-SHP-MOVEMENT)
|
||||
Formula mainAccelerationFormula; // forward acceleration, tiles/s²
|
||||
Formula maneuveringAccelerationFormula;// omnidirectional acceleration, tiles/s²
|
||||
Formula angularAccelerationFormula; // angular acceleration, rad/s²
|
||||
Formula maxRotationSpeedFormula; // angular velocity cap, rad/s
|
||||
};
|
||||
|
||||
struct ShipSensor
|
||||
@@ -69,6 +73,7 @@ struct ShipDef
|
||||
{
|
||||
std::string id;
|
||||
bool availableFromStart;
|
||||
std::vector<std::string> layout;
|
||||
|
||||
ShipSchematic schematic;
|
||||
ShipThreat threat;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "ItemType.h"
|
||||
#include "Port.h"
|
||||
#include "Rotation.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "Tick.h"
|
||||
|
||||
// Per-material input buffer for a production building.
|
||||
@@ -50,6 +51,7 @@ struct ConstructionSite
|
||||
BuildingType type = BuildingType::Miner;
|
||||
std::string recipeId; // may be configured before completion
|
||||
Tick completesAt = 0; // 0 = queued but not yet started
|
||||
std::optional<ShipLayoutConfig> shipLayout;
|
||||
};
|
||||
|
||||
// Weapon state for stationary structures (defence stations).
|
||||
@@ -85,6 +87,9 @@ struct Building
|
||||
std::vector<Port> inputPorts; // perimeter tiles (minus output-port tiles),
|
||||
// direction pointing INTO building
|
||||
|
||||
// Module layout for shipyards (REQ-MOD-LAYOUT).
|
||||
std::optional<ShipLayoutConfig> shipLayout;
|
||||
|
||||
// Set only for defence stations; nullopt for all other building types.
|
||||
std::optional<StationWeapon> weapon;
|
||||
};
|
||||
|
||||
@@ -11,7 +11,8 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
|
||||
BeltSystem& belts,
|
||||
std::function<EntityId()> allocateId,
|
||||
std::function<void(int)> addBuildingBlocks,
|
||||
std::function<void(const std::string&, QVector2D)> spawnShip,
|
||||
std::function<void(const std::string&, QVector2D,
|
||||
const std::optional<ShipLayoutConfig>&)> spawnShip,
|
||||
std::mt19937& rng)
|
||||
: m_config(config)
|
||||
, m_belts(belts)
|
||||
@@ -63,6 +64,18 @@ const ShipDef* BuildingSystem::findShipDef(const std::string& id) const
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const ModuleDef* BuildingSystem::findModuleDef(const std::string& id) const
|
||||
{
|
||||
for (const ModuleDef& def : m_config.modules.modules)
|
||||
{
|
||||
if (def.id == id)
|
||||
{
|
||||
return &def;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void BuildingSystem::initBuffers(Building& b, const RecipeDef& recipe) const
|
||||
{
|
||||
b.inputBuffer.counts.clear();
|
||||
@@ -117,6 +130,23 @@ void BuildingSystem::initShipyardBuffers(Building& b) const
|
||||
b.inputBuffer.counts[type] = 0;
|
||||
b.inputBuffer.caps[type] = 2 * ing.amount;
|
||||
}
|
||||
if (b.shipLayout.has_value())
|
||||
{
|
||||
for (const PlacedModule& pm : b.shipLayout->placedModules)
|
||||
{
|
||||
const ModuleDef* modDef = findModuleDef(pm.moduleId);
|
||||
if (!modDef)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (const RecipeIngredient& ing : modDef->materials)
|
||||
{
|
||||
const ItemType type{ing.item};
|
||||
b.inputBuffer.counts.try_emplace(type, 0);
|
||||
b.inputBuffer.caps[type] += 2 * ing.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
|
||||
@@ -303,6 +333,7 @@ void BuildingSystem::setRecipe(EntityId id, const std::string& recipeId)
|
||||
if (site.id == id)
|
||||
{
|
||||
site.recipeId = recipeId;
|
||||
site.shipLayout = std::nullopt;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -313,6 +344,7 @@ void BuildingSystem::setRecipe(EntityId id, const std::string& recipeId)
|
||||
if (building.id == id)
|
||||
{
|
||||
building.recipeId = recipeId;
|
||||
building.shipLayout = std::nullopt;
|
||||
building.inputBuffer.counts.clear();
|
||||
building.inputBuffer.caps.clear();
|
||||
building.outputBuffer.items.clear();
|
||||
@@ -339,6 +371,39 @@ void BuildingSystem::setRecipe(EntityId id, const std::string& recipeId)
|
||||
}
|
||||
}
|
||||
|
||||
void BuildingSystem::setShipLayout(EntityId id, const ShipLayoutConfig& layout)
|
||||
{
|
||||
for (ConstructionSite& site : m_constructionQueue)
|
||||
{
|
||||
if (site.id == id)
|
||||
{
|
||||
site.shipLayout = layout;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (Building& building : m_buildings)
|
||||
{
|
||||
if (building.id == id)
|
||||
{
|
||||
if (building.production.has_value())
|
||||
{
|
||||
building.production = std::nullopt;
|
||||
}
|
||||
building.shipLayout = layout;
|
||||
building.inputBuffer.counts.clear();
|
||||
building.inputBuffer.caps.clear();
|
||||
building.outputBuffer.items.clear();
|
||||
building.outputBuffer.capacity = 0;
|
||||
if (!building.recipeId.empty() && building.type == BuildingType::Shipyard)
|
||||
{
|
||||
initShipyardBuffers(building);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tick hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -383,6 +448,7 @@ void BuildingSystem::tickConstruction(Tick currentTick)
|
||||
building.hp = 100.0f;
|
||||
building.maxHp = 100.0f;
|
||||
building.recipeId = front.recipeId;
|
||||
building.shipLayout = front.shipLayout;
|
||||
|
||||
for (const QPoint& cell : mask.bodyCells)
|
||||
{
|
||||
@@ -657,22 +723,44 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
|
||||
{
|
||||
const Port& p = building.outputPorts[0];
|
||||
const QVector2D spawnPos(p.tile.x() + 0.5f, p.tile.y() + 0.5f);
|
||||
m_spawnShip(building.recipeId, spawnPos);
|
||||
m_spawnShip(building.recipeId, spawnPos, building.shipLayout);
|
||||
}
|
||||
building.production = std::nullopt;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Idle: check if all materials are available to start a new cycle.
|
||||
bool inputsOk = true;
|
||||
// Build combined materials list (base + modules).
|
||||
std::map<std::string, int> requiredMaterials;
|
||||
for (const RecipeIngredient& ing : shipDef->schematic.materials)
|
||||
{
|
||||
const ItemType type{ing.item};
|
||||
requiredMaterials[ing.item] += ing.amount;
|
||||
}
|
||||
if (building.shipLayout.has_value())
|
||||
{
|
||||
for (const PlacedModule& pm : building.shipLayout->placedModules)
|
||||
{
|
||||
const ModuleDef* modDef = findModuleDef(pm.moduleId);
|
||||
if (!modDef)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (const RecipeIngredient& ing : modDef->materials)
|
||||
{
|
||||
requiredMaterials[ing.item] += ing.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Idle: check if all combined materials are available.
|
||||
bool inputsOk = true;
|
||||
for (const std::pair<const std::string, int>& req : requiredMaterials)
|
||||
{
|
||||
const ItemType type{req.first};
|
||||
const std::map<ItemType, int>::const_iterator it =
|
||||
building.inputBuffer.counts.find(type);
|
||||
const int have = (it != building.inputBuffer.counts.end()) ? it->second : 0;
|
||||
if (have < ing.amount)
|
||||
if (have < req.second)
|
||||
{
|
||||
inputsOk = false;
|
||||
break;
|
||||
@@ -683,16 +771,28 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Consume materials and start the production cycle.
|
||||
for (const RecipeIngredient& ing : shipDef->schematic.materials)
|
||||
// Consume combined materials and start the production cycle.
|
||||
for (const std::pair<const std::string, int>& req : requiredMaterials)
|
||||
{
|
||||
building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount;
|
||||
building.inputBuffer.counts[ItemType{req.first}] -= req.second;
|
||||
}
|
||||
|
||||
double totalTime = shipDef->schematic.productionTimeSeconds;
|
||||
if (building.shipLayout.has_value())
|
||||
{
|
||||
for (const PlacedModule& pm : building.shipLayout->placedModules)
|
||||
{
|
||||
const ModuleDef* modDef = findModuleDef(pm.moduleId);
|
||||
if (modDef)
|
||||
{
|
||||
totalTime += modDef->productionTimeSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Production prod;
|
||||
prod.recipeId = building.recipeId;
|
||||
prod.completesAt = currentTick
|
||||
+ secondsToTicks(shipDef->schematic.productionTimeSeconds);
|
||||
prod.completesAt = currentTick + secondsToTicks(totalTime);
|
||||
building.production = std::move(prod);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
#include "EntityId.h"
|
||||
#include "GameConfig.h"
|
||||
#include "Rotation.h"
|
||||
#include "ModulesConfig.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "ShipsConfig.h"
|
||||
#include "Tick.h"
|
||||
|
||||
@@ -32,7 +34,8 @@ public:
|
||||
BeltSystem& belts,
|
||||
std::function<EntityId()> allocateId,
|
||||
std::function<void(int)> addBuildingBlocks,
|
||||
std::function<void(const std::string&, QVector2D)> spawnShip,
|
||||
std::function<void(const std::string&, QVector2D,
|
||||
const std::optional<ShipLayoutConfig>&)> spawnShip,
|
||||
std::mt19937& rng);
|
||||
|
||||
// -- Placement / demolish ------------------------------------------------
|
||||
@@ -50,6 +53,10 @@ public:
|
||||
// construction site. Clears both buffers on an operational building.
|
||||
void setRecipe(EntityId id, const std::string& recipeId);
|
||||
|
||||
// Set the module layout for a shipyard. Cancels in-progress production
|
||||
// (materials discarded) and reinitializes input buffers (REQ-BLD-SHIPYARD).
|
||||
void setShipLayout(EntityId id, const ShipLayoutConfig& layout);
|
||||
|
||||
// -- Tick hooks (called from Simulation::tick in the documented order) ---
|
||||
void tickConstruction(Tick currentTick);
|
||||
void tickBeltPull();
|
||||
@@ -121,6 +128,7 @@ private:
|
||||
const BuildingDef* findBuildingDef(BuildingType type) const;
|
||||
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;
|
||||
const ShipDef* findShipDef(const std::string& id) const;
|
||||
const ModuleDef* findModuleDef(const std::string& id) const;
|
||||
void initBuffers(Building& b, const RecipeDef& recipe) const;
|
||||
void initShipyardBuffers(Building& b) const;
|
||||
std::vector<Port> computeInputPorts(const Building& b) const;
|
||||
@@ -130,7 +138,8 @@ private:
|
||||
BeltSystem& m_belts;
|
||||
std::function<EntityId()> m_allocateId;
|
||||
std::function<void(int)> m_addBuildingBlocks;
|
||||
std::function<void(const std::string&, QVector2D)> m_spawnShip;
|
||||
std::function<void(const std::string&, QVector2D,
|
||||
const std::optional<ShipLayoutConfig>&)> m_spawnShip;
|
||||
std::mt19937& m_rng;
|
||||
|
||||
std::vector<Building> m_buildings;
|
||||
|
||||
@@ -7,6 +7,8 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Scrap.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Ship.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
|
||||
|
||||
@@ -75,10 +75,16 @@ struct Ship
|
||||
EntityId id;
|
||||
QVector2D position;
|
||||
QVector2D velocity;
|
||||
float facing; // heading in radians (0 = east/+x)
|
||||
float rotationSpeed; // angular velocity in radians per tick
|
||||
float hp;
|
||||
float maxHp;
|
||||
float speedPerTick; // pre-evaluated from speedFormula / kTickRateHz
|
||||
float sensorRange; // pre-evaluated from sensorRangeFormula (REQ-SHP-SENSOR)
|
||||
float maxSpeedPerTick; // linear speed cap (tiles/tick)
|
||||
float mainAccelerationPerTick; // forward acceleration (tiles/tick²)
|
||||
float maneuveringAccelerationPerTick; // omnidirectional acceleration (tiles/tick²)
|
||||
float angularAccelerationPerTick; // angular acceleration (rad/tick²)
|
||||
float maxRotationSpeedPerTick; // angular velocity cap (rad/tick)
|
||||
float sensorRange; // pre-evaluated from sensorRangeFormula (REQ-SHP-SENSOR)
|
||||
int level;
|
||||
std::string schematicId;
|
||||
|
||||
|
||||
22
src/lib/sim/ShipLayout.h
Normal file
22
src/lib/sim/ShipLayout.h
Normal file
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#include "Rotation.h"
|
||||
|
||||
// A single module placed on a ship's layout grid (REQ-MOD-PLACEMENT).
|
||||
struct PlacedModule
|
||||
{
|
||||
std::string moduleId;
|
||||
QPoint position;
|
||||
Rotation rotation;
|
||||
};
|
||||
|
||||
// The complete module configuration for a shipyard's current ship (REQ-MOD-CONFIG).
|
||||
struct ShipLayoutConfig
|
||||
{
|
||||
std::vector<PlacedModule> placedModules;
|
||||
};
|
||||
15
src/lib/sim/ShipLayoutBlueprint.h
Normal file
15
src/lib/sim/ShipLayoutBlueprint.h
Normal file
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include "ShipLayout.h"
|
||||
|
||||
struct ShipLayoutBlueprint
|
||||
{
|
||||
QString name;
|
||||
std::string shipType;
|
||||
std::vector<PlacedModule> modules;
|
||||
};
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <utility>
|
||||
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "BuildingType.h"
|
||||
#include "ModulesConfig.h"
|
||||
#include "Scrap.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "Tick.h"
|
||||
@@ -30,8 +34,21 @@ const ShipDef* ShipSystem::findShipDef(const std::string& schematicId) const
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const ModuleDef* ShipSystem::findModuleDef(const std::string& id) const
|
||||
{
|
||||
for (const ModuleDef& def : m_config.modules.modules)
|
||||
{
|
||||
if (def.id == id)
|
||||
{
|
||||
return &def;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D position,
|
||||
bool isEnemy)
|
||||
bool isEnemy,
|
||||
const std::optional<ShipLayoutConfig>& layout)
|
||||
{
|
||||
const ShipDef* def = findShipDef(schematicId);
|
||||
assert(def != nullptr);
|
||||
@@ -39,19 +56,29 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
|
||||
const double x = static_cast<double>(level);
|
||||
|
||||
Ship ship;
|
||||
ship.id = m_allocateId();
|
||||
ship.position = position;
|
||||
ship.velocity = QVector2D(0.0f, 0.0f);
|
||||
ship.maxHp = static_cast<float>(def->health.hpFormula.evaluate(x));
|
||||
ship.hp = ship.maxHp;
|
||||
ship.speedPerTick = static_cast<float>(
|
||||
def->movement.speedFormula.evaluate(x))
|
||||
/ static_cast<float>(kTickRateHz);
|
||||
ship.sensorRange = static_cast<float>(def->sensor.sensorRangeFormula.evaluate(x));
|
||||
ship.level = level;
|
||||
ship.schematicId = schematicId;
|
||||
ship.isEnemy = isEnemy;
|
||||
ship.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)};
|
||||
ship.id = m_allocateId();
|
||||
ship.position = position;
|
||||
ship.velocity = QVector2D(0.0f, 0.0f);
|
||||
ship.facing = 0.0f;
|
||||
ship.rotationSpeed = 0.0f;
|
||||
ship.maxHp = static_cast<float>(def->health.hpFormula.evaluate(x));
|
||||
ship.hp = ship.maxHp;
|
||||
const float tickRate = static_cast<float>(kTickRateHz);
|
||||
ship.maxSpeedPerTick = static_cast<float>(def->movement.speedFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.mainAccelerationPerTick = static_cast<float>(def->movement.mainAccelerationFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.maneuveringAccelerationPerTick = static_cast<float>(def->movement.maneuveringAccelerationFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.angularAccelerationPerTick = static_cast<float>(def->movement.angularAccelerationFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.maxRotationSpeedPerTick = static_cast<float>(def->movement.maxRotationSpeedFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.sensorRange = static_cast<float>(def->sensor.sensorRangeFormula.evaluate(x));
|
||||
ship.level = level;
|
||||
ship.schematicId = schematicId;
|
||||
ship.isEnemy = isEnemy;
|
||||
ship.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)};
|
||||
|
||||
if (def->combat)
|
||||
{
|
||||
@@ -95,6 +122,64 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
|
||||
ship.repairBehavior = rb;
|
||||
}
|
||||
|
||||
// Apply module stat modifiers (REQ-MOD-STAT-CALC).
|
||||
if (layout.has_value() && !layout->placedModules.empty())
|
||||
{
|
||||
std::map<std::string, std::pair<double, double>> mods;
|
||||
for (const PlacedModule& pm : layout->placedModules)
|
||||
{
|
||||
const ModuleDef* modDef = findModuleDef(pm.moduleId);
|
||||
if (!modDef)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (const ModuleStatModifier& sm : modDef->statModifiers)
|
||||
{
|
||||
const double val = sm.formula.evaluate(
|
||||
static_cast<double>(modDef->playerProductionLevel));
|
||||
std::pair<double, double>& acc = mods[sm.stat];
|
||||
if (sm.modifierType == "multiplicative")
|
||||
{
|
||||
acc.first += (val - 1.0);
|
||||
}
|
||||
else
|
||||
{
|
||||
acc.second += val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto applyMod = [&mods](float& stat, const std::string& name) {
|
||||
const std::map<std::string, std::pair<double, double>>::const_iterator it =
|
||||
mods.find(name);
|
||||
if (it != mods.end())
|
||||
{
|
||||
stat = static_cast<float>(
|
||||
static_cast<double>(stat) * (1.0 + it->second.first) + it->second.second);
|
||||
}
|
||||
};
|
||||
|
||||
applyMod(ship.maxHp, "hp");
|
||||
ship.hp = ship.maxHp;
|
||||
applyMod(ship.maxSpeedPerTick, "speed");
|
||||
applyMod(ship.mainAccelerationPerTick, "main_acceleration");
|
||||
applyMod(ship.maneuveringAccelerationPerTick, "maneuvering_acceleration");
|
||||
applyMod(ship.angularAccelerationPerTick, "angular_acceleration");
|
||||
applyMod(ship.maxRotationSpeedPerTick, "max_rotation_speed");
|
||||
applyMod(ship.sensorRange, "sensor_range");
|
||||
if (ship.weapon.has_value())
|
||||
{
|
||||
applyMod(ship.weapon->damage, "damage");
|
||||
applyMod(ship.weapon->range, "attack_range");
|
||||
applyMod(ship.weapon->fireRateHz, "attack_rate");
|
||||
}
|
||||
if (ship.repairTool.has_value())
|
||||
{
|
||||
applyMod(ship.repairTool->ratePerTick, "repair_rate");
|
||||
applyMod(ship.repairTool->range, "repair_range");
|
||||
}
|
||||
}
|
||||
|
||||
m_ships.push_back(ship);
|
||||
return ship.id;
|
||||
}
|
||||
@@ -694,25 +779,98 @@ void ShipSystem::triggerRallyDeparture()
|
||||
// tickMovement (tick-order step 10)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Reduces angle to [-π, π].
|
||||
static float wrapAngle(float a)
|
||||
{
|
||||
constexpr float kPi = 3.14159265f;
|
||||
a = std::fmod(a, 2.0f * kPi);
|
||||
if (a > kPi) { a -= 2.0f * kPi; }
|
||||
if (a < -kPi) { a += 2.0f * kPi; }
|
||||
return a;
|
||||
}
|
||||
|
||||
void ShipSystem::tickMovement()
|
||||
{
|
||||
for (Ship& s : m_ships)
|
||||
{
|
||||
if (s.intent.priority == 0)
|
||||
{
|
||||
s.velocity = QVector2D(0.0f, 0.0f);
|
||||
s.rotationSpeed = 0.0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
const QVector2D delta = s.intent.target - s.position;
|
||||
const float dist = delta.length();
|
||||
|
||||
if (dist < 0.001f)
|
||||
{
|
||||
s.velocity = QVector2D(0.0f, 0.0f);
|
||||
continue;
|
||||
}
|
||||
QVector2D delta = s.intent.target - s.position;
|
||||
float dist = delta.length();
|
||||
if (dist <= s.speedPerTick)
|
||||
|
||||
// ── Rotate toward target ──────────────────────────────────────────
|
||||
const float desiredAngle = std::atan2(delta.y(), delta.x());
|
||||
const float angleDiff = wrapAngle(desiredAngle - s.facing);
|
||||
|
||||
// Clamp angular acceleration, accumulate rotation speed.
|
||||
const float rotDelta = std::max(-s.angularAccelerationPerTick,
|
||||
std::min(angleDiff, s.angularAccelerationPerTick));
|
||||
s.rotationSpeed += rotDelta;
|
||||
s.rotationSpeed = std::max(-s.maxRotationSpeedPerTick,
|
||||
std::min(s.rotationSpeed, s.maxRotationSpeedPerTick));
|
||||
|
||||
// Prevent overshooting the desired angle this tick.
|
||||
const bool sameSign = (s.rotationSpeed >= 0.0f) == (angleDiff >= 0.0f);
|
||||
if (sameSign && std::abs(s.rotationSpeed) > std::abs(angleDiff))
|
||||
{
|
||||
s.rotationSpeed = angleDiff;
|
||||
}
|
||||
|
||||
s.facing = wrapAngle(s.facing + s.rotationSpeed);
|
||||
|
||||
// ── Desired velocity (with braking near target) ───────────────────
|
||||
// Stopping distance using maneuvering acceleration as the worst-case brake.
|
||||
const float manAccel = s.maneuveringAccelerationPerTick;
|
||||
const float stoppingDist = (s.maxSpeedPerTick * s.maxSpeedPerTick)
|
||||
/ (2.0f * manAccel);
|
||||
const float desiredSpeed = (dist <= stoppingDist)
|
||||
? std::sqrt(2.0f * manAccel * dist)
|
||||
: s.maxSpeedPerTick;
|
||||
const QVector2D desiredVel = delta.normalized() * desiredSpeed;
|
||||
const QVector2D velError = desiredVel - s.velocity;
|
||||
|
||||
// ── Main acceleration: forward only, along facing ─────────────────
|
||||
const QVector2D facingVec(std::cos(s.facing), std::sin(s.facing));
|
||||
const float mainAligned = std::max(0.0f,
|
||||
QVector2D::dotProduct(velError, facingVec));
|
||||
const float mainApplied = std::min(mainAligned, s.mainAccelerationPerTick);
|
||||
const QVector2D mainDelta = facingVec * mainApplied;
|
||||
|
||||
// ── Maneuvering acceleration: any direction, handles the remainder ─
|
||||
const QVector2D remaining = velError - mainDelta;
|
||||
const float remainLen = remaining.length();
|
||||
const QVector2D maneuverDelta = (remainLen > manAccel)
|
||||
? remaining.normalized() * manAccel
|
||||
: remaining;
|
||||
|
||||
s.velocity += mainDelta + maneuverDelta;
|
||||
|
||||
// ── Speed cap ─────────────────────────────────────────────────────
|
||||
const float speed = s.velocity.length();
|
||||
if (speed > s.maxSpeedPerTick)
|
||||
{
|
||||
s.velocity = s.velocity.normalized() * s.maxSpeedPerTick;
|
||||
}
|
||||
|
||||
// ── Snap to target or advance ─────────────────────────────────────
|
||||
if (dist <= s.velocity.length())
|
||||
{
|
||||
s.position = s.intent.target;
|
||||
s.velocity = QVector2D(0.0f, 0.0f);
|
||||
}
|
||||
else
|
||||
{
|
||||
s.velocity = delta.normalized() * s.speedPerTick;
|
||||
s.position += s.velocity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "EntityId.h"
|
||||
#include "GameConfig.h"
|
||||
#include "Ship.h"
|
||||
#include "ShipLayout.h"
|
||||
|
||||
class BuildingSystem;
|
||||
class ScrapSystem;
|
||||
@@ -20,7 +21,8 @@ public:
|
||||
|
||||
// isEnemy defaults to false; set true for enemy-faction ships (step 7 wave spawning).
|
||||
EntityId spawn(const std::string& schematicId, int level, QVector2D position,
|
||||
bool isEnemy = false);
|
||||
bool isEnemy = false,
|
||||
const std::optional<ShipLayoutConfig>& layout = std::nullopt);
|
||||
void despawn(EntityId id);
|
||||
|
||||
const Ship* findShip(EntityId id) const;
|
||||
@@ -58,7 +60,8 @@ public:
|
||||
bool damageShip(EntityId id, float amount);
|
||||
|
||||
private:
|
||||
const ShipDef* findShipDef(const std::string& schematicId) const;
|
||||
const ShipDef* findShipDef(const std::string& schematicId) const;
|
||||
const ModuleDef* findModuleDef(const std::string& id) const;
|
||||
|
||||
// True if the entity identified by id is alive and within range of ship.
|
||||
// Searches both the ship list and (for buildings) the supplied BuildingSystem.
|
||||
|
||||
@@ -30,14 +30,15 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
|
||||
m_beltSystem,
|
||||
[this]() { return allocateId(); },
|
||||
[this](int amount) { m_buildingBlocksStock += amount; },
|
||||
[this](const std::string& id, QVector2D pos) {
|
||||
[this](const std::string& id, QVector2D pos,
|
||||
const std::optional<ShipLayoutConfig>& layout) {
|
||||
const std::map<std::string, SchematicState>::const_iterator it =
|
||||
m_schematicLevels.find(id);
|
||||
if (it == m_schematicLevels.end() || !it->second.unlocked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false);
|
||||
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout);
|
||||
},
|
||||
m_rng);
|
||||
m_shipSystem = std::make_unique<ShipSystem>(m_config, [this]() { return allocateId(); });
|
||||
@@ -92,14 +93,15 @@ void Simulation::reset(unsigned int seed)
|
||||
m_beltSystem,
|
||||
[this]() { return allocateId(); },
|
||||
[this](int amount) { m_buildingBlocksStock += amount; },
|
||||
[this](const std::string& id, QVector2D pos) {
|
||||
[this](const std::string& id, QVector2D pos,
|
||||
const std::optional<ShipLayoutConfig>& layout) {
|
||||
const std::map<std::string, SchematicState>::const_iterator it =
|
||||
m_schematicLevels.find(id);
|
||||
if (it == m_schematicLevels.end() || !it->second.unlocked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false);
|
||||
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout);
|
||||
},
|
||||
m_rng);
|
||||
m_shipSystem = std::make_unique<ShipSystem>(m_config, [this]() { return allocateId(); });
|
||||
|
||||
@@ -46,7 +46,7 @@ struct Fixture
|
||||
, buildings(cfg, belts,
|
||||
[this]() { return nextId++; },
|
||||
[this](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng)
|
||||
, ships(cfg, [this]() { return nextId++; })
|
||||
, scraps([this]() { return nextId++; })
|
||||
@@ -93,13 +93,15 @@ TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to priority 0",
|
||||
// tickMovement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickMovement advances ship by speedPerTick toward target",
|
||||
// With facing=0 and target due east, main thrust drives the ship east. The test
|
||||
// config uses very high thrust so the ship reaches maxSpeedPerTick in one tick.
|
||||
TEST_CASE("BehaviorSystem: tickMovement advances ship by maxSpeedPerTick toward target",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
const float speed = f.ships.findShip(id)->speedPerTick;
|
||||
const float speed = f.ships.findShip(id)->maxSpeedPerTick;
|
||||
const QVector2D target(100.0f, 0.0f);
|
||||
|
||||
f.ships.forEach([&target](Ship& s) {
|
||||
@@ -112,6 +114,8 @@ TEST_CASE("BehaviorSystem: tickMovement advances ship by speedPerTick toward tar
|
||||
REQUIRE(s->position.y() == Approx(0.0f));
|
||||
}
|
||||
|
||||
// With very high maneuvering thrust the stopping distance is ~0, so desiredSpeed
|
||||
// still exceeds maxSpeedPerTick and the snap-to-target branch fires.
|
||||
TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoot",
|
||||
"[behavior]")
|
||||
{
|
||||
@@ -119,7 +123,7 @@ TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoo
|
||||
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
// Place target closer than one tick's travel.
|
||||
const float speed = f.ships.findShip(id)->speedPerTick;
|
||||
const float speed = f.ships.findShip(id)->maxSpeedPerTick;
|
||||
const QVector2D target(speed * 0.5f, 0.0f);
|
||||
|
||||
f.ships.forEach([&target](Ship& s) {
|
||||
|
||||
@@ -78,7 +78,7 @@ TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -103,7 +103,7 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);
|
||||
@@ -131,7 +131,7 @@ TEST_CASE("BuildingSystem: placed building enters construction queue", "[buildin
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -151,7 +151,7 @@ TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building]
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -184,7 +184,7 @@ TEST_CASE("BuildingSystem: first queued building starts construction immediately
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -201,7 +201,7 @@ TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[b
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -222,7 +222,7 @@ TEST_CASE("BuildingSystem: construction completes after configured duration", "[
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -246,7 +246,7 @@ TEST_CASE("BuildingSystem: second building starts after first completes", "[buil
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -275,7 +275,7 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -304,7 +304,7 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -343,7 +343,7 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
|
||||
@@ -384,7 +384,7 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -423,7 +423,7 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -463,7 +463,7 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::ReprocessingPlant,
|
||||
@@ -493,7 +493,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::ReprocessingPlant,
|
||||
@@ -551,7 +551,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
REQUIRE_FALSE(
|
||||
@@ -569,7 +569,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a que
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -591,7 +591,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -617,7 +617,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -638,7 +638,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprin
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
// Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1).
|
||||
@@ -661,7 +661,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
// Smelter is a fully filled 2×2 footprint — rotating the ghost produces the
|
||||
@@ -689,7 +689,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a constru
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -711,7 +711,7 @@ TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -734,7 +734,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direct
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||||
@@ -764,7 +764,7 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextId]() { return nextId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||||
|
||||
@@ -17,4 +17,6 @@ add_files(
|
||||
ShipyardTest.cpp
|
||||
BlueprintTest.cpp
|
||||
BlueprintSerializerTest.cpp
|
||||
ModuleConfigTest.cpp
|
||||
ShipModuleTest.cpp
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[comb
|
||||
BuildingSystem buildings(cfg, belts,
|
||||
[&nextBldId]() { return nextBldId++; },
|
||||
[](int){},
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
// Spawn an enemy combat ship close to the player side.
|
||||
@@ -114,7 +114,7 @@ TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]"
|
||||
BuildingSystem buildings(cfg, belts,
|
||||
[&nextBldId]() { return nextBldId++; },
|
||||
[](int){},
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
|
||||
@@ -163,7 +163,7 @@ TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
|
||||
BuildingSystem buildings(cfg, belts,
|
||||
[&nextBldId]() { return nextBldId++; },
|
||||
[](int){},
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(0.0f, 0.0f), true);
|
||||
@@ -344,7 +344,7 @@ TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]")
|
||||
BuildingSystem buildings(cfg, belts,
|
||||
[&nextBldId]() { return nextBldId++; },
|
||||
[](int){},
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
|
||||
@@ -401,7 +401,7 @@ TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]")
|
||||
BuildingSystem buildings(cfg, belts,
|
||||
[&nextBldId]() { return nextBldId++; },
|
||||
[](int){},
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
|
||||
@@ -455,7 +455,7 @@ TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[comb
|
||||
BuildingSystem buildings(cfg, belts,
|
||||
[&nextBldId]() { return nextBldId++; },
|
||||
[](int){},
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
|
||||
@@ -502,7 +502,7 @@ TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat
|
||||
BuildingSystem buildings(cfg, belts,
|
||||
[&nextBldId]() { return nextBldId++; },
|
||||
[](int){},
|
||||
[](const std::string&, QVector2D) {},
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng);
|
||||
|
||||
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
|
||||
|
||||
57
src/test/ModuleConfigTest.cpp
Normal file
57
src/test/ModuleConfigTest.cpp
Normal file
@@ -0,0 +1,57 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "ModulesConfig.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
REQUIRE(cfg.modules.modules.size() >= 2);
|
||||
|
||||
const ModuleDef& armor = cfg.modules.modules[0];
|
||||
CHECK(armor.id == "armor_plate");
|
||||
CHECK(armor.surfaceMask.size() == 1);
|
||||
CHECK(armor.surfaceMask[0] == "OO");
|
||||
CHECK(armor.materials.size() == 1);
|
||||
CHECK(armor.materials[0].item == "iron_ingot");
|
||||
CHECK(armor.materials[0].amount == 2);
|
||||
CHECK(armor.playerProductionLevel == 1);
|
||||
CHECK(armor.productionTimeSeconds == Approx(3.0));
|
||||
CHECK(armor.threatCost == Approx(2.0));
|
||||
CHECK(armor.fillColor == "#808080");
|
||||
CHECK(armor.glyph == "A");
|
||||
REQUIRE(armor.statModifiers.size() == 1);
|
||||
CHECK(armor.statModifiers[0].stat == "hp");
|
||||
CHECK(armor.statModifiers[0].modifierType == "multiplicative");
|
||||
CHECK(armor.statModifiers[0].formula.evaluate(1.0) == Approx(1.5));
|
||||
}
|
||||
|
||||
TEST_CASE("ConfigLoader: loadModules parses additive modifiers", "[config][modules]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
REQUIRE(cfg.modules.modules.size() >= 2);
|
||||
|
||||
const ModuleDef& sensor = cfg.modules.modules[1];
|
||||
CHECK(sensor.id == "sensor_booster");
|
||||
REQUIRE(sensor.statModifiers.size() == 1);
|
||||
CHECK(sensor.statModifiers[0].stat == "sensor_range");
|
||||
CHECK(sensor.statModifiers[0].modifierType == "additive");
|
||||
CHECK(sensor.statModifiers[0].formula.evaluate(1.0) == Approx(10.0));
|
||||
}
|
||||
|
||||
TEST_CASE("ConfigLoader: loadShips parses layout field", "[config][ships]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
REQUIRE(!cfg.ships.ships.empty());
|
||||
|
||||
const ShipDef& ship = cfg.ships.ships[0];
|
||||
REQUIRE(!ship.layout.empty());
|
||||
CHECK(ship.layout[0] == "XOX");
|
||||
CHECK(ship.layout[1] == "OOO");
|
||||
CHECK(ship.layout[2] == "XOX");
|
||||
}
|
||||
287
src/test/ShipModuleTest.cpp
Normal file
287
src/test/ShipModuleTest.cpp
Normal file
@@ -0,0 +1,287 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "BuildingType.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "GameConfig.h"
|
||||
#include "ItemType.h"
|
||||
#include "ModulesConfig.h"
|
||||
#include "Rotation.h"
|
||||
#include "Ship.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "Simulation.h"
|
||||
#include "Tick.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
static const ShipDef* findSchematic(const GameConfig& cfg, const std::string& id)
|
||||
{
|
||||
for (const ShipDef& def : cfg.ships.ships)
|
||||
{
|
||||
if (def.id == id)
|
||||
{
|
||||
return &def;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static const BuildingDef* findShipyardDef(const GameConfig& cfg)
|
||||
{
|
||||
for (const BuildingDef& def : cfg.buildings.buildings)
|
||||
{
|
||||
if (def.type == BuildingType::Shipyard)
|
||||
{
|
||||
return &def;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static EntityId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
|
||||
{
|
||||
return sim.buildings().placeImmediate(
|
||||
BuildingType::Shipyard,
|
||||
yardDef.surfaceMask,
|
||||
QPoint(0, 0),
|
||||
Rotation::East,
|
||||
100.0f, 100.0f);
|
||||
}
|
||||
|
||||
static void fillMaterials(Simulation& sim, EntityId yardId,
|
||||
const ShipDef& def,
|
||||
const ShipLayoutConfig& layout)
|
||||
{
|
||||
sim.buildings().forEachBuilding([&](Building& b) {
|
||||
if (b.id != yardId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (const RecipeIngredient& ing : def.schematic.materials)
|
||||
{
|
||||
b.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
|
||||
}
|
||||
for (const PlacedModule& pm : layout.placedModules)
|
||||
{
|
||||
for (const ModuleDef& modDef : sim.config().modules.modules)
|
||||
{
|
||||
if (modDef.id == pm.moduleId)
|
||||
{
|
||||
for (const RecipeIngredient& ing : modDef.materials)
|
||||
{
|
||||
b.inputBuffer.counts[ItemType{ing.item}] += ing.amount;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ship stat modifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.config(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
const double x = static_cast<double>(def->schematic.playerProductionLevel);
|
||||
const float expectedHp = static_cast<float>(def->health.hpFormula.evaluate(x));
|
||||
|
||||
const EntityId id = sim.ships().spawn("interceptor",
|
||||
def->schematic.playerProductionLevel,
|
||||
QVector2D(5.0f, 5.0f), false, std::nullopt);
|
||||
|
||||
const Ship* ship = sim.ships().findShip(id);
|
||||
REQUIRE(ship != nullptr);
|
||||
CHECK(ship->maxHp == Approx(expectedHp));
|
||||
}
|
||||
|
||||
TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.config(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
const double x = static_cast<double>(def->schematic.playerProductionLevel);
|
||||
const float baseHp = static_cast<float>(def->health.hpFormula.evaluate(x));
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
PlacedModule pm;
|
||||
pm.moduleId = "armor_plate";
|
||||
pm.position = QPoint(0, 0);
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
const EntityId id = sim.ships().spawn("interceptor",
|
||||
def->schematic.playerProductionLevel,
|
||||
QVector2D(5.0f, 5.0f), false, layout);
|
||||
|
||||
const Ship* ship = sim.ships().findShip(id);
|
||||
REQUIRE(ship != nullptr);
|
||||
// armor_plate has multiplied_hp_formula = "1.5"
|
||||
// final = base * (1 + (1.5 - 1)) + 0 = base * 1.5
|
||||
CHECK(ship->maxHp == Approx(baseHp * 1.5f));
|
||||
CHECK(ship->hp == ship->maxHp);
|
||||
}
|
||||
|
||||
TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.config(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
const double x = static_cast<double>(def->schematic.playerProductionLevel);
|
||||
const float baseRange = static_cast<float>(def->sensor.sensorRangeFormula.evaluate(x));
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
PlacedModule pm;
|
||||
pm.moduleId = "sensor_booster";
|
||||
pm.position = QPoint(0, 0);
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
const EntityId id = sim.ships().spawn("interceptor",
|
||||
def->schematic.playerProductionLevel,
|
||||
QVector2D(5.0f, 5.0f), false, layout);
|
||||
|
||||
const Ship* ship = sim.ships().findShip(id);
|
||||
REQUIRE(ship != nullptr);
|
||||
// sensor_booster has added_sensor_range_formula = "10"
|
||||
// final = base * 1.0 + 10 = base + 10
|
||||
CHECK(ship->sensorRange == Approx(baseRange + 10.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.config(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
|
||||
const double x = static_cast<double>(def->schematic.playerProductionLevel);
|
||||
const float baseHp = static_cast<float>(def->health.hpFormula.evaluate(x));
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
for (int i = 0; i < 2; ++i)
|
||||
{
|
||||
PlacedModule pm;
|
||||
pm.moduleId = "armor_plate";
|
||||
pm.position = QPoint(i * 2, 0);
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
}
|
||||
|
||||
const EntityId id = sim.ships().spawn("interceptor",
|
||||
def->schematic.playerProductionLevel,
|
||||
QVector2D(5.0f, 5.0f), false, layout);
|
||||
|
||||
const Ship* ship = sim.ships().findShip(id);
|
||||
REQUIRE(ship != nullptr);
|
||||
// Two armor_plates: each 1.5 multiplier
|
||||
// total_mult = 1 + (1.5 - 1) + (1.5 - 1) = 2.0
|
||||
// final = base * 2.0
|
||||
CHECK(ship->maxHp == Approx(baseHp * 2.0f));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shipyard module integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
|
||||
"[modules][shipyard]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
const BuildingDef* yardDef = findShipyardDef(sim.config());
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
const EntityId yardId = placeShipyard(sim, *yardDef);
|
||||
sim.buildings().setRecipe(yardId, "interceptor");
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
PlacedModule pm;
|
||||
pm.moduleId = "armor_plate";
|
||||
pm.position = QPoint(0, 0);
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
sim.buildings().setShipLayout(yardId, layout);
|
||||
|
||||
const Building* b = sim.buildings().findBuilding(yardId);
|
||||
REQUIRE(b != nullptr);
|
||||
// armor_plate needs 2 iron_ingot; interceptor needs 3 iron_ingot + 1 circuit_board
|
||||
// Total iron_ingot = 5, buffer cap = 2 * 5 = 10
|
||||
CHECK(b->inputBuffer.caps.at(ItemType{"iron_ingot"}) == 10);
|
||||
CHECK(b->inputBuffer.caps.at(ItemType{"circuit_board"}) == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
|
||||
"[modules][shipyard]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
const ShipDef* def = findSchematic(sim.config(), "interceptor");
|
||||
REQUIRE(def != nullptr);
|
||||
const BuildingDef* yardDef = findShipyardDef(sim.config());
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
const EntityId yardId = placeShipyard(sim, *yardDef);
|
||||
sim.buildings().setRecipe(yardId, "interceptor");
|
||||
|
||||
// Fill materials and tick to start production.
|
||||
ShipLayoutConfig emptyLayout;
|
||||
fillMaterials(sim, yardId, *def, emptyLayout);
|
||||
sim.tick();
|
||||
|
||||
const Building* b1 = sim.buildings().findBuilding(yardId);
|
||||
REQUIRE(b1 != nullptr);
|
||||
REQUIRE(b1->production.has_value());
|
||||
|
||||
// Now set a layout — should cancel production.
|
||||
ShipLayoutConfig layout;
|
||||
PlacedModule pm;
|
||||
pm.moduleId = "sensor_booster";
|
||||
pm.position = QPoint(0, 0);
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
sim.buildings().setShipLayout(yardId, layout);
|
||||
|
||||
const Building* b2 = sim.buildings().findBuilding(yardId);
|
||||
REQUIRE(b2 != nullptr);
|
||||
CHECK_FALSE(b2->production.has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
const BuildingDef* yardDef = findShipyardDef(sim.config());
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
const EntityId yardId = placeShipyard(sim, *yardDef);
|
||||
sim.buildings().setRecipe(yardId, "interceptor");
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
PlacedModule pm;
|
||||
pm.moduleId = "armor_plate";
|
||||
pm.position = QPoint(0, 0);
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
sim.buildings().setShipLayout(yardId, layout);
|
||||
|
||||
const Building* b1 = sim.buildings().findBuilding(yardId);
|
||||
REQUIRE(b1 != nullptr);
|
||||
REQUIRE(b1->shipLayout.has_value());
|
||||
|
||||
sim.buildings().setRecipe(yardId, "destroyer");
|
||||
|
||||
const Building* b2 = sim.buildings().findBuilding(yardId);
|
||||
REQUIRE(b2 != nullptr);
|
||||
CHECK_FALSE(b2->shipLayout.has_value());
|
||||
}
|
||||
@@ -77,7 +77,7 @@ TEST_CASE("ShipSystem: interceptor level 5 hp matches formula", "[ship]")
|
||||
REQUIRE(ship->maxHp == Approx(65.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("ShipSystem: interceptor level 0 speedPerTick matches formula / kTickRateHz", "[ship]")
|
||||
TEST_CASE("ShipSystem: interceptor level 0 maxSpeedPerTick matches formula / kTickRateHz", "[ship]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
EntityId nextId = 1;
|
||||
@@ -86,9 +86,9 @@ TEST_CASE("ShipSystem: interceptor level 0 speedPerTick matches formula / kTickR
|
||||
const EntityId id = ss.spawn("interceptor", 0, QVector2D(0.0f, 0.0f));
|
||||
const Ship* ship = ss.findShip(id);
|
||||
|
||||
// speed_formula = "200 + 5*x" at x=0 → 200; speedPerTick = 200/30
|
||||
// speed_formula = "200 + 5*x" at x=0 → 200; maxSpeedPerTick = 200/30
|
||||
const float expected = 200.0f / static_cast<float>(kTickRateHz);
|
||||
REQUIRE(ship->speedPerTick == Approx(expected));
|
||||
REQUIRE(ship->maxSpeedPerTick == Approx(expected));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,6 +8,8 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -20,5 +22,7 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -810,10 +810,7 @@ void GameWorldView::drawShips(QPainter& painter)
|
||||
if (it == m_visuals->ships.end()) { continue; }
|
||||
|
||||
const QPointF center = worldToWidget(ship.position);
|
||||
const QVector2D vel = ship.velocity;
|
||||
const QVector2D dir = (vel.length() > 0.0001f)
|
||||
? vel.normalized()
|
||||
: QVector2D(1.0f, 0.0f);
|
||||
const QVector2D dir(std::cos(ship.facing), std::sin(ship.facing));
|
||||
const QVector2D perp(-dir.y(), dir.x());
|
||||
|
||||
const float fwd = tilePx() * 0.45f;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "MainWindow.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCloseEvent>
|
||||
#include <QFile>
|
||||
#include <QHBoxLayout>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
@@ -9,10 +11,13 @@
|
||||
|
||||
#include "BlueprintPanel.h"
|
||||
#include "BuildButtonGrid.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "GameWorldView.h"
|
||||
#include "HeaderBar.h"
|
||||
#include "SelectedBuildingPanel.h"
|
||||
#include "ShipLayoutBlueprintSerializer.h"
|
||||
#include "ShipLayoutDialog.h"
|
||||
#include "Simulation.h"
|
||||
#include "Tick.h"
|
||||
#include "VisualsLoader.h"
|
||||
@@ -62,6 +67,9 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* p
|
||||
connect(m_gameWorldView, &GameWorldView::escapeMenuRequested,
|
||||
this, &MainWindow::onEscapeMenuRequested);
|
||||
|
||||
connect(m_selectedBuildingPanel, &SelectedBuildingPanel::layoutDialogRequested,
|
||||
this, &MainWindow::onLayoutDialogRequested);
|
||||
|
||||
// Signals: build grid → game world
|
||||
connect(m_buildButtonGrid, &BuildButtonGrid::buildingTypeSelected,
|
||||
m_gameWorldView, &GameWorldView::enterBuilderMode);
|
||||
@@ -106,6 +114,24 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* p
|
||||
m_gameWorldView->setFocus();
|
||||
}
|
||||
});
|
||||
|
||||
// Load layout blueprints from disk. Missing file is silently ignored.
|
||||
const QString bpPath = QCoreApplication::applicationDirPath() + "/ship_layouts.toml";
|
||||
QFile bpFile(bpPath);
|
||||
if (bpFile.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||
{
|
||||
try
|
||||
{
|
||||
m_layoutBlueprints = ShipLayoutBlueprintSerializer::deserialize(
|
||||
bpFile.readAll().toStdString());
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
QMessageBox::critical(this, "Load Error",
|
||||
QString("Failed to load ship_layouts.toml:\n%1").arg(e.what()));
|
||||
m_layoutBlueprints.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::resizeEvent(QResizeEvent* event)
|
||||
@@ -114,6 +140,23 @@ void MainWindow::resizeEvent(QResizeEvent* event)
|
||||
layoutPanels();
|
||||
}
|
||||
|
||||
void MainWindow::closeEvent(QCloseEvent* event)
|
||||
{
|
||||
const QString path = QCoreApplication::applicationDirPath() + "/ship_layouts.toml";
|
||||
QFile file(path);
|
||||
if (file.open(QIODevice::WriteOnly | QIODevice::Text))
|
||||
{
|
||||
try
|
||||
{
|
||||
const std::string content =
|
||||
ShipLayoutBlueprintSerializer::serialize(m_layoutBlueprints);
|
||||
file.write(QByteArray::fromStdString(content));
|
||||
}
|
||||
catch (...) {}
|
||||
}
|
||||
QWidget::closeEvent(event);
|
||||
}
|
||||
|
||||
void MainWindow::layoutPanels()
|
||||
{
|
||||
const int totalW = width();
|
||||
@@ -176,6 +219,34 @@ void MainWindow::onEscapeMenuRequested()
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onLayoutDialogRequested(EntityId shipyardId)
|
||||
{
|
||||
const double prevSpeed = m_gameWorldView->gameSpeed();
|
||||
m_gameWorldView->setGameSpeed(0.0);
|
||||
|
||||
const Building* b = m_sim->buildings().findBuilding(shipyardId);
|
||||
if (!b)
|
||||
{
|
||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||
return;
|
||||
}
|
||||
|
||||
ShipLayoutConfig currentLayout;
|
||||
if (b->shipLayout.has_value())
|
||||
{
|
||||
currentLayout = *b->shipLayout;
|
||||
}
|
||||
|
||||
ShipLayoutDialog dialog(&m_sim->config(), b->recipeId, currentLayout,
|
||||
m_layoutBlueprints, this);
|
||||
if (dialog.exec() == QDialog::Accepted && dialog.result().has_value())
|
||||
{
|
||||
m_sim->buildings().setShipLayout(shipyardId, *dialog.result());
|
||||
}
|
||||
|
||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||
}
|
||||
|
||||
void MainWindow::onGameOver()
|
||||
{
|
||||
const Tick tick = m_sim->currentTick();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
#include "EntityId.h"
|
||||
#include "ShipLayoutBlueprint.h"
|
||||
#include "Tick.h"
|
||||
#include "VisualsConfig.h"
|
||||
|
||||
@@ -13,6 +16,7 @@ class HeaderBar;
|
||||
class SelectedBuildingPanel;
|
||||
class BuildButtonGrid;
|
||||
class BlueprintPanel;
|
||||
class QCloseEvent;
|
||||
class QResizeEvent;
|
||||
|
||||
class MainWindow : public QWidget
|
||||
@@ -24,11 +28,13 @@ public:
|
||||
|
||||
protected:
|
||||
void resizeEvent(QResizeEvent* event) override;
|
||||
void closeEvent(QCloseEvent* event) override;
|
||||
|
||||
private slots:
|
||||
void onGameOver();
|
||||
void onStateUpdated(Tick tick, int blocks, double speed);
|
||||
void onEscapeMenuRequested();
|
||||
void onLayoutDialogRequested(EntityId shipyardId);
|
||||
|
||||
private:
|
||||
void layoutPanels();
|
||||
@@ -42,4 +48,6 @@ private:
|
||||
BuildButtonGrid* m_buildButtonGrid;
|
||||
BlueprintPanel* m_blueprintPanel;
|
||||
QWidget* m_bottomPanel;
|
||||
|
||||
std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
#include "BuildingSystem.h"
|
||||
#include "BuildingType.h"
|
||||
#include "ItemType.h"
|
||||
#include "ModulesConfig.h"
|
||||
#include "Rotation.h"
|
||||
#include "ShipLayoutPreview.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
namespace
|
||||
@@ -99,6 +101,8 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
|
||||
m_filterAList = new QListWidget(this);
|
||||
m_filterBLabel = new QLabel(this);
|
||||
m_filterBList = new QListWidget(this);
|
||||
m_layoutPreview = new ShipLayoutPreview(this);
|
||||
m_configureLayoutBtn = new QPushButton("Configure Layout", this);
|
||||
m_buffersLabel = new QLabel(this);
|
||||
m_buffersLabel->setWordWrap(true);
|
||||
|
||||
@@ -107,6 +111,8 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
|
||||
|
||||
m_layout->addWidget(m_titleLabel);
|
||||
m_layout->addWidget(m_recipeCombo);
|
||||
m_layout->addWidget(m_layoutPreview);
|
||||
m_layout->addWidget(m_configureLayoutBtn);
|
||||
m_layout->addWidget(m_clearBeltBtn);
|
||||
m_layout->addWidget(m_filterALabel);
|
||||
m_layout->addWidget(m_filterAList);
|
||||
@@ -118,6 +124,12 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
|
||||
this, &SelectedBuildingPanel::onRecipeChanged);
|
||||
connect(m_clearBeltBtn, &QPushButton::clicked,
|
||||
this, &SelectedBuildingPanel::onClearBelt);
|
||||
connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() {
|
||||
if (m_singleId != kInvalidEntityId)
|
||||
{
|
||||
emit layoutDialogRequested(m_singleId);
|
||||
}
|
||||
});
|
||||
connect(m_filterAList, &QListWidget::itemChanged,
|
||||
this, &SelectedBuildingPanel::onSplitterFilterChanged);
|
||||
connect(m_filterBList, &QListWidget::itemChanged,
|
||||
@@ -153,6 +165,8 @@ void SelectedBuildingPanel::buildEmpty()
|
||||
m_singleId = kInvalidEntityId;
|
||||
m_titleLabel->hide();
|
||||
m_recipeCombo->hide();
|
||||
m_layoutPreview->hide();
|
||||
m_configureLayoutBtn->hide();
|
||||
m_clearBeltBtn->hide();
|
||||
m_filterALabel->hide();
|
||||
m_filterAList->hide();
|
||||
@@ -248,10 +262,39 @@ void SelectedBuildingPanel::buildSingle(EntityId id)
|
||||
m_recipeCombo->setCurrentIndex(currentIdx >= 0 ? currentIdx : 0);
|
||||
m_recipeCombo->blockSignals(false);
|
||||
m_recipeCombo->show();
|
||||
|
||||
if (b->type == BuildingType::Shipyard && !b->recipeId.empty())
|
||||
{
|
||||
const ShipDef* sDef = findShipDef(b->recipeId);
|
||||
if (sDef && !sDef->layout.empty())
|
||||
{
|
||||
ShipLayoutConfig layout;
|
||||
if (b->shipLayout.has_value())
|
||||
{
|
||||
layout = *b->shipLayout;
|
||||
}
|
||||
m_layoutPreview->setShipAndLayout(
|
||||
sDef->layout, layout, &m_config->modules.modules);
|
||||
m_layoutPreview->show();
|
||||
m_configureLayoutBtn->show();
|
||||
}
|
||||
else
|
||||
{
|
||||
m_layoutPreview->hide();
|
||||
m_configureLayoutBtn->hide();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
m_layoutPreview->hide();
|
||||
m_configureLayoutBtn->hide();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
m_recipeCombo->hide();
|
||||
m_layoutPreview->hide();
|
||||
m_configureLayoutBtn->hide();
|
||||
}
|
||||
|
||||
if (isBeltLike(b->type))
|
||||
@@ -307,6 +350,26 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
|
||||
{
|
||||
if (mat.item == entry.first.id) { perCycle = mat.amount; break; }
|
||||
}
|
||||
if (b->shipLayout.has_value())
|
||||
{
|
||||
for (const PlacedModule& pm : b->shipLayout->placedModules)
|
||||
{
|
||||
for (const ModuleDef& modDef : m_config->modules.modules)
|
||||
{
|
||||
if (modDef.id == pm.moduleId)
|
||||
{
|
||||
for (const RecipeIngredient& ing : modDef.materials)
|
||||
{
|
||||
if (ing.item == entry.first.id)
|
||||
{
|
||||
perCycle += ing.amount;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bufText += QString::fromStdString(entry.first.id)
|
||||
+ ": " + QString::number(entry.second);
|
||||
@@ -354,10 +417,25 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
|
||||
|
||||
if (isProductionBuilding(b->type) && (recipe || shipDef))
|
||||
{
|
||||
const double durationSeconds = recipe
|
||||
double durationSeconds = recipe
|
||||
? recipe->durationSeconds
|
||||
: shipDef->schematic.productionTimeSeconds;
|
||||
|
||||
if (shipDef && b->shipLayout.has_value())
|
||||
{
|
||||
for (const PlacedModule& pm : b->shipLayout->placedModules)
|
||||
{
|
||||
for (const ModuleDef& modDef : m_config->modules.modules)
|
||||
{
|
||||
if (modDef.id == pm.moduleId)
|
||||
{
|
||||
durationSeconds += modDef.productionTimeSeconds;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bufText += QString("Cycle: %1 s\n").arg(durationSeconds, 0, 'f', 1);
|
||||
|
||||
if (b->production.has_value())
|
||||
@@ -377,6 +455,17 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
|
||||
}
|
||||
|
||||
m_buffersLabel->setText(bufText);
|
||||
|
||||
if (b->type == BuildingType::Shipyard && shipDef && !shipDef->layout.empty())
|
||||
{
|
||||
ShipLayoutConfig layout;
|
||||
if (b->shipLayout.has_value())
|
||||
{
|
||||
layout = *b->shipLayout;
|
||||
}
|
||||
m_layoutPreview->setShipAndLayout(
|
||||
shipDef->layout, layout, &m_config->modules.modules);
|
||||
}
|
||||
}
|
||||
|
||||
const RecipeDef* SelectedBuildingPanel::findRecipe(const Building* b) const
|
||||
@@ -481,6 +570,7 @@ void SelectedBuildingPanel::onRecipeChanged(int comboIndex)
|
||||
}
|
||||
const QString recipeId = m_recipeCombo->itemData(comboIndex).toString();
|
||||
m_sim->buildings().setRecipe(m_singleId, recipeId.toStdString());
|
||||
rebuild();
|
||||
}
|
||||
|
||||
void SelectedBuildingPanel::buildSplitterFilters(QPoint splitterTile)
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
#include "EntityId.h"
|
||||
#include "GameConfig.h"
|
||||
#include "RecipesConfig.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "ShipsConfig.h"
|
||||
#include "Tick.h"
|
||||
|
||||
class Simulation;
|
||||
class ShipLayoutPreview;
|
||||
class QLabel;
|
||||
class QComboBox;
|
||||
class QListWidget;
|
||||
@@ -28,6 +30,9 @@ public:
|
||||
SelectedBuildingPanel(Simulation* sim, const GameConfig* config,
|
||||
QWidget* parent = nullptr);
|
||||
|
||||
signals:
|
||||
void layoutDialogRequested(EntityId shipyardId);
|
||||
|
||||
public slots:
|
||||
void onSelectionChanged(const std::vector<EntityId>& ids);
|
||||
void onStateUpdated(Tick tick, int blocks, double speed);
|
||||
@@ -63,6 +68,9 @@ private:
|
||||
QListWidget* m_filterBList;
|
||||
QLabel* m_buffersLabel;
|
||||
|
||||
ShipLayoutPreview* m_layoutPreview;
|
||||
QPushButton* m_configureLayoutBtn;
|
||||
|
||||
EntityId m_singleId;
|
||||
QPoint m_splitterTile;
|
||||
std::string m_currentRecipeId;
|
||||
|
||||
798
src/ui/ShipLayoutDialog.cpp
Normal file
798
src/ui/ShipLayoutDialog.cpp
Normal file
@@ -0,0 +1,798 @@
|
||||
#include "ShipLayoutDialog.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <functional>
|
||||
|
||||
#include <QGridLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QInputDialog>
|
||||
#include <QKeyEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QSignalMapper>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
const int kCellSize = 32;
|
||||
|
||||
QString displayName(const std::string& id)
|
||||
{
|
||||
QString result;
|
||||
bool nextUpper = true;
|
||||
for (char c : id)
|
||||
{
|
||||
if (c == '_')
|
||||
{
|
||||
result += ' ';
|
||||
nextUpper = true;
|
||||
}
|
||||
else if (nextUpper)
|
||||
{
|
||||
result += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
nextUpper = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
result += c;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<std::string> rotateMaskCW(const std::vector<std::string>& grid)
|
||||
{
|
||||
if (grid.empty())
|
||||
{
|
||||
return {};
|
||||
}
|
||||
const int srcH = static_cast<int>(grid.size());
|
||||
int srcW = 0;
|
||||
for (const std::string& row : grid)
|
||||
{
|
||||
const int w = static_cast<int>(row.size());
|
||||
if (w > srcW)
|
||||
{
|
||||
srcW = w;
|
||||
}
|
||||
}
|
||||
const int dstW = srcH;
|
||||
const int dstH = srcW;
|
||||
std::vector<std::string> dst(dstH, std::string(dstW, 'X'));
|
||||
for (int row = 0; row < srcH; ++row)
|
||||
{
|
||||
for (int col = 0; col < srcW; ++col)
|
||||
{
|
||||
const char ch = (col < static_cast<int>(grid[row].size()))
|
||||
? grid[row][col]
|
||||
: 'X';
|
||||
dst[col][srcH - 1 - row] = ch;
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid rendering widget (nested inside dialog)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class LayoutGridWidget : public QWidget
|
||||
{
|
||||
public:
|
||||
LayoutGridWidget(ShipLayoutDialog* dialog, QWidget* parent = nullptr)
|
||||
: QWidget(parent)
|
||||
, m_dialog(dialog)
|
||||
{
|
||||
setMouseTracking(true);
|
||||
}
|
||||
|
||||
void setGridData(const std::vector<std::vector<ShipLayoutDialog::CellInfo>>* grid,
|
||||
int rows, int cols,
|
||||
const std::vector<PlacedModule>* placed,
|
||||
const GameConfig* config)
|
||||
{
|
||||
m_grid = grid;
|
||||
m_rows = rows;
|
||||
m_cols = cols;
|
||||
m_placed = placed;
|
||||
m_config = config;
|
||||
setFixedSize(cols * kCellSize + 1, rows * kCellSize + 1);
|
||||
}
|
||||
|
||||
void setGhostData(int moduleIndex, Rotation rotation)
|
||||
{
|
||||
m_ghostModuleIdx = moduleIndex;
|
||||
m_ghostRotation = rotation;
|
||||
}
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent* /*event*/) override
|
||||
{
|
||||
if (!m_grid || m_rows == 0 || m_cols == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing, false);
|
||||
|
||||
for (int r = 0; r < m_rows; ++r)
|
||||
{
|
||||
for (int c = 0; c < m_cols; ++c)
|
||||
{
|
||||
const QRect cellRect(c * kCellSize, r * kCellSize, kCellSize, kCellSize);
|
||||
const ShipLayoutDialog::CellInfo& cell = (*m_grid)[r][c];
|
||||
|
||||
if (!cell.buildable)
|
||||
{
|
||||
painter.fillRect(cellRect, QColor(30, 30, 30));
|
||||
}
|
||||
else if (cell.moduleIndex >= 0)
|
||||
{
|
||||
const PlacedModule& pm = (*m_placed)[cell.moduleIndex];
|
||||
const ModuleDef* def = findModule(pm.moduleId);
|
||||
QColor color(Qt::gray);
|
||||
QString glyph;
|
||||
if (def)
|
||||
{
|
||||
color = QColor(QString::fromStdString(def->fillColor));
|
||||
glyph = QString::fromStdString(def->glyph);
|
||||
}
|
||||
painter.fillRect(cellRect, color);
|
||||
if (!glyph.isEmpty())
|
||||
{
|
||||
painter.setPen(Qt::white);
|
||||
painter.drawText(cellRect, Qt::AlignCenter, glyph);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
painter.fillRect(cellRect, QColor(240, 240, 240));
|
||||
}
|
||||
|
||||
painter.setPen(QColor(100, 100, 100));
|
||||
painter.drawRect(cellRect);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw ghost
|
||||
if (m_ghostModuleIdx >= 0 && m_hoverCell.x() >= 0 && m_config)
|
||||
{
|
||||
const ModuleDef& def = m_config->modules.modules[m_ghostModuleIdx];
|
||||
const std::vector<std::string> mask = rotateMask(def.surfaceMask, m_ghostRotation);
|
||||
QColor ghostColor(QString::fromStdString(def.fillColor));
|
||||
ghostColor.setAlpha(100);
|
||||
|
||||
for (int mr = 0; mr < static_cast<int>(mask.size()); ++mr)
|
||||
{
|
||||
for (int mc = 0; mc < static_cast<int>(mask[mr].size()); ++mc)
|
||||
{
|
||||
if (mask[mr][mc] != 'O')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const int gr = m_hoverCell.y() + mr;
|
||||
const int gc = m_hoverCell.x() + mc;
|
||||
if (gr >= 0 && gr < m_rows && gc >= 0 && gc < m_cols)
|
||||
{
|
||||
const QRect cellRect(gc * kCellSize, gr * kCellSize,
|
||||
kCellSize, kCellSize);
|
||||
painter.fillRect(cellRect, ghostColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void mouseMoveEvent(QMouseEvent* event) override
|
||||
{
|
||||
const QPoint pos = event->pos();
|
||||
const QPoint cell(pos.x() / kCellSize, pos.y() / kCellSize);
|
||||
if (cell != m_hoverCell)
|
||||
{
|
||||
m_hoverCell = cell;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
void mousePressEvent(QMouseEvent* event) override
|
||||
{
|
||||
if (event->button() != Qt::LeftButton)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const QPoint pos = event->pos();
|
||||
const QPoint cell(pos.x() / kCellSize, pos.y() / kCellSize);
|
||||
emit m_dialog->gridCellClicked(cell);
|
||||
}
|
||||
|
||||
void leaveEvent(QEvent* /*event*/) override
|
||||
{
|
||||
m_hoverCell = QPoint(-1, -1);
|
||||
update();
|
||||
}
|
||||
|
||||
private:
|
||||
const ModuleDef* findModule(const std::string& id) const
|
||||
{
|
||||
if (!m_config)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
for (const ModuleDef& def : m_config->modules.modules)
|
||||
{
|
||||
if (def.id == id)
|
||||
{
|
||||
return &def;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::vector<std::string> rotateMask(const std::vector<std::string>& mask,
|
||||
Rotation rotation) const
|
||||
{
|
||||
int steps = 0;
|
||||
switch (rotation)
|
||||
{
|
||||
case Rotation::East: steps = 0; break;
|
||||
case Rotation::South: steps = 1; break;
|
||||
case Rotation::West: steps = 2; break;
|
||||
case Rotation::North: steps = 3; break;
|
||||
}
|
||||
std::vector<std::string> result = mask;
|
||||
for (int i = 0; i < steps; ++i)
|
||||
{
|
||||
result = rotateMaskCW(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
ShipLayoutDialog* m_dialog;
|
||||
const std::vector<std::vector<ShipLayoutDialog::CellInfo>>* m_grid = nullptr;
|
||||
int m_rows = 0;
|
||||
int m_cols = 0;
|
||||
const std::vector<PlacedModule>* m_placed = nullptr;
|
||||
const GameConfig* m_config = nullptr;
|
||||
int m_ghostModuleIdx = -2;
|
||||
Rotation m_ghostRotation = Rotation::East;
|
||||
QPoint m_hoverCell = QPoint(-1, -1);
|
||||
};
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blueprint panel (third column of the layout dialog)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ShipLayoutBlueprintPanel : public QWidget
|
||||
{
|
||||
public:
|
||||
ShipLayoutBlueprintPanel(
|
||||
std::vector<ShipLayoutBlueprint>& allBlueprints,
|
||||
const std::string& shipType,
|
||||
std::function<std::vector<PlacedModule>()> getModules,
|
||||
std::function<void(const std::vector<PlacedModule>&)> loadModules,
|
||||
QWidget* parent = nullptr)
|
||||
: QWidget(parent)
|
||||
, m_allBlueprints(allBlueprints)
|
||||
, m_shipType(shipType)
|
||||
, m_getModules(std::move(getModules))
|
||||
, m_loadModules(std::move(loadModules))
|
||||
{
|
||||
QVBoxLayout* layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(4, 4, 4, 4);
|
||||
layout->setSpacing(4);
|
||||
|
||||
QPushButton* createBtn = new QPushButton("Create Blueprint", this);
|
||||
createBtn->setFixedHeight(36);
|
||||
layout->addWidget(createBtn);
|
||||
|
||||
m_scrollArea = new QScrollArea(this);
|
||||
m_scrollArea->setWidgetResizable(true);
|
||||
m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
|
||||
m_scrollContainer = new QWidget(m_scrollArea);
|
||||
m_listLayout = new QVBoxLayout(m_scrollContainer);
|
||||
m_listLayout->setContentsMargins(0, 0, 0, 0);
|
||||
m_listLayout->setSpacing(2);
|
||||
m_listLayout->addStretch();
|
||||
m_scrollArea->setWidget(m_scrollContainer);
|
||||
layout->addWidget(m_scrollArea, 1);
|
||||
|
||||
connect(createBtn, &QPushButton::clicked, this, [this]() {
|
||||
bool ok = false;
|
||||
const QString name = QInputDialog::getText(
|
||||
this, "Create Blueprint", "Blueprint name:",
|
||||
QLineEdit::Normal, QString(), &ok);
|
||||
if (!ok || name.trimmed().isEmpty()) { return; }
|
||||
|
||||
ShipLayoutBlueprint bp;
|
||||
bp.name = name.trimmed();
|
||||
bp.shipType = m_shipType;
|
||||
bp.modules = m_getModules();
|
||||
m_allBlueprints.push_back(std::move(bp));
|
||||
rebuildList();
|
||||
});
|
||||
|
||||
rebuildList();
|
||||
}
|
||||
|
||||
private:
|
||||
void rebuildList()
|
||||
{
|
||||
// Remove all items except the trailing stretch.
|
||||
while (m_listLayout->count() > 1)
|
||||
{
|
||||
QLayoutItem* item = m_listLayout->takeAt(0);
|
||||
if (item->widget()) { delete item->widget(); }
|
||||
delete item;
|
||||
}
|
||||
|
||||
for (std::size_t i = 0; i < m_allBlueprints.size(); ++i)
|
||||
{
|
||||
const ShipLayoutBlueprint& bp = m_allBlueprints[i];
|
||||
if (bp.shipType != m_shipType) { continue; }
|
||||
|
||||
QWidget* row = new QWidget(m_scrollContainer);
|
||||
QHBoxLayout* rowLayout = new QHBoxLayout(row);
|
||||
rowLayout->setContentsMargins(0, 0, 0, 0);
|
||||
rowLayout->setSpacing(2);
|
||||
|
||||
QPushButton* nameBtn = new QPushButton(bp.name, row);
|
||||
nameBtn->setFixedHeight(36);
|
||||
|
||||
QPushButton* delBtn = new QPushButton("\xc3\x97", row);
|
||||
delBtn->setFixedWidth(28);
|
||||
delBtn->setFixedHeight(36);
|
||||
|
||||
rowLayout->addWidget(nameBtn, 1);
|
||||
rowLayout->addWidget(delBtn, 0);
|
||||
m_listLayout->insertWidget(m_listLayout->count() - 1, row);
|
||||
|
||||
const std::vector<PlacedModule> modules = bp.modules;
|
||||
connect(nameBtn, &QPushButton::clicked, this, [this, modules]() {
|
||||
m_loadModules(modules);
|
||||
});
|
||||
|
||||
connect(delBtn, &QPushButton::clicked, this, [this, i]() {
|
||||
m_allBlueprints.erase(m_allBlueprints.begin()
|
||||
+ static_cast<std::ptrdiff_t>(i));
|
||||
rebuildList();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<ShipLayoutBlueprint>& m_allBlueprints;
|
||||
std::string m_shipType;
|
||||
std::function<std::vector<PlacedModule>()> m_getModules;
|
||||
std::function<void(const std::vector<PlacedModule>&)> m_loadModules;
|
||||
QScrollArea* m_scrollArea = nullptr;
|
||||
QWidget* m_scrollContainer = nullptr;
|
||||
QVBoxLayout* m_listLayout = nullptr;
|
||||
};
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ShipLayoutDialog implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
|
||||
const std::string& shipId,
|
||||
const ShipLayoutConfig& currentLayout,
|
||||
std::vector<ShipLayoutBlueprint>& allBlueprints,
|
||||
QWidget* parent)
|
||||
: QDialog(parent)
|
||||
, m_config(config)
|
||||
, m_shipId(shipId)
|
||||
, m_rows(0)
|
||||
, m_cols(0)
|
||||
, m_placedModules(currentLayout.placedModules)
|
||||
, m_activeModuleIndex(-2)
|
||||
, m_currentRotation(Rotation::East)
|
||||
, m_removeButton(nullptr)
|
||||
, m_gridWidget(nullptr)
|
||||
{
|
||||
setWindowTitle("Configure Ship Layout");
|
||||
setModal(true);
|
||||
|
||||
// Find the ship's layout grid.
|
||||
for (const ShipDef& def : config->ships.ships)
|
||||
{
|
||||
if (def.id == shipId)
|
||||
{
|
||||
m_shipLayout = def.layout;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
m_rows = static_cast<int>(m_shipLayout.size());
|
||||
m_cols = 0;
|
||||
for (const std::string& row : m_shipLayout)
|
||||
{
|
||||
const int w = static_cast<int>(row.size());
|
||||
if (w > m_cols)
|
||||
{
|
||||
m_cols = w;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize grid.
|
||||
m_grid.assign(m_rows, std::vector<CellInfo>(m_cols, {false, -1}));
|
||||
for (int r = 0; r < m_rows; ++r)
|
||||
{
|
||||
for (int c = 0; c < static_cast<int>(m_shipLayout[r].size()); ++c)
|
||||
{
|
||||
if (m_shipLayout[r][c] == 'O')
|
||||
{
|
||||
m_grid[r][c].buildable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
rebuildOccupancy();
|
||||
|
||||
// --- UI layout ---
|
||||
QHBoxLayout* mainLayout = new QHBoxLayout(this);
|
||||
|
||||
// Left: grid widget.
|
||||
LayoutGridWidget* gridW = new LayoutGridWidget(this, this);
|
||||
gridW->setGridData(&m_grid, m_rows, m_cols, &m_placedModules, m_config);
|
||||
gridW->setGhostData(m_activeModuleIndex, m_currentRotation);
|
||||
m_gridWidget = gridW;
|
||||
mainLayout->addWidget(m_gridWidget);
|
||||
|
||||
// Right: module buttons + confirm/cancel.
|
||||
QVBoxLayout* rightLayout = new QVBoxLayout();
|
||||
|
||||
QGridLayout* buttonGrid = new QGridLayout();
|
||||
buttonGrid->setSpacing(4);
|
||||
|
||||
QSignalMapper* mapper = new QSignalMapper(this);
|
||||
int col = 0;
|
||||
int row = 0;
|
||||
const int kCols = 2;
|
||||
|
||||
for (int i = 0; i < static_cast<int>(config->modules.modules.size()); ++i)
|
||||
{
|
||||
const ModuleDef& def = config->modules.modules[i];
|
||||
const QString label = displayName(def.id)
|
||||
+ "\n" + QString::fromStdString(def.glyph);
|
||||
QPushButton* btn = new QPushButton(label, this);
|
||||
btn->setCheckable(true);
|
||||
btn->setFixedHeight(48);
|
||||
buttonGrid->addWidget(btn, row, col);
|
||||
m_moduleButtons.push_back(btn);
|
||||
|
||||
mapper->setMapping(btn, i);
|
||||
connect(btn, &QPushButton::clicked, mapper, qOverload<>(&QSignalMapper::map));
|
||||
|
||||
++col;
|
||||
if (col >= kCols)
|
||||
{
|
||||
col = 0;
|
||||
++row;
|
||||
}
|
||||
}
|
||||
connect(mapper, qOverload<int>(&QSignalMapper::mapped),
|
||||
this, &ShipLayoutDialog::onModuleButtonClicked);
|
||||
|
||||
// Remove button.
|
||||
m_removeButton = new QPushButton("Remove", this);
|
||||
m_removeButton->setCheckable(true);
|
||||
m_removeButton->setFixedHeight(48);
|
||||
if (col > 0)
|
||||
{
|
||||
++row;
|
||||
}
|
||||
buttonGrid->addWidget(m_removeButton, row, 0, 1, kCols);
|
||||
connect(m_removeButton, &QPushButton::clicked, this, [this]() {
|
||||
if (m_activeModuleIndex == -1)
|
||||
{
|
||||
m_activeModuleIndex = -2;
|
||||
m_removeButton->setChecked(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (QPushButton* btn : m_moduleButtons)
|
||||
{
|
||||
btn->setChecked(false);
|
||||
}
|
||||
m_activeModuleIndex = -1;
|
||||
m_removeButton->setChecked(true);
|
||||
}
|
||||
updateGridWidget();
|
||||
});
|
||||
|
||||
rightLayout->addLayout(buttonGrid);
|
||||
rightLayout->addStretch();
|
||||
|
||||
// Confirm / Cancel buttons.
|
||||
QHBoxLayout* bottomBar = new QHBoxLayout();
|
||||
QPushButton* confirmBtn = new QPushButton("Confirm", this);
|
||||
QPushButton* cancelBtn = new QPushButton("Cancel", this);
|
||||
bottomBar->addWidget(confirmBtn);
|
||||
bottomBar->addWidget(cancelBtn);
|
||||
rightLayout->addLayout(bottomBar);
|
||||
|
||||
connect(confirmBtn, &QPushButton::clicked, this, &ShipLayoutDialog::onConfirm);
|
||||
connect(cancelBtn, &QPushButton::clicked, this, &ShipLayoutDialog::onCancel);
|
||||
|
||||
mainLayout->addLayout(rightLayout);
|
||||
|
||||
// Right: blueprint panel (third column).
|
||||
ShipLayoutBlueprintPanel* bpPanel = new ShipLayoutBlueprintPanel(
|
||||
allBlueprints,
|
||||
m_shipId,
|
||||
[this]() { return m_placedModules; },
|
||||
[this](const std::vector<PlacedModule>& mods) { loadLayoutBlueprint(mods); },
|
||||
this);
|
||||
mainLayout->addWidget(bpPanel);
|
||||
|
||||
// Grid click handler.
|
||||
connect(this, &ShipLayoutDialog::gridCellClicked, this, [this](QPoint cell) {
|
||||
if (m_activeModuleIndex == -2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_activeModuleIndex == -1)
|
||||
{
|
||||
// Remove mode: find and remove module at cell.
|
||||
if (cell.y() >= 0 && cell.y() < m_rows && cell.x() >= 0 && cell.x() < m_cols)
|
||||
{
|
||||
const int idx = m_grid[cell.y()][cell.x()].moduleIndex;
|
||||
if (idx >= 0)
|
||||
{
|
||||
m_placedModules.erase(m_placedModules.begin() + idx);
|
||||
rebuildOccupancy();
|
||||
updateGridWidget();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Place module.
|
||||
const ModuleDef& def = m_config->modules.modules[m_activeModuleIndex];
|
||||
if (canPlaceModule(def, cell, m_currentRotation))
|
||||
{
|
||||
PlacedModule pm;
|
||||
pm.moduleId = def.id;
|
||||
pm.position = cell;
|
||||
pm.rotation = m_currentRotation;
|
||||
m_placedModules.push_back(pm);
|
||||
rebuildOccupancy();
|
||||
updateGridWidget();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
std::optional<ShipLayoutConfig> ShipLayoutDialog::result() const
|
||||
{
|
||||
return m_result;
|
||||
}
|
||||
|
||||
void ShipLayoutDialog::keyPressEvent(QKeyEvent* event)
|
||||
{
|
||||
if (event->key() == Qt::Key_Q)
|
||||
{
|
||||
// Rotate CCW = 3 CW steps.
|
||||
switch (m_currentRotation)
|
||||
{
|
||||
case Rotation::East: m_currentRotation = Rotation::North; break;
|
||||
case Rotation::North: m_currentRotation = Rotation::West; break;
|
||||
case Rotation::West: m_currentRotation = Rotation::South; break;
|
||||
case Rotation::South: m_currentRotation = Rotation::East; break;
|
||||
}
|
||||
updateGridWidget();
|
||||
}
|
||||
else if (event->key() == Qt::Key_E)
|
||||
{
|
||||
// Rotate CW.
|
||||
switch (m_currentRotation)
|
||||
{
|
||||
case Rotation::East: m_currentRotation = Rotation::South; break;
|
||||
case Rotation::South: m_currentRotation = Rotation::West; break;
|
||||
case Rotation::West: m_currentRotation = Rotation::North; break;
|
||||
case Rotation::North: m_currentRotation = Rotation::East; break;
|
||||
}
|
||||
updateGridWidget();
|
||||
}
|
||||
else
|
||||
{
|
||||
QDialog::keyPressEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
void ShipLayoutDialog::onModuleButtonClicked(int index)
|
||||
{
|
||||
if (m_activeModuleIndex == index)
|
||||
{
|
||||
m_moduleButtons[index]->setChecked(false);
|
||||
m_activeModuleIndex = -2;
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < static_cast<int>(m_moduleButtons.size()); ++i)
|
||||
{
|
||||
m_moduleButtons[i]->setChecked(i == index);
|
||||
}
|
||||
m_removeButton->setChecked(false);
|
||||
m_activeModuleIndex = index;
|
||||
}
|
||||
updateGridWidget();
|
||||
}
|
||||
|
||||
void ShipLayoutDialog::onConfirm()
|
||||
{
|
||||
ShipLayoutConfig layout;
|
||||
layout.placedModules = m_placedModules;
|
||||
m_result = layout;
|
||||
accept();
|
||||
}
|
||||
|
||||
void ShipLayoutDialog::onCancel()
|
||||
{
|
||||
m_result = std::nullopt;
|
||||
reject();
|
||||
}
|
||||
|
||||
void ShipLayoutDialog::rebuildOccupancy()
|
||||
{
|
||||
for (int r = 0; r < m_rows; ++r)
|
||||
{
|
||||
for (int c = 0; c < m_cols; ++c)
|
||||
{
|
||||
m_grid[r][c].moduleIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < static_cast<int>(m_placedModules.size()); ++i)
|
||||
{
|
||||
const PlacedModule& pm = m_placedModules[i];
|
||||
const ModuleDef* def = nullptr;
|
||||
for (const ModuleDef& d : m_config->modules.modules)
|
||||
{
|
||||
if (d.id == pm.moduleId) { def = &d; break; }
|
||||
}
|
||||
if (!def)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const std::vector<std::string> mask = rotatedMask(*def, pm.rotation);
|
||||
for (int mr = 0; mr < static_cast<int>(mask.size()); ++mr)
|
||||
{
|
||||
for (int mc = 0; mc < static_cast<int>(mask[mr].size()); ++mc)
|
||||
{
|
||||
if (mask[mr][mc] != 'O')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const int gr = pm.position.y() + mr;
|
||||
const int gc = pm.position.x() + mc;
|
||||
if (gr >= 0 && gr < m_rows && gc >= 0 && gc < m_cols)
|
||||
{
|
||||
m_grid[gr][gc].moduleIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ShipLayoutDialog::updateGridWidget()
|
||||
{
|
||||
LayoutGridWidget* gridW = static_cast<LayoutGridWidget*>(m_gridWidget);
|
||||
gridW->setGhostData(m_activeModuleIndex, m_currentRotation);
|
||||
gridW->update();
|
||||
}
|
||||
|
||||
bool ShipLayoutDialog::canPlaceModule(const ModuleDef& def, QPoint position,
|
||||
Rotation rotation) const
|
||||
{
|
||||
const std::vector<std::string> mask = rotatedMask(def, rotation);
|
||||
for (int mr = 0; mr < static_cast<int>(mask.size()); ++mr)
|
||||
{
|
||||
for (int mc = 0; mc < static_cast<int>(mask[mr].size()); ++mc)
|
||||
{
|
||||
if (mask[mr][mc] != 'O')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const int gr = position.y() + mr;
|
||||
const int gc = position.x() + mc;
|
||||
if (gr < 0 || gr >= m_rows || gc < 0 || gc >= m_cols)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!m_grid[gr][gc].buildable)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (m_grid[gr][gc].moduleIndex >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<std::string> ShipLayoutDialog::rotatedMask(const ModuleDef& def,
|
||||
Rotation rotation) const
|
||||
{
|
||||
int steps = 0;
|
||||
switch (rotation)
|
||||
{
|
||||
case Rotation::East: steps = 0; break;
|
||||
case Rotation::South: steps = 1; break;
|
||||
case Rotation::West: steps = 2; break;
|
||||
case Rotation::North: steps = 3; break;
|
||||
}
|
||||
std::vector<std::string> result = def.surfaceMask;
|
||||
for (int i = 0; i < steps; ++i)
|
||||
{
|
||||
result = rotateMaskCW(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void ShipLayoutDialog::loadLayoutBlueprint(const std::vector<PlacedModule>& modules)
|
||||
{
|
||||
m_placedModules.clear();
|
||||
|
||||
// Build a temporary occupancy grid to detect overlaps within the blueprint.
|
||||
std::vector<std::vector<bool>> occupied(m_rows, std::vector<bool>(m_cols, false));
|
||||
|
||||
for (const PlacedModule& pm : modules)
|
||||
{
|
||||
// Validate module type exists.
|
||||
const ModuleDef* def = nullptr;
|
||||
for (const ModuleDef& d : m_config->modules.modules)
|
||||
{
|
||||
if (d.id == pm.moduleId) { def = &d; break; }
|
||||
}
|
||||
if (!def) { continue; }
|
||||
|
||||
const std::vector<std::string> mask = rotatedMask(*def, pm.rotation);
|
||||
bool valid = true;
|
||||
|
||||
for (int mr = 0; mr < static_cast<int>(mask.size()) && valid; ++mr)
|
||||
{
|
||||
for (int mc = 0; mc < static_cast<int>(mask[mr].size()) && valid; ++mc)
|
||||
{
|
||||
if (mask[mr][mc] != 'O') { continue; }
|
||||
const int gr = pm.position.y() + mr;
|
||||
const int gc = pm.position.x() + mc;
|
||||
if (gr < 0 || gr >= m_rows || gc < 0 || gc >= m_cols) { valid = false; break; }
|
||||
if (!m_grid[gr][gc].buildable) { valid = false; break; }
|
||||
if (occupied[gr][gc]) { valid = false; break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (!valid) { continue; }
|
||||
|
||||
// Mark cells as occupied.
|
||||
for (int mr = 0; mr < static_cast<int>(mask.size()); ++mr)
|
||||
{
|
||||
for (int mc = 0; mc < static_cast<int>(mask[mr].size()); ++mc)
|
||||
{
|
||||
if (mask[mr][mc] != 'O') { continue; }
|
||||
const int gr = pm.position.y() + mr;
|
||||
const int gc = pm.position.x() + mc;
|
||||
if (gr >= 0 && gr < m_rows && gc >= 0 && gc < m_cols)
|
||||
{
|
||||
occupied[gr][gc] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m_placedModules.push_back(pm);
|
||||
}
|
||||
|
||||
rebuildOccupancy();
|
||||
updateGridWidget();
|
||||
}
|
||||
73
src/ui/ShipLayoutDialog.h
Normal file
73
src/ui/ShipLayoutDialog.h
Normal file
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QDialog>
|
||||
#include <QPoint>
|
||||
|
||||
#include "GameConfig.h"
|
||||
#include "Rotation.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "ShipLayoutBlueprint.h"
|
||||
|
||||
class QPushButton;
|
||||
|
||||
class ShipLayoutDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ShipLayoutDialog(const GameConfig* config,
|
||||
const std::string& shipId,
|
||||
const ShipLayoutConfig& currentLayout,
|
||||
std::vector<ShipLayoutBlueprint>& allBlueprints,
|
||||
QWidget* parent = nullptr);
|
||||
|
||||
std::optional<ShipLayoutConfig> result() const;
|
||||
|
||||
protected:
|
||||
void keyPressEvent(QKeyEvent* event) override;
|
||||
|
||||
signals:
|
||||
void gridCellClicked(QPoint cell);
|
||||
|
||||
private slots:
|
||||
void onModuleButtonClicked(int index);
|
||||
void onConfirm();
|
||||
void onCancel();
|
||||
|
||||
public:
|
||||
struct CellInfo
|
||||
{
|
||||
bool buildable;
|
||||
int moduleIndex; // -1 if empty
|
||||
};
|
||||
|
||||
private:
|
||||
|
||||
void rebuildOccupancy();
|
||||
void updateGridWidget();
|
||||
bool canPlaceModule(const ModuleDef& def, QPoint position, Rotation rotation) const;
|
||||
std::vector<std::string> rotatedMask(const ModuleDef& def, Rotation rotation) const;
|
||||
void loadLayoutBlueprint(const std::vector<PlacedModule>& modules);
|
||||
|
||||
const GameConfig* m_config;
|
||||
std::string m_shipId;
|
||||
std::vector<std::string> m_shipLayout;
|
||||
int m_rows;
|
||||
int m_cols;
|
||||
|
||||
std::vector<PlacedModule> m_placedModules;
|
||||
std::vector<std::vector<CellInfo>> m_grid;
|
||||
|
||||
int m_activeModuleIndex; // -1 = remove mode, -2 = no selection
|
||||
Rotation m_currentRotation;
|
||||
|
||||
std::vector<QPushButton*> m_moduleButtons;
|
||||
QPushButton* m_removeButton;
|
||||
QWidget* m_gridWidget;
|
||||
|
||||
std::optional<ShipLayoutConfig> m_result;
|
||||
};
|
||||
198
src/ui/ShipLayoutPreview.cpp
Normal file
198
src/ui/ShipLayoutPreview.cpp
Normal file
@@ -0,0 +1,198 @@
|
||||
#include "ShipLayoutPreview.h"
|
||||
|
||||
#include <QPainter>
|
||||
#include <QPaintEvent>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
const int kCellSize = 8;
|
||||
|
||||
const ModuleDef* findModuleDef(const std::vector<ModuleDef>& modules,
|
||||
const std::string& id)
|
||||
{
|
||||
for (const ModuleDef& def : modules)
|
||||
{
|
||||
if (def.id == id)
|
||||
{
|
||||
return &def;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::vector<std::string> rotateMaskCW(const std::vector<std::string>& grid)
|
||||
{
|
||||
if (grid.empty())
|
||||
{
|
||||
return {};
|
||||
}
|
||||
const int srcH = static_cast<int>(grid.size());
|
||||
int srcW = 0;
|
||||
for (const std::string& row : grid)
|
||||
{
|
||||
const int w = static_cast<int>(row.size());
|
||||
if (w > srcW)
|
||||
{
|
||||
srcW = w;
|
||||
}
|
||||
}
|
||||
const int dstW = srcH;
|
||||
const int dstH = srcW;
|
||||
std::vector<std::string> dst(dstH, std::string(dstW, 'X'));
|
||||
for (int row = 0; row < srcH; ++row)
|
||||
{
|
||||
for (int col = 0; col < srcW; ++col)
|
||||
{
|
||||
const char ch = (col < static_cast<int>(grid[row].size()))
|
||||
? grid[row][col]
|
||||
: 'X';
|
||||
dst[col][srcH - 1 - row] = ch;
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
std::vector<std::string> rotateMask(const std::vector<std::string>& mask,
|
||||
Rotation rotation)
|
||||
{
|
||||
int steps = 0;
|
||||
switch (rotation)
|
||||
{
|
||||
case Rotation::East: steps = 0; break;
|
||||
case Rotation::South: steps = 1; break;
|
||||
case Rotation::West: steps = 2; break;
|
||||
case Rotation::North: steps = 3; break;
|
||||
}
|
||||
std::vector<std::string> result = mask;
|
||||
for (int i = 0; i < steps; ++i)
|
||||
{
|
||||
result = rotateMaskCW(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
ShipLayoutPreview::ShipLayoutPreview(QWidget* parent)
|
||||
: QWidget(parent)
|
||||
, m_modules(nullptr)
|
||||
, m_rows(0)
|
||||
, m_cols(0)
|
||||
{
|
||||
}
|
||||
|
||||
void ShipLayoutPreview::clear()
|
||||
{
|
||||
m_grid.clear();
|
||||
m_placedModules.clear();
|
||||
m_modules = nullptr;
|
||||
m_rows = 0;
|
||||
m_cols = 0;
|
||||
setFixedSize(0, 0);
|
||||
update();
|
||||
}
|
||||
|
||||
void ShipLayoutPreview::setShipAndLayout(const std::vector<std::string>& shipLayout,
|
||||
const ShipLayoutConfig& layout,
|
||||
const std::vector<ModuleDef>* modules)
|
||||
{
|
||||
m_modules = modules;
|
||||
m_placedModules = layout.placedModules;
|
||||
m_rows = static_cast<int>(shipLayout.size());
|
||||
m_cols = 0;
|
||||
for (const std::string& row : shipLayout)
|
||||
{
|
||||
const int w = static_cast<int>(row.size());
|
||||
if (w > m_cols)
|
||||
{
|
||||
m_cols = w;
|
||||
}
|
||||
}
|
||||
|
||||
m_grid.assign(m_rows, std::vector<CellInfo>(m_cols, {false, -1}));
|
||||
for (int r = 0; r < m_rows; ++r)
|
||||
{
|
||||
for (int c = 0; c < static_cast<int>(shipLayout[r].size()); ++c)
|
||||
{
|
||||
if (shipLayout[r][c] == 'O')
|
||||
{
|
||||
m_grid[r][c].buildable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < static_cast<int>(m_placedModules.size()); ++i)
|
||||
{
|
||||
const PlacedModule& pm = m_placedModules[i];
|
||||
const ModuleDef* def = findModuleDef(*m_modules, pm.moduleId);
|
||||
if (!def)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const std::vector<std::string> rotated = rotateMask(def->surfaceMask, pm.rotation);
|
||||
for (int mr = 0; mr < static_cast<int>(rotated.size()); ++mr)
|
||||
{
|
||||
for (int mc = 0; mc < static_cast<int>(rotated[mr].size()); ++mc)
|
||||
{
|
||||
if (rotated[mr][mc] != 'O')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const int gr = pm.position.y() + mr;
|
||||
const int gc = pm.position.x() + mc;
|
||||
if (gr >= 0 && gr < m_rows && gc >= 0 && gc < m_cols)
|
||||
{
|
||||
m_grid[gr][gc].moduleIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setFixedSize(m_cols * kCellSize, m_rows * kCellSize);
|
||||
update();
|
||||
}
|
||||
|
||||
void ShipLayoutPreview::paintEvent(QPaintEvent* /*event*/)
|
||||
{
|
||||
if (m_rows == 0 || m_cols == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing, false);
|
||||
|
||||
for (int r = 0; r < m_rows; ++r)
|
||||
{
|
||||
for (int c = 0; c < m_cols; ++c)
|
||||
{
|
||||
const QRect cellRect(c * kCellSize, r * kCellSize, kCellSize, kCellSize);
|
||||
const CellInfo& cell = m_grid[r][c];
|
||||
|
||||
if (!cell.buildable)
|
||||
{
|
||||
painter.fillRect(cellRect, Qt::black);
|
||||
}
|
||||
else if (cell.moduleIndex >= 0)
|
||||
{
|
||||
const PlacedModule& pm = m_placedModules[cell.moduleIndex];
|
||||
const ModuleDef* def = findModuleDef(*m_modules, pm.moduleId);
|
||||
QColor color(Qt::gray);
|
||||
if (def)
|
||||
{
|
||||
color = QColor(QString::fromStdString(def->fillColor));
|
||||
}
|
||||
painter.fillRect(cellRect, color);
|
||||
}
|
||||
else
|
||||
{
|
||||
painter.fillRect(cellRect, Qt::white);
|
||||
}
|
||||
|
||||
painter.setPen(QColor(128, 128, 128));
|
||||
painter.drawRect(cellRect);
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/ui/ShipLayoutPreview.h
Normal file
38
src/ui/ShipLayoutPreview.h
Normal file
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
#include "ModulesConfig.h"
|
||||
#include "ShipLayout.h"
|
||||
|
||||
class ShipLayoutPreview : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ShipLayoutPreview(QWidget* parent = nullptr);
|
||||
|
||||
void setShipAndLayout(const std::vector<std::string>& shipLayout,
|
||||
const ShipLayoutConfig& layout,
|
||||
const std::vector<ModuleDef>* modules);
|
||||
void clear();
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent* event) override;
|
||||
|
||||
private:
|
||||
struct CellInfo
|
||||
{
|
||||
bool buildable;
|
||||
int moduleIndex; // -1 if empty
|
||||
};
|
||||
|
||||
std::vector<std::vector<CellInfo>> m_grid;
|
||||
std::vector<PlacedModule> m_placedModules;
|
||||
const std::vector<ModuleDef>* m_modules;
|
||||
int m_rows;
|
||||
int m_cols;
|
||||
};
|
||||
Reference in New Issue
Block a user