Compare commits

...

18 Commits

Author SHA1 Message Date
7924e037aa increase asteroid size 2026-06-18 21:48:43 +02:00
c371b43a6d make repair ships standby with rest of fleet if there is no one to repair (instead of advancing towards the enemy stations) 2026-06-18 21:45:15 +02:00
abab2bbb6e make repair ships not retreat if someone needs help 2026-06-17 22:40:58 +02:00
313fed02ca fix range of repair tool in config 2026-06-17 22:39:10 +02:00
b95eaaaded fix repair tool targeting 2026-06-17 21:52:47 +02:00
41c8ed2938 draw debug lines to repair and salvage behavior targets 2026-06-17 21:41:35 +02:00
7f4ea93a70 show accumulated threat for teams in balancing target 2026-06-17 21:29:02 +02:00
1a682fdb79 make drone movement look more spaceship-like 2026-06-17 20:51:45 +02:00
e0e11b7933 fix mutually canceling orbits 2026-06-17 20:50:31 +02:00
0cf3d64983 allow custom orbit rotations directions 2026-06-17 20:36:11 +02:00
1324a320e2 fix issue where ships cancel their attacks and advance if on low health in balancing target 2026-06-16 22:17:09 +02:00
5219b227c5 improve targeting rules config 2026-06-16 21:51:45 +02:00
0e02d9ec4a make sensor range semi transparent in debug draw mode 2026-06-16 21:51:26 +02:00
74615f5293 add debug draw mode for balancing target 2026-06-16 21:47:41 +02:00
bd2391876c draw debug lines to target 2026-06-16 21:38:58 +02:00
ac97652c60 make ships claim targets 2026-06-16 21:18:28 +02:00
4153b7e2f5 make ships orbit their targets 2026-06-15 21:37:47 +02:00
6b7c3df64a advance towards enemy buildings 2026-06-15 20:52:43 +02:00
49 changed files with 1236 additions and 95 deletions

View File

@@ -106,7 +106,7 @@ glyph = "Rp"
[module.repair]
repair_rate_hz_formula = "5 + x"
repair_range_m_formula = "800"
repair_range_m_formula = "80"
# -----------------------------------------------------------------------------
# Propulsion

View File

@@ -31,8 +31,8 @@ hp_formula = "3"
[ship.movement]
speed_mps_formula = "40"
main_acceleration_mpss_formula = "80"
maneuvering_acceleration_mpss_formula = "40"
main_acceleration_mpss_formula = "2"
maneuvering_acceleration_mpss_formula = "1"
angular_acceleration_radpss_formula = "12.56"
max_rotation_speed_radps_formula = "6.28"

View File

@@ -1,5 +1,5 @@
[world]
height_tiles = 30
height_tiles = 40
refund_percentage = 75
starting_building_blocks = 1000
scrap_despawn_seconds = 30
@@ -7,9 +7,11 @@ tile_size_m = 10
belt_speed_mps = 20
tunnel_max_distance_tiles = 10
departure_interval_seconds = 20
orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0
[regions]
asteroid_width_tiles = 40
asteroid_width_tiles = 60
player_buffer_width_tiles = 20
contest_zone_width_tiles = 60
enemy_buffer_width_tiles = 20
@@ -22,6 +24,11 @@ cost_building_blocks = 200
push_expand_columns_tiles = 10
boss_advance_seconds = 60
[targeting]
target_score_formula = "1 / (1 + x)" # x = distance / max weapon range; higher = better, clamped to >=0
overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1]
target_hysteresis = 0.40 # keep current target unless a challenger beats it by >10%
[waves]
threat_rate_formula = "x"
ship_level_formula = "1"

View File

@@ -7,6 +7,8 @@ tile_size_m = 10
belt_speed_mps = 20
tunnel_max_distance_tiles = 10
departure_interval_seconds = 20
orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0
[regions]
asteroid_width_tiles = 40
@@ -22,6 +24,11 @@ cost_building_blocks = 200
push_expand_columns_tiles = 20
boss_advance_seconds = 60
[targeting]
target_score_formula = "1 / (1 + x)" # x = distance / max weapon range; higher = better, clamped to >=0
overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1]
target_hysteresis = 0.10 # keep current target unless a challenger beats it by >10%
[waves]
threat_rate_formula = "x"
ship_level_formula = "1 + x / 10"

View File

@@ -4,7 +4,7 @@
Config files use the TOML format. The following config files drive game parameters:
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, enemy ship level formula, belt speed, starting building blocks, departure interval.
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, enemy ship level formula, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, and combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis).
- **buildings.toml** — building block cost and construction time per building type.
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP).
- **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).
@@ -153,25 +153,36 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-SHP-SPAWN-PLAYER: A ship produced by a shipyard spawns centered on the shipyard's output port tile.
- REQ-SHP-SPAWN-ENEMY: Enemy ships spawn at a uniformly random position within the current enemy buffer zone — random X across the buffer's width and random Y across the world height.
- REQ-SHP-MOVEMENT: Ships move using a physics-based model. Each ship has a velocity and a facing direction, both updated each tick. The main acceleration (`main_acceleration_formula`) is applied along the ship's current facing direction only. The maneuvering acceleration (`maneuvering_acceleration_formula`) can be applied in any direction independently of the facing direction, enabling lateral or braking movement without rotating. The angular acceleration (`angular_acceleration_formula`) controls how quickly the ship rotates. Linear speed is capped at the ship's `speed_formula` value; rotation rate is capped at the ship's `max_rotation_speed_formula` value. Ship position refers to the ship's center for all range, sensor, and attack checks.
- REQ-SHP-ORBIT: Several behaviors keep a ship circling its target at a fixed standoff distance (an **orbit**) rather than approaching a fixed point. The orbit radius depends on the behavior:
- **Combat engagement** (REQ-SHP-COMBAT, REQ-SHP-ENEMY-AI): `world.toml [world].orbit_factor` multiplied by the maximum weapon `attack_range` across the ship's weapon module instances.
- **Repair** (REQ-SHP-REPAIR): `orbit_factor` multiplied by the maximum `repair_range` across the ship's repair module instances.
- **Salvage** (REQ-SHP-SALVAGE): `orbit_factor` multiplied by the maximum `collection_range` across the ship's salvage module instances.
- **Rally** (REQ-SHP-RALLY): `world.toml [world].rally_orbit_radius_tiles` — a fixed radius in tiles, independent of any tool range (the rally point is a position, not a tool-bearing target).
All tool ranges incorporate passive module modifiers (REQ-MOD-STAT-CALC). While orbiting, the ship navigates to maintain the orbit radius from the target's current center (REQ-SHP-MOVEMENT) while moving tangentially around it: if it is farther than the orbit radius it closes in, if it is nearer it backs off, and at the radius it circles. The orbit direction (clockwise or counter-clockwise) is fixed for the duration of orbiting a given target. Orbiting uses the standard physics movement model (REQ-SHP-MOVEMENT) and introduces no new movement constraints. Orbiting does not by itself trigger tool use — weapons, repair tools, and salvage bays still fire/heal/collect strictly per their own range and rate checks (REQ-SHP-FIRING, REQ-SHP-REPAIR, REQ-SHP-SALVAGE). With `orbit_factor` ≤ 1 the orbit lies within the maximum tool range, so the longest-range tool of that type remains in range while the ship orbits.
- REQ-SHP-NO-COLLISION: Ships do not collide with each other or with defence stations; they may visually overlap.
- REQ-SHP-SENSOR: A ship perceives only entities within its sensor range. Behavior is driven by what is in sensor range; entities outside sensor range are ignored.
- REQ-SHP-FIRING: All weapons — on ships and on defence stations — fire when off cooldown and the target is within attack range. Firing emits a fire event and starts a 0.15-second damage delay (half the beam duration). When that delay expires, damage is applied to the target — unless the target has already been destroyed, in which case the damage is silently dropped. If the shooter is destroyed before the delay expires, damage is still applied when the delay expires. There is no projectile entity and no intervening collision. The weapon's cooldown begins at the moment of firing, not at damage application.
- 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-COMBAT: Ships with at least one **weapon module** (player) — engage enemy ships within sensor range. 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).
- Target priority: closest / highest HP / structures first.
- REQ-SHP-RALLY: After spawning, aggressive-stance ships with weapon modules move to and loiter at the **rally point** — the midpoint between the two player defence stations (center of their Y-span, at the player defence stations' X position). While at the rally point, ships still engage any enemy that enters sensor range. 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, move to it; 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; 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-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.
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.
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).
- REQ-SHP-REPAIR: Ships with at least one **repair module** (player) — patrol by moving forward (rightward, away from the asteroid) while searching sensor range. If a damaged player defence station or player ship enters sensor range, move to it and repair. If an enemy ship enters sensor range, the ship retreats (REQ-SHP-RETREAT) until no enemy is in sensor range, 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.
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.
- 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. 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. If no target is in sensor range, they move toward the asteroid (leftward in world coordinates).
- 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-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-TARGET-SELECT: **Combat target selection.** Both player combat ships (REQ-SHP-COMBAT) and enemy ships (REQ-SHP-ENEMY-AI) pick which hostile to engage by scoring every valid target (an opposing-faction ship, defence station, or HQ) within sensor range and engaging the highest-scoring one. A target's score is the product of a **base desirability** and an **overclaim penalty** (REQ-SHP-TARGET-CLAIM). The base desirability is `world.toml [targeting].target_score_formula` evaluated with `x` set to the target's distance from the ship divided by the ship's maximum weapon `attack_range` (falling back to sensor range for a ship with no weapon), clamped to a minimum of 0. The default formula `1 / (1 + x)` decreases with distance, so — absent any claims — the nearest target is chosen, realizing the closest-target priority referenced by REQ-SHP-COMBAT and REQ-SHP-ENEMY-AI. A ship engages at most one target at a time; all of its weapons fire on that target subject to their own range and rate checks (REQ-SHP-FIRING).
- REQ-SHP-TARGET-CLAIM: **Overclaim penalty.** To stop every ship from dogpiling the same hostile, each target a ship is currently engaging counts as a **claim** on that target. When scoring a candidate, its base desirability (REQ-SHP-TARGET-SELECT) is multiplied by `world.toml [targeting].overclaim_penalty_formula` evaluated with `x` set to the number of ships currently claiming that candidate — a ship never counts its own claim against the target it already holds — clamped to the range [0, 1]. The penalty is 1 (no reduction) at zero claims and decreases as claims accumulate, so heavily-claimed targets become less attractive and ships spread across the available hostiles. The default formula `max(0.5, 1 - 0.1*x)` reduces desirability by 0.1 per claim down to a floor of 0.5. Because claims reflect the previous tick's engagements, target distribution converges over successive ticks rather than instantaneously.
- REQ-SHP-TARGET-HYSTERESIS: **Target stickiness.** A ship keeps engaging its current target as long as that target remains valid and within sensor range, switching to a different target only when the best alternative's score exceeds the current target's score by more than the fractional margin `world.toml [targeting].target_hysteresis` (default 0.10). This prevents ships from rapidly oscillating between targets of near-equal score and preserves focus fire.
- REQ-SHP-SCHEMATICS: The player selects a schematic per shipyard by clicking it. New schematics are unlocked by destroying enemy defence station sets (REQ-DEF-SCHEMATIC-DROP) — there is no physical loot to collect.
## Ship Modules
@@ -487,7 +498,7 @@ A separate executable target (`balancing`) that links against `lib` but contains
- REQ-BAL-UI-WINDOW: On startup the tool displays a window containing a "Reload Config" button and a "Start All" button at the top (in that order, left to right), followed by a scrollable vertical list of arena widgets, one per arena defined in `balancing.toml`. Simulations do not start automatically on startup. All buttons and controls in the main window are disabled while an arena is being inspected (REQ-BAL-UI-INSPECT).
- REQ-BAL-UI-RELOAD: The "Reload Config" button reloads all config files from disk (`balancing.toml`, `ships.toml`, `stations.toml`), stops any running simulations, and replaces the arena widget list with freshly created widgets from the reloaded config. The button is disabled while any arena simulation is currently running.
- REQ-BAL-UI-START-ALL: The "Start All" button is placed above the scrollable arena list, to the right of the "Reload Config" button. Clicking it starts (or restarts) the simulation for every arena that is not currently running. The button is disabled when all arenas are currently running.
- REQ-BAL-UI-WIDGET: Each arena widget displays the arena name, an "Inspect" button (to the right of the arena name), and two columns (one per team). Each column shows the team name as a header, followed by a list of entries. The HQ is always the first entry in each column. Below the HQ, ship types are listed, followed by defence stations (if any). Each entry uses the format `surviving/total TypeName Llevel` — for example `2/3 Fighter L5` or `1/1 HQ L1`. The surviving count updates live as the simulation progresses. When the fight ends, the winning team's name header is prefixed with `[WON]`.
- REQ-BAL-UI-WIDGET: Each arena widget displays the arena name, an "Inspect" button (to the right of the arena name), and two columns (one per team). Each column shows the team name as a header, then directly below the header the team's **accumulated threat level** — the sum, across the team's configured ship entries, of each entry's `count` multiplied by the threat cost (REQ-MOD-THREAT) of one ship of that entry computed from its level-independent module layout. Only ships contribute; the HQ and defence stations are excluded. This value is static: it is computed once from the full configured roster and does not change as ships are destroyed during the fight. Below the threat level, the column shows a list of entries. The HQ is always the first entry in each column. Below the HQ, ship types are listed, followed by defence stations (if any). Each entry uses the format `surviving/total TypeName Llevel` — for example `2/3 Fighter L5` or `1/1 HQ L1`. The surviving count updates live as the simulation progresses. When the fight ends, the winning team's name header is prefixed with `[WON]`.
- REQ-BAL-UI-WIDGET-START: Each arena widget contains a "Start" button that starts the simulation for that arena. The button is disabled while the arena's simulation is running. When a finished arena's Start button is clicked, a fresh simulation is created and started (the widget resets to initial unit counts, the border returns to blue, and the previous results are replaced).
- REQ-BAL-UI-WIDGET-BORDER: Each arena widget has a colored border indicating its state: grey when not yet started, blue while its simulation is running, and green when the fight has ended.
- REQ-BAL-UI-INSPECT: Clicking an arena widget's "Inspect" button opens a new inspect window for that arena. Any previously open inspect window is closed first (its arena's simulation is aborted and its widget border returns to grey). The inspected arena is restarted with a fresh simulation that runs at controllable game speed with full rendering (REQ-BAL-SIM-SPEED). The arena widget updates live during inspection (surviving counts, border color, `[WON]` prefix) as it does for non-inspected arenas. Only one inspect window may be open at a time.

