change repair_tool application and add beams for salvager and repair_tool

This commit is contained in:
2026-06-18 22:14:09 +02:00
parent 7924e037aa
commit 9573b9789a
37 changed files with 498 additions and 199 deletions

View File

@@ -105,7 +105,8 @@ fill_color = "#66CCFF"
glyph = "Rp" glyph = "Rp"
[module.repair] [module.repair]
repair_rate_hz_formula = "5 + x" repair_rate_hz_formula = "1"
repair_amount_hp_formula = "5 + x"
repair_range_m_formula = "80" repair_range_m_formula = "80"
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@@ -325,7 +325,9 @@ outline = "#ffffff"
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
[beams] [beams]
color = "#ff6600" weapon_color = "#ff6600"
repair_color = "#33ff66"
salvage_color = "#33ccff"
width_px = 2 width_px = 2
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@@ -78,7 +78,8 @@ fill_color = "#66CCFF"
glyph = "Rp" glyph = "Rp"
[module.repair] [module.repair]
repair_rate_hz_formula = "5 + x" repair_rate_hz_formula = "1"
repair_amount_hp_formula = "5 + x"
repair_range_m_formula = "800" repair_range_m_formula = "800"
[[module]] [[module]]

View File

@@ -59,7 +59,7 @@ Simulation types shared across subsystems:
- `Item``struct Item { ItemType type; }`. Items on belts have no persistent identity across ticks. - `Item``struct Item { ItemType type; }`. Items on belts have no persistent identity across ticks.
- `Port``struct Port { QPoint tile; Rotation direction; }`. Identifies a belt-adjacent cell and the direction of flow across that cell. - `Port``struct Port { QPoint tile; Rotation direction; }`. Identifies a belt-adjacent cell and the direction of flow across that cell.
- `MovementIntent``struct MovementIntent { bool active; QVector2D target; }`. Written by the winning behavior's executor (see Movement Arbitration). Cleared (`active = false`) at the start of each tick; `tickMovement` brakes when inactive, otherwise drives toward `target`. - `MovementIntent``struct MovementIntent { bool active; QVector2D target; }`. Written by the winning behavior's executor (see Movement Arbitration). Cleared (`active = false`) at the start of each tick; `tickMovement` brakes when inactive, otherwise drives toward `target`.
- `WeaponFiredEvent``struct WeaponFiredEvent : public Event { entt::entity shooter; entt::entity target; Tick emittedAt; }`. Transient record emitted each time a weapon fires (REQ-SHP-FIRING, REQ-SHP-FIRING-BEAM). Buffered in a sim-owned vector during the tick, then drained and re-emitted via EventManager by the UI frame handler; see Sim → UI Events. - `BeamFiredEvent``struct BeamFiredEvent : public Event { BeamKind kind; entt::entity shooter; entt::entity target; Tick emittedAt; }`. Transient record emitted each time a weapon fires, a repair tool starts a heal cycle, or a salvage module starts a collection cycle (REQ-SHP-FIRING, REQ-SHP-FIRING-BEAM). `BeamKind` (`Weapon`/`Repair`/`Salvage`) selects the beam color. Buffered in a sim-owned vector during the tick, then drained and re-emitted via EventManager by the UI frame handler; see Sim → UI Events.
- `SchematicChoiceOption``struct SchematicChoiceOption { string schematicId; SchematicType type; string displayName; bool isNewUnlock; int targetLevel; }`. Describes one option in the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP). Up to three are generated when an enemy station set is destroyed. `SchematicType` is `Ship`, `Module`, or `Recipe`. - `SchematicChoiceOption``struct SchematicChoiceOption { string schematicId; SchematicType type; string displayName; bool isNewUnlock; int targetLevel; }`. Describes one option in the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP). Up to three are generated when an enemy station set is destroyed. `SchematicType` is `Ship`, `Module`, or `Recipe`.
- `SchematicChoicesAvailableEvent` — EventManager event carrying a `vector<SchematicChoiceOption>`. Sent by the UI each frame when pending choices are detected; handled by `MainWindow` which opens the schematic choice dialog. - `SchematicChoicesAvailableEvent` — EventManager event carrying a `vector<SchematicChoiceOption>`. Sent by the UI each frame when pending choices are detected; handled by `MainWindow` which opens the schematic choice dialog.
@@ -85,9 +85,9 @@ The EventManager is thread-safe (mutex-guarded).
### Sim → UI Events ### Sim → UI Events
The simulation layer stays free of EventManager — it uses a plain `std::vector<WeaponFiredEvent>` internally (owned by `CombatSystem`). This preserves determinism, tick-order fidelity, and headless testability (Catch2 tests read the queue directly via `drainWeaponFiredEvents()` after `tick()`). The simulation layer stays free of EventManager — it uses a plain `std::vector<BeamFiredEvent>` internally (owned by `Simulation`, filled by the combat, repair, and salvage systems). This preserves determinism, tick-order fidelity, and headless testability (Catch2 tests read the queue directly via `drainBeamFiredEvents()` after `tick()`).
The UI frame handler (`GameWorldView::onFrame` / `ArenaView::onFrame`) bridges the gap: each frame it calls `simulation.drainWeaponFiredEvents()`, then re-emits each `WeaponFiredEvent` via `EventManager::sendEventImmediately()`. Subscribers (the same view's `handleEvent(WeaponFiredEvent)`) create `ActiveBeam` records tracked for 0.3 s of wall time, then discarded. If either the shooter or target entity is gone when the renderer looks them up, the beam is dropped early. The UI frame handler (`GameWorldView::onFrame` / `ArenaView::onFrame`) bridges the gap: each frame it calls `simulation.drainBeamFiredEvents()`, then re-emits each `BeamFiredEvent` via `EventManager::sendEventImmediately()`. Subscribers (the same view's `handleEvent(BeamFiredEvent)`) create `ActiveBeam` records tracked for 0.3 s of wall time, then discarded. If either the shooter or target entity is gone when the renderer looks them up, the beam is dropped early.
Schematic drops: when an enemy station set is destroyed, the simulation generates up to 3 `SchematicChoiceOption` entries and stores them as pending state. The UI polls `hasSchematicChoicesPending()` each frame and, when true, sends a `SchematicChoicesAvailableEvent` via EventManager. `MainWindow` handles this event by pausing the game and opening a modal `SchematicChoiceDialog`. The player's selection is fed back via `applySchematicChoice(index)`. Schematic drops: when an enemy station set is destroyed, the simulation generates up to 3 `SchematicChoiceOption` entries and stores them as pending state. The UI polls `hasSchematicChoicesPending()` each frame and, when true, sends a `SchematicChoicesAvailableEvent` via EventManager. `MainWindow` handles this event by pausing the game and opening a modal `SchematicChoiceDialog`. The player's selection is fed back via `applySchematicChoice(index)`.
@@ -108,7 +108,7 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
5. **Building → belt push** — buildings push items from output buffer onto the belt tile at their output port (REQ-MAT-OUTPUT-PORT). 5. **Building → belt push** — buildings push items from output buffer onto the belt tile at their output port (REQ-MAT-OUTPUT-PORT).
6. **Belt tick** — advance items along belt tiles; apply splitter routing (REQ-BLD-SPLITTER). 6. **Belt tick** — advance items along belt tiles; apply splitter routing (REQ-BLD-SPLITTER).
7. **Ship behavior systems** — clear `MovementIntent` on each ship, then the `AiSystem` runs three batched phases: every behavior **evaluator** scores its behavior and sets its target data; a **selection** pass records the highest-scoring behavior per ship in `SelectedBehaviorComponent`; each behavior **executor** runs for the winner, writing `MovementIntent` and preferred module targets. The module systems then perform world mutation: `SalvagerSystem` (scrap collection/delivery) and `RepairSystem` (healing). See Movement Arbitration. 7. **Ship behavior systems** — clear `MovementIntent` on each ship, then the `AiSystem` runs three batched phases: every behavior **evaluator** scores its behavior and sets its target data; a **selection** pass records the highest-scoring behavior per ship in `SelectedBehaviorComponent`; each behavior **executor** runs for the winner, writing `MovementIntent` and preferred module targets. The module systems then perform world mutation: `SalvagerSystem` (scrap collection/delivery) and `RepairSystem` (healing). See Movement Arbitration.
8. **Combat resolution** — ships and defence stations validate/acquire targets, fire, apply damage; queue deaths. Each fire appends a `WeaponFiredEvent` to the sim's weapon-fired-event queue (REQ-SHP-FIRING-BEAM). 8. **Combat resolution** — ships and defence stations validate/acquire targets, fire, apply damage; queue deaths. Each fire appends a `BeamFiredEvent` to the sim's beam-fired-event queue (REQ-SHP-FIRING-BEAM). The repair and salvage module systems (tick step 7d) append their own `BeamFiredEvent`s to the same queue when they start a cycle.
9. **Deaths & loot** — process queued deaths: drop scrap (REQ-RES-SCRAP-DROP); if a full enemy-defence-station set was destroyed this tick, generate up to 3 schematic choice options (REQ-DEF-SCHEMATIC-DROP) stored as pending state for the UI to present; remove entities. 9. **Deaths & loot** — process queued deaths: drop scrap (REQ-RES-SCRAP-DROP); if a full enemy-defence-station set was destroyed this tick, generate up to 3 schematic choice options (REQ-DEF-SCHEMATIC-DROP) stored as pending state for the UI to present; remove entities.
10. **`tickMovement`** — advance ship positions based on final `MovementIntent`. 10. **`tickMovement`** — advance ship positions based on final `MovementIntent`.
11. **Scrap despawn** — decrement scrap timers; remove expired scrap (REQ-RES-SCRAP-DROP). 11. **Scrap despawn** — decrement scrap timers; remove expired scrap (REQ-RES-SCRAP-DROP).
@@ -212,7 +212,8 @@ Ships follow a component-composition model using `std::optional<Component>` memb
struct Weapon { float damage; float range; float fireRateHz; float cooldownTicks; struct Weapon { float damage; float range; float fireRateHz; float cooldownTicks;
std::optional<EntityId> currentTarget; }; std::optional<EntityId> currentTarget; };
struct SalvageCargo { int capacity; int current; }; struct SalvageCargo { int capacity; int current; };
struct RepairTool { float ratePerTick; std::optional<EntityId> currentTarget; }; struct RepairTool { float repairAmountHp; int repairIntervalTicks; int cooldownTicksRemaining;
float range; std::optional<EntityId> currentTarget; };
``` ```
### Behavior Components ### Behavior Components
@@ -308,7 +309,7 @@ The game world is rendered by a single `GameWorldView` widget that inherits `QOp
### Threading ### Threading
Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly without locks. If profiling later justifies moving the sim to a worker thread, the pull-style `drainWeaponFiredEvents()` / `getPendingSchematicChoices()` / `applySchematicChoice()` / `forEachVisualItem()` APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice. The `ArenaSimulation` used by the balancing tool runs headlessly on a worker thread; fire events accumulate in its internal vector and are only drained when `ArenaView` drives `tickOnce()` on the main thread during interactive inspection. Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly without locks. If profiling later justifies moving the sim to a worker thread, the pull-style `drainBeamFiredEvents()` / `getPendingSchematicChoices()` / `applySchematicChoice()` / `forEachVisualItem()` APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice. The `ArenaSimulation` used by the balancing tool runs headlessly on a worker thread; fire events accumulate in its internal vector and are only drained when `ArenaView` drives `tickOnce()` on the main thread during interactive inspection.
### Layer Order (back to front) ### Layer Order (back to front)
@@ -317,7 +318,7 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly
3. **Belt items** — 10×10 colored squares emitted by `BeltSystem::forEachVisualItem`. 3. **Belt items** — 10×10 colored squares emitted by `BeltSystem::forEachVisualItem`.
4. **Scrap** — glyphs at world positions. 4. **Scrap** — glyphs at world positions.
5. **Ships** — colored arrows oriented by velocity; color keyed to role (player combat / salvage / repair / enemy). 5. **Ships** — colored arrows oriented by velocity; color keyed to role (player combat / salvage / repair / enemy).
6. **Laser beams** — lines derived from live `WeaponFiredEvent`s kept by the renderer for 0.3 s (REQ-SHP-FIRING-BEAM). 6. **Laser beams** — lines derived from live `BeamFiredEvent`s kept by the renderer for 0.3 s, colored per `BeamKind` (weapon/repair/salvage) (REQ-SHP-FIRING-BEAM).
7. **Build overlays** — ghost in builder mode (REQ-BLD-GHOST), demolish-mode tint, tile highlight under cursor, box-drag selection rectangle. 7. **Build overlays** — ghost in builder mode (REQ-BLD-GHOST), demolish-mode tint, tile highlight under cursor, box-drag selection rectangle.
8. **Screen-space UI** — screen-anchored elements, drawn after resetting the world-space transform. 8. **Screen-space UI** — screen-anchored elements, drawn after resetting the world-space transform.

View File

@@ -10,7 +10,7 @@ Config files use the TOML format. The following config files drive game paramete
- **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as formulas of ship level, required build materials, player production level, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), a layout grid defining the ship's module slots, a `scrap_drop` loot value, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES). - **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as formulas of ship level, required build materials, player production level, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), a layout grid defining the ship's module slots, a `scrap_drop` loot value, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES).
- **modules.toml** — per module type: id, surface mask, materials list, initial player production level, production time, fill color, glyph, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the module schematic already unlocked), and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC). - **modules.toml** — per module type: id, surface mask, materials list, initial player production level, production time, fill color, glyph, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the module schematic already unlocked), and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC).
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level. - **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; beam color and width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it. - **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; a distinct beam color per tool type (weapon, repair, salvage) and beam 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. - **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. - 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.
@@ -143,7 +143,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
## Resources ## Resources
- REQ-RES-SCRAP-DROP: Destroyed ships (both player and enemy) and destroyed defence stations (both player and enemy) drop scrap at their location. The scrap amount per ship is defined in `ships.toml [ship.loot].scrap_drop`; for stations it is defined as `stations.toml [player_station].scrap_drop_formula` and `[enemy_station].scrap_drop_formula`. Scrap despawns after `world.toml [world].scrap_despawn_seconds` seconds if not collected. - REQ-RES-SCRAP-DROP: Destroyed ships (both player and enemy) and destroyed defence stations (both player and enemy) drop scrap at their location. The scrap amount per ship is defined in `ships.toml [ship.loot].scrap_drop`; for stations it is defined as `stations.toml [player_station].scrap_drop_formula` and `[enemy_station].scrap_drop_formula`. A scrap drop carries an amount; salvage modules collect it one scrap per cycle (REQ-SHP-SALVAGE), and the drop is removed from the world once its remaining amount reaches zero or `world.toml [world].scrap_despawn_seconds` seconds have elapsed since it was dropped, whichever comes first.
- REQ-RES-SCRAP-COLLECT: Scrap is collected by salvage ships and delivered to a Salvage Bay on the asteroid. From there it can be fed via belt into a smelter (same output as ore) or a Reprocessing Plant. - REQ-RES-SCRAP-COLLECT: Scrap is collected by salvage ships and delivered to a Salvage Bay on the asteroid. From there it can be fed via belt into a smelter (same output as ore) or a Reprocessing Plant.
## Ships ## Ships
@@ -163,20 +163,20 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-SHP-NO-COLLISION: Ships do not collide with each other or with defence stations; they may visually overlap. - 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-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. - 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.
- REQ-SHP-FIRING-BEAM: Each fire event produces a visual laser beam drawn from the shooter's position to the target for 0.3 seconds. The beam endpoint is not the target's center but a point randomly offset from it: the offset direction is uniformly random and the offset magnitude is uniformly random up to half the target's visual size (for ships: half their rendered radius; for buildings/stations: half the shorter side of their tile footprint, in world units). The offset is chosen once per fire event and held fixed for the beam's lifetime. The beam is a pure rendering effect and has no simulation state (does not block movement, does not re-apply damage over its lifetime). Beams follow the shooter and target positions if either moves during the 0.3-second window. The beam is rendered for its full 0.3-second duration even if the shooter or target is destroyed before it expires. - REQ-SHP-FIRING-BEAM: Each weapon fire event (REQ-SHP-FIRING), repair-tool activation (REQ-SHP-REPAIR), and salvage activation (REQ-SHP-SALVAGE) produces a visual beam drawn from the acting ship's position to the target for 0.3 seconds; repair and salvage beams have the same duration as weapon beams. The beam is rendered in the tool type's beam color from `visuals.toml` (a distinct color for weapon, repair, and salvage beams). The beam endpoint is not the target's center but a point randomly offset from it: the offset direction is uniformly random and the offset magnitude is uniformly random up to half the target's visual size (for ships: half their rendered radius; for buildings/stations: half the shorter side of their tile footprint, in world units; for a scrap pile: half its rendered size). The offset is chosen once per activation event and held fixed for the beam's lifetime. The beam is a pure rendering effect and has no simulation state (does not block movement, does not re-apply its effect over its lifetime). Beams follow the acting ship and target positions if either moves during the 0.3-second window. The beam is rendered for its full 0.3-second duration even if the acting ship or target is destroyed before it expires.
- REQ-SHP-COMBAT: Ships with at least one **weapon module** (player) — engage enemy ships within sensor range. When engaging an enemy, the ship orbits it at the combat orbit radius (REQ-SHP-ORBIT) rather than approaching its center. The player can configure the following per shipyard (applied to all ships produced by that shipyard): - REQ-SHP-COMBAT: Ships with at least one **weapon module** (player) — engage enemy ships within sensor range. When engaging an enemy, the ship orbits it at the combat orbit radius (REQ-SHP-ORBIT) rather than approaching its center. The player can configure the following per shipyard (applied to all ships produced by that shipyard):
- Stance: aggressive (advance toward enemies) / defensive (hold position near asteroid). - Stance: aggressive (advance toward enemies) / defensive (hold position near asteroid).
- Target priority: closest / highest HP / structures first. - 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-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 collects it (consuming the scrap entity). 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 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.
Each salvage module instance operates independently: it has its own cargo hold (`cargo_capacity`), collection range (`collection_range`), and collection rate (`collection_rate`, in collections per second). After collecting a piece of scrap, the module cannot collect again until `1 / collection_rate` seconds have elapsed. A ship with multiple salvage modules can therefore collect multiple pieces of scrap per tick (one per ready module), and installs of different module types may have different ranges and rates. The ship navigates based on the maximum collection range across all installed salvage modules. 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.
Salvage collection and delivery are world-state changes performed every tick 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). 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: - 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:
- Defence stations first / ships first / nearest target. - Defence stations first / ships first / nearest target.
Each repair module instance operates independently: it has its own repair rate (`repair_rate`) and repair range (`repair_range`). On each tick, a module first attempts to heal the ship's current behavior-level navigation target if that target is within the module's `repair_range` and is damaged (HP above zero and below maximum HP). If those conditions are not met — because the target is out of the module's `repair_range`, already at full health, or destroyed — the module independently searches for the nearest damaged friendly (player ship or player defence station) within its own `repair_range` and heals that instead. If no valid target is found within range, the module idles. A ship with multiple repair modules can therefore heal different targets simultaneously. Navigation is driven solely by the behavior-level target; individual module fallback targets do not affect which direction the ship moves. Repair healing is a world-state change applied every tick regardless of which behavior the ship is currently executing. Each repair module instance operates independently: it has its own repair rate (`repair_rate`, in repair cycles per second), per-cycle heal amount (`repair_amount_hp`), and repair range (`repair_range`). A module starts a repair cycle when it is off cooldown and a valid repair target is in range. To choose the target, the module first considers the ship's current behavior-level navigation target if that target is within the module's `repair_range` and is damaged (HP above zero and below maximum HP). If those conditions are not met — because the target is out of the module's `repair_range`, already at full health, or destroyed — the module independently searches for the nearest damaged friendly (player ship or player defence station) within its own `repair_range`. If no valid target is found within range, the module idles and starts no cycle. On starting a cycle, the module emits a repair beam toward the chosen target (REQ-SHP-FIRING-BEAM) and begins a 0.15-second effect delay (half the beam duration); the module's cooldown of `1 / repair_rate` seconds begins at cycle start, not at effect application. When the delay expires, `repair_amount_hp` HP is restored to the targeted entity, clamped to its maximum HP — unless that entity is no longer damaged or has been destroyed, in which case the heal is silently dropped. A ship with multiple repair modules can therefore run multiple repair cycles concurrently, healing different targets. Navigation is driven solely by the behavior-level target; individual module fallback targets do not affect which direction the ship moves. Repair cycles are processed regardless of which behavior the ship is currently executing.
- REQ-SHP-STANDBY: **Ships with at least one repair module hold with their fleet when idle**, whether or not they also carry weapon modules. Standby is a low-priority fallback — above the baseline forward advance (REQ-SHP-COMBAT/REQ-SHP-ENEMY-AI advance) but below rally (REQ-SHP-RALLY), so it only wins when no attack, repair, salvage, rally, or retreat behavior applies. A standing-by ship navigates toward the centroid of its other same-faction ships, falling back to the centroid of its own defence stations, and holding position when it has no allies. This keeps repair ships among the allies they exist to heal instead of advancing alone into the enemy. Armed repair ships therefore still rally and depart on the normal schedule (REQ-SHP-RALLY); standby only governs them once rally no longer applies. - REQ-SHP-STANDBY: **Ships with at least one repair module hold with their fleet when idle**, whether or not they also carry weapon modules. Standby is a low-priority fallback — above the baseline forward advance (REQ-SHP-COMBAT/REQ-SHP-ENEMY-AI advance) but below rally (REQ-SHP-RALLY), so it only wins when no attack, repair, salvage, rally, or retreat behavior applies. A standing-by ship navigates toward the centroid of its other same-faction ships, falling back to the centroid of its own defence stations, and holding position when it has no allies. This keeps repair ships among the allies they exist to heal instead of advancing alone into the enemy. Armed repair ships therefore still rally and depart on the normal schedule (REQ-SHP-RALLY); standby only governs them once rally no longer applies.
- REQ-SHP-RETREAT: **Player ships retreat to the rally point (REQ-SHP-RALLY) when threatened.** A ship retreats while either condition holds: (a) its HP is below a low-HP threshold (currently 30% of its maximum HP); or (b) it has no weapon modules and an enemy ship is within its sensor range — with one exception: a weaponless ship that has at least one repair module does **not** retreat under condition (b) while a damaged friendly (player ship or player defence station, excluding itself) is within its sensor range, so it can keep repairing under fire; it retreats only when no such repair target remains in range. Condition (a) still forces a low-HP repair ship to retreat regardless of available repair targets. Retreating takes priority over the ship's other behaviors and moves it toward the rally point; the ship resumes its normal behavior once neither condition holds. Enemy ships never retreat (REQ-SHP-ENEMY-AI). - REQ-SHP-RETREAT: **Player ships retreat to the rally point (REQ-SHP-RALLY) when threatened.** A ship retreats while either condition holds: (a) its HP is below a low-HP threshold (currently 30% of its maximum HP); or (b) it has no weapon modules and an enemy ship is within its sensor range — with one exception: a weaponless ship that has at least one repair module does **not** retreat under condition (b) while a damaged friendly (player ship or player defence station, excluding itself) is within its sensor range, so it can keep repairing under fire; it retreats only when no such repair target remains in range. Condition (a) still forces a low-HP repair ship to retreat regardless of available repair targets. Retreating takes priority over the ship's other behaviors and moves it toward the rally point; the ship resumes its normal behavior once neither condition holds. Enemy ships never retreat (REQ-SHP-ENEMY-AI).
- REQ-SHP-ENEMY-AI: **Enemy ships** — engage the closest valid target (player defence station, HQ, or player ship) within their sensor range, orbiting the engaged target at the combat orbit radius (REQ-SHP-ORBIT). If no target is in sensor range, they move toward the asteroid (leftward in world coordinates). - REQ-SHP-ENEMY-AI: **Enemy ships** — engage the closest valid target (player defence station, HQ, or player ship) within their sensor range, orbiting the engaged target at the combat orbit radius (REQ-SHP-ORBIT). If no target is in sensor range, they move toward the asteroid (leftward in world coordinates).
@@ -200,8 +200,8 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- `glyph` — single character rendered on this module's cells in the layout grid and preview widget. - `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, cargo). 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`. - **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` (collections per second). - **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).
- **Repair** (`[module.repair]`): `repair_rate_formula` (HP/s), `repair_range_formula` (tiles). - **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. - 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.
- 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. - 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.
@@ -268,7 +268,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
In addition, the panel shows capability module stats conditioned on which capability module types are present in the current layout: 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. - **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.
- **Salvage** (shown only if at least one salvage module is placed): combined collection rate = Σ(collection_rate_i) across all salvage module instances; maximum range = max(collection_range_i) across all salvage module instances. - **Salvage** (shown only if at least one salvage module is placed): combined collection rate = Σ(collection_rate_i) across all salvage module instances; maximum range = max(collection_range_i) across all salvage module instances.
- **Repair** (shown only if at least one repair module is placed): combined repair rate = Σ(repair_rate_i) across all repair module instances; maximum range = max(repair_range_i) across all repair module instances. - **Repair** (shown only if at least one repair module is placed): combined repair rate (HP/s) = Σ(repair_rate_i × repair_amount_hp_i) across all repair module instances; maximum range = max(repair_range_i) across all repair module instances.
All capability module stat values incorporate passive modifiers targeting the relevant capability category per REQ-MOD-STAT-CALC. Each capability module instance uses its own `player_production_level` for formula evaluation. All capability module stat values incorporate passive modifiers targeting the relevant capability category per REQ-MOD-STAT-CALC. Each capability module instance uses its own `player_production_level` for formula evaluation.

