fix mutually canceling orbits

This commit is contained in:
2026-06-17 20:50:31 +02:00
parent 0cf3d64983
commit e0e11b7933
6 changed files with 54 additions and 11 deletions

View File

@@ -11,4 +11,7 @@ struct MovementIntentComponent
bool active = false; bool active = false;
QVector2D target; // straight-line destination, or orbit center when orbitRadius_tiles > 0 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 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

@@ -52,8 +52,9 @@ void MovementIntentSystem::tick(EntityAdmin& admin)
QVector2D destination = intent.target; QVector2D destination = intent.target;
if (intent.orbitRadius_tiles > 0.0f) if (intent.orbitRadius_tiles > 0.0f)
{ {
const float sign = OrbitMath::resolveOrbitSign(pos.value, intent.target, const float sign = OrbitMath::resolveOrbitSign(
body.velocity_tpt); pos.value, intent.target, body.velocity_tpt,
intent.orbitCenterVelocity_tpt);
destination = OrbitMath::computeOrbitDestination( destination = OrbitMath::computeOrbitDestination(
pos.value, intent.target, intent.orbitRadius_tiles, sign); pos.value, intent.target, intent.orbitRadius_tiles, sign);
} }

View File

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

View File

@@ -18,24 +18,32 @@ namespace OrbitMath
constexpr float kOrbitLeadAngle_rad = 0.6f; constexpr float kOrbitLeadAngle_rad = 0.6f;
// Returns the orbit sense (+1 counter-clockwise, -1 clockwise) that matches // Returns the orbit sense (+1 counter-clockwise, -1 clockwise) that matches
// the ship's current movement around `center`, so steering reinforces the // the ship's movement around `center`, so steering reinforces the motion the
// motion the ship already has. When the velocity is nearly radial or near // ship already has. The sense is taken from the ship's velocity *relative to
// zero (e.g. a head-on approach or a freshly spawned ship) the sense is // 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 // ill-defined; this is an unstable point the ship leaves within a tick or
// two, so a deterministic fallback of +1 is returned. // two, so a deterministic fallback of +1 is returned.
inline float resolveOrbitSign(const QVector2D& shipPos, const QVector2D& center, 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 radial = shipPos - center;
const QVector2D relativeVelocity = velocity - centerVelocity;
const float radialLength = radial.length(); const float radialLength = radial.length();
const float velocityLength = velocity.length(); const float velocityLength = relativeVelocity.length();
if (radialLength < 1.0e-4f || velocityLength < 1.0e-4f) if (radialLength < 1.0e-4f || velocityLength < 1.0e-4f)
{ {
return 1.0f; return 1.0f;
} }
// z-component of radial x velocity, normalised to sin(angle) between them. // z-component of radial x relativeVelocity, normalised to sin(angle).
const float cross = radial.x() * velocity.y() - radial.y() * velocity.x(); const float cross = radial.x() * relativeVelocity.y()
- radial.y() * relativeVelocity.x();
const float sinAngle = cross / (radialLength * velocityLength); const float sinAngle = cross / (radialLength * velocityLength);
constexpr float kRadialEpsilon = 1.0e-3f; constexpr float kRadialEpsilon = 1.0e-3f;

View File

@@ -1,6 +1,7 @@
#include "RepairExecutor.h" #include "RepairExecutor.h"
#include "BehaviorKind.h" #include "BehaviorKind.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "ModuleOwnerComponent.h" #include "ModuleOwnerComponent.h"
#include "MovementIntentComponent.h" #include "MovementIntentComponent.h"
@@ -27,12 +28,17 @@ void RepairExecutor::execute(EntityAdmin& admin)
const entt::entity t = *repair.currentTarget; const entt::entity t = *repair.currentTarget;
QVector2D center = pos.value; QVector2D center = pos.value;
float radius = 0.0f; float radius = 0.0f;
QVector2D centerVelocity;
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t)) if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
{ {
center = admin.get<PositionComponent>(t).value; center = admin.get<PositionComponent>(t).value;
radius = repair.orbitRadius_tiles; radius = repair.orbitRadius_tiles;
if (admin.hasAll<DynamicBodyComponent>(t))
{
centerVelocity = admin.get<DynamicBodyComponent>(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. // Repair tools: prefer the behavior target if it is within tool range.

View File

@@ -1260,3 +1260,22 @@ TEST_CASE("OrbitMath: orbit sign follows the ship's tangential velocity",
// Zero velocity (e.g. freshly spawned) → fallback +1. // Zero velocity (e.g. freshly spawned) → fallback +1.
REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(0.0f, 0.0f)) == Approx(1.0f)); 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));
}