View File

@@ -26,6 +26,7 @@
#include "StationBodyComponent.h"
#include "StationsConfig.h"
#include "SurfaceMask.h"
#include "ThreatCostCalculator.h"
#include "WeaponComponent.h"
ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
@@ -56,7 +57,7 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
// Arena fights are symmetric and aggressive: player-faction ships must not
// retreat (REQ-BAL-SIM-AI). Only one faction would otherwise get retreat.
m_shipSystem->setRetreatEnabled(false);
m_aiSystem = std::make_unique<AiSystem>();
m_aiSystem = std::make_unique<AiSystem>(m_gameConfig);
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
m_combatSystem = std::make_unique<CombatSystem>(m_gameConfig);
@@ -64,6 +65,24 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
m_salvagerSystem = std::make_unique<SalvagerSystem>(m_admin);
m_repairSystem = std::make_unique<RepairSystem>(m_admin);
// Static accumulated threat per team: sum of count * per-ship threat cost
// (REQ-MOD-THREAT) over the configured ship roster. Ships only; HQ and
// defence stations are excluded. Level-independent, so computed once here.
for (int ti = 0; ti < 2; ++ti)
{
double teamThreat = 0.0;
for (const ArenaShipEntry& shipEntry : m_arenaConfig.teams[ti].ships)
{
const std::vector<PlacedModule>& modules = shipEntry.layout
? shipEntry.layout->placedModules
: std::vector<PlacedModule>{};
const double shipThreat = calculateShipThreatCost(
m_gameConfig.threatCosts, m_gameConfig, shipEntry.schematicId, modules);
teamThreat += shipThreat * shipEntry.count;
}
m_teamThreat[ti] = teamThreat;
}
placeStructures();
spawnShips();
@@ -460,6 +479,7 @@ void ArenaSimulation::updateStatus()
{
ArenaStatus::TeamStatus& teamStatus = newStatus.teams[ti];
teamStatus.name = m_arenaConfig.teams[ti].name;
teamStatus.threatLevel = m_teamThreat[ti];
// HQ entry (always first).
{

View File

@@ -40,6 +40,7 @@ struct ArenaStatus
struct TeamStatus
{
std::string name;
double threatLevel = 0.0; // accumulated threat of the team's configured ships
std::vector<Entry> entries; // HQ first, then ships, then stations
};
@@ -108,6 +109,9 @@ private:
int m_winnerTeam;
std::atomic<bool> m_stopRequested;
// Static accumulated threat per team, computed once from the configured roster.
double m_teamThreat[2] = {0.0, 0.0};
std::vector<WeaponFiredEvent> m_weaponFiredEvents;
mutable std::mutex m_statusMutex;

View File

@@ -2,13 +2,16 @@
#include <algorithm>
#include <cmath>
#include <functional>
#include <optional>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QPainter>
#include <QPoint>
#include "ArenaSimulation.h"
#include "AttackBehavior.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "EntityHitTest.h"
@@ -19,7 +22,10 @@
#include "GameSpeedChangedEvent.h"
#include "HealthComponent.h"
#include "PositionComponent.h"
#include "RepairBehavior.h"
#include "SalvageScrapBehavior.h"
#include "ScrapSystem.h"
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
#include "StationBodyComponent.h"
@@ -38,6 +44,7 @@ ArenaView::ArenaView(ArenaSimulation* sim, const VisualsConfig* visuals,
, m_prevNonZeroSpeed(1.0)
, m_rng(std::random_device{}())
, m_finishedEmitted(false)
, m_debugDraw(false)
{
setFocusPolicy(Qt::StrongFocus);
@@ -167,6 +174,11 @@ void ArenaView::paintGL()
drawBuildings(painter);
drawStations(painter);
drawScrap(painter);
if (m_debugDraw)
{
drawDebugSensorRanges(painter);
drawDebugTargetLines(painter);
}
drawShips(painter);
drawBeams(painter);
}
@@ -249,6 +261,16 @@ void ArenaView::mousePressEvent(QMouseEvent* event)
QOpenGLWidget::mousePressEvent(event);
}
void ArenaView::keyPressEvent(QKeyEvent* event)
{
if (event->key() == Qt::Key_M)
{
m_debugDraw = !m_debugDraw;
return;
}
QOpenGLWidget::keyPressEvent(event);
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
@@ -418,6 +440,90 @@ void ArenaView::drawShips(QPainter& painter)
});
}
void ArenaView::drawDebugSensorRanges(QPainter& painter)
{
painter.setBrush(Qt::NoBrush);
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const SensorRangeComponent& sensor)
{
const std::map<std::string, ShipVisuals>::const_iterator it =
m_visuals->ships.find(si.schematicId);
if (it == m_visuals->ships.end()) { return; }
const QPointF center = worldToWidget(pos.value);
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
* static_cast<qreal>(tilePx());
QColor circleColor = it->second.outline;
circleColor.setAlpha(77);
painter.setPen(QPen(circleColor, 1));
painter.drawEllipse(center, radiusPx, radiusPx);
});
}
void ArenaView::drawDebugTargetLines(QPainter& painter)
{
// Draw a thin translucent line from a ship to a target, colored by the ship's
// team to match the per-side HQ/station colors used elsewhere in the arena
// (team 1 player, team 2 enemy). Shared by the attack, repair and salvage lines.
const std::function<void(bool, const QVector2D&, const QVector2D&)> drawTargetLine =
[&](bool isEnemy, const QVector2D& from, const QVector2D& to)
{
const BuildingType visType = isEnemy
? BuildingType::EnemyDefenceStation
: BuildingType::PlayerDefenceStation;
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
m_visuals->buildings.find(visType);
if (it == m_visuals->buildings.end()) { return; }
QColor lineColor = it->second.fill;
lineColor.setAlpha(128);
painter.setPen(QPen(lineColor, 1));
painter.drawLine(worldToWidget(from), worldToWidget(to));
};
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent,
FactionComponent, AttackBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& fac,
const AttackBehavior& attack)
{
if (!attack.currentTarget.has_value()) { return; }
const std::optional<QVector2D> targetPos =
entityPosition(*attack.currentTarget);
if (!targetPos.has_value()) { return; }
drawTargetLine(fac.isEnemy, pos.value, *targetPos);
});
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent,
FactionComponent, RepairBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& fac,
const RepairBehavior& repair)
{
if (!repair.currentTarget.has_value()) { return; }
const std::optional<QVector2D> targetPos =
entityPosition(*repair.currentTarget);
if (!targetPos.has_value()) { return; }
drawTargetLine(fac.isEnemy, pos.value, *targetPos);
});
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent,
FactionComponent, SalvageScrapBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& fac,
const SalvageScrapBehavior& salvage)
{
if (!salvage.scrapTarget.has_value()) { return; }
drawTargetLine(fac.isEnemy, pos.value, *salvage.scrapTarget);
});
}
void ArenaView::drawBeams(QPainter& painter)
{
painter.setPen(QPen(m_visuals->beams.color, m_visuals->beams.widthPx));

View File

@@ -39,6 +39,7 @@ public:
protected:
void paintGL() override;
void mousePressEvent(QMouseEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
private slots:
void onFrame();
@@ -51,6 +52,8 @@ private:
void drawStations(QPainter& painter);
void drawScrap(QPainter& painter);
void drawShips(QPainter& painter);
void drawDebugSensorRanges(QPainter& painter);
void drawDebugTargetLines(QPainter& painter);
void drawBeams(QPainter& painter);
float tilePx() const;
@@ -86,4 +89,6 @@ private:
bool m_finishedEmitted;
std::optional<entt::entity> m_selectedEntity;
bool m_debugDraw;
};

View File

@@ -61,6 +61,8 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
headerFont.setBold(true);
m_team1Header->setFont(headerFont);
team1Layout->addWidget(m_team1Header);
m_team1Threat = new QLabel(this);
team1Layout->addWidget(m_team1Threat);
m_team1Content = new QLabel(this);
team1Layout->addWidget(m_team1Content);
team1Layout->addStretch();
@@ -71,6 +73,8 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
m_team2Header = new QLabel(this);
m_team2Header->setFont(headerFont);
team2Layout->addWidget(m_team2Header);
m_team2Threat = new QLabel(this);
team2Layout->addWidget(m_team2Threat);
m_team2Content = new QLabel(this);
team2Layout->addWidget(m_team2Content);
team2Layout->addStretch();
@@ -101,6 +105,7 @@ void ArenaWidget::updateStatus(const ArenaStatus& status)
{
const ArenaStatus::TeamStatus& team = status.teams[ti];
QLabel* header = (ti == 0) ? m_team1Header : m_team2Header;
QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat;
QLabel* content = (ti == 0) ? m_team1Content : m_team2Content;
if (status.finished && status.winnerTeam == ti)
@@ -112,6 +117,8 @@ void ArenaWidget::updateStatus(const ArenaStatus& status)
header->setText(QString::fromStdString(team.name));
}
threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0)));
QString lines;
for (const ArenaStatus::Entry& entry : team.entries)
{

View File

@@ -27,6 +27,8 @@ private:
QLabel* m_titleLabel;
QLabel* m_team1Header;
QLabel* m_team2Header;
QLabel* m_team1Threat;
QLabel* m_team2Threat;
QLabel* m_team1Content;
QLabel* m_team2Content;
QPushButton* m_inspectButton;

View File

@@ -91,6 +91,8 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
headerFont.setBold(true);
m_team1Header->setFont(headerFont);
team1Layout->addWidget(m_team1Header);
m_team1Threat = new QLabel(infoPanel);
team1Layout->addWidget(m_team1Threat);
m_team1Content = new QLabel(infoPanel);
team1Layout->addWidget(m_team1Content);
team1Layout->addStretch();
@@ -100,6 +102,8 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
m_team2Header = new QLabel(infoPanel);
m_team2Header->setFont(headerFont);
team2Layout->addWidget(m_team2Header);
m_team2Threat = new QLabel(infoPanel);
team2Layout->addWidget(m_team2Threat);
m_team2Content = new QLabel(infoPanel);
team2Layout->addWidget(m_team2Content);
team2Layout->addStretch();
@@ -198,6 +202,7 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
{
const ArenaStatus::TeamStatus& team = status.teams[ti];
QLabel* header = (ti == 0) ? m_team1Header : m_team2Header;
QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat;
QLabel* content = (ti == 0) ? m_team1Content : m_team2Content;
if (status.finished && status.winnerTeam == ti)
@@ -209,6 +214,8 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
header->setText(QString::fromStdString(team.name));
}
threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0)));
QString lines;
for (const ArenaStatus::Entry& entry : team.entries)
{

View File

@@ -56,6 +56,8 @@ private:
std::vector<QPushButton*> m_speedButtons;
QLabel* m_team1Header;
QLabel* m_team2Header;
QLabel* m_team1Threat;
QLabel* m_team2Threat;
QLabel* m_team1Content;
QLabel* m_team2Content;
QTimer* m_pollTimer;

View File

@@ -268,6 +268,8 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
cfg.beltSpeed_tps = requireDouble(tbl["world"]["belt_speed_mps"], file, "world.belt_speed_mps") / cfg.tileSize_m;
cfg.tunnelMaxDistance_tiles = static_cast<int>(requireInt(tbl["world"]["tunnel_max_distance_tiles"], file, "world.tunnel_max_distance_tiles"));
cfg.departureIntervalSeconds = requireDouble(tbl["world"]["departure_interval_seconds"], file, "world.departure_interval_seconds");
cfg.orbitFactor = requireDouble(tbl["world"]["orbit_factor"], file, "world.orbit_factor");
cfg.rallyOrbitRadius_tiles = requireDouble(tbl["world"]["rally_orbit_radius_tiles"], file, "world.rally_orbit_radius_tiles");
cfg.regions.asteroidWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["asteroid_width_tiles"], file, "regions.asteroid_width_tiles"));
cfg.regions.playerBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["player_buffer_width_tiles"], file, "regions.player_buffer_width_tiles"));
@@ -295,6 +297,10 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
throw makeError(file, "waves", "gap_min_seconds > gap_max_seconds");
}
cfg.targeting.targetScoreFormula = requireFormula(tbl["targeting"]["target_score_formula"], file, "targeting.target_score_formula");
cfg.targeting.overclaimPenaltyFormula = requireFormula(tbl["targeting"]["overclaim_penalty_formula"], file, "targeting.overclaim_penalty_formula");
cfg.targeting.hysteresis = requireDouble(tbl["targeting"]["target_hysteresis"], file, "targeting.target_hysteresis");
return cfg;
}

