make ships orbit their targets
This commit is contained in:
@@ -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<int>(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<int>(requireInt(tbl["regions"]["asteroid_width_tiles"], file, "regions.asteroid_width_tiles"));
|
||||
cfg.regions.playerBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["player_buffer_width_tiles"], file, "regions.player_buffer_width_tiles"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,5 +9,6 @@
|
||||
struct AttackBehavior
|
||||
{
|
||||
std::optional<entt::entity> currentTarget;
|
||||
float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT
|
||||
float score = 0.0f;
|
||||
};
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
struct RallyBehavior
|
||||
{
|
||||
QVector2D rallyPoint;
|
||||
float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT
|
||||
float score = 0.0f;
|
||||
};
|
||||
|
||||
@@ -11,5 +11,6 @@ struct RepairBehavior
|
||||
{
|
||||
std::optional<entt::entity> currentTarget;
|
||||
float maxRepairRange_tiles = 0.0f;
|
||||
float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT
|
||||
float score = 0.0f;
|
||||
};
|
||||
|
||||
@@ -10,5 +10,6 @@ struct SalvageScrapBehavior
|
||||
{
|
||||
std::optional<QVector2D> scrapTarget;
|
||||
float maxCollectionRange_tiles = 0.0f;
|
||||
float orbitRadius_tiles = 0.0f; // REQ-SHP-ORBIT
|
||||
float score = 0.0f;
|
||||
};
|
||||
|
||||
@@ -343,12 +343,24 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
||||
|
||||
if (!weaponChildren.empty())
|
||||
{
|
||||
m_admin.addComponent<AttackBehavior>(entity, AttackBehavior{});
|
||||
float maxWeaponRange = 0.0f;
|
||||
for (entt::entity child : weaponChildren)
|
||||
{
|
||||
const float r = m_admin.get<WeaponComponent>(child).range_tiles;
|
||||
if (r > maxWeaponRange) { maxWeaponRange = r; }
|
||||
}
|
||||
|
||||
AttackBehavior attack;
|
||||
attack.orbitRadius_tiles =
|
||||
maxWeaponRange * static_cast<float>(m_config.world.orbitFactor);
|
||||
m_admin.addComponent<AttackBehavior>(entity, attack);
|
||||
|
||||
if (!isEnemy)
|
||||
{
|
||||
RallyBehavior rally;
|
||||
rally.rallyPoint = m_rallyPoint;
|
||||
rally.rallyPoint = m_rallyPoint;
|
||||
rally.orbitRadius_tiles =
|
||||
static_cast<float>(m_config.world.rallyOrbitRadius_tiles);
|
||||
m_admin.addComponent<RallyBehavior>(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<float>(m_config.world.orbitFactor);
|
||||
m_admin.addComponent<SalvageScrapBehavior>(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<float>(m_config.world.orbitFactor);
|
||||
m_admin.addComponent<RepairBehavior>(entity, repair);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PositionComponent>(t))
|
||||
{
|
||||
dest = admin.get<PositionComponent>(t).value;
|
||||
const QVector2D targetPos = admin.get<PositionComponent>(t).value;
|
||||
dest = OrbitMath::computeOrbitDestination(pos.value, targetPos,
|
||||
attack.orbitRadius_tiles);
|
||||
}
|
||||
intent = MovementIntentComponent{true, dest};
|
||||
});
|
||||
|
||||
45
src/lib/ecs/system/ai/OrbitMath.h
Normal file
45
src/lib/ecs/system/ai/OrbitMath.h
Normal file
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -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<RallyBehavior, SelectedBehaviorComponent, MovementIntentComponent>(
|
||||
admin.forEach<RallyBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||
MovementIntentComponent>(
|
||||
[](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};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<PositionComponent>(t))
|
||||
{
|
||||
dest = admin.get<PositionComponent>(t).value;
|
||||
const QVector2D targetPos = admin.get<PositionComponent>(t).value;
|
||||
dest = OrbitMath::computeOrbitDestination(pos.value, targetPos,
|
||||
repair.orbitRadius_tiles);
|
||||
}
|
||||
intent = MovementIntentComponent{true, dest};
|
||||
});
|
||||
|
||||
@@ -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<SalvageScrapBehavior, SelectedBehaviorComponent, MovementIntentComponent>(
|
||||
admin.forEach<SalvageScrapBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||
MovementIntentComponent>(
|
||||
[](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};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user