make ships orbit their targets

This commit is contained in:
2026-06-15 21:09:40 +02:00
parent 6b7c3df64a
commit 4153b7e2f5
17 changed files with 209 additions and 20 deletions

View File

@@ -1,5 +1,6 @@
#include "catch.hpp"
#include <cmath>
#include <random>
#include <QPoint>
@@ -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<RepairBehavior>(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<SalvageScrapBehavior>(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<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 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<AttackBehavior>(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<RallyBehavior>(player).orbitRadius_tiles;
REQUIRE(orbitRadius == Approx(static_cast<float>(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<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);
}

View File

@@ -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