View File

@@ -4,6 +4,14 @@
#include "tinyexpr.h"
namespace
{
// tinyexpr has no built-in min/max; expose them so config formulas can
// clamp (e.g. a floored overclaim penalty "max(0.5, 1 - 0.1*x)").
double formulaMin(double a, double b) { return a < b ? a : b; }
double formulaMax(double a, double b) { return a > b ? a : b; }
}
Formula::Formula(Formula&& other) noexcept
: m_source(std::move(other.m_source))
, m_x(std::move(other.m_x))
@@ -37,11 +45,14 @@ Formula Formula::compile(const std::string& source)
result.m_x = std::make_unique<double>(0.0);
const te_variable variables[] = {
{ "x", result.m_x.get(), 0, nullptr },
{ "x", result.m_x.get(), TE_VARIABLE, nullptr },
{ "min", reinterpret_cast<const void*>(&formulaMin), TE_FUNCTION2 | TE_FLAG_PURE, nullptr },
{ "max", reinterpret_cast<const void*>(&formulaMax), TE_FUNCTION2 | TE_FLAG_PURE, nullptr },
};
int errorPos = 0;
result.m_expr = te_compile(result.m_source.c_str(), variables, 1, &errorPos);
const int variableCount = static_cast<int>(sizeof(variables) / sizeof(variables[0]));
result.m_expr = te_compile(result.m_source.c_str(), variables, variableCount, &errorPos);
if (result.m_expr == nullptr)
{
@@ -66,3 +77,4 @@ double Formula::evaluate(double x) const
*m_x = x;
return te_eval(m_expr);
}

View File

@@ -39,6 +39,14 @@ struct WorldWaves
double bossQuietAfterSeconds; // suppress normal waves this long after boss (REQ-WAV-QUIET)
};
// Ship target selection (claim-aware scoring).
struct WorldTargeting
{
Formula targetScoreFormula; // x = distance / max weapon range; higher = better
Formula overclaimPenaltyFormula; // x = competing claim count; factor in [0,1]
double hysteresis; // fractional margin a challenger must beat the current target by
};
struct WorldConfig
{
int heightTiles; // REQ-GW-HEIGHT
@@ -49,9 +57,12 @@ struct WorldConfig
double beltSpeed_tps; // REQ-GW-BELT-SPEED (tiles/s, converted from m/s in config)
int tunnelMaxDistance_tiles; // REQ-BLD-TUNNEL-PAIR
double departureIntervalSeconds; // REQ-SHP-RALLY
double orbitFactor; // REQ-SHP-ORBIT (multiplies tool range for orbit radius)
double rallyOrbitRadius_tiles; // REQ-SHP-ORBIT (fixed orbit radius around the rally point)
WorldRegions regions;
WorldExpansion expansion;
WorldPush push;
WorldWaves waves;
WorldTargeting targeting;
};

View File

@@ -9,5 +9,6 @@
struct AttackBehavior
{
std::optional<entt::entity> currentTarget;
float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT
float score = 0.0f;
};

View File

@@ -6,6 +6,7 @@ enum class BehaviorKind
{
None,
Advance,
Standby,
Rally,
Retreat,
Attack,

View File

@@ -3,12 +3,13 @@
// Score bands for ship-behavior evaluation. The AiSystem selection pass picks
// the behavior with the highest score per ship; these constants define a single
// comparable scale so the desired priority falls out:
// Retreat > Attack > Repair / Salvage / Deliver > Rally > Advance.
// Retreat > Attack > Repair / Salvage / Deliver > Rally > Standby > Advance.
// Evaluators may return kInactive when their behavior does not apply this tick.
namespace BehaviorScores
{
constexpr float kInactive = 0.0f;
constexpr float kAdvance = 0.05f; // baseline fallback; always present
constexpr float kStandby = 0.10f; // repair-capable ships; hold with the fleet
constexpr float kRally = 0.20f;
constexpr float kDeliver = 0.50f; // cargo full
constexpr float kRepair = 0.55f;
@@ -16,7 +17,7 @@ namespace BehaviorScores
constexpr float kAttack = 0.60f; // healthy and target in sensor range
constexpr float kRetreat = 0.90f;
// Health fraction at/below which a ship is considered "low HP" — used by the
// Attack evaluator (do not attack when low) and the Retreat evaluator.
// Health fraction below which a ship is considered "low HP" — used by the
// Retreat evaluator to trigger retreat (which outscores attack).
constexpr float kLowHpFraction = 0.3f;
}

View File

@@ -23,6 +23,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBehaviorComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/SensorRangeComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipIdentityComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/StandbyBehavior.h
${CMAKE_CURRENT_SOURCE_DIR}/StationBodyComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/WeaponComponent.h
PARENT_SCOPE

View File

@@ -9,5 +9,9 @@
struct MovementIntentComponent
{
bool active = false;
QVector2D target;
QVector2D target; // straight-line destination, or orbit center when orbitRadius_tiles > 0
float orbitRadius_tiles = 0.0f; // 0 ⇒ go straight to target; >0 ⇒ orbit target at this radius
QVector2D orbitCenterVelocity_tpt; // velocity of the orbit center (0 for a static center); the orbit
// sense is resolved relative to this so a moving target's own motion
// does not bias it
};

View File

@@ -7,5 +7,6 @@
struct RallyBehavior
{
QVector2D rallyPoint;
float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT
float score = 0.0f;
};

View File

@@ -11,5 +11,6 @@ struct RepairBehavior
{
std::optional<entt::entity> currentTarget;
float maxRepairRange_tiles = 0.0f;
float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT
float score = 0.0f;
};

View File

@@ -10,5 +10,6 @@ struct SalvageScrapBehavior
{
std::optional<QVector2D> scrapTarget;
float maxCollectionRange_tiles = 0.0f;
float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT
float score = 0.0f;
};

View File

@@ -0,0 +1,11 @@
#pragma once
// Fallback for ships with a repair capability: instead of charging the enemy
// like AdvanceBehavior, the ship holds with its fleet so damaged allies stay in
// sensor range and it can heal them. Scored just above Advance and below Rally,
// so it only wins when no more urgent behavior applies. The executor decides the
// destination (StandbyExecutor).
struct StandbyBehavior
{
float score = 0.0f;
};

View File

@@ -2,6 +2,8 @@
#include <limits>
#include "GameConfig.h"
#include "AdvanceBehavior.h"
#include "AttackBehavior.h"
#include "BehaviorKind.h"
@@ -12,6 +14,7 @@
#include "RetreatBehavior.h"
#include "SalvageScrapBehavior.h"
#include "SelectedBehaviorComponent.h"
#include "StandbyBehavior.h"
#include "tracing.h"
namespace
@@ -34,6 +37,11 @@ namespace
}
}
AiSystem::AiSystem(const GameConfig& config)
: m_attackEvaluator(config.world.targeting)
{
}
void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
const ScrapSystem& scraps)
{
@@ -41,6 +49,7 @@ void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
// Phase 1: evaluators score behaviors and set their target data.
m_advanceEvaluator.evaluate(admin);
m_standbyEvaluator.evaluate(admin);
m_rallyEvaluator.evaluate(admin);
m_retreatEvaluator.evaluate(admin);
m_attackEvaluator.evaluate(admin);
@@ -53,6 +62,7 @@ void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
// Phase 3: executors run for the winning behavior.
m_advanceExecutor.execute(admin);
m_standbyExecutor.execute(admin);
m_rallyExecutor.execute(admin);
m_retreatExecutor.execute(admin);
m_attackExecutor.execute(admin);
@@ -78,5 +88,6 @@ void AiSystem::selectWinningBehaviors(EntityAdmin& admin)
consider<SalvageScrapBehavior>(admin, BehaviorKind::SalvageScrap);
consider<DeliverScrapBehavior>(admin, BehaviorKind::DeliverScrap);
consider<RallyBehavior>(admin, BehaviorKind::Rally);
consider<StandbyBehavior>(admin, BehaviorKind::Standby);
consider<AdvanceBehavior>(admin, BehaviorKind::Advance);
}