View File

@@ -277,15 +277,16 @@ ArenaStatus ArenaSimulation::status() const
void ArenaSimulation::tick() void ArenaSimulation::tick()
{ {
// Ship behavior systems (tick step 7): evaluate, select winner, execute. // Ship behavior systems (tick step 7): evaluate, select winner, execute.
// Module + combat systems emit their tool beams into a shared buffer.
m_shipSystem->clearMovementIntents(); m_shipSystem->clearMovementIntents();
m_aiSystem->tick(m_admin, *m_buildingSystem, *m_scrapSystem); m_aiSystem->tick(m_admin, *m_buildingSystem, *m_scrapSystem);
m_salvagerSystem->tick(*m_scrapSystem, *m_buildingSystem); std::vector<BeamFiredEvent> beamFiredEvents;
m_repairSystem->tick(); m_salvagerSystem->tick(m_currentTick, *m_scrapSystem, *m_buildingSystem, beamFiredEvents);
m_repairSystem->tick(m_currentTick, beamFiredEvents);
// Combat resolution (tick step 8). // Combat resolution (tick step 8).
std::vector<WeaponFiredEvent> weaponFiredEvents; m_combatSystem->tick(m_currentTick, m_admin, *m_buildingSystem, beamFiredEvents);
m_combatSystem->tick(m_currentTick, m_admin, *m_buildingSystem, weaponFiredEvents); m_beamFiredEvents.insert(m_beamFiredEvents.end(), beamFiredEvents.begin(), beamFiredEvents.end());
m_weaponFiredEvents.insert(m_weaponFiredEvents.end(), weaponFiredEvents.begin(), weaponFiredEvents.end());
m_combatSystem->applyPendingDamage(m_currentTick, m_admin); m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
// Deaths (tick step 9, simplified). // Deaths (tick step 9, simplified).
@@ -417,10 +418,10 @@ void ArenaSimulation::tickOnce()
} }
} }
std::vector<WeaponFiredEvent> ArenaSimulation::drainWeaponFiredEvents() std::vector<BeamFiredEvent> ArenaSimulation::drainBeamFiredEvents()
{ {
std::vector<WeaponFiredEvent> result; std::vector<BeamFiredEvent> result;
result.swap(m_weaponFiredEvents); result.swap(m_beamFiredEvents);
return result; return result;
} }

