Compare commits
5 Commits
665060bcd2
...
c7218c7c1e
| Author | SHA1 | Date | |
|---|---|---|---|
| c7218c7c1e | |||
| c43225b6fa | |||
| 405206211e | |||
| 3d577a11db | |||
| a472ec196c |
@@ -168,9 +168,9 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- Stance: aggressive (advance toward enemies) / defensive (hold position near asteroid).
|
||||
- Target priority: closest / highest HP / structures first.
|
||||
- REQ-SHP-RALLY: After spawning, aggressive-stance ships with weapon modules move to and orbit the **rally point** — the midpoint between the two player defence stations (center of their Y-span, at the player defence stations' X position) — at the rally orbit radius (REQ-SHP-ORBIT). While orbiting the rally point, ships still engage any enemy that enters sensor range (switching to the combat orbit per REQ-SHP-COMBAT). Every `world.toml [world].departure_interval_seconds` seconds (default 20), all ships with weapon modules currently at the rally point depart simultaneously and begin their normal aggressive advance toward the enemy. The departure timer is global and shared across all shipyards; it is not reset by individual ship arrivals at the rally point.
|
||||
- REQ-SHP-SALVAGE: Ships with at least one **salvage module** (player) — patrol by moving forward (rightward, away from the asteroid) while searching sensor range. If scrap enters sensor range, navigate toward it by orbiting it at the salvage orbit radius (REQ-SHP-ORBIT); when it is within a module's `collection_range`, that module begins collecting from it, one scrap per cycle (see below). Once all cargo is full, fly to a Salvage Bay and deliver (a direct approach, not an orbit — the ship must reach the bay); after delivery, resume patrol. If an enemy ship enters sensor range, the ship retreats (REQ-SHP-RETREAT) until no enemy is in sensor range, then resumes patrol — this applies regardless of whether the ship is targeting or carrying scrap. Ships with salvage modules are vulnerable to enemy ships while operating.
|
||||
- REQ-SHP-SALVAGE: Ships with at least one **salvage module** (player) — patrol by moving forward (rightward, away from the asteroid) while searching sensor range. If scrap enters sensor range, navigate toward it by orbiting it at the salvage orbit radius (REQ-SHP-ORBIT); when it is within a module's `collection_range`, that module begins collecting from it, one scrap per cycle (see below). Once the ship's cargo pool is full, fly to a Salvage Bay and deliver (a direct approach, not an orbit — the ship must reach the bay); after delivery, resume patrol. If an enemy ship enters sensor range, the ship retreats (REQ-SHP-RETREAT) until no enemy is in sensor range, then resumes patrol — this applies regardless of whether the ship is targeting or carrying scrap. Ships with salvage modules are vulnerable to enemy ships while operating.
|
||||
|
||||
Each salvage module instance operates independently: it has its own cargo hold (`cargo_capacity`), collection range (`collection_range`), and collection rate (`collection_rate`, in collection cycles per second). A module starts a collection cycle when it is off cooldown, has free cargo space, and a scrap pile is within its `collection_range`. Starting a cycle emits a collection beam toward that scrap pile (REQ-SHP-FIRING-BEAM) and begins a 0.15-second effect delay (half the beam duration); the module's cooldown of `1 / collection_rate` seconds begins at cycle start, not at effect application. When the delay expires, exactly 1 scrap is removed from the targeted pile and added to the module's cargo — unless the pile has already been fully depleted or despawned, or the cargo is now full, in which case the collection is silently dropped. A scrap pile worth more than 1 (REQ-RES-SCRAP-DROP) is depleted one scrap per cycle and persists, with its remaining amount decremented, until it is fully collected or despawns. A ship with multiple salvage modules can therefore run multiple collection cycles concurrently (one per ready module), and instances of different module types may have different ranges and rates. The ship navigates based on the maximum collection range across all installed salvage modules.
|
||||
All salvage modules on a ship deposit into a single shared **cargo pool** whose size is the ship's cargo capacity stat (REQ-MOD-CARGO-CAPACITY). Each salvage module instance still runs its own collection cycle independently, with its own collection range (`collection_range`) and collection rate (`collection_rate`, in collection cycles per second). A module starts a collection cycle when it is off cooldown, the shared cargo pool has free space, and a scrap pile is within its `collection_range`. Free space is measured against the pool's current contents **plus the collection cycles already in flight toward the pool** (scrap claimed by cycles whose effect delay has not yet elapsed); each in-flight cycle is registered against the ship so that concurrent modules on the same ship never start more cycles than the remaining capacity can hold. Starting a cycle emits a collection beam toward that scrap pile (REQ-SHP-FIRING-BEAM) and begins a 0.15-second effect delay (half the beam duration); the module's cooldown of `1 / collection_rate` seconds begins at cycle start, not at effect application. When the delay expires, exactly 1 scrap is removed from the targeted pile and added to the ship's cargo pool — unless the pile has already been fully depleted or despawned, or the pool is now full, in which case the collection is silently dropped. A scrap pile worth more than 1 (REQ-RES-SCRAP-DROP) is depleted one scrap per cycle and persists, with its remaining amount decremented, until it is fully collected or despawns. A ship with multiple salvage modules can therefore run multiple collection cycles concurrently (one per ready module), and instances of different module types may have different ranges and rates. The ship navigates based on the maximum collection range across all installed salvage modules.
|
||||
|
||||
Salvage collection cycles and delivery are processed regardless of which behavior the ship is currently executing; the salvage behavior only governs where the ship navigates (toward scrap, toward a Salvage Bay, or — when retreating — toward the rally point).
|
||||
- REQ-SHP-REPAIR: Ships with at least one **repair module** (player) — when no more urgent behavior applies, hold with the fleet (REQ-SHP-STANDBY) rather than charging the enemy, so damaged allies stay within sensor range. If a damaged player defence station or player ship enters sensor range, navigate toward it by orbiting it at the repair orbit radius (REQ-SHP-ORBIT) and repair. If an enemy ship enters sensor range, the ship retreats (REQ-SHP-RETREAT) until no enemy is in sensor range — except that it holds its ground and keeps repairing while a damaged friendly remains within sensor range (REQ-SHP-RETREAT), retreating only once there is nothing left to repair — then resumes patrol. The player can configure the target priority per shipyard:
|
||||
@@ -198,9 +198,9 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- `production_time_seconds` — time added to the ship's production cycle 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.
|
||||
- An optional **capability section** (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas. A module with base stat formulas is a capability module — each placed instance grants the ship an independent weapon, salvage bay, or repair tool with its own state (cooldown, target, cargo). A ship may have multiple capability module instances of the same or different types. Base stat formulas per capability type:
|
||||
- An optional **capability section** (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas. A module with base stat formulas is a capability module — each placed instance grants the ship an independent weapon, salvage bay, or repair tool with its own state (cooldown, target). A ship may have multiple capability module instances of the same or different types. Base stat formulas per capability type:
|
||||
- **Weapon** (`[module.weapon]`): `damage_formula`, `attack_range_formula`, `attack_rate_formula`.
|
||||
- **Salvage** (`[module.salvage]`): `collection_range_formula` (tiles), `cargo_capacity_formula` (integer scrap units), `collection_rate_formula` (collection cycles per second; each cycle collects 1 scrap).
|
||||
- **Salvage** (`[module.salvage]`): `collection_range_formula` (tiles), `cargo_capacity_formula` (integer scrap units; contributes to the ship's cargo capacity stat per REQ-MOD-CARGO-CAPACITY), `collection_rate_formula` (collection cycles per second; each cycle collects 1 scrap).
|
||||
- **Repair** (`[module.repair]`): `repair_rate_formula` (repair cycles per second), `repair_amount_hp_formula` (HP restored per repair cycle), `repair_range_formula` (tiles).
|
||||
- Zero or more **passive stat modifier formulas** (`added_*`/`multiplied_*`) that boost stats on the ship hull or on capability module instances (see REQ-MOD-STAT-CALC). A single module may be both a capability module and provide passive modifiers.
|
||||
|
||||
@@ -239,12 +239,15 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- `[module.weapon]` — modifiers apply to every weapon module instance on the ship.
|
||||
- `[module.salvage]` — modifiers apply to every salvage module instance on the ship.
|
||||
- `[module.repair]` — modifiers apply to every repair module instance on the ship.
|
||||
- `[module.cargo]` — modifiers (`added_cargo_capacity`/`multiplied_cargo_capacity`) apply to the ship's **cargo capacity**, a ship-level stat (see REQ-MOD-CARGO-CAPACITY).
|
||||
|
||||
Example: `[module.sensor].added_sensor_range_formula` adds to the ship's sensor range. `[module.weapon].multiplied_damage_formula` multiplies the damage of every weapon module instance on the ship.
|
||||
|
||||
- REQ-MOD-CARGO-CAPACITY: **Cargo capacity** is a first-class ship stat — the total number of scrap units the ship can hold in the single shared cargo pool used by its salvage modules (REQ-SHP-SALVAGE). Unlike other ship stats it has no hull base formula; its `base` (per REQ-MOD-STAT-CALC) is the **sum** of the `cargo_capacity` base values of every cargo-providing capability module instance on the ship — currently each salvage module's `cargo_capacity_formula`, evaluated at that module instance's `player_production_level`. Passive modifiers targeting `cargo_capacity` are declared under the `[module.cargo]` category and apply to this ship-level sum (`final = base × total_multiplier + total_additive`); they are not salvage-category modifiers and therefore scale the whole pool rather than any single instance. A ship whose cargo capacity is 0 (no cargo-providing module) is given no cargo pool.
|
||||
|
||||
### 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-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 selection button (REQ-UI-SELECT-BUTTON). 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:
|
||||
@@ -264,6 +267,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- Maneuvering acceleration
|
||||
- Angular acceleration
|
||||
- Max rotation speed
|
||||
- Cargo capacity — shown only when the ship's cargo capacity (REQ-MOD-CARGO-CAPACITY) is greater than 0
|
||||
|
||||
In addition, the panel shows capability module stats conditioned on which capability module types are present in the current layout:
|
||||
- **Weapons** (shown only if at least one weapon module is placed): combined DPS = Σ(damage_i × attack_rate_i) across all weapon module instances; maximum range = max(attack_range_i) across all weapon module instances.
|
||||
@@ -314,7 +318,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
|
||||
For a **ship or module schematic**: if the player does not yet have the schematic, it is unlocked (ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG)). If the player already has it, the schematic's `player_production_level` is incremented by 1 — for ship schematics, subsequent ships of that type are produced at a higher level; for module schematics, all instances of that module type use the higher level in their stat formulas.
|
||||
|
||||
For an **assembler recipe schematic**: the recipe is explicitly unlocked and becomes available in the assembler recipe-selection dropdown (subject to REQ-LOCK-UI-RECIPE). The schematic is removed from the drop pool permanently (REQ-LOCK-EXPLICIT). The implicit unlock set is recomputed (REQ-LOCK-IMPLICIT).
|
||||
For an **assembler recipe schematic**: the recipe is explicitly unlocked and becomes available in the assembler recipe-selection dialog (subject to REQ-LOCK-UI-RECIPE). The schematic is removed from the drop pool permanently (REQ-LOCK-EXPLICIT). The implicit unlock set is recomputed (REQ-LOCK-IMPLICIT).
|
||||
|
||||
## Progression & Locking
|
||||
|
||||
@@ -322,15 +326,15 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
|
||||
- REQ-LOCK-IMPLICIT: Item types and miner/assembler recipes are **implicitly** unlocked or locked based on the current set of unlocked ship, module, and assembler recipe schematics. The implicit unlock set is recomputed whenever any schematic changes lock state (on Restart or after REQ-DEF-SCHEMATIC-DROP). Computation:
|
||||
1. Start with the union of: (a) all item types listed in `materials` across all currently unlocked ship schematics and all currently unlocked module schematics, and (b) the output item type of every currently explicitly unlocked assembler recipe schematic (REQ-LOCK-EXPLICIT).
|
||||
2. For each item type in the current set: for every recipe (miner, smelter, or assembler) that produces it — skipping any assembler recipe schematic that defines `unlock_at_station_level` and is not yet explicitly unlocked — add each of that recipe's input item types to the set. If the recipe is a miner recipe or an assembler recipe that does not define `unlock_at_station_level`, mark it as implicitly unlocked. Explicitly unlocked assembler recipe schematics are available in the assembler dropdown by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step.
|
||||
2. For each item type in the current set: for every recipe (miner, smelter, or assembler) that produces it — skipping any assembler recipe schematic that defines `unlock_at_station_level` and is not yet explicitly unlocked — add each of that recipe's input item types to the set. If the recipe is a miner recipe or an assembler recipe that does not define `unlock_at_station_level`, mark it as implicitly unlocked. Explicitly unlocked assembler recipe schematics are available in the assembler recipe-selection dialog by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step.
|
||||
3. Repeat step 2 until no new item types are added.
|
||||
Item types and miner/assembler recipes not reached by this process (and not explicitly unlocked) are locked. Smelter recipes participate in the traversal to propagate unlocking to their inputs but are never themselves shown in any UI dropdown.
|
||||
|
||||
- REQ-LOCK-REPROCESSING-POOL: The pool of possible outputs for a Reprocessing Plant cycle (REQ-BLD-REPROCESSING) is restricted to item types that are currently implicitly unlocked (REQ-LOCK-IMPLICIT). Weights are renormalized over the eligible outputs. If no eligible outputs remain, the Reprocessing Plant cannot start a production cycle.
|
||||
|
||||
- REQ-LOCK-UI-RECIPE: Locked miner ore-type recipes and assembler recipes are not shown in their respective recipe-selection dropdowns.
|
||||
- REQ-LOCK-UI-RECIPE: Locked miner ore-type recipes and assembler recipes are not shown in their respective recipe-selection dialogs (REQ-UI-SELECT-BUTTON).
|
||||
|
||||
- REQ-LOCK-UI-SCHEMATIC: Locked ship schematics are not shown in the shipyard's schematic-selection dropdown.
|
||||
- REQ-LOCK-UI-SCHEMATIC: Locked ship schematics are not shown in the shipyard's schematic-selection dialog (REQ-UI-SELECT-BUTTON).
|
||||
|
||||
- REQ-LOCK-UI-SPLITTER: Item types that are not implicitly unlocked are excluded from splitter filter dropdowns (REQ-BLD-SPLITTER).
|
||||
|
||||
@@ -430,7 +434,11 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
- 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. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic dropdown (REQ-MOD-UI-PREVIEW).
|
||||
- REQ-UI-CONFIG-INLINE: Recipe, schematic, ship stance, and target priority configuration for a selected building is shown within this panel. Ship stance and target priority are selected and changed inline. Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
|
||||
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selected building panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selected building panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
|
||||
- REQ-UI-SELECT-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selected building panel when a selection is set, displays an info tooltip:
|
||||
- For a **recipe** (Miner or Assembler): the recipe name; the name and quantity of each input item (no inputs are listed for miner recipes, which consume nothing); the completion time (`duration_seconds`); and the name and quantity of the produced output item.
|
||||
- For a **ship schematic** (Shipyard): the ship's `display_name`; the name and quantity of each base required material (`[ship.schematic].materials`, excluding any module contributions); the base production time (`[ship.schematic].production_time_seconds`); and "Produces: 1 <ship display name>".
|
||||
- REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels.
|
||||
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. Clicking a ship or defence station clears any existing selection and establishes a single-entity selection containing only that entity. Ships and defence stations cannot participate in multi-select together with buildings. Clicking empty world space (no building, ship, or defence station) clears the selection.
|
||||
- REQ-UI-SHIP-STATS-PANEL: When a single ship is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **ship stats panel**. The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed at the ship's actual level with its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
|
||||
|
||||
@@ -40,7 +40,6 @@ ArenaView::ArenaView(ArenaSimulation* sim, const VisualsConfig* visuals,
|
||||
: QOpenGLWidget(parent)
|
||||
, m_sim(sim)
|
||||
, m_visuals(visuals)
|
||||
, m_wallMs(0)
|
||||
, m_gameSpeedMultiplier(1.0)
|
||||
, m_prevNonZeroSpeed(1.0)
|
||||
, m_rng(std::random_device{}())
|
||||
@@ -99,7 +98,6 @@ void ArenaView::togglePause()
|
||||
void ArenaView::onFrame()
|
||||
{
|
||||
const qint64 elapsed = m_frameTimer.restart();
|
||||
m_wallMs += elapsed;
|
||||
|
||||
{
|
||||
const int ticks = m_tickDriver.advance(
|
||||
@@ -120,12 +118,14 @@ void ArenaView::onFrame()
|
||||
}
|
||||
}
|
||||
|
||||
// Expire old beams
|
||||
// Expire old beams. Lifetime is measured in game ticks so beams stay
|
||||
// visible while the simulation is paused or slowed (REQ-SHP-FIRING-BEAM).
|
||||
{
|
||||
const Tick now = m_sim->currentTick();
|
||||
std::vector<ActiveBeam> live;
|
||||
for (const ActiveBeam& b : m_activeBeams)
|
||||
{
|
||||
if (m_wallMs - b.emittedWallMs < kBeamLifetimeMs)
|
||||
if (now - b.event.emittedAt < kBeamLifetimeTicks)
|
||||
{
|
||||
live.push_back(b);
|
||||
}
|
||||
@@ -165,7 +165,6 @@ void ArenaView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
|
||||
|
||||
ActiveBeam beam;
|
||||
beam.event = *event;
|
||||
beam.emittedWallMs = m_wallMs;
|
||||
beam.targetOffset = QVector2D(radius * std::cos(angle),
|
||||
radius * std::sin(angle));
|
||||
m_activeBeams.push_back(beam);
|
||||
|
||||
@@ -67,18 +67,18 @@ private:
|
||||
struct ActiveBeam
|
||||
{
|
||||
BeamFiredEvent event;
|
||||
qint64 emittedWallMs;
|
||||
QVector2D targetOffset;
|
||||
};
|
||||
|
||||
static constexpr qint64 kBeamLifetimeMs = 300;
|
||||
// Beam lifetime in game ticks so beams freeze with the simulation when
|
||||
// paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM).
|
||||
static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3);
|
||||
|
||||
ArenaSimulation* m_sim;
|
||||
const VisualsConfig* m_visuals;
|
||||
|
||||
TickDriver m_tickDriver;
|
||||
QElapsedTimer m_frameTimer;
|
||||
qint64 m_wallMs;
|
||||
std::mt19937 m_rng;
|
||||
double m_gameSpeedMultiplier;
|
||||
double m_prevNonZeroSpeed;
|
||||
|
||||
@@ -546,8 +546,8 @@ static const StatEntry kKnownStats[] = {
|
||||
{"weapon", "attack_range", "_m"},
|
||||
{"weapon", "attack_rate", "_hz"},
|
||||
{"salvage", "collection_range", "_m"},
|
||||
{"salvage", "cargo_capacity", ""},
|
||||
{"salvage", "collection_rate", "_hz"},
|
||||
{"cargo", "cargo_capacity", ""},
|
||||
{"repair", "repair_rate", "_hz"},
|
||||
{"repair", "repair_range", "_m"},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/AttackBehavior.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BehaviorKind.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BehaviorScores.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CargoComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DeliverScrapBehavior.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DespawnAtComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodyComponent.h
|
||||
@@ -17,7 +18,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RepairBehavior.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RepairToolComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RetreatBehavior.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SalvageCargoComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SalvagerComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SalvageScrapBehavior.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ScrapDataComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBehaviorComponent.h
|
||||
|
||||
10
src/lib/ecs/component/CargoComponent.h
Normal file
10
src/lib/ecs/component/CargoComponent.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
// Single shared salvage cargo pool for a ship (REQ-MOD-CARGO-CAPACITY). Attached
|
||||
// to a ship at spawn only when its cargo capacity stat is greater than 0. All of
|
||||
// the ship's salvage modules deposit into this one pool; delivery draws from it.
|
||||
struct CargoComponent
|
||||
{
|
||||
int maxCapacity;
|
||||
int current;
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
struct SalvageCargoComponent
|
||||
{
|
||||
int capacity;
|
||||
int current;
|
||||
float collectionRange_tiles;
|
||||
int collectionIntervalTicks;
|
||||
int cooldownTicksRemaining;
|
||||
};
|
||||
11
src/lib/ecs/component/SalvagerComponent.h
Normal file
11
src/lib/ecs/component/SalvagerComponent.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
// Per-instance salvage collection emitter. Each placed salvage module owns one of
|
||||
// these and runs its own collection cycle on its own cooldown. The collected scrap
|
||||
// is stored in the owning ship's shared CargoComponent (REQ-SHP-SALVAGE), not here.
|
||||
struct SalvagerComponent
|
||||
{
|
||||
float collectionRange_tiles;
|
||||
int collectionIntervalTicks;
|
||||
int cooldownTicksRemaining;
|
||||
};
|
||||
@@ -6,13 +6,14 @@
|
||||
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "CargoComponent.h"
|
||||
#include "DeliverScrapBehavior.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include <map>
|
||||
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "SalvagerComponent.h"
|
||||
#include "ScrapDataComponent.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "tracing.h"
|
||||
@@ -32,44 +33,53 @@ void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem&
|
||||
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
|
||||
|
||||
// Tick down per-module collection cooldowns.
|
||||
m_admin.forEach<SalvageCargoComponent>(
|
||||
[](entt::entity /*e*/, SalvageCargoComponent& c)
|
||||
m_admin.forEach<SalvagerComponent>(
|
||||
[](entt::entity /*e*/, SalvagerComponent& s)
|
||||
{
|
||||
if (c.cooldownTicksRemaining > 0) { --c.cooldownTicksRemaining; }
|
||||
if (s.cooldownTicksRemaining > 0) { --s.cooldownTicksRemaining; }
|
||||
});
|
||||
|
||||
// Scrap units already claimed by not-yet-applied collection cycles, so two
|
||||
// modules don't both target the last unit of the same pile (the claim would be
|
||||
// dropped at apply time). A pile is available while its amount exceeds its claims.
|
||||
std::map<entt::entity, int> claimedUnits;
|
||||
// Collection cycles already in flight toward each ship's shared cargo pool, so
|
||||
// concurrent modules on the same ship never start more cycles than the remaining
|
||||
// capacity can hold (REQ-SHP-SALVAGE).
|
||||
std::map<entt::entity, int> pendingByShip;
|
||||
for (const PendingCollection& pc : m_pendingCollections)
|
||||
{
|
||||
++claimedUnits[pc.scrap];
|
||||
++pendingByShip[pc.ship];
|
||||
}
|
||||
|
||||
// Cycle start: each ready, in-range module with free cargo begins a collection
|
||||
// cycle — emit the beam now, collect one scrap mid-beam, start the cooldown now.
|
||||
m_admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity moduleEntity, SalvageCargoComponent& c, const ModuleOwnerComponent& o)
|
||||
// Cycle start: each ready, in-range module whose ship's pool has free space begins
|
||||
// a collection cycle — emit the beam now, collect one scrap mid-beam, start the
|
||||
// cooldown now.
|
||||
m_admin.forEach<SalvagerComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*moduleEntity*/, SalvagerComponent& s, const ModuleOwnerComponent& o)
|
||||
{
|
||||
if (c.current >= c.capacity || c.cooldownTicksRemaining > 0) { return; }
|
||||
if (c.collectionIntervalTicks <= 0) { return; }
|
||||
if (!m_admin.hasAll<PositionComponent>(o.owner)) { return; }
|
||||
if (s.cooldownTicksRemaining > 0 || s.collectionIntervalTicks <= 0) { return; }
|
||||
if (!m_admin.hasAll<PositionComponent, CargoComponent>(o.owner)) { return; }
|
||||
|
||||
const CargoComponent& cargo = m_admin.get<CargoComponent>(o.owner);
|
||||
if (cargo.current + pendingByShip[o.owner] >= cargo.maxCapacity) { return; }
|
||||
|
||||
const QVector2D ownerPos = m_admin.get<PositionComponent>(o.owner).value;
|
||||
for (const ScrapInfo& si : allScrap)
|
||||
{
|
||||
if ((si.position - ownerPos).length() > c.collectionRange_tiles) { continue; }
|
||||
if ((si.position - ownerPos).length() > s.collectionRange_tiles) { continue; }
|
||||
if (claimedUnits[si.entity] >= m_admin.get<ScrapDataComponent>(si.entity).amount)
|
||||
{
|
||||
continue; // every remaining unit of this pile is already spoken for
|
||||
}
|
||||
outBeamFiredEvents.push_back(
|
||||
BeamFiredEvent{BeamKind::Salvage, o.owner, si.entity, currentTick});
|
||||
m_pendingCollections.push_back({moduleEntity, si.entity,
|
||||
m_pendingCollections.push_back({o.owner, si.entity,
|
||||
currentTick + kBeamImpactDelayTicks});
|
||||
++claimedUnits[si.entity];
|
||||
c.cooldownTicksRemaining = c.collectionIntervalTicks;
|
||||
++pendingByShip[o.owner];
|
||||
s.cooldownTicksRemaining = s.collectionIntervalTicks;
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -86,18 +96,14 @@ void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem&
|
||||
bay->anchor.y() + bay->footprint.height() / 2.0f);
|
||||
if ((pos.value - bayCenter).length() > 1.0f) { return; }
|
||||
|
||||
// Decrement the first non-empty salvage child belonging to this ship.
|
||||
bool delivered = false;
|
||||
m_admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*ce*/, SalvageCargoComponent& c, const ModuleOwnerComponent& o)
|
||||
{
|
||||
if (delivered || o.owner != ship || c.current <= 0) { return; }
|
||||
if (buildings.deliverScrapToSalvageBay(deliver.deliveryBay))
|
||||
{
|
||||
--c.current;
|
||||
delivered = true;
|
||||
}
|
||||
});
|
||||
// Hand over one unit from the ship's shared cargo pool.
|
||||
if (!m_admin.hasAll<CargoComponent>(ship)) { return; }
|
||||
CargoComponent& cargo = m_admin.get<CargoComponent>(ship);
|
||||
if (cargo.current <= 0) { return; }
|
||||
if (buildings.deliverScrapToSalvageBay(deliver.deliveryBay))
|
||||
{
|
||||
--cargo.current;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,12 +114,12 @@ void SalvagerSystem::applyPendingCollections(Tick currentTick, ScrapSystem& scra
|
||||
{
|
||||
if (it->appliesAt <= currentTick)
|
||||
{
|
||||
if (m_admin.isValid(it->module) && m_admin.hasAll<SalvageCargoComponent>(it->module))
|
||||
if (m_admin.isValid(it->ship) && m_admin.hasAll<CargoComponent>(it->ship))
|
||||
{
|
||||
SalvageCargoComponent& c = m_admin.get<SalvageCargoComponent>(it->module);
|
||||
if (c.current < c.capacity && scraps.collectOne(it->scrap))
|
||||
CargoComponent& cargo = m_admin.get<CargoComponent>(it->ship);
|
||||
if (cargo.current < cargo.maxCapacity && scraps.collectOne(it->scrap))
|
||||
{
|
||||
++c.current;
|
||||
++cargo.current;
|
||||
}
|
||||
}
|
||||
it = m_pendingCollections.erase(it);
|
||||
|
||||
@@ -27,7 +27,7 @@ public:
|
||||
private:
|
||||
struct PendingCollection
|
||||
{
|
||||
entt::entity module;
|
||||
entt::entity ship;
|
||||
entt::entity scrap;
|
||||
Tick appliesAt;
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "AdvanceBehavior.h"
|
||||
#include "AttackBehavior.h"
|
||||
#include "BehaviorScores.h"
|
||||
#include "CargoComponent.h"
|
||||
#include "DeliverScrapBehavior.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
#include "EntityAdmin.h"
|
||||
@@ -21,8 +22,8 @@
|
||||
#include "RepairBehavior.h"
|
||||
#include "RepairToolComponent.h"
|
||||
#include "RetreatBehavior.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "SalvageScrapBehavior.h"
|
||||
#include "SalvagerComponent.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "StandbyBehavior.h"
|
||||
@@ -107,6 +108,10 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
||||
std::vector<entt::entity> salvageChildren;
|
||||
std::vector<entt::entity> repairChildren;
|
||||
|
||||
// Cargo capacity is a ship-level stat (REQ-MOD-CARGO-CAPACITY): its base is the
|
||||
// sum of every cargo-providing module's contribution, accumulated here.
|
||||
double cargoCapacityBase = 0.0;
|
||||
|
||||
for (const PlacedModule& pm : modules)
|
||||
{
|
||||
const ModuleDef* modDef = findModuleDef(pm.moduleId);
|
||||
@@ -136,20 +141,19 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
||||
|
||||
if (modDef->salvageCapability)
|
||||
{
|
||||
SalvageCargoComponent cargo;
|
||||
cargo.capacity = static_cast<int>(
|
||||
modDef->salvageCapability->cargoCapacityFormula.evaluate(mx));
|
||||
cargo.current = 0;
|
||||
cargo.collectionRange_tiles = static_cast<float>(
|
||||
cargoCapacityBase += modDef->salvageCapability->cargoCapacityFormula.evaluate(mx);
|
||||
|
||||
SalvagerComponent salvager;
|
||||
salvager.collectionRange_tiles = static_cast<float>(
|
||||
modDef->salvageCapability->collectionRangeFormula.evaluate(mx)) / tileSize;
|
||||
const double rate = modDef->salvageCapability->collectionRateFormula.evaluate(mx);
|
||||
cargo.collectionIntervalTicks = (rate > 0.0)
|
||||
salvager.collectionIntervalTicks = (rate > 0.0)
|
||||
? static_cast<int>(kTickRateHz / rate + 0.5)
|
||||
: 0;
|
||||
cargo.cooldownTicksRemaining = 0;
|
||||
salvager.cooldownTicksRemaining = 0;
|
||||
|
||||
entt::entity child = m_admin.createModuleEntity();
|
||||
m_admin.addComponent<SalvageCargoComponent>(child, cargo);
|
||||
m_admin.addComponent<SalvagerComponent>(child, salvager);
|
||||
m_admin.addComponent<ModuleOwnerComponent>(child, ModuleOwnerComponent{entity});
|
||||
salvageChildren.push_back(child);
|
||||
}
|
||||
@@ -184,6 +188,8 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
||||
std::map<std::string, std::pair<double, double>> weaponMods;
|
||||
std::map<std::string, std::pair<double, double>> salvageMods;
|
||||
std::map<std::string, std::pair<double, double>> repairMods;
|
||||
// Ship-level cargo capacity modifiers ([module.cargo]); applied to the pool.
|
||||
std::map<std::string, std::pair<double, double>> cargoMods;
|
||||
|
||||
for (const PlacedModule& pm : modules)
|
||||
{
|
||||
@@ -204,15 +210,16 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
||||
const bool isWeaponStat = (sm.stat == "damage"
|
||||
|| sm.stat == "attack_range"
|
||||
|| sm.stat == "attack_rate");
|
||||
const bool isSalvageStat = (sm.stat == "collection_range"
|
||||
|| sm.stat == "cargo_capacity");
|
||||
const bool isSalvageStat = (sm.stat == "collection_range");
|
||||
const bool isRepairStat = (sm.stat == "repair_rate"
|
||||
|| sm.stat == "repair_range");
|
||||
const bool isCargoStat = (sm.stat == "cargo_capacity");
|
||||
|
||||
std::map<std::string, std::pair<double, double>>* target = &hullMods;
|
||||
if (isWeaponStat) { target = &weaponMods; }
|
||||
if (isSalvageStat) { target = &salvageMods; }
|
||||
if (isRepairStat) { target = &repairMods; }
|
||||
if (isCargoStat) { target = &cargoMods; }
|
||||
|
||||
std::pair<double, double>& acc = (*target)[sm.stat];
|
||||
if (sm.modifierType == "multiplicative")
|
||||
@@ -305,23 +312,33 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
||||
// Apply salvage modifiers to each salvage child.
|
||||
for (entt::entity child : salvageChildren)
|
||||
{
|
||||
SalvageCargoComponent& c = m_admin.get<SalvageCargoComponent>(child);
|
||||
float fRange = c.collectionRange_tiles;
|
||||
float fCapacity = static_cast<float>(c.capacity);
|
||||
SalvagerComponent& c = m_admin.get<SalvagerComponent>(child);
|
||||
float fRange = c.collectionRange_tiles;
|
||||
// Apply rate modifier: compute rate from interval, apply multiplier, convert back.
|
||||
float fRate = (c.collectionIntervalTicks > 0)
|
||||
? static_cast<float>(kTickRateHz) / static_cast<float>(c.collectionIntervalTicks)
|
||||
: 0.0f;
|
||||
applyMod(fRange, "collection_range", salvageMods);
|
||||
applyMod(fCapacity, "cargo_capacity", salvageMods);
|
||||
applyMod(fRate, "collection_rate", salvageMods);
|
||||
applyMod(fRange, "collection_range", salvageMods);
|
||||
applyMod(fRate, "collection_rate", salvageMods);
|
||||
c.collectionRange_tiles = fRange;
|
||||
c.capacity = static_cast<int>(fCapacity + 0.5f);
|
||||
c.collectionIntervalTicks = (fRate > 0.0f)
|
||||
? static_cast<int>(static_cast<float>(kTickRateHz) / fRate + 0.5f)
|
||||
: 0;
|
||||
}
|
||||
|
||||
// Cargo capacity is a ship-level stat: apply [module.cargo] modifiers to the
|
||||
// summed base, then attach the shared cargo pool when the ship can hold anything
|
||||
// (REQ-MOD-CARGO-CAPACITY).
|
||||
{
|
||||
float fCapacity = static_cast<float>(cargoCapacityBase);
|
||||
applyMod(fCapacity, "cargo_capacity", cargoMods);
|
||||
const int maxCapacity = static_cast<int>(fCapacity + 0.5f);
|
||||
if (maxCapacity > 0)
|
||||
{
|
||||
m_admin.addComponent<CargoComponent>(entity, CargoComponent{maxCapacity, 0});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply repair modifiers to each repair child.
|
||||
for (entt::entity child : repairChildren)
|
||||
{
|
||||
@@ -383,7 +400,7 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
||||
float maxCollRange = 0.0f;
|
||||
for (entt::entity child : salvageChildren)
|
||||
{
|
||||
const float r = m_admin.get<SalvageCargoComponent>(child).collectionRange_tiles;
|
||||
const float r = m_admin.get<SalvagerComponent>(child).collectionRange_tiles;
|
||||
if (r > maxCollRange) { maxCollRange = r; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
#include "BehaviorTargeting.h"
|
||||
|
||||
#include "CargoComponent.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "HqProxyComponent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "StationBodyComponent.h"
|
||||
|
||||
@@ -72,13 +71,10 @@ std::vector<CombatantInfo> buildCombatants(EntityAdmin& admin)
|
||||
std::unordered_map<entt::entity, CargoState> buildCargoByShip(EntityAdmin& admin)
|
||||
{
|
||||
std::unordered_map<entt::entity, CargoState> cargoByShip;
|
||||
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||
[&cargoByShip](entt::entity /*ce*/, const SalvageCargoComponent& c,
|
||||
const ModuleOwnerComponent& o)
|
||||
admin.forEach<CargoComponent>(
|
||||
[&cargoByShip](entt::entity ship, const CargoComponent& c)
|
||||
{
|
||||
CargoState& agg = cargoByShip[o.owner];
|
||||
agg.current += c.current;
|
||||
agg.capacity += c.capacity;
|
||||
cargoByShip[ship] = CargoState{c.current, c.maxCapacity};
|
||||
});
|
||||
return cargoByShip;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ std::vector<RepairableInfo> buildRepairables(EntityAdmin& admin);
|
||||
// All ships, stations, and the HQ proxy — candidates for attack targeting.
|
||||
std::vector<CombatantInfo> buildCombatants(EntityAdmin& admin);
|
||||
|
||||
// Aggregated salvage cargo per owning ship, summed across its salvage modules.
|
||||
// Salvage cargo pool per ship, read from each ship's shared CargoComponent.
|
||||
std::unordered_map<entt::entity, CargoState> buildCargoByShip(EntityAdmin& admin);
|
||||
|
||||
// True when the ship's aggregated cargo is at capacity (and it has any capacity).
|
||||
|
||||
@@ -20,6 +20,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ExitBlueprintModeRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SpeedChangeRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/LayoutDialogRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindowClosedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ArenaStartRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h
|
||||
|
||||
12
src/lib/eventsystem/event/RecipeSelectionRequestedEvent.h
Normal file
12
src/lib/eventsystem/event/RecipeSelectionRequestedEvent.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include "BuildingId.h"
|
||||
#include "Event.h"
|
||||
|
||||
class RecipeSelectionRequestedEvent : public Event
|
||||
{
|
||||
public:
|
||||
explicit RecipeSelectionRequestedEvent(BuildingId buildingId)
|
||||
: buildingId(buildingId) {}
|
||||
const BuildingId buildingId;
|
||||
};
|
||||
@@ -4,12 +4,13 @@
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
#include "CargoComponent.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "RepairToolComponent.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "SalvagerComponent.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "Tick.h"
|
||||
#include "WeaponComponent.h"
|
||||
@@ -66,6 +67,10 @@ ShipStats calculateShipStats(const GameConfig& config,
|
||||
std::vector<SalvageInstance> salvageInstances;
|
||||
std::vector<RepairInstance> repairInstances;
|
||||
|
||||
// Cargo capacity is a ship-level stat (REQ-MOD-CARGO-CAPACITY): base is the sum
|
||||
// of every salvage module's contribution.
|
||||
double cargoCapacityBase = 0.0;
|
||||
|
||||
for (const PlacedModule& pm : modules)
|
||||
{
|
||||
const ModuleDef* def = findModuleDef(pm.moduleId);
|
||||
@@ -85,6 +90,8 @@ ShipStats calculateShipStats(const GameConfig& config,
|
||||
}
|
||||
if (def->salvageCapability)
|
||||
{
|
||||
cargoCapacityBase += def->salvageCapability->cargoCapacityFormula.evaluate(mx);
|
||||
|
||||
SalvageInstance si;
|
||||
si.range_tiles = static_cast<float>(def->salvageCapability->collectionRangeFormula.evaluate(mx) / tileSize);
|
||||
si.rate = static_cast<float>(def->salvageCapability->collectionRateFormula.evaluate(mx));
|
||||
@@ -106,6 +113,7 @@ ShipStats calculateShipStats(const GameConfig& config,
|
||||
std::map<std::string, std::pair<double, double>> weaponMods;
|
||||
std::map<std::string, std::pair<double, double>> salvageMods;
|
||||
std::map<std::string, std::pair<double, double>> repairMods;
|
||||
std::map<std::string, std::pair<double, double>> cargoMods;
|
||||
|
||||
for (const PlacedModule& pm : modules)
|
||||
{
|
||||
@@ -123,15 +131,16 @@ ShipStats calculateShipStats(const GameConfig& config,
|
||||
const bool isWeaponStat = (sm.stat == "damage"
|
||||
|| sm.stat == "attack_range"
|
||||
|| sm.stat == "attack_rate");
|
||||
const bool isSalvageStat = (sm.stat == "collection_range"
|
||||
|| sm.stat == "cargo_capacity");
|
||||
const bool isSalvageStat = (sm.stat == "collection_range");
|
||||
const bool isRepairStat = (sm.stat == "repair_rate"
|
||||
|| sm.stat == "repair_range");
|
||||
const bool isCargoStat = (sm.stat == "cargo_capacity");
|
||||
|
||||
std::map<std::string, std::pair<double, double>>* target = &hullMods;
|
||||
if (isWeaponStat) { target = &weaponMods; }
|
||||
if (isSalvageStat) { target = &salvageMods; }
|
||||
if (isRepairStat) { target = &repairMods; }
|
||||
if (isCargoStat) { target = &cargoMods; }
|
||||
|
||||
std::pair<double, double>& acc = (*target)[sm.stat];
|
||||
if (sm.modifierType == "multiplicative")
|
||||
@@ -232,6 +241,14 @@ ShipStats calculateShipStats(const GameConfig& config,
|
||||
result.salvage = ShipStats::SalvageStats{combinedRate, maxRange};
|
||||
}
|
||||
|
||||
// Cargo capacity is a ship-level stat: apply [module.cargo] modifiers to the
|
||||
// summed base (REQ-MOD-CARGO-CAPACITY).
|
||||
{
|
||||
float capacity = static_cast<float>(cargoCapacityBase);
|
||||
applyMod(capacity, "cargo_capacity", cargoMods);
|
||||
result.cargoCapacity = static_cast<int>(capacity + 0.5f);
|
||||
}
|
||||
|
||||
// Apply repair modifiers and compute combined stats.
|
||||
if (!repairInstances.empty())
|
||||
{
|
||||
@@ -287,8 +304,8 @@ ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEn
|
||||
if (w.range_tiles > weaponMaxRange) { weaponMaxRange = w.range_tiles; }
|
||||
});
|
||||
|
||||
admin.forEach<ModuleOwnerComponent, SalvageCargoComponent>(
|
||||
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const SalvageCargoComponent& s)
|
||||
admin.forEach<ModuleOwnerComponent, SalvagerComponent>(
|
||||
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const SalvagerComponent& s)
|
||||
{
|
||||
if (owner.owner != shipEntity) { return; }
|
||||
hasSalvage = true;
|
||||
@@ -299,6 +316,12 @@ ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEn
|
||||
if (s.collectionRange_tiles > salvageMaxRange) { salvageMaxRange = s.collectionRange_tiles; }
|
||||
});
|
||||
|
||||
admin.forEach<CargoComponent>(
|
||||
[&](entt::entity ship, const CargoComponent& c)
|
||||
{
|
||||
if (ship == shipEntity) { result.cargoCapacity = c.maxCapacity; }
|
||||
});
|
||||
|
||||
admin.forEach<ModuleOwnerComponent, RepairToolComponent>(
|
||||
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const RepairToolComponent& r)
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ struct ShipStats
|
||||
float maneuveringAcceleration_tpss;
|
||||
float angularAcceleration_radpss;
|
||||
float maxRotationSpeed_radps;
|
||||
int cargoCapacity = 0;
|
||||
|
||||
struct WeaponStats
|
||||
{
|
||||
|
||||
@@ -33,9 +33,10 @@
|
||||
#include "RepairSystem.h"
|
||||
#include "RepairToolComponent.h"
|
||||
#include "RetreatBehavior.h"
|
||||
#include "CargoComponent.h"
|
||||
#include "Rotation.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "SalvageScrapBehavior.h"
|
||||
#include "SalvagerComponent.h"
|
||||
#include "SalvagerSystem.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
@@ -179,8 +180,8 @@ static ShipLayoutConfig makeTwoModuleLayout(const std::string& moduleId)
|
||||
static entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship)
|
||||
{
|
||||
entt::entity result = entt::null;
|
||||
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity ce, const SalvageCargoComponent&, const ModuleOwnerComponent& o)
|
||||
admin.forEach<SalvagerComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity ce, const SalvagerComponent&, const ModuleOwnerComponent& o)
|
||||
{
|
||||
if (o.owner == ship && result == entt::null) { result = ce; }
|
||||
});
|
||||
@@ -944,7 +945,7 @@ TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]"
|
||||
|
||||
const entt::entity sc = firstSalvageChild(f.admin, ship);
|
||||
REQUIRE(f.admin.isValid(sc));
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 1);
|
||||
REQUIRE(f.admin.get<CargoComponent>(ship).current == 1);
|
||||
REQUIRE_FALSE(f.admin.isValid(scrapEntity));
|
||||
}
|
||||
|
||||
@@ -969,10 +970,9 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
|
||||
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f),
|
||||
false, salvageLayout);
|
||||
{
|
||||
const entt::entity sc = firstSalvageChild(f.admin, ship);
|
||||
REQUIRE(f.admin.isValid(sc));
|
||||
SalvageCargoComponent& cargo = f.admin.get<SalvageCargoComponent>(sc);
|
||||
cargo.current = cargo.capacity; // full cargo
|
||||
REQUIRE(f.admin.isValid(firstSalvageChild(f.admin, ship)));
|
||||
CargoComponent& cargo = f.admin.get<CargoComponent>(ship);
|
||||
cargo.current = cargo.maxCapacity; // full cargo
|
||||
}
|
||||
|
||||
f.decide();
|
||||
@@ -990,13 +990,9 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
|
||||
|
||||
static int totalSalvageCurrent(EntityAdmin& admin, entt::entity ship)
|
||||
{
|
||||
int total = 0;
|
||||
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*ce*/, const SalvageCargoComponent& c, const ModuleOwnerComponent& o)
|
||||
{
|
||||
if (o.owner == ship) { total += c.current; }
|
||||
});
|
||||
return total;
|
||||
return admin.hasAll<CargoComponent>(ship)
|
||||
? admin.get<CargoComponent>(ship).current
|
||||
: 0;
|
||||
}
|
||||
|
||||
TEST_CASE("SalvagerSystem: module does not collect scrap beyond its collection range",
|
||||
@@ -1011,7 +1007,7 @@ TEST_CASE("SalvagerSystem: module does not collect scrap beyond its collection r
|
||||
|
||||
f.runSalvageCollect();
|
||||
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
|
||||
REQUIRE(f.admin.get<CargoComponent>(ship).current == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("SalvagerSystem: module collects scrap within its collection range",
|
||||
@@ -1026,7 +1022,7 @@ TEST_CASE("SalvagerSystem: module collects scrap within its collection range",
|
||||
|
||||
f.runSalvageCollect();
|
||||
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 1);
|
||||
REQUIRE(f.admin.get<CargoComponent>(ship).current == 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1045,10 +1041,10 @@ TEST_CASE("SalvagerSystem: collection sets cooldown on module", "[behavior]")
|
||||
// collected until mid-beam (REQ-SHP-SALVAGE), so cargo is still empty now.
|
||||
f.salvageTick();
|
||||
|
||||
const SalvageCargoComponent& cargo =
|
||||
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship));
|
||||
REQUIRE(cargo.current == 0);
|
||||
REQUIRE(cargo.cooldownTicksRemaining == cargo.collectionIntervalTicks);
|
||||
REQUIRE(f.admin.get<CargoComponent>(ship).current == 0);
|
||||
const SalvagerComponent& salvager =
|
||||
f.admin.get<SalvagerComponent>(firstSalvageChild(f.admin, ship));
|
||||
REQUIRE(salvager.cooldownTicksRemaining == salvager.collectionIntervalTicks);
|
||||
}
|
||||
|
||||
TEST_CASE("SalvagerSystem: module on cooldown does not collect scrap", "[behavior]")
|
||||
@@ -1059,11 +1055,11 @@ TEST_CASE("SalvagerSystem: module on cooldown does not collect scrap", "[behavio
|
||||
false, salvageLayout);
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10;
|
||||
f.admin.get<SalvagerComponent>(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10;
|
||||
|
||||
f.runSalvageCollect();
|
||||
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
|
||||
REQUIRE(f.admin.get<CargoComponent>(ship).current == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("SalvagerSystem: module collects again after cooldown expires", "[behavior]")
|
||||
@@ -1076,17 +1072,17 @@ TEST_CASE("SalvagerSystem: module collects again after cooldown expires", "[beha
|
||||
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
f.runSalvageCollect();
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 1);
|
||||
REQUIRE(f.admin.get<CargoComponent>(ship).current == 1);
|
||||
|
||||
// Shorten cooldown to 1 tick and place a second scrap.
|
||||
f.admin.get<SalvageCargoComponent>(sc).cooldownTicksRemaining = 1;
|
||||
f.admin.get<SalvagerComponent>(sc).cooldownTicksRemaining = 1;
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
|
||||
// Once the cooldown expires the module starts another cycle and collects the
|
||||
// second scrap after the mid-beam delay.
|
||||
f.runSalvageCollect();
|
||||
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 2);
|
||||
REQUIRE(f.admin.get<CargoComponent>(ship).current == 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1119,12 +1115,12 @@ TEST_CASE("SalvagerSystem: second salvage module does not collect when first is
|
||||
|
||||
// Put the first salvage child on cooldown.
|
||||
entt::entity blocked = entt::null;
|
||||
f.admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity ce, SalvageCargoComponent& c, const ModuleOwnerComponent& o)
|
||||
f.admin.forEach<SalvagerComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity ce, SalvagerComponent& s, const ModuleOwnerComponent& o)
|
||||
{
|
||||
if (o.owner == ship && blocked == entt::null)
|
||||
{
|
||||
c.cooldownTicksRemaining = 99;
|
||||
s.cooldownTicksRemaining = 99;
|
||||
blocked = ce;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
#include "RepairBehavior.h"
|
||||
#include "RepairToolComponent.h"
|
||||
#include "RetreatBehavior.h"
|
||||
#include "CargoComponent.h"
|
||||
#include "Rotation.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "SalvageScrapBehavior.h"
|
||||
#include "SalvagerComponent.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "ShipLayout.h"
|
||||
@@ -52,8 +53,8 @@ static entt::entity firstWeaponChild(EntityAdmin& admin, entt::entity ship)
|
||||
static entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship)
|
||||
{
|
||||
entt::entity result = entt::null;
|
||||
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity ce, const SalvageCargoComponent&, const ModuleOwnerComponent& o)
|
||||
admin.forEach<SalvagerComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity ce, const SalvagerComponent&, const ModuleOwnerComponent& o)
|
||||
{
|
||||
if (o.owner == ship && result == entt::null) { result = ce; }
|
||||
});
|
||||
@@ -220,8 +221,9 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
|
||||
// salvager: cargo_capacity_formula = "10", collection_range_m_formula = "500" m → 500/10 = 50 tiles
|
||||
const entt::entity sc = firstSalvageChild(admin, e);
|
||||
REQUIRE(admin.isValid(sc));
|
||||
REQUIRE(admin.get<SalvageCargoComponent>(sc).capacity == 10);
|
||||
REQUIRE(admin.get<SalvageCargoComponent>(sc).current == 0);
|
||||
// Cargo capacity is now a ship-level pool (REQ-MOD-CARGO-CAPACITY).
|
||||
REQUIRE(admin.get<CargoComponent>(e).maxCapacity == 10);
|
||||
REQUIRE(admin.get<CargoComponent>(e).current == 0);
|
||||
REQUIRE(admin.get<DeliverScrapBehavior>(e).deliveryBay == kInvalidBuildingId);
|
||||
REQUIRE_FALSE(admin.get<SalvageScrapBehavior>(e).scrapTarget.has_value());
|
||||
REQUIRE(admin.get<SalvageScrapBehavior>(e).maxCollectionRange_tiles == Approx(50.0f));
|
||||
|
||||
@@ -12,6 +12,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -28,5 +29,6 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -102,7 +102,6 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
||||
, m_sim(sim)
|
||||
, m_config(config)
|
||||
, m_visuals(visuals)
|
||||
, m_wallMs(0)
|
||||
, m_gameSpeedMultiplier(1.0)
|
||||
, m_prevNonZeroSpeed(1.0)
|
||||
, m_scrollXTiles(0.0f)
|
||||
@@ -144,7 +143,6 @@ void GameWorldView::initializeGL()
|
||||
void GameWorldView::onFrame()
|
||||
{
|
||||
const qint64 elapsed = m_frameTimer.restart();
|
||||
m_wallMs += elapsed;
|
||||
|
||||
// Advance simulation
|
||||
{
|
||||
@@ -166,12 +164,14 @@ void GameWorldView::onFrame()
|
||||
}
|
||||
}
|
||||
|
||||
// Expire old beams
|
||||
// Expire old beams. Lifetime is measured in game ticks so beams stay
|
||||
// visible while the simulation is paused or slowed (REQ-SHP-FIRING-BEAM).
|
||||
{
|
||||
const Tick now = m_sim->currentTick();
|
||||
std::vector<ActiveBeam> live;
|
||||
for (const ActiveBeam& b : m_activeBeams)
|
||||
{
|
||||
if (m_wallMs - b.emittedWallMs < kBeamLifetimeMs)
|
||||
if (now - b.event.emittedAt < kBeamLifetimeTicks)
|
||||
{
|
||||
live.push_back(b);
|
||||
}
|
||||
@@ -1262,6 +1262,7 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
||||
{
|
||||
if (m_builderType.has_value()) { exitBuilderMode(); }
|
||||
else if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
|
||||
else if (m_demolishMode) { toggleDemolishMode(); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1602,7 +1603,6 @@ void GameWorldView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
|
||||
|
||||
ActiveBeam beam;
|
||||
beam.event = *event;
|
||||
beam.emittedWallMs = m_wallMs;
|
||||
beam.targetOffset = QVector2D(radius * std::cos(angle),
|
||||
radius * std::sin(angle));
|
||||
m_activeBeams.push_back(beam);
|
||||
|
||||
@@ -141,11 +141,12 @@ private:
|
||||
struct ActiveBeam
|
||||
{
|
||||
BeamFiredEvent event;
|
||||
qint64 emittedWallMs;
|
||||
QVector2D targetOffset;
|
||||
};
|
||||
|
||||
static constexpr qint64 kBeamLifetimeMs = 300;
|
||||
// Beam lifetime in game ticks so beams freeze with the simulation when
|
||||
// paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM).
|
||||
static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3);
|
||||
static constexpr float kScrollSpeedTilesPerSec = 10.0f;
|
||||
|
||||
Simulation* m_sim;
|
||||
@@ -154,7 +155,6 @@ private:
|
||||
|
||||
TickDriver m_tickDriver;
|
||||
QElapsedTimer m_frameTimer;
|
||||
qint64 m_wallMs;
|
||||
std::mt19937 m_rng;
|
||||
double m_gameSpeedMultiplier;
|
||||
double m_prevNonZeroSpeed;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "BuildingSystem.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "GameWorldView.h"
|
||||
#include "RecipeSelectionDialog.h"
|
||||
#include "SchematicChoiceDialog.h"
|
||||
#include "HeaderBar.h"
|
||||
#include "SelectedBuildingPanel.h"
|
||||
@@ -232,6 +233,35 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
|
||||
m_gameWorldView->resetFrameTimer();
|
||||
}
|
||||
|
||||
void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event)
|
||||
{
|
||||
const double prevSpeed = m_gameWorldView->gameSpeed();
|
||||
m_gameWorldView->setGameSpeed(0.0);
|
||||
|
||||
const Building* b = m_sim->buildings().findBuilding(event->buildingId);
|
||||
if (!b)
|
||||
{
|
||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||
m_gameWorldView->resetFrameTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const std::vector<RecipeSelectionOption> options =
|
||||
buildRecipeSelectionOptions(*b, *m_sim, m_sim->config());
|
||||
const QString title = (b->type == BuildingType::Shipyard)
|
||||
? tr("Select Schematic")
|
||||
: tr("Select Recipe");
|
||||
|
||||
RecipeSelectionDialog dialog(options, title, this);
|
||||
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
|
||||
{
|
||||
m_sim->buildings().setRecipe(event->buildingId, *dialog.getChosenId());
|
||||
}
|
||||
|
||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||
m_gameWorldView->resetFrameTimer();
|
||||
}
|
||||
|
||||
void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
|
||||
{
|
||||
const Tick tick = m_sim->currentTick();
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "EventHandler.h"
|
||||
#include "GameOverEvent.h"
|
||||
#include "LayoutDialogRequestedEvent.h"
|
||||
#include "RecipeSelectionRequestedEvent.h"
|
||||
#include "SchematicChoicesAvailableEvent.h"
|
||||
#include "ShipLayoutBlueprint.h"
|
||||
#include "Tick.h"
|
||||
@@ -30,7 +31,8 @@ class MainWindow : public QWidget,
|
||||
SchematicChoicesAvailableEvent,
|
||||
GameOverEvent,
|
||||
EscapeMenuRequestedEvent,
|
||||
LayoutDialogRequestedEvent>
|
||||
LayoutDialogRequestedEvent,
|
||||
RecipeSelectionRequestedEvent>
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
@@ -48,6 +50,7 @@ private:
|
||||
void handleEvent(std::shared_ptr<const GameOverEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override;
|
||||
void layoutPanels();
|
||||
|
||||
private:
|
||||
|
||||
172
src/ui/RecipeSelectionDialog.cpp
Normal file
172
src/ui/RecipeSelectionDialog.cpp
Normal file
@@ -0,0 +1,172 @@
|
||||
#include "RecipeSelectionDialog.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
#include <QGridLayout>
|
||||
#include <QPushButton>
|
||||
#include <QStringList>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "Building.h"
|
||||
#include "BuildingType.h"
|
||||
#include "DisplayName.h"
|
||||
#include "GameConfig.h"
|
||||
#include "RecipesConfig.h"
|
||||
#include "ShipsConfig.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
QString itemLine(const std::string& itemId, int amount)
|
||||
{
|
||||
return QStringLiteral(" ")
|
||||
+ QString::fromStdString(toDisplayName(itemId))
|
||||
+ QStringLiteral(" ×") + QString::number(amount);
|
||||
}
|
||||
|
||||
QString recipeTooltip(const RecipeDef& recipe)
|
||||
{
|
||||
QStringList lines;
|
||||
lines << QObject::tr("Recipe: %1")
|
||||
.arg(QString::fromStdString(toDisplayName(recipe.id)));
|
||||
|
||||
if (recipe.inputs.empty())
|
||||
{
|
||||
lines << QObject::tr("Inputs: none");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines << QObject::tr("Inputs:");
|
||||
for (const RecipeIngredient& ingredient : recipe.inputs)
|
||||
{
|
||||
lines << itemLine(ingredient.item, ingredient.amount);
|
||||
}
|
||||
}
|
||||
|
||||
lines << QObject::tr("Completion time: %1 s").arg(recipe.durationSeconds);
|
||||
|
||||
if (!recipe.outputs.empty())
|
||||
{
|
||||
lines << QObject::tr("Produces:");
|
||||
for (const RecipeOutput& output : recipe.outputs)
|
||||
{
|
||||
lines << itemLine(output.item, output.amount);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
QString shipTooltip(const ShipDef& def)
|
||||
{
|
||||
const QString name = QString::fromStdString(toDisplayName(def.id));
|
||||
|
||||
QStringList lines;
|
||||
lines << QObject::tr("Ship: %1").arg(name);
|
||||
|
||||
if (def.schematic.materials.empty())
|
||||
{
|
||||
lines << QObject::tr("Materials: none");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines << QObject::tr("Materials:");
|
||||
for (const RecipeIngredient& material : def.schematic.materials)
|
||||
{
|
||||
lines << itemLine(material.item, material.amount);
|
||||
}
|
||||
}
|
||||
|
||||
lines << QObject::tr("Completion time: %1 s")
|
||||
.arg(def.schematic.productionTimeSeconds);
|
||||
lines << QObject::tr("Produces: 1 %1").arg(name);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
|
||||
const Building& building, Simulation& sim, const GameConfig& config)
|
||||
{
|
||||
std::vector<RecipeSelectionOption> options;
|
||||
options.push_back({std::string(), QObject::tr("(None)"), QString()});
|
||||
|
||||
if (building.type == BuildingType::Shipyard)
|
||||
{
|
||||
for (const ShipDef& def : config.ships.ships)
|
||||
{
|
||||
if (!sim.isSchematicUnlocked(def.id)) { continue; }
|
||||
options.push_back({def.id,
|
||||
QString::fromStdString(toDisplayName(def.id)),
|
||||
shipTooltip(def)});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (const RecipeDef& recipe : config.recipes.recipes)
|
||||
{
|
||||
if (recipe.building != building.type) { continue; }
|
||||
if ((building.type == BuildingType::Miner
|
||||
|| building.type == BuildingType::Assembler)
|
||||
&& !sim.isRecipeUnlocked(recipe.id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
options.push_back({recipe.id,
|
||||
QString::fromStdString(toDisplayName(recipe.id)),
|
||||
recipeTooltip(recipe)});
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
RecipeSelectionDialog::RecipeSelectionDialog(
|
||||
const std::vector<RecipeSelectionOption>& options,
|
||||
const QString& title, QWidget* parent)
|
||||
: QDialog(parent)
|
||||
{
|
||||
setWindowTitle(title);
|
||||
setModal(true);
|
||||
|
||||
QVBoxLayout* mainLayout = new QVBoxLayout(this);
|
||||
QGridLayout* grid = new QGridLayout();
|
||||
mainLayout->addLayout(grid);
|
||||
|
||||
const int columnCount = 2;
|
||||
for (int i = 0; i < static_cast<int>(options.size()); ++i)
|
||||
{
|
||||
const RecipeSelectionOption& option = options[static_cast<std::size_t>(i)];
|
||||
|
||||
QPushButton* button = new QPushButton(option.caption, this);
|
||||
if (!option.tooltip.isEmpty())
|
||||
{
|
||||
button->setToolTip(option.tooltip);
|
||||
}
|
||||
button->setMinimumHeight(40);
|
||||
grid->addWidget(button, i / columnCount, i % columnCount);
|
||||
|
||||
m_optionIds.push_back(option.id);
|
||||
const int index = i;
|
||||
connect(button, &QPushButton::clicked, this, [this, index]()
|
||||
{
|
||||
onOptionClicked(index);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<std::string> RecipeSelectionDialog::getChosenId() const
|
||||
{
|
||||
return m_chosenId;
|
||||
}
|
||||
|
||||
void RecipeSelectionDialog::onOptionClicked(int index)
|
||||
{
|
||||
if (index >= 0 && index < static_cast<int>(m_optionIds.size()))
|
||||
{
|
||||
m_chosenId = m_optionIds[static_cast<std::size_t>(index)];
|
||||
}
|
||||
accept();
|
||||
}
|
||||
50
src/ui/RecipeSelectionDialog.h
Normal file
50
src/ui/RecipeSelectionDialog.h
Normal file
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QDialog>
|
||||
#include <QString>
|
||||
|
||||
struct Building;
|
||||
struct GameConfig;
|
||||
class Simulation;
|
||||
class QPushButton;
|
||||
|
||||
// One selectable entry in the recipe/schematic selection dialog
|
||||
// (REQ-UI-SELECT-BUTTON). The "(None)" entry uses an empty id.
|
||||
struct RecipeSelectionOption
|
||||
{
|
||||
std::string id;
|
||||
QString caption;
|
||||
QString tooltip;
|
||||
};
|
||||
|
||||
// Builds the lock-aware list of selectable options for a production building,
|
||||
// with display captions and info tooltips (REQ-UI-SELECT-BUTTON,
|
||||
// REQ-UI-SELECT-TOOLTIP). The first entry is always a "(None)" option with an
|
||||
// empty id. Shared by the selection dialog and the panel selection button so
|
||||
// both render identical captions and tooltips.
|
||||
std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
|
||||
const Building& building, Simulation& sim, const GameConfig& config);
|
||||
|
||||
// Modal dialog showing a grid of option buttons (REQ-UI-SELECT-BUTTON). The
|
||||
// game is paused by the caller while it is open. Clicking an option selects it
|
||||
// and closes the dialog; dismissing it (close/Esc) leaves no choice.
|
||||
class RecipeSelectionDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
RecipeSelectionDialog(const std::vector<RecipeSelectionOption>& options,
|
||||
const QString& title, QWidget* parent = nullptr);
|
||||
|
||||
std::optional<std::string> getChosenId() const;
|
||||
|
||||
private:
|
||||
void onOptionClicked(int index);
|
||||
|
||||
std::vector<std::string> m_optionIds;
|
||||
std::optional<std::string> m_chosenId;
|
||||
};
|
||||
@@ -6,7 +6,6 @@
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QLabel>
|
||||
#include <QListWidget>
|
||||
#include <QPushButton>
|
||||
@@ -32,6 +31,8 @@
|
||||
#include "ItemType.h"
|
||||
#include "LayoutDialogRequestedEvent.h"
|
||||
#include "ModulesConfig.h"
|
||||
#include "RecipeSelectionDialog.h"
|
||||
#include "RecipeSelectionRequestedEvent.h"
|
||||
#include "Rotation.h"
|
||||
#include "ShipLayoutPreview.h"
|
||||
#include "Simulation.h"
|
||||
@@ -110,7 +111,7 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
|
||||
m_layout->setAlignment(Qt::AlignTop);
|
||||
|
||||
m_titleLabel = new QLabel(this);
|
||||
m_recipeCombo = new QComboBox(this);
|
||||
m_recipeSelectButton = new QPushButton(this);
|
||||
m_clearBeltBtn = new QPushButton(tr("Clear Items"), this);
|
||||
m_filterALabel = new QLabel(this);
|
||||
m_filterAList = new QListWidget(this);
|
||||
@@ -125,7 +126,7 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
|
||||
m_filterBList->setMaximumHeight(100);
|
||||
|
||||
m_layout->addWidget(m_titleLabel);
|
||||
m_layout->addWidget(m_recipeCombo);
|
||||
m_layout->addWidget(m_recipeSelectButton);
|
||||
m_layout->addWidget(m_layoutPreview);
|
||||
m_layout->addWidget(m_configureLayoutBtn);
|
||||
m_layout->addWidget(m_clearBeltBtn);
|
||||
@@ -135,8 +136,8 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
|
||||
m_layout->addWidget(m_filterBList);
|
||||
m_layout->addWidget(m_buffersLabel);
|
||||
|
||||
connect(m_recipeCombo, qOverload<int>(&QComboBox::currentIndexChanged),
|
||||
this, &SelectedBuildingPanel::onRecipeChanged);
|
||||
connect(m_recipeSelectButton, &QPushButton::clicked,
|
||||
this, &SelectedBuildingPanel::onSelectRecipeClicked);
|
||||
connect(m_clearBeltBtn, &QPushButton::clicked,
|
||||
this, &SelectedBuildingPanel::onClearBelt);
|
||||
connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() {
|
||||
@@ -203,11 +204,10 @@ void SelectedBuildingPanel::rebuild()
|
||||
}
|
||||
}
|
||||
|
||||
void SelectedBuildingPanel::clearContent()
|
||||
void SelectedBuildingPanel::hideAllWidgets()
|
||||
{
|
||||
m_singleBuildingId = kInvalidBuildingId;
|
||||
m_titleLabel->hide();
|
||||
m_recipeCombo->hide();
|
||||
m_recipeSelectButton->hide();
|
||||
m_layoutPreview->hide();
|
||||
m_configureLayoutBtn->hide();
|
||||
m_clearBeltBtn->hide();
|
||||
@@ -218,6 +218,12 @@ void SelectedBuildingPanel::clearContent()
|
||||
m_buffersLabel->hide();
|
||||
}
|
||||
|
||||
void SelectedBuildingPanel::clearContent()
|
||||
{
|
||||
m_singleBuildingId = kInvalidBuildingId;
|
||||
hideAllWidgets();
|
||||
}
|
||||
|
||||
void SelectedBuildingPanel::buildEmpty()
|
||||
{
|
||||
clearContent();
|
||||
@@ -229,6 +235,7 @@ void SelectedBuildingPanel::buildEmpty()
|
||||
void SelectedBuildingPanel::buildSingle(BuildingId id)
|
||||
{
|
||||
m_singleBuildingId = id;
|
||||
hideAllWidgets();
|
||||
|
||||
const Building* b = m_sim->buildings().findBuilding(id);
|
||||
if (!b)
|
||||
@@ -279,43 +286,33 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
|
||||
|
||||
if (isProductionBuilding(b->type))
|
||||
{
|
||||
m_recipeCombo->blockSignals(true);
|
||||
m_recipeCombo->clear();
|
||||
const std::vector<RecipeSelectionOption> options =
|
||||
buildRecipeSelectionOptions(*b, *m_sim, *m_config);
|
||||
|
||||
m_recipeCombo->addItem(tr("(none)"), QString());
|
||||
|
||||
if (b->type == BuildingType::Shipyard)
|
||||
const RecipeSelectionOption* current = nullptr;
|
||||
for (const RecipeSelectionOption& option : options)
|
||||
{
|
||||
for (const ShipDef& def : m_config->ships.ships)
|
||||
if (option.id == b->recipeId)
|
||||
{
|
||||
if (m_sim->isSchematicUnlocked(def.id))
|
||||
{
|
||||
m_recipeCombo->addItem(
|
||||
QString::fromStdString(def.id),
|
||||
QString::fromStdString(def.id));
|
||||
}
|
||||
current = &option;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (current && !current->id.empty())
|
||||
{
|
||||
m_recipeSelectButton->setText(current->caption);
|
||||
m_recipeSelectButton->setToolTip(current->tooltip);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (const RecipeDef& recipe : m_config->recipes.recipes)
|
||||
{
|
||||
if (recipe.building != b->type) { continue; }
|
||||
if ((b->type == BuildingType::Miner || b->type == BuildingType::Assembler)
|
||||
&& !m_sim->isRecipeUnlocked(recipe.id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
m_recipeCombo->addItem(
|
||||
QString::fromStdString(recipe.id),
|
||||
QString::fromStdString(recipe.id));
|
||||
}
|
||||
const QString placeholder = (b->type == BuildingType::Shipyard)
|
||||
? tr("Select schematic")
|
||||
: tr("Select recipe");
|
||||
m_recipeSelectButton->setText(placeholder);
|
||||
m_recipeSelectButton->setToolTip(QString());
|
||||
}
|
||||
|
||||
const int currentIdx = m_recipeCombo->findData(QString::fromStdString(b->recipeId));
|
||||
m_recipeCombo->setCurrentIndex(currentIdx >= 0 ? currentIdx : 0);
|
||||
m_recipeCombo->blockSignals(false);
|
||||
m_recipeCombo->show();
|
||||
m_recipeSelectButton->show();
|
||||
|
||||
if (b->type == BuildingType::Shipyard && !b->recipeId.empty())
|
||||
{
|
||||
@@ -346,7 +343,7 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
|
||||
}
|
||||
else
|
||||
{
|
||||
m_recipeCombo->hide();
|
||||
m_recipeSelectButton->hide();
|
||||
m_layoutPreview->hide();
|
||||
m_configureLayoutBtn->hide();
|
||||
}
|
||||
@@ -576,7 +573,7 @@ void SelectedBuildingPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent>
|
||||
void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
|
||||
{
|
||||
m_singleBuildingId = kInvalidBuildingId;
|
||||
m_recipeCombo->hide();
|
||||
m_recipeSelectButton->hide();
|
||||
m_clearBeltBtn->hide();
|
||||
m_filterALabel->hide();
|
||||
m_filterAList->hide();
|
||||
@@ -620,14 +617,18 @@ void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
|
||||
}
|
||||
}
|
||||
|
||||
void SelectedBuildingPanel::onRecipeChanged(int comboIndex)
|
||||
void SelectedBuildingPanel::onSelectRecipeClicked()
|
||||
{
|
||||
if (m_singleBuildingId == kInvalidBuildingId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const QString recipeId = m_recipeCombo->itemData(comboIndex).toString();
|
||||
m_sim->buildings().setRecipe(m_singleBuildingId, recipeId.toStdString());
|
||||
// The emit is synchronous: MainWindow pauses the game, runs the modal
|
||||
// selection dialog, applies the chosen recipe/schematic, and restores the
|
||||
// speed before this returns. rebuild() then refreshes the button caption,
|
||||
// tooltip, preview, and buffers for the new selection.
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<RecipeSelectionRequestedEvent>(m_singleBuildingId));
|
||||
rebuild();
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ class Simulation;
|
||||
class ShipLayoutPreview;
|
||||
class ShipStatsPanel;
|
||||
class QLabel;
|
||||
class QComboBox;
|
||||
class QListWidget;
|
||||
class QPushButton;
|
||||
class QVBoxLayout;
|
||||
@@ -51,13 +50,14 @@ private:
|
||||
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
|
||||
|
||||
private slots:
|
||||
void onRecipeChanged(int comboIndex);
|
||||
void onSelectRecipeClicked();
|
||||
void onClearBelt();
|
||||
void onSplitterFilterChanged();
|
||||
|
||||
private:
|
||||
void onSelectionChanged(const std::vector<BuildingId>& ids);
|
||||
void rebuild();
|
||||
void hideAllWidgets();
|
||||
void clearContent();
|
||||
void buildEmpty();
|
||||
void buildSingle(BuildingId id);
|
||||
@@ -74,7 +74,7 @@ private:
|
||||
|
||||
QVBoxLayout* m_layout;
|
||||
QLabel* m_titleLabel;
|
||||
QComboBox* m_recipeCombo;
|
||||
QPushButton* m_recipeSelectButton;
|
||||
QPushButton* m_clearBeltBtn;
|
||||
QLabel* m_filterALabel;
|
||||
QListWidget* m_filterAList;
|
||||
|
||||
@@ -50,6 +50,8 @@ ShipStatsPanel::ShipStatsPanel(const GameConfig* config, QWidget* parent)
|
||||
m_maneuveringAccelLabel = makeStatLabel(this);
|
||||
m_angularAccelLabel = makeStatLabel(this);
|
||||
m_maxRotSpeedLabel = makeStatLabel(this);
|
||||
m_cargoCapacityLabel = makeStatLabel(this);
|
||||
m_cargoCapacityLabel->setVisible(false);
|
||||
|
||||
layout->addWidget(m_hpLabel);
|
||||
layout->addWidget(m_speedLabel);
|
||||
@@ -58,6 +60,7 @@ ShipStatsPanel::ShipStatsPanel(const GameConfig* config, QWidget* parent)
|
||||
layout->addWidget(m_maneuveringAccelLabel);
|
||||
layout->addWidget(m_angularAccelLabel);
|
||||
layout->addWidget(m_maxRotSpeedLabel);
|
||||
layout->addWidget(m_cargoCapacityLabel);
|
||||
|
||||
// Weapon capability section.
|
||||
m_weaponSection = new QWidget(this);
|
||||
@@ -151,6 +154,19 @@ void ShipStatsPanel::applyStats(const ShipStats& stats, const QString& hpText)
|
||||
m_maxRotSpeedLabel->setText(
|
||||
tr("Max Rotation: %1 rad/s").arg(fmt(stats.maxRotationSpeed_radps)));
|
||||
|
||||
// Cargo capacity is shown only when the ship can actually hold cargo
|
||||
// (REQ-MOD-UI-STATS-PANEL).
|
||||
if (stats.cargoCapacity > 0)
|
||||
{
|
||||
m_cargoCapacityLabel->setText(
|
||||
tr("Cargo Capacity: %1").arg(stats.cargoCapacity));
|
||||
m_cargoCapacityLabel->setVisible(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
m_cargoCapacityLabel->setVisible(false);
|
||||
}
|
||||
|
||||
if (stats.weapons.has_value())
|
||||
{
|
||||
m_weaponDpsLabel->setText(
|
||||
|
||||
@@ -42,6 +42,7 @@ private:
|
||||
QLabel* m_maneuveringAccelLabel;
|
||||
QLabel* m_angularAccelLabel;
|
||||
QLabel* m_maxRotSpeedLabel;
|
||||
QLabel* m_cargoCapacityLabel;
|
||||
|
||||
QWidget* m_weaponSection;
|
||||
QLabel* m_weaponDpsLabel;
|
||||
|
||||
Reference in New Issue
Block a user