View File

@@ -14,10 +14,13 @@
#include "RetreatExecutor.h"
#include "SalvageScrapEvaluator.h"
#include "SalvageScrapExecutor.h"
#include "StandbyEvaluator.h"
#include "StandbyExecutor.h"
class BuildingSystem;
class EntityAdmin;
class ScrapSystem;
struct GameConfig;
// Orchestrates ship-behavior decision-making in three batched phases:
// 1. evaluators score each behavior and set its target data,
@@ -29,12 +32,15 @@ class ScrapSystem;
class AiSystem
{
public:
explicit AiSystem(const GameConfig& config);
void tick(EntityAdmin& admin, const BuildingSystem& buildings, const ScrapSystem& scraps);
private:
void selectWinningBehaviors(EntityAdmin& admin);
AdvanceEvaluator m_advanceEvaluator;
StandbyEvaluator m_standbyEvaluator;
RallyEvaluator m_rallyEvaluator;
RetreatEvaluator m_retreatEvaluator;
AttackEvaluator m_attackEvaluator;
@@ -43,6 +49,7 @@ private:
DeliverScrapEvaluator m_deliverScrapEvaluator;
AdvanceExecutor m_advanceExecutor;
StandbyExecutor m_standbyExecutor;
RallyExecutor m_rallyExecutor;
RetreatExecutor m_retreatExecutor;
AttackExecutor m_attackExecutor;

View File

@@ -15,6 +15,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapEvaluator.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/StandbyEvaluator.h
${CMAKE_CURRENT_SOURCE_DIR}/ai/StandbyExecutor.h
${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodySystem.h
@@ -43,6 +45,8 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatExecutor.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapEvaluator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapExecutor.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/StandbyEvaluator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ai/StandbyExecutor.cpp
${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodySystem.cpp

View File

@@ -9,6 +9,7 @@
#include "EntityAdmin.h"
#include "FacingComponent.h"
#include "MovementIntentComponent.h"
#include "OrbitMath.h"
#include "PositionComponent.h"
#include "tracing.h"
@@ -45,7 +46,20 @@ void MovementIntentSystem::tick(EntityAdmin& admin)
return;
}
const QVector2D delta = intent.target - pos.value;
// Resolve the steering destination. For orbit intents, pick the orbit
// sense from the ship's current velocity (so ships circling the same
// target spread to both sides) and aim at a point on the orbit circle.
QVector2D destination = intent.target;
if (intent.orbitRadius_tiles > 0.0f)
{
const float sign = OrbitMath::resolveOrbitSign(
pos.value, intent.target, body.velocity_tpt,
intent.orbitCenterVelocity_tpt);
destination = OrbitMath::computeOrbitDestination(
pos.value, intent.target, intent.orbitRadius_tiles, sign);
}
const QVector2D delta = destination - pos.value;
const float dist = delta.length();
if (dist < 0.001f)

View File

@@ -25,6 +25,7 @@
#include "SalvageScrapBehavior.h"
#include "SelectedBehaviorComponent.h"
#include "SensorRangeComponent.h"
#include "StandbyBehavior.h"
#include "Tick.h"
#include "tracing.h"
#include "WeaponComponent.h"
@@ -343,12 +344,24 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
if (!weaponChildren.empty())
{
m_admin.addComponent<AttackBehavior>(entity, AttackBehavior{});
float maxWeaponRange = 0.0f;
for (entt::entity child : weaponChildren)
{
const float r = m_admin.get<WeaponComponent>(child).range_tiles;
if (r > maxWeaponRange) { maxWeaponRange = r; }
}
AttackBehavior attack;
attack.orbitRadius_tiles =
maxWeaponRange * static_cast<float>(m_config.world.orbitFactor);
m_admin.addComponent<AttackBehavior>(entity, attack);
if (!isEnemy)
{
RallyBehavior rally;
rally.rallyPoint = m_rallyPoint;
rally.orbitRadius_tiles =
static_cast<float>(m_config.world.rallyOrbitRadius_tiles);
m_admin.addComponent<RallyBehavior>(entity, rally);
}
}
@@ -365,6 +378,8 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
SalvageScrapBehavior salvage;
salvage.scrapTarget = std::nullopt;
salvage.maxCollectionRange_tiles = maxCollRange;
salvage.orbitRadius_tiles =
maxCollRange * static_cast<float>(m_config.world.orbitFactor);
m_admin.addComponent<SalvageScrapBehavior>(entity, salvage);
DeliverScrapBehavior deliver;
@@ -384,7 +399,14 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
RepairBehavior repair;
repair.currentTarget = std::nullopt;
repair.maxRepairRange_tiles = maxRepairRange;
repair.orbitRadius_tiles =
maxRepairRange * static_cast<float>(m_config.world.orbitFactor);
m_admin.addComponent<RepairBehavior>(entity, repair);
// Repair-capable ships hold with the fleet (REQ-SHP-STANDBY) instead of
// charging the enemy when no more urgent behavior applies; this applies
// whether or not the ship also carries weapons.
m_admin.addComponent<StandbyBehavior>(entity, StandbyBehavior{});
}
return entity;

View File

@@ -1,30 +1,112 @@
#include "AdvanceExecutor.h"
#include <optional>
#include <QVector2D>
#include "AdvanceBehavior.h"
#include "BehaviorKind.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "MovementIntentComponent.h"
#include "PositionComponent.h"
#include "SelectedBehaviorComponent.h"
#include "StationBodyComponent.h"
#include "tracing.h"
namespace
{
// Accumulates positions to produce their centroid (the center between them).
struct Centroid
{
QVector2D sum;
int count = 0;
void add(const QVector2D& point)
{
sum += point;
count += 1;
}
std::optional<QVector2D> value() const
{
if (count == 0) { return std::nullopt; }
return sum / static_cast<float>(count);
}
};
}
void AdvanceExecutor::execute(EntityAdmin& admin)
{
TRACE();
// Centroid of each faction's alive defence stations. In the arena the HQ is
// spawned as a station, so it is part of this centroid; in the main game the
// enemy side has only its defence stations.
Centroid enemyStations;
Centroid playerStations;
admin.forEach<StationBodyComponent, PositionComponent, FactionComponent, HealthComponent>(
[&enemyStations, &playerStations](entt::entity /*e*/,
const StationBodyComponent& /*sb*/, const PositionComponent& pos,
const FactionComponent& faction, const HealthComponent& health)
{
if (health.hp <= 0.0f) { return; }
Centroid& centroid = faction.isEnemy ? enemyStations : playerStations;
centroid.add(pos.value);
});
// Fallback target per faction: the HQ proxy (main game only), used when a side
// has lost all of its defence stations.
Centroid enemyHq;
Centroid playerHq;
admin.forEach<HqProxyComponent, PositionComponent, FactionComponent, HealthComponent>(
[&enemyHq, &playerHq](entt::entity /*e*/, const HqProxyComponent& /*hq*/,
const PositionComponent& pos, const FactionComponent& faction,
const HealthComponent& health)
{
if (health.hp <= 0.0f) { return; }
Centroid& centroid = faction.isEnemy ? enemyHq : playerHq;
centroid.add(pos.value);
});
const std::optional<QVector2D> enemyStationCenter = enemyStations.value();
const std::optional<QVector2D> playerStationCenter = playerStations.value();
const std::optional<QVector2D> enemyHqCenter = enemyHq.value();
const std::optional<QVector2D> playerHqCenter = playerHq.value();
admin.forEach<AdvanceBehavior, SelectedBehaviorComponent, PositionComponent,
FactionComponent, MovementIntentComponent>(
[](entt::entity /*e*/, const AdvanceBehavior& /*advance*/,
[&](entt::entity /*e*/, const AdvanceBehavior& /*advance*/,
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
const FactionComponent& faction, MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::Advance) { return; }
const QVector2D target = faction.isEnemy
// Aim at the center between the opposing side's defence stations; fall
// back to the opposing HQ, then to an off-world point in the advance
// direction so the ship keeps moving when no target structure exists.
const std::optional<QVector2D>& stationCenter =
faction.isEnemy ? playerStationCenter : enemyStationCenter;
const std::optional<QVector2D>& hqCenter =
faction.isEnemy ? playerHqCenter : enemyHqCenter;
QVector2D target;
if (stationCenter)
{
target = *stationCenter;
}
else if (hqCenter)
{
target = *hqCenter;
}
else
{
target = faction.isEnemy
? QVector2D(-10000.0f, pos.value.y())
: QVector2D(pos.value.x() + 1000.0f, pos.value.y());
}
intent = MovementIntentComponent{true, target};
});
}

View File

@@ -1,5 +1,7 @@
#include "AttackEvaluator.h"
#include <algorithm>
#include <unordered_map>
#include <vector>
#include <QVector2D>
@@ -9,63 +11,136 @@
#include "BehaviorTargeting.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "PositionComponent.h"
#include "SensorRangeComponent.h"
#include "tracing.h"
#include "WeaponComponent.h"
#include "WorldConfig.h"
AttackEvaluator::AttackEvaluator(const WorldTargeting& targeting)
: m_targeting(&targeting)
{
}
void AttackEvaluator::evaluate(EntityAdmin& admin)
{
TRACE();
const std::vector<CombatantInfo> combatants = buildCombatants(admin);
admin.forEach<AttackBehavior, PositionComponent, FactionComponent,
SensorRangeComponent, HealthComponent>(
[&](entt::entity e, AttackBehavior& attack, const PositionComponent& pos,
const FactionComponent& faction, const SensorRangeComponent& sensor,
const HealthComponent& health)
// Pass A: the maximum weapon range per ship, used to normalise target
// distance. Ships without a weapon fall back to their sensor range below.
std::unordered_map<entt::entity, float> maxWeaponRange_tiles;
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
[&maxWeaponRange_tiles](entt::entity /*we*/, const WeaponComponent& weapon,
const ModuleOwnerComponent& owner)
{
const float range = sensor.value_tiles;
float& best = maxWeaponRange_tiles[owner.owner];
best = std::max(best, weapon.range_tiles);
});
// Validate current target: still valid, still in range.
bool targetValid = false;
// Pass B: claim counts, taken from every ship's current target before any
// target is reassigned this tick. Each ship reads the previous tick's claim
// state and excludes its own contribution when scoring its current target.
std::unordered_map<entt::entity, int> claimsByTarget;
admin.forEach<AttackBehavior>(
[&claimsByTarget, &admin](entt::entity /*e*/, const AttackBehavior& attack)
{
if (attack.currentTarget && admin.isValid(*attack.currentTarget))
{
++claimsByTarget[*attack.currentTarget];
}
});
// Pass C: per-ship target selection.
admin.forEach<AttackBehavior, PositionComponent, FactionComponent,
SensorRangeComponent>(
[&](entt::entity e, AttackBehavior& attack, const PositionComponent& pos,
const FactionComponent& faction, const SensorRangeComponent& sensor)
{
const float sensorRange_tiles = sensor.value_tiles;
// Distance normaliser: max weapon range, or sensor range if unarmed.
float weaponRange_tiles = sensorRange_tiles;
const auto weaponRangeIt = maxWeaponRange_tiles.find(e);
if (weaponRangeIt != maxWeaponRange_tiles.end() && weaponRangeIt->second > 0.0f)
{
weaponRange_tiles = weaponRangeIt->second;
}
// Scores a single candidate: base desirability from distance, reduced
// by the overclaim penalty. selfClaimed subtracts this ship's own claim
// so it does not penalise the target it already holds.
const auto scoreOf =
[&](const QVector2D& candidatePos, entt::entity candidate) -> float
{
const float dist = (candidatePos - pos.value).length();
const float x = dist / weaponRange_tiles;
float base = static_cast<float>(m_targeting->targetScoreFormula.evaluate(x));
base = std::max(base, 0.0f);
int claims = 0;
const auto claimIt = claimsByTarget.find(candidate);
if (claimIt != claimsByTarget.end()) { claims = claimIt->second; }
if (attack.currentTarget && candidate == *attack.currentTarget) { --claims; }
float penalty = static_cast<float>(
m_targeting->overclaimPenaltyFormula.evaluate(claims));
penalty = std::clamp(penalty, 0.0f, 1.0f);
return base * penalty;
};
// Find the best candidate among in-range enemies.
std::optional<entt::entity> bestTarget;
float bestScore = 0.0f;
for (const CombatantInfo& c : combatants)
{
if (c.entity == e) { continue; }
const bool isValidTarget = faction.isEnemy ? !c.isEnemy : c.isEnemy;
if (!isValidTarget) { continue; }
const float dist = (c.position - pos.value).length();
if (dist > sensorRange_tiles) { continue; }
const float score = scoreOf(c.position, c.entity);
if (!bestTarget || score > bestScore)
{
bestScore = score;
bestTarget = c.entity;
}
}
// Hysteresis: keep the current target if it is still valid and in
// range, unless a challenger beats its score by more than the margin.
bool keptCurrent = false;
if (attack.currentTarget)
{
const entt::entity t = *attack.currentTarget;
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
{
const float dist =
(admin.get<PositionComponent>(t).value - pos.value).length();
if (dist <= range) { targetValid = true; }
const QVector2D targetPos = admin.get<PositionComponent>(t).value;
const float dist = (targetPos - pos.value).length();
if (dist <= sensorRange_tiles)
{
const float currentScore = scoreOf(targetPos, t);
const float margin = 1.0f + static_cast<float>(m_targeting->hysteresis);
if (!bestTarget || bestScore <= currentScore * margin)
{
keptCurrent = true;
}
}
// Acquire nearest valid target if needed.
if (!targetValid)
{
attack.currentTarget = std::nullopt;
float bestDist = range;
for (const CombatantInfo& c : combatants)
{
if (c.entity == e) { continue; }
const bool isValidTarget =
faction.isEnemy ? !c.isEnemy : c.isEnemy;
if (!isValidTarget) { continue; }
const float dist = (c.position - pos.value).length();
if (dist < bestDist)
{
bestDist = dist;
attack.currentTarget = c.entity;
}
}
}
const bool healthy =
(health.maxHp > 0.0f)
&& (health.hp / health.maxHp >= BehaviorScores::kLowHpFraction);
attack.score = (healthy && attack.currentTarget)
if (!keptCurrent)
{
attack.currentTarget = bestTarget;
}
attack.score = attack.currentTarget
? BehaviorScores::kAttack
: BehaviorScores::kInactive;
});
}

View File

@@ -1,11 +1,22 @@
#pragma once
class EntityAdmin;
struct WorldTargeting;
// Acquires/validates a combat target for ships with weapons. Scores high only
// when the ship's health is not low and a valid target is within sensor range.
//
// Target choice is claim-aware: each tick the desirability of every candidate is
// scored from a configurable distance formula and reduced by a soft overclaim
// penalty that scales with how many other ships already target it, spreading
// ships across enemies instead of dogpiling the nearest one.
class AttackEvaluator
{
public:
explicit AttackEvaluator(const WorldTargeting& targeting);
void evaluate(EntityAdmin& admin);
private:
const WorldTargeting* m_targeting;
};

View File

@@ -2,6 +2,7 @@
#include "AttackBehavior.h"
#include "BehaviorKind.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "ModuleOwnerComponent.h"
#include "MovementIntentComponent.h"
@@ -25,12 +26,19 @@ void AttackExecutor::execute(EntityAdmin& admin)
if (!attack.currentTarget) { return; }
const entt::entity t = *attack.currentTarget;
QVector2D dest = pos.value;
QVector2D center = pos.value;
float radius = 0.0f;
QVector2D centerVelocity;
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
{
dest = admin.get<PositionComponent>(t).value;
center = admin.get<PositionComponent>(t).value;
radius = attack.orbitRadius_tiles;
if (admin.hasAll<DynamicBodyComponent>(t))
{
centerVelocity = admin.get<DynamicBodyComponent>(t).velocity_tpt;
}
intent = MovementIntentComponent{true, dest};
}
intent = MovementIntentComponent{true, center, radius, centerVelocity};
});
// Weapons: assign the behavior target only if it is within this weapon's range.

View File

@@ -0,0 +1,88 @@
#pragma once
#include <cmath>
#include <QVector2D>
// Orbit movement helper (REQ-SHP-ORBIT). Behaviors that keep a ship circling a
// target (attack, repair, salvage, rally) supply an orbit center and radius via
// the movement intent; MovementIntentSystem resolves the orbit direction and
// destination using these helpers.
namespace OrbitMath
{
// Lead angle (radians) by which the radial direction is rotated to produce
// tangential motion. The orbit direction (sign of the rotation) is chosen
// per ship by resolveOrbitSign from the ship's current velocity, so ships
// approaching a target from different sides circle it in different senses
// instead of all bunching on one side.
constexpr float kOrbitLeadAngle_rad = 0.6f;
// Returns the orbit sense (+1 counter-clockwise, -1 clockwise) that matches
// the ship's movement around `center`, so steering reinforces the motion the
// ship already has. The sense is taken from the ship's velocity *relative to
// the center* (`centerVelocity`): for a moving target this both removes the
// target's own motion from the decision and dissolves the degenerate case
// where two ships orbiting each other translate in a straight line — there
// their shared velocity cancels, leaving ~zero relative velocity. When the
// relative velocity is nearly radial or near zero (a head-on approach, a
// freshly spawned ship, or that mutual-translation case) the sense is
// ill-defined; this is an unstable point the ship leaves within a tick or
// two, so a deterministic fallback of +1 is returned.
inline float resolveOrbitSign(const QVector2D& shipPos, const QVector2D& center,
const QVector2D& velocity,
const QVector2D& centerVelocity = QVector2D())
{
const QVector2D radial = shipPos - center;
const QVector2D relativeVelocity = velocity - centerVelocity;
const float radialLength = radial.length();
const float velocityLength = relativeVelocity.length();
if (radialLength < 1.0e-4f || velocityLength < 1.0e-4f)
{
return 1.0f;
}
// z-component of radial x relativeVelocity, normalised to sin(angle).
const float cross = radial.x() * relativeVelocity.y()
- radial.y() * relativeVelocity.x();
const float sinAngle = cross / (radialLength * velocityLength);
constexpr float kRadialEpsilon = 1.0e-3f;
if (std::abs(sinAngle) < kRadialEpsilon)
{
return 1.0f;
}
return (sinAngle > 0.0f) ? 1.0f : -1.0f;
}
// Returns a destination on the orbit circle of `radius` around `center`. The
// result always lies exactly `radius` from `center`, so steering toward it
// both corrects the standoff distance and advances the ship tangentially.
// `sign` selects the orbit sense (+1 counter-clockwise, -1 clockwise). A
// radius of zero or less falls back to the center (legacy "approach the
// target" behavior), e.g. when the ship has no tool range to orbit at.
inline QVector2D computeOrbitDestination(const QVector2D& shipPos,
const QVector2D& center, float radius,
float sign = 1.0f)
{
if (radius <= 0.0f) { return center; }
QVector2D radial = shipPos - center;
float length = radial.length();
if (length < 1.0e-4f)
{
// Ship sits on the center; pick an arbitrary radial direction.
radial = QVector2D(1.0f, 0.0f);
length = 1.0f;
}
const QVector2D radialDirection = radial / length;
const float leadAngle = sign * kOrbitLeadAngle_rad;
const float cosLead = std::cos(leadAngle);
const float sinLead = std::sin(leadAngle);
const QVector2D leadDirection(
radialDirection.x() * cosLead - radialDirection.y() * sinLead,
radialDirection.x() * sinLead + radialDirection.y() * cosLead);
return center + radius * leadDirection;
}
}

View File

@@ -10,11 +10,14 @@
void RallyExecutor::execute(EntityAdmin& admin)
{
TRACE();
admin.forEach<RallyBehavior, SelectedBehaviorComponent, MovementIntentComponent>(
admin.forEach<RallyBehavior, SelectedBehaviorComponent,
MovementIntentComponent>(
[](entt::entity /*e*/, const RallyBehavior& rally,
const SelectedBehaviorComponent& selected, MovementIntentComponent& intent)
const SelectedBehaviorComponent& selected,
MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::Rally) { return; }
intent = MovementIntentComponent{true, rally.rallyPoint};
intent = MovementIntentComponent{true, rally.rallyPoint,
rally.orbitRadius_tiles};
});
}

