From 4153b7e2f520bdd65d5fbc9978e04bcd51446150 Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Mon, 15 Jun 2026 21:09:40 +0200 Subject: [PATCH] make ships orbit their targets --- bin/app/data/config/world.toml | 2 + bin/test/data/config/world.toml | 2 + docs/requirements.md | 19 ++-- src/lib/config/ConfigLoader.cpp | 2 + src/lib/config/WorldConfig.h | 2 + src/lib/ecs/component/AttackBehavior.h | 1 + src/lib/ecs/component/RallyBehavior.h | 1 + src/lib/ecs/component/RepairBehavior.h | 1 + src/lib/ecs/component/SalvageScrapBehavior.h | 1 + src/lib/ecs/system/ShipSystem.cpp | 20 +++- src/lib/ecs/system/ai/AttackExecutor.cpp | 5 +- src/lib/ecs/system/ai/OrbitMath.h | 45 +++++++++ src/lib/ecs/system/ai/RallyExecutor.cpp | 12 ++- src/lib/ecs/system/ai/RepairExecutor.cpp | 5 +- .../ecs/system/ai/SalvageScrapExecutor.cpp | 12 ++- src/test/BehaviorSystemTest.cpp | 93 ++++++++++++++++++- src/test/ConfigLoaderTest.cpp | 6 ++ 17 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 src/lib/ecs/system/ai/OrbitMath.h diff --git a/bin/app/data/config/world.toml b/bin/app/data/config/world.toml index 23c6baf..c143efd 100644 --- a/bin/app/data/config/world.toml +++ b/bin/app/data/config/world.toml @@ -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 diff --git a/bin/test/data/config/world.toml b/bin/test/data/config/world.toml index 4b50be7..27bc0c0 100644 --- a/bin/test/data/config/world.toml +++ b/bin/test/data/config/world.toml @@ -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 diff --git a/docs/requirements.md b/docs/requirements.md index 30cf466..9bff623 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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. - **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,32 @@ 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) — 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, 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, 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-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-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 diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index d357a09..6bbbc41 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -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(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(requireInt(tbl["regions"]["asteroid_width_tiles"], file, "regions.asteroid_width_tiles")); cfg.regions.playerBufferWidth_tiles = static_cast(requireInt(tbl["regions"]["player_buffer_width_tiles"], file, "regions.player_buffer_width_tiles")); diff --git a/src/lib/config/WorldConfig.h b/src/lib/config/WorldConfig.h index be92c7f..2ab2661 100644 --- a/src/lib/config/WorldConfig.h +++ b/src/lib/config/WorldConfig.h @@ -49,6 +49,8 @@ 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; diff --git a/src/lib/ecs/component/AttackBehavior.h b/src/lib/ecs/component/AttackBehavior.h index 9e735cf..e94d020 100644 --- a/src/lib/ecs/component/AttackBehavior.h +++ b/src/lib/ecs/component/AttackBehavior.h @@ -9,5 +9,6 @@ struct AttackBehavior { std::optional currentTarget; + float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT float score = 0.0f; }; diff --git a/src/lib/ecs/component/RallyBehavior.h b/src/lib/ecs/component/RallyBehavior.h index ecbb650..0cfd023 100644 --- a/src/lib/ecs/component/RallyBehavior.h +++ b/src/lib/ecs/component/RallyBehavior.h @@ -7,5 +7,6 @@ struct RallyBehavior { QVector2D rallyPoint; + float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT float score = 0.0f; }; diff --git a/src/lib/ecs/component/RepairBehavior.h b/src/lib/ecs/component/RepairBehavior.h index d624eef..75c6a5d 100644 --- a/src/lib/ecs/component/RepairBehavior.h +++ b/src/lib/ecs/component/RepairBehavior.h @@ -11,5 +11,6 @@ struct RepairBehavior { std::optional currentTarget; float maxRepairRange_tiles = 0.0f; + float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT float score = 0.0f; }; diff --git a/src/lib/ecs/component/SalvageScrapBehavior.h b/src/lib/ecs/component/SalvageScrapBehavior.h index 7aedd79..24fad19 100644 --- a/src/lib/ecs/component/SalvageScrapBehavior.h +++ b/src/lib/ecs/component/SalvageScrapBehavior.h @@ -10,5 +10,6 @@ struct SalvageScrapBehavior { std::optional scrapTarget; float maxCollectionRange_tiles = 0.0f; + float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT float score = 0.0f; }; diff --git a/src/lib/ecs/system/ShipSystem.cpp b/src/lib/ecs/system/ShipSystem.cpp index f41dcc0..e66b0d6 100644 --- a/src/lib/ecs/system/ShipSystem.cpp +++ b/src/lib/ecs/system/ShipSystem.cpp @@ -343,12 +343,24 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level, if (!weaponChildren.empty()) { - m_admin.addComponent(entity, AttackBehavior{}); + float maxWeaponRange = 0.0f; + for (entt::entity child : weaponChildren) + { + const float r = m_admin.get(child).range_tiles; + if (r > maxWeaponRange) { maxWeaponRange = r; } + } + + AttackBehavior attack; + attack.orbitRadius_tiles = + maxWeaponRange * static_cast(m_config.world.orbitFactor); + m_admin.addComponent(entity, attack); if (!isEnemy) { RallyBehavior rally; - rally.rallyPoint = m_rallyPoint; + rally.rallyPoint = m_rallyPoint; + rally.orbitRadius_tiles = + static_cast(m_config.world.rallyOrbitRadius_tiles); m_admin.addComponent(entity, rally); } } @@ -365,6 +377,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(m_config.world.orbitFactor); m_admin.addComponent(entity, salvage); DeliverScrapBehavior deliver; @@ -384,6 +398,8 @@ 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(m_config.world.orbitFactor); m_admin.addComponent(entity, repair); } diff --git a/src/lib/ecs/system/ai/AttackExecutor.cpp b/src/lib/ecs/system/ai/AttackExecutor.cpp index 48d269c..f1576d0 100644 --- a/src/lib/ecs/system/ai/AttackExecutor.cpp +++ b/src/lib/ecs/system/ai/AttackExecutor.cpp @@ -5,6 +5,7 @@ #include "EntityAdmin.h" #include "ModuleOwnerComponent.h" #include "MovementIntentComponent.h" +#include "OrbitMath.h" #include "PositionComponent.h" #include "SelectedBehaviorComponent.h" #include "tracing.h" @@ -28,7 +29,9 @@ void AttackExecutor::execute(EntityAdmin& admin) QVector2D dest = pos.value; if (admin.isValid(t) && admin.hasAll(t)) { - dest = admin.get(t).value; + const QVector2D targetPos = admin.get(t).value; + dest = OrbitMath::computeOrbitDestination(pos.value, targetPos, + attack.orbitRadius_tiles); } intent = MovementIntentComponent{true, dest}; }); diff --git a/src/lib/ecs/system/ai/OrbitMath.h b/src/lib/ecs/system/ai/OrbitMath.h new file mode 100644 index 0000000..3e58078 --- /dev/null +++ b/src/lib/ecs/system/ai/OrbitMath.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +#include + +// Orbit movement helper (REQ-SHP-ORBIT). Behaviors that keep a ship circling a +// target (attack, repair, salvage, rally) feed the result of this function as the +// movement intent destination instead of the target's center. +namespace OrbitMath +{ + // Lead angle (radians) by which the radial direction is rotated to produce + // tangential motion. The fixed positive (counter-clockwise) sense makes the + // orbit direction stable for the duration of orbiting a given target. + constexpr float kOrbitLeadAngle_rad = 0.6f; + + // Returns a destination on the orbit circle of `radius` around `target`. The + // result always lies exactly `radius` from `target`, so steering toward it + // both corrects the standoff distance and advances the ship tangentially. + // A radius of zero or less falls back to the target 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& target, float radius) + { + if (radius <= 0.0f) { return target; } + + QVector2D radial = shipPos - target; + float length = radial.length(); + if (length < 1.0e-4f) + { + // Ship sits on the target; pick an arbitrary radial direction. + radial = QVector2D(1.0f, 0.0f); + length = 1.0f; + } + const QVector2D radialDirection = radial / length; + + const float cosLead = std::cos(kOrbitLeadAngle_rad); + const float sinLead = std::sin(kOrbitLeadAngle_rad); + const QVector2D leadDirection( + radialDirection.x() * cosLead - radialDirection.y() * sinLead, + radialDirection.x() * sinLead + radialDirection.y() * cosLead); + + return target + radius * leadDirection; + } +} diff --git a/src/lib/ecs/system/ai/RallyExecutor.cpp b/src/lib/ecs/system/ai/RallyExecutor.cpp index 8c9c6cb..10d2895 100644 --- a/src/lib/ecs/system/ai/RallyExecutor.cpp +++ b/src/lib/ecs/system/ai/RallyExecutor.cpp @@ -3,6 +3,8 @@ #include "BehaviorKind.h" #include "EntityAdmin.h" #include "MovementIntentComponent.h" +#include "OrbitMath.h" +#include "PositionComponent.h" #include "RallyBehavior.h" #include "SelectedBehaviorComponent.h" #include "tracing.h" @@ -10,11 +12,15 @@ void RallyExecutor::execute(EntityAdmin& admin) { TRACE(); - admin.forEach( + admin.forEach( [](entt::entity /*e*/, const RallyBehavior& rally, - const SelectedBehaviorComponent& selected, MovementIntentComponent& intent) + const SelectedBehaviorComponent& selected, const PositionComponent& pos, + MovementIntentComponent& intent) { if (selected.winner != BehaviorKind::Rally) { return; } - intent = MovementIntentComponent{true, rally.rallyPoint}; + const QVector2D dest = OrbitMath::computeOrbitDestination( + pos.value, rally.rallyPoint, rally.orbitRadius_tiles); + intent = MovementIntentComponent{true, dest}; }); } diff --git a/src/lib/ecs/system/ai/RepairExecutor.cpp b/src/lib/ecs/system/ai/RepairExecutor.cpp index bac0f7c..0ceddd2 100644 --- a/src/lib/ecs/system/ai/RepairExecutor.cpp +++ b/src/lib/ecs/system/ai/RepairExecutor.cpp @@ -4,6 +4,7 @@ #include "EntityAdmin.h" #include "ModuleOwnerComponent.h" #include "MovementIntentComponent.h" +#include "OrbitMath.h" #include "PositionComponent.h" #include "RepairBehavior.h" #include "RepairToolComponent.h" @@ -28,7 +29,9 @@ void RepairExecutor::execute(EntityAdmin& admin) QVector2D dest = pos.value; if (admin.isValid(t) && admin.hasAll(t)) { - dest = admin.get(t).value; + const QVector2D targetPos = admin.get(t).value; + dest = OrbitMath::computeOrbitDestination(pos.value, targetPos, + repair.orbitRadius_tiles); } intent = MovementIntentComponent{true, dest}; }); diff --git a/src/lib/ecs/system/ai/SalvageScrapExecutor.cpp b/src/lib/ecs/system/ai/SalvageScrapExecutor.cpp index c0dc4c3..27bd77a 100644 --- a/src/lib/ecs/system/ai/SalvageScrapExecutor.cpp +++ b/src/lib/ecs/system/ai/SalvageScrapExecutor.cpp @@ -3,6 +3,8 @@ #include "BehaviorKind.h" #include "EntityAdmin.h" #include "MovementIntentComponent.h" +#include "OrbitMath.h" +#include "PositionComponent.h" #include "SalvageScrapBehavior.h" #include "SelectedBehaviorComponent.h" #include "tracing.h" @@ -10,12 +12,16 @@ void SalvageScrapExecutor::execute(EntityAdmin& admin) { TRACE(); - admin.forEach( + admin.forEach( [](entt::entity /*e*/, const SalvageScrapBehavior& salvage, - const SelectedBehaviorComponent& selected, MovementIntentComponent& intent) + const SelectedBehaviorComponent& selected, const PositionComponent& pos, + MovementIntentComponent& intent) { if (selected.winner != BehaviorKind::SalvageScrap) { return; } if (!salvage.scrapTarget) { return; } - intent = MovementIntentComponent{true, *salvage.scrapTarget}; + const QVector2D dest = OrbitMath::computeOrbitDestination( + pos.value, *salvage.scrapTarget, salvage.orbitRadius_tiles); + intent = MovementIntentComponent{true, dest}; }); } diff --git a/src/test/BehaviorSystemTest.cpp b/src/test/BehaviorSystemTest.cpp index 61bfa92..a0858e6 100644 --- a/src/test/BehaviorSystemTest.cpp +++ b/src/test/BehaviorSystemTest.cpp @@ -1,5 +1,6 @@ #include "catch.hpp" +#include #include #include @@ -25,6 +26,7 @@ #include "MovementIntentComponent.h" #include "MovementIntentSystem.h" #include "PositionComponent.h" +#include "RallyBehavior.h" #include "RepairBehavior.h" #include "RepairSystem.h" #include "RepairToolComponent.h" @@ -440,7 +442,7 @@ TEST_CASE("BehaviorSystem: advancing ship falls back to enemy HQ, then off-world // RepairBehavior // --------------------------------------------------------------------------- -TEST_CASE("BehaviorSystem: repair ship moves toward damaged friendly ship", +TEST_CASE("BehaviorSystem: repair ship orbits damaged friendly ship", "[behavior]") { Fixture f; @@ -455,7 +457,13 @@ 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 movement + // destination lies exactly the orbit radius from the target's center. + const float orbitRadius = f.admin.get(repairShip).orbitRadius_tiles; + REQUIRE(orbitRadius > 0.0f); + REQUIRE((intent(f.admin, repairShip).target - pos(f.admin, friendly).value).length() + == Approx(orbitRadius)); } TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range", @@ -703,7 +711,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"); @@ -717,7 +725,11 @@ 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). + const float orbitRadius = f.admin.get(ship).orbitRadius_tiles; + REQUIRE(orbitRadius > 0.0f); + REQUIRE((intent(f.admin, ship).target - scrapPos).length() == Approx(orbitRadius)); } TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]") @@ -1038,3 +1050,76 @@ TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[senso REQUIRE_FALSE(f.admin.get(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 aims at a point on the orbit circle around its target", + "[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(player).orbitRadius_tiles; + REQUIRE(orbitRadius > 0.0f); + // The movement destination lies exactly the orbit radius from the enemy center. + REQUIRE((intent(f.admin, player).target - pos(f.admin, enemy).value).length() + == 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(player).orbitRadius_tiles; + REQUIRE(orbitRadius == Approx(static_cast(f.cfg.world.rallyOrbitRadius_tiles))); + REQUIRE((intent(f.admin, player).target - rallyPoint).length() == 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(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(enemy).value = enemyPos; // keep target fixed + f.admin.get(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); +} diff --git a/src/test/ConfigLoaderTest.cpp b/src/test/ConfigLoaderTest.cpp index 7cfb053..e4ca910 100644 --- a/src/test/ConfigLoaderTest.cpp +++ b/src/test/ConfigLoaderTest.cpp @@ -76,6 +76,8 @@ 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. @@ -163,6 +165,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 +215,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