diff --git a/src/lib/ecs/component/MovementIntentComponent.h b/src/lib/ecs/component/MovementIntentComponent.h index c4a7834..fc478bb 100644 --- a/src/lib/ecs/component/MovementIntentComponent.h +++ b/src/lib/ecs/component/MovementIntentComponent.h @@ -11,4 +11,7 @@ struct MovementIntentComponent bool active = false; 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 }; diff --git a/src/lib/ecs/system/MovementIntentSystem.cpp b/src/lib/ecs/system/MovementIntentSystem.cpp index a3187e6..2e9a62d 100644 --- a/src/lib/ecs/system/MovementIntentSystem.cpp +++ b/src/lib/ecs/system/MovementIntentSystem.cpp @@ -52,8 +52,9 @@ void MovementIntentSystem::tick(EntityAdmin& admin) QVector2D destination = intent.target; if (intent.orbitRadius_tiles > 0.0f) { - const float sign = OrbitMath::resolveOrbitSign(pos.value, intent.target, - body.velocity_tpt); + 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); } diff --git a/src/lib/ecs/system/ai/AttackExecutor.cpp b/src/lib/ecs/system/ai/AttackExecutor.cpp index 04f47a6..9b434b3 100644 --- a/src/lib/ecs/system/ai/AttackExecutor.cpp +++ b/src/lib/ecs/system/ai/AttackExecutor.cpp @@ -2,6 +2,7 @@ #include "AttackBehavior.h" #include "BehaviorKind.h" +#include "DynamicBodyComponent.h" #include "EntityAdmin.h" #include "ModuleOwnerComponent.h" #include "MovementIntentComponent.h" @@ -27,12 +28,17 @@ void AttackExecutor::execute(EntityAdmin& admin) const entt::entity t = *attack.currentTarget; QVector2D center = pos.value; float radius = 0.0f; + QVector2D centerVelocity; if (admin.isValid(t) && admin.hasAll(t)) { center = admin.get(t).value; radius = attack.orbitRadius_tiles; + if (admin.hasAll(t)) + { + centerVelocity = admin.get(t).velocity_tpt; + } } - intent = MovementIntentComponent{true, center, radius}; + intent = MovementIntentComponent{true, center, radius, centerVelocity}; }); // 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 a868aef..9a9dfe5 100644 --- a/src/lib/ecs/system/ai/OrbitMath.h +++ b/src/lib/ecs/system/ai/OrbitMath.h @@ -18,24 +18,32 @@ namespace OrbitMath constexpr float kOrbitLeadAngle_rad = 0.6f; // 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 + // 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& velocity, + const QVector2D& centerVelocity = QVector2D()) { const QVector2D radial = shipPos - center; + const QVector2D relativeVelocity = velocity - centerVelocity; const float radialLength = radial.length(); - const float velocityLength = velocity.length(); + const float velocityLength = relativeVelocity.length(); if (radialLength < 1.0e-4f || velocityLength < 1.0e-4f) { return 1.0f; } - // z-component of radial x velocity, normalised to sin(angle) between them. - const float cross = radial.x() * velocity.y() - radial.y() * velocity.x(); + // 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; diff --git a/src/lib/ecs/system/ai/RepairExecutor.cpp b/src/lib/ecs/system/ai/RepairExecutor.cpp index f9b8bb0..44af5ad 100644 --- a/src/lib/ecs/system/ai/RepairExecutor.cpp +++ b/src/lib/ecs/system/ai/RepairExecutor.cpp @@ -1,6 +1,7 @@ #include "RepairExecutor.h" #include "BehaviorKind.h" +#include "DynamicBodyComponent.h" #include "EntityAdmin.h" #include "ModuleOwnerComponent.h" #include "MovementIntentComponent.h" @@ -27,12 +28,17 @@ void RepairExecutor::execute(EntityAdmin& admin) const entt::entity t = *repair.currentTarget; QVector2D center = pos.value; float radius = 0.0f; + QVector2D centerVelocity; if (admin.isValid(t) && admin.hasAll(t)) { center = admin.get(t).value; radius = repair.orbitRadius_tiles; + if (admin.hasAll(t)) + { + centerVelocity = admin.get(t).velocity_tpt; + } } - intent = MovementIntentComponent{true, center, radius}; + intent = MovementIntentComponent{true, center, radius, centerVelocity}; }); // Repair tools: prefer the behavior target if it is within tool range. diff --git a/src/test/BehaviorSystemTest.cpp b/src/test/BehaviorSystemTest.cpp index 0fe0495..d49fe0a 100644 --- a/src/test/BehaviorSystemTest.cpp +++ b/src/test/BehaviorSystemTest.cpp @@ -1260,3 +1260,22 @@ TEST_CASE("OrbitMath: orbit sign follows the ship's tangential velocity", // 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)); +}