View File

@@ -13,7 +13,7 @@
#include "BuildingId.h" #include "BuildingId.h"
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "WeaponFiredEvent.h" #include "BeamFiredEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Tick.h" #include "Tick.h"
@@ -61,7 +61,7 @@ public:
void requestStop(); void requestStop();
void tickOnce(); void tickOnce();
std::vector<WeaponFiredEvent> drainWeaponFiredEvents(); std::vector<BeamFiredEvent> drainBeamFiredEvents();
ArenaStatus status() const; ArenaStatus status() const;
bool isFinished() const; bool isFinished() const;
@@ -112,7 +112,7 @@ private:
// Static accumulated threat per team, computed once from the configured roster. // Static accumulated threat per team, computed once from the configured roster.
double m_teamThreat[2] = {0.0, 0.0}; double m_teamThreat[2] = {0.0, 0.0};
std::vector<WeaponFiredEvent> m_weaponFiredEvents; std::vector<BeamFiredEvent> m_beamFiredEvents;
mutable std::mutex m_statusMutex; mutable std::mutex m_statusMutex;
ArenaStatus m_status; ArenaStatus m_status;

View File

@@ -28,6 +28,7 @@
#include "SensorRangeComponent.h" #include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "ScrapDataComponent.h"
namespace namespace
{ {
@@ -111,11 +112,11 @@ void ArenaView::onFrame()
// Emit fire events via EventManager // Emit fire events via EventManager
{ {
const std::vector<WeaponFiredEvent> fires = m_sim->drainWeaponFiredEvents(); const std::vector<BeamFiredEvent> fires = m_sim->drainBeamFiredEvents();
for (const WeaponFiredEvent& fe : fires) for (const BeamFiredEvent& fe : fires)
{ {
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<WeaponFiredEvent>(fe)); std::make_shared<BeamFiredEvent>(fe));
} }
} }
@@ -140,7 +141,7 @@ void ArenaView::onFrame()
update(); update();
} }
void ArenaView::handleEvent(std::shared_ptr<const WeaponFiredEvent> event) void ArenaView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
{ {
float maxRadius = 0.125f; float maxRadius = 0.125f;
if (m_sim->admin().isValid(event->target) if (m_sim->admin().isValid(event->target)
@@ -151,6 +152,11 @@ void ArenaView::handleEvent(std::shared_ptr<const WeaponFiredEvent> event)
sb.footprint.height()); sb.footprint.height());
maxRadius = shorter / 2.0f; maxRadius = shorter / 2.0f;
} }
else if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<ScrapDataComponent>(event->target))
{
maxRadius = 0.1f;
}
std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f); std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f);
std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius); std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius);
@@ -526,12 +532,20 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
void ArenaView::drawBeams(QPainter& painter) void ArenaView::drawBeams(QPainter& painter)
{ {
painter.setPen(QPen(m_visuals->beams.color, m_visuals->beams.widthPx));
for (const ActiveBeam& beam : m_activeBeams) for (const ActiveBeam& beam : m_activeBeams)
{ {
const std::optional<QVector2D> shooterPos = entityPosition(beam.event.shooter); const std::optional<QVector2D> shooterPos = entityPosition(beam.event.shooter);
const std::optional<QVector2D> targetPos = entityPosition(beam.event.target); const std::optional<QVector2D> targetPos = entityPosition(beam.event.target);
if (!shooterPos.has_value() || !targetPos.has_value()) { continue; } if (!shooterPos.has_value() || !targetPos.has_value()) { continue; }
QColor color = m_visuals->beams.weaponColor;
switch (beam.event.kind)
{
case BeamKind::Weapon: color = m_visuals->beams.weaponColor; break;
case BeamKind::Repair: color = m_visuals->beams.repairColor; break;
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
}
painter.setPen(QPen(color, m_visuals->beams.widthPx));
painter.drawLine(worldToWidget(*shooterPos), painter.drawLine(worldToWidget(*shooterPos),
worldToWidget(*targetPos + beam.targetOffset)); worldToWidget(*targetPos + beam.targetOffset));
} }

View File

@@ -10,7 +10,7 @@
#include <QVector2D> #include <QVector2D>
#include "EventHandler.h" #include "EventHandler.h"
#include "WeaponFiredEvent.h" #include "BeamFiredEvent.h"
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "EntitySelectedEvent.h" #include "EntitySelectedEvent.h"
@@ -22,7 +22,7 @@ class ArenaSimulation;
class QPainter; class QPainter;
class ArenaView : public QOpenGLWidget, class ArenaView : public QOpenGLWidget,
public EventHandler<WeaponFiredEvent> public EventHandler<BeamFiredEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -45,7 +45,7 @@ private slots:
void onFrame(); void onFrame();
private: private:
void handleEvent(std::shared_ptr<const WeaponFiredEvent> event) override; void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
void drawTiles(QPainter& painter); void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter); void drawBuildings(QPainter& painter);
@@ -66,7 +66,7 @@ private:
struct ActiveBeam struct ActiveBeam
{ {
WeaponFiredEvent event; BeamFiredEvent event;
qint64 emittedWallMs; qint64 emittedWallMs;
QVector2D targetOffset; QVector2D targetOffset;
}; };

View File

@@ -680,6 +680,8 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
ModuleRepairCapability cap; ModuleRepairCapability cap;
cap.repairRateFormula = requireFormula(rMt["repair_rate_hz_formula"], cap.repairRateFormula = requireFormula(rMt["repair_rate_hz_formula"],
file, rPath + ".repair_rate_hz_formula"); file, rPath + ".repair_rate_hz_formula");
cap.repairAmountHpFormula = requireFormula(rMt["repair_amount_hp_formula"],
file, rPath + ".repair_amount_hp_formula");
cap.repairRangeFormula = requireFormula(rMt["repair_range_m_formula"], cap.repairRangeFormula = requireFormula(rMt["repair_range_m_formula"],
file, rPath + ".repair_range_m_formula"); file, rPath + ".repair_range_m_formula");
def.repairCapability = std::move(cap); def.repairCapability = std::move(cap);

View File

@@ -33,7 +33,8 @@ struct ModuleSalvageCapability
struct ModuleRepairCapability struct ModuleRepairCapability
{ {
Formula repairRateFormula; Formula repairRateFormula; // repair cycles per second
Formula repairAmountHpFormula; // HP restored per cycle
Formula repairRangeFormula; Formula repairRangeFormula;
}; };

View File

@@ -8,6 +8,12 @@ constexpr int kTickRateHz = 30;
constexpr double kTickDurationMs = 1000.0 / kTickRateHz; constexpr double kTickDurationMs = 1000.0 / kTickRateHz;
constexpr double kTickDurationSeconds = 1.0 / kTickRateHz; constexpr double kTickDurationSeconds = 1.0 / kTickRateHz;
// Delay between a tool activating (emitting its beam) and its effect being
// applied — half the 0.3 s beam duration. Shared by weapons, repair tools, and
// salvage modules so all three apply their effect mid-beam (REQ-SHP-FIRING,
// REQ-SHP-FIRING-BEAM).
constexpr Tick kBeamImpactDelayTicks = 5;
// Converts a wall-clock duration (in seconds, as it appears in config TOML) to // Converts a wall-clock duration (in seconds, as it appears in config TOML) to
// an integer tick count. Rounds to nearest to avoid systematic drift from // an integer tick count. Rounds to nearest to avoid systematic drift from
// repeated conversions. // repeated conversions.

View File

@@ -6,7 +6,9 @@
struct RepairToolComponent struct RepairToolComponent
{ {
float ratePerTick; float repairAmountHp; // HP restored per repair cycle
int repairIntervalTicks; // cycle period = kTickRateHz / repair-rate (cycles/s); 0 = never
int cooldownTicksRemaining; // ticks until this tool may start its next cycle
float range_tiles; float range_tiles;
std::optional<entt::entity> currentTarget; std::optional<entt::entity> currentTarget;
}; };

View File

@@ -10,8 +10,6 @@
#include "tracing.h" #include "tracing.h"
#include "WeaponComponent.h" #include "WeaponComponent.h"
static constexpr Tick kWeaponImpactDelayTicks = 5;
CombatSystem::CombatSystem(const GameConfig& config) CombatSystem::CombatSystem(const GameConfig& config)
: m_config(config) : m_config(config)
{ {
@@ -20,7 +18,7 @@ CombatSystem::CombatSystem(const GameConfig& config)
void CombatSystem::tick(Tick currentTick, void CombatSystem::tick(Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
BuildingSystem& /*buildings*/, BuildingSystem& /*buildings*/,
std::vector<WeaponFiredEvent>& outWeaponFiredEvents) std::vector<BeamFiredEvent>& outBeamFiredEvents)
{ {
TRACE(); TRACE();
// All weapons (ships and stations) are child entities linked via ModuleOwnerComponent. // All weapons (ships and stations) are child entities linked via ModuleOwnerComponent.
@@ -31,7 +29,7 @@ void CombatSystem::tick(Tick currentTick,
{ {
const PositionComponent& pos = admin.get<PositionComponent>(owner.owner); const PositionComponent& pos = admin.get<PositionComponent>(owner.owner);
const FactionComponent& faction = admin.get<FactionComponent>(owner.owner); const FactionComponent& faction = admin.get<FactionComponent>(owner.owner);
resolveWeapon(owner.owner, weapon, pos, faction, currentTick, admin, outWeaponFiredEvents); resolveWeapon(owner.owner, weapon, pos, faction, currentTick, admin, outBeamFiredEvents);
}); });
} }
@@ -42,7 +40,7 @@ void CombatSystem::resolveWeapon(
const FactionComponent& ownFaction, const FactionComponent& ownFaction,
Tick currentTick, Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
std::vector<WeaponFiredEvent>& out) std::vector<BeamFiredEvent>& out)
{ {
if (weapon.cooldownTicks > 0.0f) if (weapon.cooldownTicks > 0.0f)
{ {
@@ -109,9 +107,10 @@ void CombatSystem::resolveWeapon(
const entt::entity targetEntity = *weapon.currentTarget; const entt::entity targetEntity = *weapon.currentTarget;
m_pendingDamage.push_back({targetEntity, weapon.damage, m_pendingDamage.push_back({targetEntity, weapon.damage,
currentTick + kWeaponImpactDelayTicks}); currentTick + kBeamImpactDelayTicks});
WeaponFiredEvent evt; BeamFiredEvent evt;
evt.kind = BeamKind::Weapon;
evt.shooter = shipEntity; evt.shooter = shipEntity;
evt.target = targetEntity; evt.target = targetEntity;
evt.emittedAt = currentTick; evt.emittedAt = currentTick;

View File

@@ -7,7 +7,7 @@
#include "Building.h" #include "Building.h"
#include "FactionComponent.h" #include "FactionComponent.h"
#include "WeaponFiredEvent.h" #include "BeamFiredEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "Tick.h" #include "Tick.h"
@@ -26,7 +26,7 @@ public:
void tick(Tick currentTick, void tick(Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
BuildingSystem& buildings, BuildingSystem& buildings,
std::vector<WeaponFiredEvent>& outWeaponFiredEvents); std::vector<BeamFiredEvent>& outBeamFiredEvents);
void applyPendingDamage(Tick currentTick, EntityAdmin& admin); void applyPendingDamage(Tick currentTick, EntityAdmin& admin);
@@ -47,7 +47,7 @@ private:
const FactionComponent& ownFaction, const FactionComponent& ownFaction,
Tick currentTick, Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
std::vector<WeaponFiredEvent>& out); std::vector<BeamFiredEvent>& out);
const GameConfig& m_config; const GameConfig& m_config;
}; };

View File

@@ -19,35 +19,43 @@ RepairSystem::RepairSystem(EntityAdmin& admin)
{ {
} }
void RepairSystem::tick() void RepairSystem::tick(Tick currentTick, std::vector<BeamFiredEvent>& outBeamFiredEvents)
{ {
TRACE(); TRACE();
// Apply heals whose mid-beam delay has elapsed (cycles started on prior ticks).
applyPendingHeals(currentTick);
const std::vector<RepairableInfo> repairables = buildRepairables(m_admin); const std::vector<RepairableInfo> repairables = buildRepairables(m_admin);
m_admin.forEach<RepairToolComponent, ModuleOwnerComponent>( m_admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
[&](entt::entity /*re*/, RepairToolComponent& tool, const ModuleOwnerComponent& owner) [&](entt::entity /*re*/, RepairToolComponent& tool, const ModuleOwnerComponent& owner)
{ {
if (tool.cooldownTicksRemaining > 0) { --tool.cooldownTicksRemaining; }
if (tool.cooldownTicksRemaining > 0) { return; }
if (tool.repairIntervalTicks <= 0) { return; }
if (!m_admin.hasAll<PositionComponent>(owner.owner)) { return; } if (!m_admin.hasAll<PositionComponent>(owner.owner)) { return; }
const QVector2D ownerPos = m_admin.get<PositionComponent>(owner.owner).value; const QVector2D ownerPos = m_admin.get<PositionComponent>(owner.owner).value;
// Honour the executor-set target if it is still valid and in range. // Choose a target: honour the executor-set target if it is still valid
// and in range, else fall back to the nearest damaged friendly in range.
std::optional<entt::entity> target;
if (tool.currentTarget) if (tool.currentTarget)
{ {
const entt::entity t = *tool.currentTarget; const entt::entity t = *tool.currentTarget;
if (m_admin.isValid(t) && m_admin.hasAll<HealthComponent, PositionComponent>(t)) if (m_admin.isValid(t) && m_admin.hasAll<HealthComponent, PositionComponent>(t))
{ {
HealthComponent& th = m_admin.get<HealthComponent>(t); const HealthComponent& th = m_admin.get<HealthComponent>(t);
const float dist = const float dist =
(m_admin.get<PositionComponent>(t).value - ownerPos).length(); (m_admin.get<PositionComponent>(t).value - ownerPos).length();
if (th.hp > 0.0f && th.hp < th.maxHp && dist <= tool.range_tiles) if (th.hp > 0.0f && th.hp < th.maxHp && dist <= tool.range_tiles)
{ {
th.hp = std::min(th.hp + tool.ratePerTick, th.maxHp); target = t;
return;
} }
} }
} }
// Fallback: heal the nearest damaged friendly within tool range. if (!target)
{
tool.currentTarget = std::nullopt; tool.currentTarget = std::nullopt;
float bestDist = tool.range_tiles; float bestDist = tool.range_tiles;
for (const RepairableInfo& r : repairables) for (const RepairableInfo& r : repairables)
@@ -61,10 +69,41 @@ void RepairSystem::tick()
tool.currentTarget = r.entity; tool.currentTarget = r.entity;
} }
} }
target = tool.currentTarget;
}
if (!tool.currentTarget) { return; } if (!target) { return; }
HealthComponent& targetHealth = m_admin.get<HealthComponent>(*tool.currentTarget); // Start a repair cycle: emit the beam now, apply the heal mid-beam, and
targetHealth.hp = std::min(targetHealth.hp + tool.ratePerTick, targetHealth.maxHp); // begin the cooldown at cycle start (not at effect application).
outBeamFiredEvents.push_back(
BeamFiredEvent{BeamKind::Repair, owner.owner, *target, currentTick});
m_pendingHeals.push_back({*target, tool.repairAmountHp,
currentTick + kBeamImpactDelayTicks});
tool.cooldownTicksRemaining = tool.repairIntervalTicks;
}); });
} }
void RepairSystem::applyPendingHeals(Tick currentTick)
{
std::vector<PendingHeal>::iterator it = m_pendingHeals.begin();
while (it != m_pendingHeals.end())
{
if (it->appliesAt <= currentTick)
{
if (m_admin.isValid(it->target) && m_admin.hasAll<HealthComponent>(it->target))
{
HealthComponent& h = m_admin.get<HealthComponent>(it->target);
if (h.hp > 0.0f && h.hp < h.maxHp)
{
h.hp = std::min(h.hp + it->amountHp, h.maxHp);
}
}
it = m_pendingHeals.erase(it);
}
else
{
++it;
}
}
}

