diff --git a/src/lib/ecs/component/MovementIntentComponent.h b/src/lib/ecs/component/MovementIntentComponent.h index 26a92bd..c4a7834 100644 --- a/src/lib/ecs/component/MovementIntentComponent.h +++ b/src/lib/ecs/component/MovementIntentComponent.h @@ -9,5 +9,6 @@ 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 }; diff --git a/src/lib/ecs/system/MovementIntentSystem.cpp b/src/lib/ecs/system/MovementIntentSystem.cpp index 7171971..a3187e6 100644 --- a/src/lib/ecs/system/MovementIntentSystem.cpp +++ b/src/lib/ecs/system/MovementIntentSystem.cpp @@ -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,19 @@ 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); + 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) diff --git a/src/lib/ecs/system/ai/AttackExecutor.cpp b/src/lib/ecs/system/ai/AttackExecutor.cpp index f1576d0..04f47a6 100644 --- a/src/lib/ecs/system/ai/AttackExecutor.cpp +++ b/src/lib/ecs/system/ai/AttackExecutor.cpp @@ -5,7 +5,6 @@ #include "EntityAdmin.h" #include "ModuleOwnerComponent.h" #include "MovementIntentComponent.h" -#include "OrbitMath.h" #include "PositionComponent.h" #include "SelectedBehaviorComponent.h" #include "tracing.h" @@ -26,14 +25,14 @@ 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; if (admin.isValid(t) && admin.hasAll(t)) { - const QVector2D targetPos = admin.get(t).value; - dest = OrbitMath::computeOrbitDestination(pos.value, targetPos, - attack.orbitRadius_tiles); + center = admin.get(t).value; + radius = attack.orbitRadius_tiles; } - intent = MovementIntentComponent{true, dest}; + intent = MovementIntentComponent{true, center, radius}; }); // Weapons: assign the behavior target only if it is within this weapon's range. diff --git a/src/lib/ecs/system/ai/OrbitMath.h b/src/lib/ecs/system/ai/OrbitMath.h index 3e58078..a868aef 100644 --- a/src/lib/ecs/system/ai/OrbitMath.h +++ b/src/lib/ecs/system/ai/OrbitMath.h @@ -5,41 +5,76 @@ #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. +// 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 fixed positive (counter-clockwise) sense makes the - // orbit direction stable for the duration of orbiting a given target. + // 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 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) + // Returns the orbit sense (+1 counter-clockwise, -1 clockwise) that matches + // the ship's current movement around `center`, so steering reinforces the + // motion the ship already has. When the velocity is nearly radial or near + // zero (e.g. a head-on approach or a freshly spawned ship) 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) { - if (radius <= 0.0f) { return target; } + const QVector2D radial = shipPos - center; + const float radialLength = radial.length(); + const float velocityLength = velocity.length(); + if (radialLength < 1.0e-4f || velocityLength < 1.0e-4f) + { + return 1.0f; + } - QVector2D radial = shipPos - target; + // z-component of radial x velocity, normalised to sin(angle) between them. + const float cross = radial.x() * velocity.y() - radial.y() * velocity.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 target; pick an arbitrary radial direction. + // 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 cosLead = std::cos(kOrbitLeadAngle_rad); - const float sinLead = std::sin(kOrbitLeadAngle_rad); + 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 target + radius * leadDirection; + return center + radius * leadDirection; } } diff --git a/src/lib/ecs/system/ai/RallyExecutor.cpp b/src/lib/ecs/system/ai/RallyExecutor.cpp index 10d2895..33ad595 100644 --- a/src/lib/ecs/system/ai/RallyExecutor.cpp +++ b/src/lib/ecs/system/ai/RallyExecutor.cpp @@ -3,8 +3,6 @@ #include "BehaviorKind.h" #include "EntityAdmin.h" #include "MovementIntentComponent.h" -#include "OrbitMath.h" -#include "PositionComponent.h" #include "RallyBehavior.h" #include "SelectedBehaviorComponent.h" #include "tracing.h" @@ -12,15 +10,14 @@ void RallyExecutor::execute(EntityAdmin& admin) { TRACE(); - admin.forEach( [](entt::entity /*e*/, const RallyBehavior& rally, - const SelectedBehaviorComponent& selected, const PositionComponent& pos, + const SelectedBehaviorComponent& selected, MovementIntentComponent& intent) { if (selected.winner != BehaviorKind::Rally) { return; } - const QVector2D dest = OrbitMath::computeOrbitDestination( - pos.value, rally.rallyPoint, rally.orbitRadius_tiles); - intent = MovementIntentComponent{true, dest}; + intent = MovementIntentComponent{true, rally.rallyPoint, + rally.orbitRadius_tiles}; }); } diff --git a/src/lib/ecs/system/ai/RepairExecutor.cpp b/src/lib/ecs/system/ai/RepairExecutor.cpp index 0ceddd2..f9b8bb0 100644 --- a/src/lib/ecs/system/ai/RepairExecutor.cpp +++ b/src/lib/ecs/system/ai/RepairExecutor.cpp @@ -4,7 +4,6 @@ #include "EntityAdmin.h" #include "ModuleOwnerComponent.h" #include "MovementIntentComponent.h" -#include "OrbitMath.h" #include "PositionComponent.h" #include "RepairBehavior.h" #include "RepairToolComponent.h" @@ -26,14 +25,14 @@ 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; if (admin.isValid(t) && admin.hasAll(t)) { - const QVector2D targetPos = admin.get(t).value; - dest = OrbitMath::computeOrbitDestination(pos.value, targetPos, - repair.orbitRadius_tiles); + center = admin.get(t).value; + radius = repair.orbitRadius_tiles; } - intent = MovementIntentComponent{true, dest}; + intent = MovementIntentComponent{true, center, radius}; }); // Repair tools: prefer the behavior target if it is within tool range. diff --git a/src/lib/ecs/system/ai/SalvageScrapExecutor.cpp b/src/lib/ecs/system/ai/SalvageScrapExecutor.cpp index 27bd77a..b56a16c 100644 --- a/src/lib/ecs/system/ai/SalvageScrapExecutor.cpp +++ b/src/lib/ecs/system/ai/SalvageScrapExecutor.cpp @@ -3,8 +3,6 @@ #include "BehaviorKind.h" #include "EntityAdmin.h" #include "MovementIntentComponent.h" -#include "OrbitMath.h" -#include "PositionComponent.h" #include "SalvageScrapBehavior.h" #include "SelectedBehaviorComponent.h" #include "tracing.h" @@ -12,16 +10,15 @@ void SalvageScrapExecutor::execute(EntityAdmin& admin) { TRACE(); - admin.forEach( [](entt::entity /*e*/, const SalvageScrapBehavior& salvage, - const SelectedBehaviorComponent& selected, const PositionComponent& pos, + const SelectedBehaviorComponent& selected, MovementIntentComponent& intent) { if (selected.winner != BehaviorKind::SalvageScrap) { return; } if (!salvage.scrapTarget) { return; } - const QVector2D dest = OrbitMath::computeOrbitDestination( - pos.value, *salvage.scrapTarget, salvage.orbitRadius_tiles); - intent = MovementIntentComponent{true, dest}; + intent = MovementIntentComponent{true, *salvage.scrapTarget, + salvage.orbitRadius_tiles}; }); } diff --git a/src/test/BehaviorSystemTest.cpp b/src/test/BehaviorSystemTest.cpp index eb5aa44..0fe0495 100644 --- a/src/test/BehaviorSystemTest.cpp +++ b/src/test/BehaviorSystemTest.cpp @@ -25,6 +25,7 @@ #include "ModuleOwnerComponent.h" #include "MovementIntentComponent.h" #include "MovementIntentSystem.h" +#include "OrbitMath.h" #include "PositionComponent.h" #include "RallyBehavior.h" #include "RepairBehavior.h" @@ -553,12 +554,13 @@ TEST_CASE("BehaviorSystem: repair ship orbits damaged friendly ship", REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Repair); REQUIRE(intent(f.admin, repairShip).active); - // Orbit at orbit_factor * max repair range (REQ-SHP-ORBIT): the movement - // destination lies exactly the orbit radius from the target's center. + // 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(repairShip).orbitRadius_tiles; REQUIRE(orbitRadius > 0.0f); - REQUIRE((intent(f.admin, repairShip).target - pos(f.admin, friendly).value).length() - == Approx(orbitRadius)); + REQUIRE(intent(f.admin, repairShip).target == pos(f.admin, friendly).value); + REQUIRE(intent(f.admin, repairShip).orbitRadius_tiles == Approx(orbitRadius)); } TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range", @@ -821,10 +823,12 @@ TEST_CASE("BehaviorSystem: salvage ship orbits nearest scrap", "[behavior]") REQUIRE(winnerOf(f.admin, ship) == BehaviorKind::SalvageScrap); REQUIRE(intent(f.admin, ship).active); - // Orbit at orbit_factor * max collection range (REQ-SHP-ORBIT). + // 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(ship).orbitRadius_tiles; REQUIRE(orbitRadius > 0.0f); - REQUIRE((intent(f.admin, ship).target - scrapPos).length() == Approx(orbitRadius)); + 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]") @@ -1150,7 +1154,7 @@ TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[senso // Orbit movement (REQ-SHP-ORBIT) // --------------------------------------------------------------------------- -TEST_CASE("Orbit: combat ship aims at a point on the orbit circle around its target", +TEST_CASE("Orbit: combat ship's intent carries the target center and orbit radius", "[orbit]") { Fixture f; @@ -1163,9 +1167,10 @@ TEST_CASE("Orbit: combat ship aims at a point on the orbit circle around its tar 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)); + // 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", @@ -1181,7 +1186,8 @@ TEST_CASE("Orbit: rally ship orbits the rally point at the configured rally radi 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)); + 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", @@ -1218,3 +1224,39 @@ TEST_CASE("Orbit: combat ship settles near the orbit radius and circles a statio // 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)); +}