View File

@@ -5,6 +5,7 @@
#include "BehaviorScores.h"
#include "BehaviorTargeting.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "PositionComponent.h"
#include "RepairBehavior.h"
@@ -16,23 +17,28 @@ void RepairEvaluator::evaluate(EntityAdmin& admin)
TRACE();
const std::vector<RepairableInfo> repairables = buildRepairables(admin);
admin.forEach<RepairBehavior, PositionComponent, SensorRangeComponent>(
admin.forEach<RepairBehavior, PositionComponent, SensorRangeComponent, FactionComponent>(
[&](entt::entity e, RepairBehavior& repair, const PositionComponent& pos,
const SensorRangeComponent& sensor)
const SensorRangeComponent& sensor, const FactionComponent& faction)
{
// Validate current target: alive and still damaged.
// Validate current target: same faction, alive and still damaged.
bool targetValid = false;
if (repair.currentTarget)
{
const entt::entity t = *repair.currentTarget;
if (admin.isValid(t) && admin.hasAll<HealthComponent>(t))
if (admin.isValid(t) && admin.hasAll<HealthComponent, FactionComponent>(t))
{
const HealthComponent& th = admin.get<HealthComponent>(t);
if (th.hp > 0.0f && th.hp < th.maxHp) { targetValid = true; }
const FactionComponent& tf = admin.get<FactionComponent>(t);
if (tf.isEnemy == faction.isEnemy && th.hp > 0.0f && th.hp < th.maxHp)
{
targetValid = true;
}
}
}
// Acquire nearest damaged friendly within sensor range.
// Acquire nearest damaged friendly within sensor range. Friendly is
// relative to this ship's faction, not the absolute isEnemy flag.
if (!targetValid)
{
repair.currentTarget = std::nullopt;
@@ -40,7 +46,7 @@ void RepairEvaluator::evaluate(EntityAdmin& admin)
for (const RepairableInfo& r : repairables)
{
if (r.entity == e) { continue; }
if (r.isEnemy) { continue; }
if (r.isEnemy != faction.isEnemy) { continue; }
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
const float dist = (r.position - pos.value).length();
if (dist < bestDist)

View File

@@ -1,6 +1,7 @@
#include "RepairExecutor.h"
#include "BehaviorKind.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "ModuleOwnerComponent.h"
#include "MovementIntentComponent.h"
@@ -25,12 +26,19 @@ void RepairExecutor::execute(EntityAdmin& admin)
if (!repair.currentTarget) { return; }
const entt::entity t = *repair.currentTarget;
QVector2D dest = pos.value;
QVector2D center = pos.value;
float radius = 0.0f;
QVector2D centerVelocity;
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
{
dest = admin.get<PositionComponent>(t).value;
center = admin.get<PositionComponent>(t).value;
radius = repair.orbitRadius_tiles;
if (admin.hasAll<DynamicBodyComponent>(t))
{
centerVelocity = admin.get<DynamicBodyComponent>(t).velocity_tpt;
}
intent = MovementIntentComponent{true, dest};
}
intent = MovementIntentComponent{true, center, radius, centerVelocity};
});
// Repair tools: prefer the behavior target if it is within tool range.

View File

@@ -6,10 +6,12 @@
#include "AttackBehavior.h"
#include "BehaviorScores.h"
#include "BehaviorTargeting.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "PositionComponent.h"
#include "RepairBehavior.h"
#include "RetreatBehavior.h"
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
@@ -28,9 +30,15 @@ void RetreatEvaluator::evaluate(EntityAdmin& admin)
if (f.isEnemy) { enemyShips.push_back(pos.value); }
});
admin.forEach<RetreatBehavior, PositionComponent, HealthComponent, SensorRangeComponent>(
// Snapshot repairables so weaponless repair ships can decide whether there is
// still a damaged ally worth holding ground for.
const std::vector<RepairableInfo> repairables = buildRepairables(admin);
admin.forEach<RetreatBehavior, PositionComponent, HealthComponent,
SensorRangeComponent, FactionComponent>(
[&](entt::entity e, RetreatBehavior& retreat, const PositionComponent& pos,
const HealthComponent& health, const SensorRangeComponent& sensor)
const HealthComponent& health, const SensorRangeComponent& sensor,
const FactionComponent& faction)
{
const bool lowHp = (health.maxHp > 0.0f)
&& (health.hp / health.maxHp < retreat.retreatHpFraction);
@@ -39,14 +47,36 @@ void RetreatEvaluator::evaluate(EntityAdmin& admin)
const bool hasWeapons = admin.hasAll<AttackBehavior>(e);
if (!hasWeapons)
{
bool enemyInRange = false;
for (const QVector2D& enemy : enemyShips)
{
if ((enemy - pos.value).length() <= sensor.value_tiles)
{
threatened = true;
enemyInRange = true;
break;
}
}
// A weaponless ship with a repair tool holds its ground while a
// damaged ally remains within sensor range; it only flees once
// there is nothing left to repair.
bool repairTargetInRange = false;
if (enemyInRange && admin.hasAll<RepairBehavior>(e))
{
for (const RepairableInfo& r : repairables)
{
if (r.entity == e) { continue; }
if (r.isEnemy != faction.isEnemy) { continue; }
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
if ((r.position - pos.value).length() <= sensor.value_tiles)
{
repairTargetInRange = true;
break;
}
}
}
threatened = enemyInRange && !repairTargetInRange;
}
retreat.score = (lowHp || threatened)

View File

@@ -10,12 +10,15 @@
void SalvageScrapExecutor::execute(EntityAdmin& admin)
{
TRACE();
admin.forEach<SalvageScrapBehavior, SelectedBehaviorComponent, MovementIntentComponent>(
admin.forEach<SalvageScrapBehavior, SelectedBehaviorComponent,
MovementIntentComponent>(
[](entt::entity /*e*/, const SalvageScrapBehavior& salvage,
const SelectedBehaviorComponent& selected, MovementIntentComponent& intent)
const SelectedBehaviorComponent& selected,
MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::SalvageScrap) { return; }
if (!salvage.scrapTarget) { return; }
intent = MovementIntentComponent{true, *salvage.scrapTarget};
intent = MovementIntentComponent{true, *salvage.scrapTarget,
salvage.orbitRadius_tiles};
});
}

View File

@@ -0,0 +1,16 @@
#include "StandbyEvaluator.h"
#include "BehaviorScores.h"
#include "EntityAdmin.h"
#include "StandbyBehavior.h"
#include "tracing.h"
void StandbyEvaluator::evaluate(EntityAdmin& admin)
{
TRACE();
admin.forEach<StandbyBehavior>(
[](entt::entity /*e*/, StandbyBehavior& standby)
{
standby.score = BehaviorScores::kStandby;
});
}

View File

@@ -0,0 +1,12 @@
#pragma once
class EntityAdmin;
// Constant low-priority fallback for repair-capable ships: gives a fixed score
// just above Advance so a repair ship with nothing more urgent to do holds with
// its fleet (StandbyExecutor) instead of charging the enemy.
class StandbyEvaluator
{
public:
void evaluate(EntityAdmin& admin);
};

View File

@@ -0,0 +1,101 @@
#include "StandbyExecutor.h"
#include <optional>
#include <QVector2D>
#include "BehaviorKind.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "MovementIntentComponent.h"
#include "PositionComponent.h"
#include "SelectedBehaviorComponent.h"
#include "ShipIdentityComponent.h"
#include "StandbyBehavior.h"
#include "StationBodyComponent.h"
#include "tracing.h"
namespace
{
// Accumulates positions to produce their centroid (the center between them).
struct Centroid
{
QVector2D sum;
int count = 0;
void add(const QVector2D& point)
{
sum += point;
count += 1;
}
std::optional<QVector2D> value() const
{
if (count == 0) { return std::nullopt; }
return sum / static_cast<float>(count);
}
};
}
void StandbyExecutor::execute(EntityAdmin& admin)
{
TRACE();
// Centroid of each faction's alive ships; a standing-by ship steers toward the
// center of its other same-faction ships so it stays among potential patients.
Centroid enemyShips;
Centroid playerShips;
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent, HealthComponent>(
[&enemyShips, &playerShips](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& faction,
const HealthComponent& health)
{
if (health.hp <= 0.0f) { return; }
Centroid& centroid = faction.isEnemy ? enemyShips : playerShips;
centroid.add(pos.value);
});
// Fallback per faction: the centroid of that side's own alive defence stations.
Centroid enemyStations;
Centroid playerStations;
admin.forEach<StationBodyComponent, PositionComponent, FactionComponent, HealthComponent>(
[&enemyStations, &playerStations](entt::entity /*e*/,
const StationBodyComponent& /*sb*/, const PositionComponent& pos,
const FactionComponent& faction, const HealthComponent& health)
{
if (health.hp <= 0.0f) { return; }
Centroid& centroid = faction.isEnemy ? enemyStations : playerStations;
centroid.add(pos.value);
});
const std::optional<QVector2D> enemyStationCenter = enemyStations.value();
const std::optional<QVector2D> playerStationCenter = playerStations.value();
admin.forEach<StandbyBehavior, SelectedBehaviorComponent, PositionComponent,
FactionComponent, MovementIntentComponent>(
[&](entt::entity /*e*/, const StandbyBehavior& /*standby*/,
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
const FactionComponent& faction, MovementIntentComponent& intent)
{
if (selected.winner != BehaviorKind::Standby) { return; }
const Centroid& allyShips = faction.isEnemy ? enemyShips : playerShips;
const std::optional<QVector2D>& friendlyStationCenter =
faction.isEnemy ? enemyStationCenter : playerStationCenter;
// Aim at the centroid of the other allied ships (excluding self), then
// fall back to the friendly stations, then hold position when alone.
QVector2D target = pos.value;
if (allyShips.count > 1)
{
target = (allyShips.sum - pos.value)
/ static_cast<float>(allyShips.count - 1);
}
else if (friendlyStationCenter)
{
target = *friendlyStationCenter;
}
intent = MovementIntentComponent{true, target};
});
}

View File

@@ -0,0 +1,12 @@
#pragma once
class EntityAdmin;
// Moves a standing-by ship toward the centroid of its other same-faction ships
// (its fleet) so it stays among allies that may need repair, falling back to its
// own defence stations and then to holding position when it has no allies.
class StandbyExecutor
{
public:
void execute(EntityAdmin& admin);
};

View File

@@ -66,7 +66,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>();
m_aiSystem = std::make_unique<AiSystem>(m_config);
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
@@ -169,7 +169,7 @@ void Simulation::reset(unsigned int seed)
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>();
m_aiSystem = std::make_unique<AiSystem>(m_config);
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);