View File

@@ -1,17 +1,36 @@
#pragma once #pragma once
#include <vector>
#include "BeamFiredEvent.h"
#include "Tick.h"
#include "entt/entity/entity.hpp"
class EntityAdmin; class EntityAdmin;
// World-mutation system for repair modules: validates each tool's target (set by // World-mutation system for repair modules: each tool runs a cycle on its own
// RepairExecutor), falls back to the nearest damaged friendly in range, and // cooldown. When a cycle starts it picks a target (the RepairExecutor-set target,
// applies healing. Runs every tick, independent of behavior selection. // else the nearest damaged friendly in range), emits a repair beam, and schedules
// the heal for mid-beam (kBeamImpactDelayTicks later) — mirroring weapon firing.
// Runs every tick, independent of behavior selection.
class RepairSystem class RepairSystem
{ {
public: public:
explicit RepairSystem(EntityAdmin& admin); explicit RepairSystem(EntityAdmin& admin);
void tick(); void tick(Tick currentTick, std::vector<BeamFiredEvent>& outBeamFiredEvents);
private: private:
struct PendingHeal
{
entt::entity target;
float amountHp;
Tick appliesAt;
};
void applyPendingHeals(Tick currentTick);
EntityAdmin& m_admin; EntityAdmin& m_admin;
std::vector<PendingHeal> m_pendingHeals;
}; };

View File

@@ -8,9 +8,12 @@
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "DeliverScrapBehavior.h" #include "DeliverScrapBehavior.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include <map>
#include "ModuleOwnerComponent.h" #include "ModuleOwnerComponent.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "SalvageCargoComponent.h" #include "SalvageCargoComponent.h"
#include "ScrapDataComponent.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include "tracing.h" #include "tracing.h"
@@ -19,9 +22,13 @@ SalvagerSystem::SalvagerSystem(EntityAdmin& admin)
{ {
} }
void SalvagerSystem::tick(ScrapSystem& scraps, BuildingSystem& buildings) void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem& buildings,
std::vector<BeamFiredEvent>& outBeamFiredEvents)
{ {
TRACE(); TRACE();
// Apply collections whose mid-beam delay has elapsed (cycles started earlier).
applyPendingCollections(currentTick, scraps);
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo(); const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
// Tick down per-module collection cooldowns. // Tick down per-module collection cooldowns.
@@ -31,24 +38,40 @@ void SalvagerSystem::tick(ScrapSystem& scraps, BuildingSystem& buildings)
if (c.cooldownTicksRemaining > 0) { --c.cooldownTicksRemaining; } if (c.cooldownTicksRemaining > 0) { --c.cooldownTicksRemaining; }
}); });
// Collection: each ready, in-range module collects one scrap. // 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;
for (const PendingCollection& pc : m_pendingCollections)
{
++claimedUnits[pc.scrap];
}
// 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>( m_admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity /*ce*/, SalvageCargoComponent& c, const ModuleOwnerComponent& o) [&](entt::entity moduleEntity, SalvageCargoComponent& c, const ModuleOwnerComponent& o)
{ {
if (c.current >= c.capacity || c.cooldownTicksRemaining > 0) { return; } if (c.current >= c.capacity || c.cooldownTicksRemaining > 0) { return; }
if (c.collectionIntervalTicks <= 0) { return; }
if (!m_admin.hasAll<PositionComponent>(o.owner)) { return; } if (!m_admin.hasAll<PositionComponent>(o.owner)) { return; }
const QVector2D ownerPos = m_admin.get<PositionComponent>(o.owner).value; const QVector2D ownerPos = m_admin.get<PositionComponent>(o.owner).value;
for (const ScrapInfo& si : allScrap) for (const ScrapInfo& si : allScrap)
{ {
if ((si.position - ownerPos).length() > c.collectionRange_tiles) { continue; } if ((si.position - ownerPos).length() > c.collectionRange_tiles) { continue; }
if (scraps.consume(si.entity)) if (claimedUnits[si.entity] >= m_admin.get<ScrapDataComponent>(si.entity).amount)
{ {
++c.current; 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,
currentTick + kBeamImpactDelayTicks});
++claimedUnits[si.entity];
c.cooldownTicksRemaining = c.collectionIntervalTicks; c.cooldownTicksRemaining = c.collectionIntervalTicks;
break; break;
} }
}
}); });
// Delivery: a ship at its assigned bay hands over one unit of cargo per tick. // Delivery: a ship at its assigned bay hands over one unit of cargo per tick.
@@ -77,3 +100,27 @@ void SalvagerSystem::tick(ScrapSystem& scraps, BuildingSystem& buildings)
}); });
}); });
} }
void SalvagerSystem::applyPendingCollections(Tick currentTick, ScrapSystem& scraps)
{
std::vector<PendingCollection>::iterator it = m_pendingCollections.begin();
while (it != m_pendingCollections.end())
{
if (it->appliesAt <= currentTick)
{
if (m_admin.isValid(it->module) && m_admin.hasAll<SalvageCargoComponent>(it->module))
{
SalvageCargoComponent& c = m_admin.get<SalvageCargoComponent>(it->module);
if (c.current < c.capacity && scraps.collectOne(it->scrap))
{
++c.current;
}
}
it = m_pendingCollections.erase(it);
}
else
{
++it;
}
}
}

View File

@@ -1,19 +1,39 @@
#pragma once #pragma once
#include <vector>
#include "BeamFiredEvent.h"
#include "Tick.h"
#include "entt/entity/entity.hpp"
class BuildingSystem; class BuildingSystem;
class EntityAdmin; class EntityAdmin;
class ScrapSystem; class ScrapSystem;
// World-mutation system for salvage modules: collects scrap into cargo and // World-mutation system for salvage modules: each module runs a collection cycle
// delivers full cargo at a SalvageBay. Runs every tick, independent of which // on its own cooldown. When a cycle starts it emits a salvage beam toward an
// behavior the AiSystem selected. // in-range scrap pile and schedules the collection of one scrap for mid-beam
// (kBeamImpactDelayTicks later) — mirroring weapon firing. Also delivers full
// cargo at a SalvageBay. Runs every tick, independent of behavior selection.
class SalvagerSystem class SalvagerSystem
{ {
public: public:
explicit SalvagerSystem(EntityAdmin& admin); explicit SalvagerSystem(EntityAdmin& admin);
void tick(ScrapSystem& scraps, BuildingSystem& buildings); void tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem& buildings,
std::vector<BeamFiredEvent>& outBeamFiredEvents);
private: private:
struct PendingCollection
{
entt::entity module;
entt::entity scrap;
Tick appliesAt;
};
void applyPendingCollections(Tick currentTick, ScrapSystem& scraps);
EntityAdmin& m_admin; EntityAdmin& m_admin;
std::vector<PendingCollection> m_pendingCollections;
}; };

View File