View File

@@ -1,8 +1,10 @@
#include "catch.hpp"
#include <cmath>
#include <random>
#include <QPoint>
#include <QSize>
#include <QVector2D>
#include "AdvanceBehavior.h"
@@ -23,7 +25,9 @@
#include "ModuleOwnerComponent.h"
#include "MovementIntentComponent.h"
#include "MovementIntentSystem.h"
#include "OrbitMath.h"
#include "PositionComponent.h"
#include "RallyBehavior.h"
#include "RepairBehavior.h"
#include "RepairSystem.h"
#include "RepairToolComponent.h"
@@ -80,6 +84,7 @@ struct Fixture
[](const std::string&) -> bool { return true; },
rng)
, ships(cfg, admin)
, ai(cfg)
, salvager(admin)
, repair(admin)
, scraps(admin)
@@ -348,6 +353,100 @@ TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement ra
REQUIRE_FALSE(f.admin.get<AttackBehavior>(player).currentTarget.has_value());
}
// ---------------------------------------------------------------------------
// AttackBehavior — overclaim penalty & hysteresis
// ---------------------------------------------------------------------------
// Absent claims, the nearer enemy wins; once another ship claims that enemy, the
// overclaim penalty steers the deciding ship to the unclaimed, equidistant one.
TEST_CASE("BehaviorSystem: overclaim penalty steers a ship off a claimed target",
"[behavior]")
{
SECTION("no claim: the nearer enemy is chosen")
{
Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const entt::entity nearEnemy = f.ships.spawn("interceptor", 1, QVector2D(8.0f, 0.0f),
/*isEnemy=*/true);
f.ships.spawn("interceptor", 1, QVector2D(0.0f, 12.0f), /*isEnemy=*/true);
f.decide();
REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == nearEnemy);
}
SECTION("enemyA already claimed: penalty redirects to the unclaimed enemyB")
{
const float d = 10.0f;
Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const entt::entity enemyA = f.ships.spawn("interceptor", 1, QVector2D(d, 0.0f),
/*isEnemy=*/true);
const entt::entity enemyB = f.ships.spawn("interceptor", 1, QVector2D(0.0f, d),
/*isEnemy=*/true);
// A second player ship already commits to enemyA, registering a claim, so
// the penalised score of enemyA falls below the unclaimed equidistant enemyB.
const entt::entity claimant = f.ships.spawn("interceptor", 1, QVector2D(d, 1.0f));
f.admin.get<AttackBehavior>(claimant).currentTarget = enemyA;
f.decide();
REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == enemyB);
}
}
// Hysteresis keeps a ship on its committed target when a fresh candidate is only
// marginally better — and self-exclusion means the ship's own claim never counts
// against the target it already holds.
TEST_CASE("BehaviorSystem: hysteresis keeps a ship on its own claimed target",
"[behavior]")
{
const float d = 10.0f;
Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const entt::entity enemyA = f.ships.spawn("interceptor", 1, QVector2D(d, 0.0f),
/*isEnemy=*/true);
f.ships.spawn("interceptor", 1, QVector2D(0.0f, d), /*isEnemy=*/true);
// The ship already holds enemyA; enemyB is equidistant. Without self-exclusion
// the ship's own claim would penalise enemyA and flip the choice.
f.admin.get<AttackBehavior>(player).currentTarget = enemyA;
f.decide();
REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == enemyA);
}
// When the held target becomes heavily overclaimed by others, its penalised score
// drops far enough that an equidistant unclaimed enemy beats the hysteresis margin
// and the ship switches.
TEST_CASE("BehaviorSystem: a ship switches off a heavily overclaimed target",
"[behavior]")
{
const float d = 10.0f;
Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const entt::entity enemyA = f.ships.spawn("interceptor", 1, QVector2D(d, 0.0f),
/*isEnemy=*/true);
const entt::entity enemyB = f.ships.spawn("interceptor", 1, QVector2D(0.0f, d),
/*isEnemy=*/true);
f.admin.get<AttackBehavior>(player).currentTarget = enemyA;
// Five other ships also commit to enemyA, saturating the claim penalty (0.5).
for (int i = 0; i < 5; ++i)
{
const entt::entity other =
f.ships.spawn("interceptor", 1, QVector2D(d, static_cast<float>(2 + i)));
f.admin.get<AttackBehavior>(other).currentTarget = enemyA;
}
f.decide();
REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == enemyB);
}
// ---------------------------------------------------------------------------
// AttackBehavior — enemy ships
// ---------------------------------------------------------------------------
@@ -383,11 +482,63 @@ TEST_CASE("BehaviorSystem: enemy ship with no target advances leftward",
REQUIRE(intent(f.admin, enemy).target.x() < 0.0f);
}
TEST_CASE("BehaviorSystem: advancing ship targets center between enemy defence stations",
"[behavior]")
{
Fixture f;
// Two enemy defence stations far from the ship (out of sensor/attack range),
// 1x1 footprint so each center is anchor + (0.5, 0.5).
const std::vector<QPoint> body{QPoint(0, 0)};
f.admin.spawnStation(QPoint(1000, 10), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true);
f.admin.spawnStation(QPoint(1000, 30), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true);
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f),
/*isEnemy=*/false);
// Player ships rally until departure; drop Rally so Advance is the fallback.
f.ships.triggerRallyDeparture();
f.decide();
// Centers (1000.5, 10.5) and (1000.5, 30.5) -> midpoint (1000.5, 20.5).
REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Advance);
REQUIRE(intent(f.admin, player).active);
REQUIRE(intent(f.admin, player).target.x() == Approx(1000.5f));
REQUIRE(intent(f.admin, player).target.y() == Approx(20.5f));
}
TEST_CASE("BehaviorSystem: advancing ship falls back to enemy HQ, then off-world",
"[behavior]")
{
Fixture f;
// Player HQ proxy (isEnemy=false) but no player defence stations.
const QVector2D hqPos(5.0f, 7.0f);
const entt::entity hq = f.admin.spawnHqProxy(hqPos, 100.0f, 100.0f);
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(1000.0f, 0.0f),
/*isEnemy=*/true);
f.decide();
REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Advance);
REQUIRE(intent(f.admin, enemy).active);
REQUIRE(intent(f.admin, enemy).target.x() == Approx(hqPos.x()));
REQUIRE(intent(f.admin, enemy).target.y() == Approx(hqPos.y()));
// With the HQ gone too, the ship falls back to advancing off-world (leftward).
f.admin.get<HealthComponent>(hq).hp = 0.0f;
f.decide();
REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Advance);
REQUIRE(intent(f.admin, enemy).target.x() < 0.0f);
}
// ---------------------------------------------------------------------------
// RepairBehavior
// ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: repair ship moves toward damaged friendly ship",
TEST_CASE("BehaviorSystem: repair ship orbits damaged friendly ship",
"[behavior]")
{
Fixture f;
@@ -402,7 +553,41 @@ TEST_CASE("BehaviorSystem: repair ship moves toward damaged friendly ship",
REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Repair);
REQUIRE(intent(f.admin, repairShip).active);
REQUIRE(intent(f.admin, repairShip).target.x() == Approx(5.0f));
// Orbit at orbit_factor * max repair range (REQ-SHP-ORBIT): the intent carries
// the target's center and the orbit radius; MovementIntentSystem turns these
// into a point on the orbit circle when it steers.
const float orbitRadius = f.admin.get<RepairBehavior>(repairShip).orbitRadius_tiles;
REQUIRE(orbitRadius > 0.0f);
REQUIRE(intent(f.admin, repairShip).target == pos(f.admin, friendly).value);
REQUIRE(intent(f.admin, repairShip).orbitRadius_tiles == Approx(orbitRadius));
}
// ---------------------------------------------------------------------------
// StandbyBehavior (repair ships hold with the fleet when idle)
// ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: idle repair ship stands by with the fleet instead of charging the enemy",
"[behavior]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
false, repairLayout);
// A healthy ally (nothing to repair) and a far enemy station (no threat in range).
const entt::entity ally = f.ships.spawn("interceptor", 1, QVector2D(50.0f, 0.0f));
const std::vector<QPoint> body{QPoint(0, 0)};
f.admin.spawnStation(QPoint(1000, 0), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true);
f.decide();
// With no damaged ally and no enemy in sensor range the repair ship neither
// repairs nor retreats: it stands by (REQ-SHP-STANDBY), steering toward its
// fleet (the ally) rather than charging the distant enemy station.
REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Standby);
REQUIRE(intent(f.admin, repairShip).active);
REQUIRE(intent(f.admin, repairShip).target.x() == Approx(pos(f.admin, ally).value.x()));
REQUIRE(intent(f.admin, repairShip).target.y() == Approx(pos(f.admin, ally).value.y()));
}
TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
@@ -650,7 +835,7 @@ TEST_CASE("RepairSystem: does not crash when a tool's owner is not a repair ship
// SalvageScrapBehavior / DeliverScrapBehavior
// ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: salvage ship moves toward nearest scrap", "[behavior]")
TEST_CASE("BehaviorSystem: salvage ship orbits nearest scrap", "[behavior]")
{
Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
@@ -664,7 +849,13 @@ TEST_CASE("BehaviorSystem: salvage ship moves toward nearest scrap", "[behavior]
REQUIRE(winnerOf(f.admin, ship) == BehaviorKind::SalvageScrap);
REQUIRE(intent(f.admin, ship).active);
REQUIRE(intent(f.admin, ship).target.x() == Approx(scrapPos.x()));
// Orbit at orbit_factor * max collection range (REQ-SHP-ORBIT): the intent
// carries the scrap center and the orbit radius.
const float orbitRadius = f.admin.get<SalvageScrapBehavior>(ship).orbitRadius_tiles;
REQUIRE(orbitRadius > 0.0f);
REQUIRE(intent(f.admin, ship).target == scrapPos);
REQUIRE(intent(f.admin, ship).orbitRadius_tiles == Approx(orbitRadius));
}
TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]")
@@ -950,8 +1141,11 @@ TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor ra
f.decide();
// Beyond sensor range the enemy is no threat, so the repair ship does not flee;
// with nothing to repair it holds with the fleet (REQ-SHP-STANDBY) rather than
// retreating or charging the enemy.
REQUIRE(winnerOf(f.admin, repairShip) != BehaviorKind::Retreat);
REQUIRE(intent(f.admin, repairShip).target.x() > pos(f.admin, repairShip).value.x());
REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Standby);
}
TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor range", "[sensor]")
@@ -985,3 +1179,133 @@ TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[senso
REQUIRE_FALSE(f.admin.get<SalvageScrapBehavior>(ship).scrapTarget.has_value());
REQUIRE(intent(f.admin, ship).target.x() > pos(f.admin, ship).value.x());
}
// ---------------------------------------------------------------------------
// Orbit movement (REQ-SHP-ORBIT)
// ---------------------------------------------------------------------------
TEST_CASE("Orbit: combat ship's intent carries the target center and orbit radius",
"[orbit]")
{
Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
/*isEnemy=*/true);
f.decide();
REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Attack);
const float orbitRadius = f.admin.get<AttackBehavior>(player).orbitRadius_tiles;
REQUIRE(orbitRadius > 0.0f);
// The intent carries the enemy center and the orbit radius; the orbit point is
// resolved later by MovementIntentSystem.
REQUIRE(intent(f.admin, player).target == pos(f.admin, enemy).value);
REQUIRE(intent(f.admin, player).orbitRadius_tiles == Approx(orbitRadius));
}
TEST_CASE("Orbit: rally ship orbits the rally point at the configured rally radius",
"[orbit]")
{
Fixture f;
const QVector2D rallyPoint(-50.0f, 0.0f);
f.ships.setRallyPoint(rallyPoint);
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
f.decide();
REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Rally);
const float orbitRadius = f.admin.get<RallyBehavior>(player).orbitRadius_tiles;
REQUIRE(orbitRadius == Approx(static_cast<float>(f.cfg.world.rallyOrbitRadius_tiles)));
REQUIRE(intent(f.admin, player).target == rallyPoint);
REQUIRE(intent(f.admin, player).orbitRadius_tiles == Approx(orbitRadius));
}
TEST_CASE("Orbit: combat ship settles near the orbit radius and circles a stationary target",
"[orbit]")
{
Fixture f;
const QVector2D enemyPos(80.0f, 0.0f);
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const entt::entity enemy = f.ships.spawn("interceptor", 1, enemyPos, /*isEnemy=*/true);
const float orbitRadius = f.admin.get<AttackBehavior>(player).orbitRadius_tiles;
REQUIRE(orbitRadius > 0.0f);
REQUIRE(orbitRadius < enemyPos.x()); // ship must close in to reach the orbit
// Run many full ticks, pinning the enemy in place so it is a stationary target.
float angleBefore = 0.0f;
for (int i = 0; i < 1200; ++i)
{
f.admin.get<PositionComponent>(enemy).value = enemyPos; // keep target fixed
f.admin.get<DynamicBodyComponent>(enemy).velocity_tpt = QVector2D(0.0f, 0.0f);
if (i == 800)
{
angleBefore = std::atan2(pos(f.admin, player).value.y() - enemyPos.y(),
pos(f.admin, player).value.x() - enemyPos.x());
}
f.runBehaviorTick();
}
const float angleAfter = std::atan2(pos(f.admin, player).value.y() - enemyPos.y(),
pos(f.admin, player).value.x() - enemyPos.x());
// Settled close to the orbit radius (chasing a moving lead point oscillates a bit).
const float dist = (pos(f.admin, player).value - enemyPos).length();
REQUIRE(dist == Approx(orbitRadius).margin(0.35f * orbitRadius));
// The ship is circling: its angular position around the target has moved.
REQUIRE(std::abs(angleAfter - angleBefore) > 0.05f);
}
TEST_CASE("OrbitMath: sign mirrors the orbit destination across the radial",
"[orbit]")
{
const QVector2D center(0.0f, 0.0f);
const QVector2D shipPos(5.0f, 0.0f);
const float radius = 5.0f;
const QVector2D ccw = OrbitMath::computeOrbitDestination(shipPos, center, radius, +1.0f);
const QVector2D cw = OrbitMath::computeOrbitDestination(shipPos, center, radius, -1.0f);
// Both lie exactly on the orbit circle.
REQUIRE((ccw - center).length() == Approx(radius));
REQUIRE((cw - center).length() == Approx(radius));
// Opposite tangential lead: CCW leads to +y, CW to -y; x components match.
REQUIRE(ccw.y() > 0.0f);
REQUIRE(cw.y() < 0.0f);
REQUIRE(ccw.y() == Approx(-cw.y()));
REQUIRE(ccw.x() == Approx(cw.x()));
}
TEST_CASE("OrbitMath: orbit sign follows the ship's tangential velocity",
"[orbit]")
{
const QVector2D center(0.0f, 0.0f);
const QVector2D shipPos(5.0f, 0.0f); // radial points +x
// Tangential +y velocity is counter-clockwise around the center → +1.
REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(0.0f, 1.0f)) == Approx(1.0f));
// Tangential -y velocity is clockwise → -1.
REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(0.0f, -1.0f)) == Approx(-1.0f));
// Radial velocity (toward/away from center) is ambiguous → fallback +1.
REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(-1.0f, 0.0f)) == Approx(1.0f));
// Zero velocity (e.g. freshly spawned) → fallback +1.
REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(0.0f, 0.0f)) == Approx(1.0f));
}
TEST_CASE("OrbitMath: orbit sense uses velocity relative to a moving center",
"[orbit]")
{
const QVector2D center(0.0f, 0.0f);
const QVector2D shipPos(5.0f, 0.0f); // radial points +x
// Two ships orbiting each other can translate in parallel: the ship and the
// center share the same velocity, so relative velocity cancels → fallback +1
// (both ships agree on the sign and break into a real mutual orbit).
const QVector2D sharedVelocity(0.0f, 3.0f);
REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, sharedVelocity, sharedVelocity)
== Approx(1.0f));
// A moving center's own motion is removed: the ship is stationary while the
// center drifts +y, so relative motion is -y → clockwise → -1.
REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(0.0f, 0.0f),
QVector2D(0.0f, 3.0f)) == Approx(-1.0f));
}