@@ -46,6 +46,25 @@ std::optional<int> ScrapSystem::consume(entt::entity entity)
return amount; return amount;
} }
bool ScrapSystem::collectOne(entt::entity entity)
{
if (!m_admin.isValid(entity) || !m_admin.hasAll<ScrapDataComponent>(entity))
{
return false;
}
ScrapDataComponent& data = m_admin.get<ScrapDataComponent>(entity);
if (data.amount <= 0)
{
return false;
}
--data.amount;
if (data.amount <= 0)
{
m_admin.destroy(entity);
}
return true;
}
std::vector<ScrapInfo> ScrapSystem::allScrapInfo() const std::vector<ScrapInfo> ScrapSystem::allScrapInfo() const
{ {
std::vector<ScrapInfo> result; std::vector<ScrapInfo> result;

View File

@@ -28,6 +28,11 @@ public:
// Removes the scrap and returns its amount, or nullopt if not found. // Removes the scrap and returns its amount, or nullopt if not found.
std::optional<int> consume(entt::entity entity); std::optional<int> consume(entt::entity entity);
// Collects a single scrap unit from the pile: decrements its amount by one,
// destroying the entity once depleted. Returns true if a scrap was collected,
// false if the entity is invalid or already empty (REQ-SHP-SALVAGE).
bool collectOne(entt::entity entity);
// Lightweight snapshot for callers that need to iterate all scrap. // Lightweight snapshot for callers that need to iterate all scrap.
std::vector<ScrapInfo> allScrapInfo() const; std::vector<ScrapInfo> allScrapInfo() const;

View File

@@ -157,9 +157,14 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
if (modDef->repairCapability) if (modDef->repairCapability)
{ {
RepairToolComponent rt; RepairToolComponent rt;
rt.ratePerTick = static_cast<float>( const double repairRateHz =
modDef->repairCapability->repairRateFormula.evaluate(mx)) modDef->repairCapability->repairRateFormula.evaluate(mx);
/ static_cast<float>(kTickRateHz); rt.repairIntervalTicks = (repairRateHz > 0.0)
? static_cast<int>(kTickRateHz / repairRateHz + 0.5)
: 0;
rt.repairAmountHp = static_cast<float>(
modDef->repairCapability->repairAmountHpFormula.evaluate(mx));
rt.cooldownTicksRemaining = 0;
rt.range_tiles = static_cast<float>( rt.range_tiles = static_cast<float>(
modDef->repairCapability->repairRangeFormula.evaluate(mx)) / tileSize; modDef->repairCapability->repairRangeFormula.evaluate(mx)) / tileSize;
rt.currentTarget = std::nullopt; rt.currentTarget = std::nullopt;
@@ -321,8 +326,15 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
for (entt::entity child : repairChildren) for (entt::entity child : repairChildren)
{ {
RepairToolComponent& rt = m_admin.get<RepairToolComponent>(child); RepairToolComponent& rt = m_admin.get<RepairToolComponent>(child);
applyMod(rt.ratePerTick, "repair_rate", repairMods); // Apply rate modifier: compute cycles/s from interval, apply, convert back.
float fRate = (rt.repairIntervalTicks > 0)
? static_cast<float>(kTickRateHz) / static_cast<float>(rt.repairIntervalTicks)
: 0.0f;
applyMod(fRate, "repair_rate", repairMods);
applyMod(rt.range_tiles, "repair_range", repairMods); applyMod(rt.range_tiles, "repair_range", repairMods);
rt.repairIntervalTicks = (fRate > 0.0f)
? static_cast<int>(static_cast<float>(kTickRateHz) / fRate + 0.5f)
: 0;
} }
// --- Pass 3: attach behavior components based on capability presence ----- // --- Pass 3: attach behavior components based on capability presence -----

View File

@@ -0,0 +1,31 @@
#pragma once
#include "Event.h"
#include "Tick.h"
#include "entt/entity/entity.hpp"
// The kind of tool that produced a beam. Used by the renderer to choose the
// beam color (REQ-SHP-FIRING-BEAM).
enum class BeamKind
{
Weapon,
Repair,
Salvage,
};
// Transient record emitted whenever a weapon fires, a repair tool starts a heal
// cycle, or a salvage module starts a collection cycle (REQ-SHP-FIRING,
// REQ-SHP-FIRING-BEAM). Buffered in a sim-owned vector during the tick, then
// drained and re-emitted via EventManager by the UI frame handler.
struct BeamFiredEvent : public Event
{
BeamFiredEvent() = default;
BeamFiredEvent(BeamKind kind, entt::entity shooter, entt::entity target, Tick emittedAt)
: kind(kind), shooter(shooter), target(target), emittedAt(emittedAt) {}
BeamKind kind = BeamKind::Weapon;
entt::entity shooter = entt::null;
entt::entity target = entt::null;
Tick emittedAt = 0;
};

View File

@@ -23,7 +23,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindowClosedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/InspectWindowClosedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ArenaStartRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/ArenaStartRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/WeaponFiredEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -1,17 +0,0 @@
#pragma once
#include "Event.h"
#include "Tick.h"
#include "entt/entity/entity.hpp"
struct WeaponFiredEvent : public Event
{
WeaponFiredEvent() = default;
WeaponFiredEvent(entt::entity shooter, entt::entity target, Tick emittedAt)
: shooter(shooter), target(target), emittedAt(emittedAt) {}
entt::entity shooter = entt::null;
entt::entity target = entt::null;
Tick emittedAt = 0;
};

View File

@@ -60,7 +60,7 @@ ShipStats calculateShipStats(const GameConfig& config,
// --- Pass 1: base capability stats per module instance ------------------- // --- Pass 1: base capability stats per module instance -------------------
struct WeaponInstance { float damage; float range_tiles; float rate_hz; }; struct WeaponInstance { float damage; float range_tiles; float rate_hz; };
struct SalvageInstance { float range_tiles; float rate; }; struct SalvageInstance { float range_tiles; float rate; };
struct RepairInstance { float rate_hps; float range_tiles; }; struct RepairInstance { float rate_hz; float amount_hp; float range_tiles; };
std::vector<WeaponInstance> weaponInstances; std::vector<WeaponInstance> weaponInstances;
std::vector<SalvageInstance> salvageInstances; std::vector<SalvageInstance> salvageInstances;
@@ -93,7 +93,8 @@ ShipStats calculateShipStats(const GameConfig& config,
if (def->repairCapability) if (def->repairCapability)
{ {
RepairInstance ri; RepairInstance ri;
ri.rate_hps = static_cast<float>(def->repairCapability->repairRateFormula.evaluate(mx)); ri.rate_hz = static_cast<float>(def->repairCapability->repairRateFormula.evaluate(mx));
ri.amount_hp = static_cast<float>(def->repairCapability->repairAmountHpFormula.evaluate(mx));
ri.range_tiles = static_cast<float>(def->repairCapability->repairRangeFormula.evaluate(mx) / tileSize); ri.range_tiles = static_cast<float>(def->repairCapability->repairRangeFormula.evaluate(mx) / tileSize);
repairInstances.push_back(ri); repairInstances.push_back(ri);
} }
@@ -238,9 +239,9 @@ ShipStats calculateShipStats(const GameConfig& config,
float maxRange = 0.0f; float maxRange = 0.0f;
for (RepairInstance& ri : repairInstances) for (RepairInstance& ri : repairInstances)
{ {
applyMod(ri.rate_hps, "repair_rate", repairMods); applyMod(ri.rate_hz, "repair_rate", repairMods);
applyMod(ri.range_tiles, "repair_range", repairMods); applyMod(ri.range_tiles, "repair_range", repairMods);
combinedRate += ri.rate_hps; combinedRate += ri.rate_hz * ri.amount_hp;
if (ri.range_tiles > maxRange) { maxRange = ri.range_tiles; } if (ri.range_tiles > maxRange) { maxRange = ri.range_tiles; }
} }
result.repair = ShipStats::RepairStats{combinedRate, maxRange}; result.repair = ShipStats::RepairStats{combinedRate, maxRange};
@@ -303,7 +304,10 @@ ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEn
{ {
if (owner.owner != shipEntity) { return; } if (owner.owner != shipEntity) { return; }
hasRepair = true; hasRepair = true;
repairRate += r.ratePerTick * kTickRateHz; const float cyclesPerSec = (r.repairIntervalTicks > 0)
? static_cast<float>(kTickRateHz) / static_cast<float>(r.repairIntervalTicks)
: 0.0f;
repairRate += cyclesPerSec * r.repairAmountHp;
if (r.range_tiles > repairMaxRange) { repairMaxRange = r.range_tiles; } if (r.range_tiles > repairMaxRange) { repairMaxRange = r.range_tiles; }
}); });

View File

@@ -140,7 +140,7 @@ void Simulation::reset(unsigned int seed)
m_playerStation2Entity = entt::null; m_playerStation2Entity = entt::null;
m_currentEnemyStationEntities[0] = entt::null; m_currentEnemyStationEntities[0] = entt::null;
m_currentEnemyStationEntities[1] = entt::null; m_currentEnemyStationEntities[1] = entt::null;
m_weaponFiredEvents.clear(); m_beamFiredEvents.clear();
m_pendingSchematicChoices.clear(); m_pendingSchematicChoices.clear();
m_admin.clear(); m_admin.clear();
@@ -248,12 +248,13 @@ void Simulation::tick()
// movement intent + preferred module targets only — no world mutation). // movement intent + preferred module targets only — no world mutation).
m_aiSystem->tick(m_admin, *m_buildingSystem, *m_scrapSystem); m_aiSystem->tick(m_admin, *m_buildingSystem, *m_scrapSystem);
// Module systems perform the world mutation (collection/delivery, healing). // Module systems perform the world mutation (collection/delivery, healing).
m_salvagerSystem->tick(*m_scrapSystem, *m_buildingSystem); // Each emits its tool beams and applies its own delayed (mid-beam) effects.
m_repairSystem->tick(); m_salvagerSystem->tick(m_currentTick, *m_scrapSystem, *m_buildingSystem, m_beamFiredEvents);
m_repairSystem->tick(m_currentTick, m_beamFiredEvents);
// Step 8: combat resolution // Step 8: combat resolution
m_combatSystem->tick(m_currentTick, m_admin, m_combatSystem->tick(m_currentTick, m_admin,
*m_buildingSystem, m_weaponFiredEvents); *m_buildingSystem, m_beamFiredEvents);
// Step 8b: deferred damage whose impact tick has arrived // Step 8b: deferred damage whose impact tick has arrived
m_combatSystem->applyPendingDamage(m_currentTick, m_admin); m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
@@ -828,10 +829,10 @@ bool Simulation::isItemUnlocked(const std::string& itemId) const
// Drains // Drains
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
std::vector<WeaponFiredEvent> Simulation::drainWeaponFiredEvents() std::vector<BeamFiredEvent> Simulation::drainBeamFiredEvents()
{ {
std::vector<WeaponFiredEvent> result; std::vector<BeamFiredEvent> result;
result.swap(m_weaponFiredEvents); result.swap(m_beamFiredEvents);
return result; return result;
} }

View File

@@ -16,7 +16,7 @@
#include "BuildingType.h" #include "BuildingType.h"
#include "BuildingId.h" #include "BuildingId.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "WeaponFiredEvent.h" #include "BeamFiredEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Rotation.h" #include "Rotation.h"
#include "Tick.h" #include "Tick.h"
@@ -52,7 +52,7 @@ public:
// Returns all fire events accumulated since the last drain, clearing the // Returns all fire events accumulated since the last drain, clearing the
// internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM). // internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM).
std::vector<WeaponFiredEvent> drainWeaponFiredEvents(); std::vector<BeamFiredEvent> drainBeamFiredEvents();
// Returns the pending schematic choices (empty if no drop is pending). // Returns the pending schematic choices (empty if no drop is pending).
const std::vector<SchematicChoiceOption>& getPendingSchematicChoices() const; const std::vector<SchematicChoiceOption>& getPendingSchematicChoices() const;
@@ -192,6 +192,6 @@ private:
std::unique_ptr<WaveSystem> m_waveSystem; std::unique_ptr<WaveSystem> m_waveSystem;
std::unique_ptr<CombatSystem> m_combatSystem; std::unique_ptr<CombatSystem> m_combatSystem;
std::vector<WeaponFiredEvent> m_weaponFiredEvents; std::vector<BeamFiredEvent> m_beamFiredEvents;
std::vector<SchematicChoiceOption> m_pendingSchematicChoices; std::vector<SchematicChoiceOption> m_pendingSchematicChoices;
}; };

View File

@@ -70,6 +70,7 @@ struct Fixture
DynamicBodySystem dynamicBody; DynamicBodySystem dynamicBody;
ScrapSystem scraps; ScrapSystem scraps;
Tick tick; Tick tick;
std::vector<BeamFiredEvent> beamEvents;
explicit Fixture() explicit Fixture()
: cfg(loadConfig()) : cfg(loadConfig())
@@ -102,8 +103,9 @@ struct Fixture
// World mutation: collection/delivery and healing. // World mutation: collection/delivery and healing.
void runModules() void runModules()
{ {
salvager.tick(scraps, buildings); beamEvents.clear();
repair.tick(); salvager.tick(tick, scraps, buildings, beamEvents);
repair.tick(tick, beamEvents);
} }
// Run one full behavior+movement tick (steps 7 and 10). // Run one full behavior+movement tick (steps 7 and 10).
@@ -115,6 +117,35 @@ struct Fixture
dynamicBody.tick(admin); dynamicBody.tick(admin);
++tick; ++tick;
} }
// One repair-system tick at the current sim time (advances the tick counter).
// Starts cycles and applies any due (mid-beam-delayed) heals.
void repairTick()
{
beamEvents.clear();
repair.tick(tick, beamEvents);
++tick;
}
// Drive the repair system long enough for a started cycle's delayed heal to land.
void runRepairHeal()
{
for (int i = 0; i <= kBeamImpactDelayTicks; ++i) { repairTick(); }
}
// One salvage-system tick at the current sim time (advances the tick counter).
void salvageTick()
{
beamEvents.clear();
salvager.tick(tick, scraps, buildings, beamEvents);
++tick;
}
// Drive the salvage system long enough for a started cycle's delayed collection.
void runSalvageCollect()
{
for (int i = 0; i <= kBeamImpactDelayTicks; ++i) { salvageTick(); }
}
}; };
static ShipLayoutConfig makeSingleModuleLayout(const std::string& moduleId) static ShipLayoutConfig makeSingleModuleLayout(const std::string& moduleId)
@@ -602,7 +633,7 @@ TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
f.admin.get<HealthComponent>(friendly).hp = initialHp; f.admin.get<HealthComponent>(friendly).hp = initialHp;
f.decide(); f.decide();
f.runModules(); f.runRepairHeal();
REQUIRE(health(f.admin, friendly).hp > initialHp); REQUIRE(health(f.admin, friendly).hp > initialHp);
} }
@@ -616,11 +647,8 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp - 0.001f; f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp - 0.001f;
for (int i = 0; i < 5; ++i)
{
f.decide(); f.decide();
f.runModules(); f.runRepairHeal();
}
const HealthComponent& h = health(f.admin, friendly); const HealthComponent& h = health(f.admin, friendly);
REQUIRE(h.hp <= h.maxHp); REQUIRE(h.hp <= h.maxHp);
@@ -644,7 +672,7 @@ TEST_CASE("RepairSystem: tool heals the in-range damaged target chosen by the ex
f.admin.get<HealthComponent>(friendly).hp = initHp; f.admin.get<HealthComponent>(friendly).hp = initHp;
f.decide(); f.decide();
f.runModules(); f.runRepairHeal();
const entt::entity rc = firstRepairChild(f.admin, repairShip); const entt::entity rc = firstRepairChild(f.admin, repairShip);
REQUIRE(f.admin.isValid(rc)); REQUIRE(f.admin.isValid(rc));
@@ -674,7 +702,7 @@ TEST_CASE("RepairSystem: tool falls back to in-range target when its target is o
const entt::entity rc = firstRepairChild(f.admin, repairShip); const entt::entity rc = firstRepairChild(f.admin, repairShip);
f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange; f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange;
f.repair.tick(); f.runRepairHeal();
REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value()); REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback); REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
@@ -699,7 +727,7 @@ TEST_CASE("RepairSystem: tool falls back when its target is fully healed",
const entt::entity rc = firstRepairChild(f.admin, repairShip); const entt::entity rc = firstRepairChild(f.admin, repairShip);
f.admin.get<RepairToolComponent>(rc).currentTarget = healed; f.admin.get<RepairToolComponent>(rc).currentTarget = healed;
f.repair.tick(); f.runRepairHeal();
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback); REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp); REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
@@ -722,7 +750,7 @@ TEST_CASE("RepairSystem: tool falls back when its target is destroyed",
f.admin.get<RepairToolComponent>(rc).currentTarget = gone; f.admin.get<RepairToolComponent>(rc).currentTarget = gone;
f.ships.despawn(gone); f.ships.despawn(gone);
f.repair.tick(); f.runRepairHeal();
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback); REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp); REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
@@ -744,7 +772,7 @@ TEST_CASE("RepairSystem: tool target is cleared when no repairable target is in
const entt::entity rc = firstRepairChild(f.admin, repairShip); const entt::entity rc = firstRepairChild(f.admin, repairShip);
f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange; f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange;
f.repair.tick(); f.runRepairHeal();
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value()); REQUIRE_FALSE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
REQUIRE(health(f.admin, outOfRange).hp == Approx(initHp)); REQUIRE(health(f.admin, outOfRange).hp == Approx(initHp));
@@ -763,11 +791,12 @@ TEST_CASE("RepairSystem: two repair modules both heal the chosen target additive
f.admin.get<HealthComponent>(targetA).hp = initHp; f.admin.get<HealthComponent>(targetA).hp = initHp;
f.decide(); f.decide();
f.runModules(); f.runRepairHeal();
// Both modules should have healed targetA — total increase is 2 * ratePerTick. // Both modules run one cycle and heal targetA — total increase is 2 * repairAmountHp.
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz); // repair_amount_hp_formula = "5 + x" at x=1 → 6 HP per cycle.
REQUIRE(health(f.admin, targetA).hp == Approx(initHp + 2.0f * ratePerTick)); const float repairAmountHp = 5.0f + 1.0f;
REQUIRE(health(f.admin, targetA).hp == Approx(initHp + 2.0f * repairAmountHp));
const std::vector<entt::entity> children = allRepairChildren(f.admin, repairShip); const std::vector<entt::entity> children = allRepairChildren(f.admin, repairShip);
REQUIRE(children.size() == 2); REQUIRE(children.size() == 2);
@@ -797,10 +826,10 @@ TEST_CASE("RepairSystem: two modules both fall back and heal the same target",
f.admin.get<RepairToolComponent>(child).currentTarget = healed; f.admin.get<RepairToolComponent>(child).currentTarget = healed;
} }
f.repair.tick(); f.runRepairHeal();
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz); const float repairAmountHp = 5.0f + 1.0f;
REQUIRE(health(f.admin, targetB).hp == Approx(initHp + 2.0f * ratePerTick)); REQUIRE(health(f.admin, targetB).hp == Approx(initHp + 2.0f * repairAmountHp));
const std::vector<entt::entity> children = allRepairChildren(f.admin, repairShip); const std::vector<entt::entity> children = allRepairChildren(f.admin, repairShip);
REQUIRE(children.size() == 2); REQUIRE(children.size() == 2);
@@ -819,14 +848,16 @@ TEST_CASE("RepairSystem: does not crash when a tool's owner is not a repair ship
const entt::entity ownerShip = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity ownerShip = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const entt::entity moduleEntity = f.admin.createModuleEntity(); const entt::entity moduleEntity = f.admin.createModuleEntity();
RepairToolComponent rt; RepairToolComponent rt;
rt.ratePerTick = 1.0f; rt.repairAmountHp = 1.0f;
rt.repairIntervalTicks = kTickRateHz;
rt.cooldownTicksRemaining = 0;
rt.range_tiles = 10.0f; rt.range_tiles = 10.0f;
rt.currentTarget = std::nullopt; rt.currentTarget = std::nullopt;
f.admin.addComponent<RepairToolComponent>(moduleEntity, rt); f.admin.addComponent<RepairToolComponent>(moduleEntity, rt);
f.admin.addComponent<ModuleOwnerComponent>(moduleEntity, ModuleOwnerComponent{ownerShip}); f.admin.addComponent<ModuleOwnerComponent>(moduleEntity, ModuleOwnerComponent{ownerShip});
// Must not crash; no damaged friendly in range, so no target is set. // Must not crash; no damaged friendly in range, so no target is set.
f.repair.tick(); f.runRepairHeal();
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(moduleEntity).currentTarget.has_value()); REQUIRE_FALSE(f.admin.get<RepairToolComponent>(moduleEntity).currentTarget.has_value());
} }
@@ -866,7 +897,7 @@ TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]"
false, salvageLayout); false, salvageLayout);
const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings); f.runSalvageCollect();
const entt::entity sc = firstSalvageChild(f.admin, ship); const entt::entity sc = firstSalvageChild(f.admin, ship);
REQUIRE(f.admin.isValid(sc)); REQUIRE(f.admin.isValid(sc));
@@ -935,7 +966,7 @@ TEST_CASE("SalvagerSystem: module does not collect scrap beyond its collection r
false, salvageLayout); false, salvageLayout);
f.scraps.spawn(QVector2D(55.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(55.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings); f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0); REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
} }
@@ -950,7 +981,7 @@ TEST_CASE("SalvagerSystem: module collects scrap within its collection range",
false, salvageLayout); false, salvageLayout);
f.scraps.spawn(QVector2D(45.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(45.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings); f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 1); REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 1);
} }
@@ -967,11 +998,13 @@ TEST_CASE("SalvagerSystem: collection sets cooldown on module", "[behavior]")
false, salvageLayout); false, salvageLayout);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings); // Starting a collection cycle sets the cooldown immediately; the scrap is not
// collected until mid-beam (REQ-SHP-SALVAGE), so cargo is still empty now.
f.salvageTick();
const SalvageCargoComponent& cargo = const SalvageCargoComponent& cargo =
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)); f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship));
REQUIRE(cargo.current == 1); REQUIRE(cargo.current == 0);
REQUIRE(cargo.cooldownTicksRemaining == cargo.collectionIntervalTicks); REQUIRE(cargo.cooldownTicksRemaining == cargo.collectionIntervalTicks);
} }
@@ -985,7 +1018,7 @@ TEST_CASE("SalvagerSystem: module on cooldown does not collect scrap", "[behavio
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10; f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10;
f.salvager.tick(f.scraps, f.buildings); f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0); REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
} }
@@ -999,15 +1032,16 @@ TEST_CASE("SalvagerSystem: module collects again after cooldown expires", "[beha
const entt::entity sc = firstSalvageChild(f.admin, ship); const entt::entity sc = firstSalvageChild(f.admin, ship);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings); f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 1); REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 1);
// Shorten cooldown to 1 tick and place a second scrap. // Shorten cooldown to 1 tick and place a second scrap.
f.admin.get<SalvageCargoComponent>(sc).cooldownTicksRemaining = 1; f.admin.get<SalvageCargoComponent>(sc).cooldownTicksRemaining = 1;
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
// Next tick: cooldown decrements to 0, module collects the second scrap. // Once the cooldown expires the module starts another cycle and collects the
f.salvager.tick(f.scraps, f.buildings); // second scrap after the mid-beam delay.
f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 2); REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 2);
} }
@@ -1026,7 +1060,7 @@ TEST_CASE("SalvagerSystem: two salvage modules collect independently in same tic
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings); f.runSalvageCollect();
REQUIRE(totalSalvageCurrent(f.admin, ship) == 2); REQUIRE(totalSalvageCurrent(f.admin, ship) == 2);
} }
@@ -1055,7 +1089,7 @@ TEST_CASE("SalvagerSystem: second salvage module does not collect when first is
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings); f.runSalvageCollect();
// Only one module was ready, so only one scrap is collected. // Only one module was ready, so only one scrap is collected.
REQUIRE(totalSalvageCurrent(f.admin, ship) == 1); REQUIRE(totalSalvageCurrent(f.admin, ship) == 1);

View File

@@ -10,7 +10,7 @@
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "FactionComponent.h" #include "FactionComponent.h"
#include "WeaponFiredEvent.h" #include "BeamFiredEvent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
#include "HqProxyComponent.h" #include "HqProxyComponent.h"
#include "ModuleOwnerComponent.h" #include "ModuleOwnerComponent.h"
@@ -112,7 +112,7 @@ TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[comb
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<WeaponFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
f.combat.applyPendingDamage(5, f.admin); f.combat.applyPendingDamage(5, f.admin);
@@ -136,16 +136,16 @@ TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]"
f.admin.get<WeaponComponent>(wc).cooldownTicks = 3.0f; // override to 3 f.admin.get<WeaponComponent>(wc).cooldownTicks = 3.0f; // override to 3
} }
auto enemyFiredIn = [&enemy](const std::vector<WeaponFiredEvent>& evts) auto enemyFiredIn = [&enemy](const std::vector<BeamFiredEvent>& evts)
{ {
for (const WeaponFiredEvent& evt : evts) for (const BeamFiredEvent& evt : evts)
{ {
if (evt.shooter == enemy) { return true; } if (evt.shooter == enemy) { return true; }
} }
return false; return false;
}; };
std::vector<WeaponFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
REQUIRE_FALSE(enemyFiredIn(events)); REQUIRE_FALSE(enemyFiredIn(events));
@@ -166,7 +166,7 @@ TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false); const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false);
f.wireEnemyTarget(enemy, player); f.wireEnemyTarget(enemy, player);
std::vector<WeaponFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
REQUIRE(events.empty()); REQUIRE(events.empty());
} }
@@ -205,9 +205,9 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]
sim.tick(); sim.tick();
const std::vector<WeaponFiredEvent> events = sim.drainWeaponFiredEvents(); const std::vector<BeamFiredEvent> events = sim.drainBeamFiredEvents();
bool stationFired = false; bool stationFired = false;
for (const WeaponFiredEvent& evt : events) for (const BeamFiredEvent& evt : events)
{ {
if (evt.shooter == stationEntity) { stationFired = true; } if (evt.shooter == stationEntity) { stationFired = true; }
} }
@@ -243,9 +243,9 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
sim.tick(); sim.tick();
const std::vector<WeaponFiredEvent> events = sim.drainWeaponFiredEvents(); const std::vector<BeamFiredEvent> events = sim.drainBeamFiredEvents();
bool stationFired = false; bool stationFired = false;
for (const WeaponFiredEvent& evt : events) for (const BeamFiredEvent& evt : events)
{ {
if (evt.shooter == stationEntity) { stationFired = true; } if (evt.shooter == stationEntity) { stationFired = true; }
} }
@@ -281,9 +281,9 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]
sim.tick(); sim.tick();
const std::vector<WeaponFiredEvent> events = sim.drainWeaponFiredEvents(); const std::vector<BeamFiredEvent> events = sim.drainBeamFiredEvents();
bool playerFiredAtStation = false; bool playerFiredAtStation = false;
for (const WeaponFiredEvent& evt : events) for (const BeamFiredEvent& evt : events)
{ {
if (evt.shooter == playerShip && evt.target == stationEntity) if (evt.shooter == playerShip && evt.target == stationEntity)
{ {
@@ -309,7 +309,7 @@ TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]")
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<WeaponFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
for (Tick t = 1; t < 5; ++t) for (Tick t = 1; t < 5; ++t)
@@ -331,7 +331,7 @@ TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]")
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<WeaponFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
f.combat.applyPendingDamage(5, f.admin); f.combat.applyPendingDamage(5, f.admin);
@@ -348,7 +348,7 @@ TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[comb
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player); f.wireEnemyTarget(enemy, player);
std::vector<WeaponFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
f.ships.despawn(player); f.ships.despawn(player);
@@ -371,7 +371,7 @@ TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<WeaponFiredEvent> events; std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
f.ships.despawn(enemy); f.ships.despawn(enemy);

View File

@@ -93,6 +93,38 @@ TEST_CASE("ScrapSystem: consume returns nullopt for invalid entity", "[scrap]")
REQUIRE_FALSE(amount.has_value()); REQUIRE_FALSE(amount.has_value());
} }
// ---------------------------------------------------------------------------
// collectOne
// ---------------------------------------------------------------------------
TEST_CASE("ScrapSystem: collectOne depletes one scrap and keeps the pile until empty", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
const entt::entity e = ss.spawn(QVector2D(0.0f, 0.0f), 3, 100);
REQUIRE(ss.collectOne(e));
REQUIRE(admin.isValid(e));
REQUIRE(admin.get<ScrapDataComponent>(e).amount == 2);
REQUIRE(ss.collectOne(e));
REQUIRE(admin.isValid(e));
REQUIRE(admin.get<ScrapDataComponent>(e).amount == 1);
// Final unit collected: the pile is removed once depleted.
REQUIRE(ss.collectOne(e));
REQUIRE_FALSE(admin.isValid(e));
}
TEST_CASE("ScrapSystem: collectOne returns false for an invalid entity", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
REQUIRE_FALSE(ss.collectOne(entt::null));
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// allScrapInfo // allScrapInfo
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -256,11 +256,13 @@ TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas",
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool");
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout); const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
// repair_tool: repair_rate_hz_formula = "5 + x" at x=1 → 6 / kTickRateHz
const float expectedRate = 6.0f / static_cast<float>(kTickRateHz);
const entt::entity rc = firstRepairChild(admin, e); const entt::entity rc = firstRepairChild(admin, e);
REQUIRE(admin.isValid(rc)); REQUIRE(admin.isValid(rc));
REQUIRE(admin.get<RepairToolComponent>(rc).ratePerTick == Approx(expectedRate)); // repair_rate_hz_formula = "1" cycle/s → interval = kTickRateHz ticks
REQUIRE(admin.get<RepairToolComponent>(rc).repairIntervalTicks == kTickRateHz);
// repair_amount_hp_formula = "5 + x" at x=1 → 6 HP per cycle
REQUIRE(admin.get<RepairToolComponent>(rc).repairAmountHp == Approx(6.0f));
REQUIRE(admin.get<RepairToolComponent>(rc).cooldownTicksRemaining == 0);
// repair_range_m_formula = "800" m → 800/10 = 80 tiles // repair_range_m_formula = "800" m → 800/10 = 80 tiles
REQUIRE(admin.get<RepairToolComponent>(rc).range_tiles == Approx(80.0f)); REQUIRE(admin.get<RepairToolComponent>(rc).range_tiles == Approx(80.0f));
REQUIRE(admin.get<RepairBehavior>(e).maxRepairRange_tiles == Approx(80.0f)); REQUIRE(admin.get<RepairBehavior>(e).maxRepairRange_tiles == Approx(80.0f));