View File

@@ -76,12 +76,21 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
REQUIRE(cfg.world.regions.enemyBufferWidth_tiles == 15);
REQUIRE(cfg.world.expansion.columnsPerExpansion_tiles == 10);
REQUIRE(cfg.world.push.bossAdvanceSeconds == Approx(60.0));
REQUIRE(cfg.world.orbitFactor == Approx(0.8));
REQUIRE(cfg.world.rallyOrbitRadius_tiles == Approx(5.0));
// Spot-check that a config-derived formula computes as expected.
// threat_rate_formula = "x": evaluates to the input value.
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(1.0) == Approx(1.0));
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(5.0) == Approx(5.0));
// targeting: distance score 1/(1+x) and overclaim penalty max(0.5, 1-0.1*x).
REQUIRE(cfg.world.targeting.hysteresis == Approx(0.10));
REQUIRE(cfg.world.targeting.targetScoreFormula.evaluate(0.0) == Approx(1.0));
REQUIRE(cfg.world.targeting.targetScoreFormula.evaluate(1.0) == Approx(0.5));
REQUIRE(cfg.world.targeting.overclaimPenaltyFormula.evaluate(0.0) == Approx(1.0));
REQUIRE(cfg.world.targeting.overclaimPenaltyFormula.evaluate(5.0) == Approx(0.5));
// buildings.toml
REQUIRE(cfg.buildings.buildings.size() >= 8);
const auto minerIt = std::find_if(
@@ -163,6 +172,8 @@ belt_speed_mps = 20
starting_building_blocks = 100
tunnel_max_distance_tiles = 10
departure_interval_seconds = 20
orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0
[regions]
asteroid_width_tiles = 40
@@ -211,6 +222,8 @@ belt_speed_mps = 20
starting_building_blocks = 100
tunnel_max_distance_tiles = 10
departure_interval_seconds = 20
orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0
[regions]
asteroid_width_tiles = 40

View File

@@ -4,6 +4,7 @@
#include <cctype>
#include <climits>
#include <cmath>
#include <functional>
#include <map>
#include <string>
@@ -18,6 +19,7 @@
#include <QStringList>
#include <QTimer>
#include "AttackBehavior.h"
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
@@ -30,6 +32,8 @@
#include "GameOverEvent.h"
#include "HealthComponent.h"
#include "PositionComponent.h"
#include "RepairBehavior.h"
#include "SalvageScrapBehavior.h"
#include "ScrapSystem.h"
#include "SelectionChangedEvent.h"
#include "SensorRangeComponent.h"
@@ -248,6 +252,7 @@ void GameWorldView::paintGL()
if (m_debugDraw)
{
drawDebugSensorRanges(painter);
drawDebugTargetLines(painter);
drawDebugOverlay(painter);
}
drawShips(painter);
@@ -916,11 +921,67 @@ void GameWorldView::drawDebugSensorRanges(QPainter& painter)
const QPointF center = worldToWidget(pos.value);
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
* static_cast<qreal>(tilePx());
painter.setPen(QPen(it->second.outline, 1));
QColor circleColor = it->second.outline;
circleColor.setAlpha(77);
painter.setPen(QPen(circleColor, 1));
painter.drawEllipse(center, radiusPx, radiusPx);
});
}
void GameWorldView::drawDebugTargetLines(QPainter& painter)
{
// Draw a thin translucent line from a ship to a target, colored by the ship's
// own schematic fill. Shared by the attack, repair and salvage target lines.
const std::function<void(const std::string&, const QVector2D&, const QVector2D&)>
drawTargetLine = [&](const std::string& schematicId, const QVector2D& from,
const QVector2D& to)
{
const std::map<std::string, ShipVisuals>::const_iterator it =
m_visuals->ships.find(schematicId);
if (it == m_visuals->ships.end()) { return; }
QColor lineColor = it->second.fill;
lineColor.setAlpha(128);
painter.setPen(QPen(lineColor, 1));
painter.drawLine(worldToWidget(from), worldToWidget(to));
};
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, AttackBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const AttackBehavior& attack)
{
if (!attack.currentTarget.has_value()) { return; }
const std::optional<QVector2D> targetPos =
entityPosition(*attack.currentTarget);
if (!targetPos.has_value()) { return; }
drawTargetLine(si.schematicId, pos.value, *targetPos);
});
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, RepairBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const RepairBehavior& repair)
{
if (!repair.currentTarget.has_value()) { return; }
const std::optional<QVector2D> targetPos =
entityPosition(*repair.currentTarget);
if (!targetPos.has_value()) { return; }
drawTargetLine(si.schematicId, pos.value, *targetPos);
});
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, SalvageScrapBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const SalvageScrapBehavior& salvage)
{
if (!salvage.scrapTarget.has_value()) { return; }
drawTargetLine(si.schematicId, pos.value, *salvage.scrapTarget);
});
}
void GameWorldView::drawDebugOverlay(QPainter& painter)
{
painter.resetTransform();

View File

@@ -99,6 +99,7 @@ private:
void drawScrap(QPainter& painter);
void drawShips(QPainter& painter);
void drawDebugSensorRanges(QPainter& painter);
void drawDebugTargetLines(QPainter& painter);
void drawDebugOverlay(QPainter& painter);
void drawBeams(QPainter& painter);
void drawOverlays(QPainter& painter);