View File

@@ -43,22 +43,22 @@ TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]")
REQUIRE(sim.currentTick() == 10); REQUIRE(sim.currentTick() == 10);
} }
TEST_CASE("Simulation::drainWeaponFiredEvents returns empty initially", "[simulation]") TEST_CASE("Simulation::drainBeamFiredEvents returns empty initially", "[simulation]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadConfig());
REQUIRE(sim.drainWeaponFiredEvents().empty()); REQUIRE(sim.drainBeamFiredEvents().empty());
} }
TEST_CASE("Simulation::drainWeaponFiredEvents clears queue on drain", "[simulation]") TEST_CASE("Simulation::drainBeamFiredEvents clears queue on drain", "[simulation]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadConfig());
// First drain: empty. // First drain: empty.
sim.drainWeaponFiredEvents(); sim.drainBeamFiredEvents();
// Second drain must also be empty (not a double-return). // Second drain must also be empty (not a double-return).
REQUIRE(sim.drainWeaponFiredEvents().empty()); REQUIRE(sim.drainBeamFiredEvents().empty());
} }
TEST_CASE("Simulation::hasSchematicChoicesPending returns false initially", "[simulation]") TEST_CASE("Simulation::hasSchematicChoicesPending returns false initially", "[simulation]")

View File

@@ -41,6 +41,7 @@
#include "ShipSystem.h" #include "ShipSystem.h"
#include "Simulation.h" #include "Simulation.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "ScrapDataComponent.h"
#include "SurfaceMask.h" #include "SurfaceMask.h"
#include "Tick.h" #include "Tick.h"
#include "EscapeMenuRequestedEvent.h" #include "EscapeMenuRequestedEvent.h"
@@ -157,11 +158,11 @@ void GameWorldView::onFrame()
// Emit fire events via EventManager // Emit fire events via EventManager
{ {
const std::vector<WeaponFiredEvent> fires = m_sim->drainWeaponFiredEvents(); const std::vector<BeamFiredEvent> fires = m_sim->drainBeamFiredEvents();
for (const WeaponFiredEvent& fe : fires) for (const BeamFiredEvent& fe : fires)
{ {
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<WeaponFiredEvent>(fe)); std::make_shared<BeamFiredEvent>(fe));
} }
} }
@@ -1031,12 +1032,20 @@ void GameWorldView::drawDebugOverlay(QPainter& painter)
void GameWorldView::drawBeams(QPainter& painter) void GameWorldView::drawBeams(QPainter& painter)
{ {
painter.setPen(QPen(m_visuals->beams.color, m_visuals->beams.widthPx));
for (const ActiveBeam& beam : m_activeBeams) for (const ActiveBeam& beam : m_activeBeams)
{ {
const std::optional<QVector2D> shooterPos = entityPosition(beam.event.shooter); const std::optional<QVector2D> shooterPos = entityPosition(beam.event.shooter);
const std::optional<QVector2D> targetPos = entityPosition(beam.event.target); const std::optional<QVector2D> targetPos = entityPosition(beam.event.target);
if (!shooterPos.has_value() || !targetPos.has_value()) { continue; } if (!shooterPos.has_value() || !targetPos.has_value()) { continue; }
QColor color = m_visuals->beams.weaponColor;
switch (beam.event.kind)
{
case BeamKind::Weapon: color = m_visuals->beams.weaponColor; break;
case BeamKind::Repair: color = m_visuals->beams.repairColor; break;
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
}
painter.setPen(QPen(color, m_visuals->beams.widthPx));
painter.drawLine(worldToWidget(*shooterPos), painter.drawLine(worldToWidget(*shooterPos),
worldToWidget(*targetPos + beam.targetOffset)); worldToWidget(*targetPos + beam.targetOffset));
} }
@@ -1567,8 +1576,11 @@ void GameWorldView::resetForNewGame()
// Event handlers // Event handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void GameWorldView::handleEvent(std::shared_ptr<const WeaponFiredEvent> event) void GameWorldView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
{ {
// Endpoint offset is a fraction of the target's visual size (REQ-SHP-FIRING-BEAM):
// half a ship's rendered radius, half a station's shorter footprint side, or
// half a scrap pile's rendered radius (scrap is drawn at tilePx()*0.2).
float maxRadius = 0.125f; float maxRadius = 0.125f;
if (m_sim->admin().isValid(event->target) if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<StationBodyComponent>(event->target)) && m_sim->admin().hasAll<StationBodyComponent>(event->target))
@@ -1577,6 +1589,11 @@ void GameWorldView::handleEvent(std::shared_ptr<const WeaponFiredEvent> event)
const int shorter = std::min(sb.footprint.width(), sb.footprint.height()); const int shorter = std::min(sb.footprint.width(), sb.footprint.height());
maxRadius = shorter / 2.0f; maxRadius = shorter / 2.0f;
} }
else if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<ScrapDataComponent>(event->target))
{
maxRadius = 0.1f;
}
std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f); std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f);
std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius); std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius);

View File

@@ -25,7 +25,7 @@
#include "ExitBlueprintModeRequestedEvent.h" #include "ExitBlueprintModeRequestedEvent.h"
#include "ExitBuilderModeRequestedEvent.h" #include "ExitBuilderModeRequestedEvent.h"
#include "DebugDrawToggledEvent.h" #include "DebugDrawToggledEvent.h"
#include "WeaponFiredEvent.h" #include "BeamFiredEvent.h"
#include "SchematicChoiceOption.h" #include "SchematicChoiceOption.h"
#include "SpeedChangeRequestedEvent.h" #include "SpeedChangeRequestedEvent.h"
@@ -50,7 +50,7 @@ struct QPointCompare
}; };
class GameWorldView : public QOpenGLWidget, class GameWorldView : public QOpenGLWidget,
public CombinedEventHandler<WeaponFiredEvent, public CombinedEventHandler<BeamFiredEvent,
BuildingTypeSelectedEvent, BuildingTypeSelectedEvent,
ExitBuilderModeRequestedEvent, ExitBuilderModeRequestedEvent,
DemolishModeToggleRequestedEvent, DemolishModeToggleRequestedEvent,
@@ -84,7 +84,7 @@ private slots:
void onFrame(); void onFrame();
private: private:
void handleEvent(std::shared_ptr<const WeaponFiredEvent> event) override; void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
void handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event) override; void handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event) override;
void handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> event) override; void handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const DemolishModeToggleRequestedEvent> event) override; void handleEvent(std::shared_ptr<const DemolishModeToggleRequestedEvent> event) override;
@@ -140,7 +140,7 @@ private:
struct ActiveBeam struct ActiveBeam
{ {
WeaponFiredEvent event; BeamFiredEvent event;
qint64 emittedWallMs; qint64 emittedWallMs;
QVector2D targetOffset; QVector2D targetOffset;
}; };

View File

@@ -34,7 +34,9 @@ struct ShipVisuals
struct BeamVisuals struct BeamVisuals
{ {
QColor color; QColor weaponColor;
QColor repairColor;
QColor salvageColor;
int widthPx; int widthPx;
}; };

View File

@@ -209,7 +209,9 @@ VisualsConfig VisualsLoader::load(const std::string& path)
// Beams // Beams
{ {
toml::table& beams = requireSubtable(tbl, "beams", "root"); toml::table& beams = requireSubtable(tbl, "beams", "root");
cfg.beams.color = parseColor(requireString(beams, "color", "beams"), "beams.color"); cfg.beams.weaponColor = parseColor(requireString(beams, "weapon_color", "beams"), "beams.weapon_color");
cfg.beams.repairColor = parseColor(requireString(beams, "repair_color", "beams"), "beams.repair_color");
cfg.beams.salvageColor = parseColor(requireString(beams, "salvage_color", "beams"), "beams.salvage_color");
cfg.beams.widthPx = requireInt(beams, "width_px", "beams"); cfg.beams.widthPx = requireInt(beams, "width_px", "beams");
} }