change to physics based ship movement
This commit is contained in:
@@ -395,7 +395,11 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
|
||||
const std::string mPath = elemPath + ".movement";
|
||||
const toml::table& mTable = requireTable(mt["movement"], file, mPath);
|
||||
toml::table& mMt = const_cast<toml::table&>(mTable);
|
||||
def.movement.speedFormula = requireFormula(mMt["speed_formula"], file, mPath + ".speed_formula");
|
||||
def.movement.speedFormula = requireFormula(mMt["speed_formula"], file, mPath + ".speed_formula");
|
||||
def.movement.mainAccelerationFormula = requireFormula(mMt["main_acceleration_formula"], file, mPath + ".main_acceleration_formula");
|
||||
def.movement.maneuveringAccelerationFormula = requireFormula(mMt["maneuvering_acceleration_formula"], file, mPath + ".maneuvering_acceleration_formula");
|
||||
def.movement.angularAccelerationFormula = requireFormula(mMt["angular_acceleration_formula"], file, mPath + ".angular_acceleration_formula");
|
||||
def.movement.maxRotationSpeedFormula = requireFormula(mMt["max_rotation_speed_formula"], file, mPath + ".max_rotation_speed_formula");
|
||||
}
|
||||
|
||||
// Sensor
|
||||
|
||||
@@ -30,7 +30,11 @@ struct ShipHealth
|
||||
|
||||
struct ShipMovement
|
||||
{
|
||||
Formula speedFormula; // REQ-SHP-STATS, REQ-SHP-MOVEMENT
|
||||
Formula speedFormula; // max linear speed cap, tiles/s (REQ-SHP-STATS, REQ-SHP-MOVEMENT)
|
||||
Formula mainAccelerationFormula; // forward acceleration, tiles/s²
|
||||
Formula maneuveringAccelerationFormula;// omnidirectional acceleration, tiles/s²
|
||||
Formula angularAccelerationFormula; // angular acceleration, rad/s²
|
||||
Formula maxRotationSpeedFormula; // angular velocity cap, rad/s
|
||||
};
|
||||
|
||||
struct ShipSensor
|
||||
|
||||
@@ -75,10 +75,16 @@ struct Ship
|
||||
EntityId id;
|
||||
QVector2D position;
|
||||
QVector2D velocity;
|
||||
float facing; // heading in radians (0 = east/+x)
|
||||
float rotationSpeed; // angular velocity in radians per tick
|
||||
float hp;
|
||||
float maxHp;
|
||||
float speedPerTick; // pre-evaluated from speedFormula / kTickRateHz
|
||||
float sensorRange; // pre-evaluated from sensorRangeFormula (REQ-SHP-SENSOR)
|
||||
float maxSpeedPerTick; // linear speed cap (tiles/tick)
|
||||
float mainAccelerationPerTick; // forward acceleration (tiles/tick²)
|
||||
float maneuveringAccelerationPerTick; // omnidirectional acceleration (tiles/tick²)
|
||||
float angularAccelerationPerTick; // angular acceleration (rad/tick²)
|
||||
float maxRotationSpeedPerTick; // angular velocity cap (rad/tick)
|
||||
float sensorRange; // pre-evaluated from sensorRangeFormula (REQ-SHP-SENSOR)
|
||||
int level;
|
||||
std::string schematicId;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <utility>
|
||||
@@ -55,19 +56,29 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
|
||||
const double x = static_cast<double>(level);
|
||||
|
||||
Ship ship;
|
||||
ship.id = m_allocateId();
|
||||
ship.position = position;
|
||||
ship.velocity = QVector2D(0.0f, 0.0f);
|
||||
ship.maxHp = static_cast<float>(def->health.hpFormula.evaluate(x));
|
||||
ship.hp = ship.maxHp;
|
||||
ship.speedPerTick = static_cast<float>(
|
||||
def->movement.speedFormula.evaluate(x))
|
||||
/ static_cast<float>(kTickRateHz);
|
||||
ship.sensorRange = static_cast<float>(def->sensor.sensorRangeFormula.evaluate(x));
|
||||
ship.level = level;
|
||||
ship.schematicId = schematicId;
|
||||
ship.isEnemy = isEnemy;
|
||||
ship.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)};
|
||||
ship.id = m_allocateId();
|
||||
ship.position = position;
|
||||
ship.velocity = QVector2D(0.0f, 0.0f);
|
||||
ship.facing = 0.0f;
|
||||
ship.rotationSpeed = 0.0f;
|
||||
ship.maxHp = static_cast<float>(def->health.hpFormula.evaluate(x));
|
||||
ship.hp = ship.maxHp;
|
||||
const float tickRate = static_cast<float>(kTickRateHz);
|
||||
ship.maxSpeedPerTick = static_cast<float>(def->movement.speedFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.mainAccelerationPerTick = static_cast<float>(def->movement.mainAccelerationFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.maneuveringAccelerationPerTick = static_cast<float>(def->movement.maneuveringAccelerationFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.angularAccelerationPerTick = static_cast<float>(def->movement.angularAccelerationFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.maxRotationSpeedPerTick = static_cast<float>(def->movement.maxRotationSpeedFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.sensorRange = static_cast<float>(def->sensor.sensorRangeFormula.evaluate(x));
|
||||
ship.level = level;
|
||||
ship.schematicId = schematicId;
|
||||
ship.isEnemy = isEnemy;
|
||||
ship.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)};
|
||||
|
||||
if (def->combat)
|
||||
{
|
||||
@@ -150,7 +161,11 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
|
||||
|
||||
applyMod(ship.maxHp, "hp");
|
||||
ship.hp = ship.maxHp;
|
||||
applyMod(ship.speedPerTick, "speed");
|
||||
applyMod(ship.maxSpeedPerTick, "speed");
|
||||
applyMod(ship.mainAccelerationPerTick, "main_acceleration");
|
||||
applyMod(ship.maneuveringAccelerationPerTick, "maneuvering_acceleration");
|
||||
applyMod(ship.angularAccelerationPerTick, "angular_acceleration");
|
||||
applyMod(ship.maxRotationSpeedPerTick, "max_rotation_speed");
|
||||
applyMod(ship.sensorRange, "sensor_range");
|
||||
if (ship.weapon.has_value())
|
||||
{
|
||||
@@ -764,25 +779,98 @@ void ShipSystem::triggerRallyDeparture()
|
||||
// tickMovement (tick-order step 10)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Reduces angle to [-π, π].
|
||||
static float wrapAngle(float a)
|
||||
{
|
||||
constexpr float kPi = 3.14159265f;
|
||||
a = std::fmod(a, 2.0f * kPi);
|
||||
if (a > kPi) { a -= 2.0f * kPi; }
|
||||
if (a < -kPi) { a += 2.0f * kPi; }
|
||||
return a;
|
||||
}
|
||||
|
||||
void ShipSystem::tickMovement()
|
||||
{
|
||||
for (Ship& s : m_ships)
|
||||
{
|
||||
if (s.intent.priority == 0)
|
||||
{
|
||||
s.velocity = QVector2D(0.0f, 0.0f);
|
||||
s.rotationSpeed = 0.0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
const QVector2D delta = s.intent.target - s.position;
|
||||
const float dist = delta.length();
|
||||
|
||||
if (dist < 0.001f)
|
||||
{
|
||||
s.velocity = QVector2D(0.0f, 0.0f);
|
||||
continue;
|
||||
}
|
||||
QVector2D delta = s.intent.target - s.position;
|
||||
float dist = delta.length();
|
||||
if (dist <= s.speedPerTick)
|
||||
|
||||
// ── Rotate toward target ──────────────────────────────────────────
|
||||
const float desiredAngle = std::atan2(delta.y(), delta.x());
|
||||
const float angleDiff = wrapAngle(desiredAngle - s.facing);
|
||||
|
||||
// Clamp angular acceleration, accumulate rotation speed.
|
||||
const float rotDelta = std::max(-s.angularAccelerationPerTick,
|
||||
std::min(angleDiff, s.angularAccelerationPerTick));
|
||||
s.rotationSpeed += rotDelta;
|
||||
s.rotationSpeed = std::max(-s.maxRotationSpeedPerTick,
|
||||
std::min(s.rotationSpeed, s.maxRotationSpeedPerTick));
|
||||
|
||||
// Prevent overshooting the desired angle this tick.
|
||||
const bool sameSign = (s.rotationSpeed >= 0.0f) == (angleDiff >= 0.0f);
|
||||
if (sameSign && std::abs(s.rotationSpeed) > std::abs(angleDiff))
|
||||
{
|
||||
s.rotationSpeed = angleDiff;
|
||||
}
|
||||
|
||||
s.facing = wrapAngle(s.facing + s.rotationSpeed);
|
||||
|
||||
// ── Desired velocity (with braking near target) ───────────────────
|
||||
// Stopping distance using maneuvering acceleration as the worst-case brake.
|
||||
const float manAccel = s.maneuveringAccelerationPerTick;
|
||||
const float stoppingDist = (s.maxSpeedPerTick * s.maxSpeedPerTick)
|
||||
/ (2.0f * manAccel);
|
||||
const float desiredSpeed = (dist <= stoppingDist)
|
||||
? std::sqrt(2.0f * manAccel * dist)
|
||||
: s.maxSpeedPerTick;
|
||||
const QVector2D desiredVel = delta.normalized() * desiredSpeed;
|
||||
const QVector2D velError = desiredVel - s.velocity;
|
||||
|
||||
// ── Main acceleration: forward only, along facing ─────────────────
|
||||
const QVector2D facingVec(std::cos(s.facing), std::sin(s.facing));
|
||||
const float mainAligned = std::max(0.0f,
|
||||
QVector2D::dotProduct(velError, facingVec));
|
||||
const float mainApplied = std::min(mainAligned, s.mainAccelerationPerTick);
|
||||
const QVector2D mainDelta = facingVec * mainApplied;
|
||||
|
||||
// ── Maneuvering acceleration: any direction, handles the remainder ─
|
||||
const QVector2D remaining = velError - mainDelta;
|
||||
const float remainLen = remaining.length();
|
||||
const QVector2D maneuverDelta = (remainLen > manAccel)
|
||||
? remaining.normalized() * manAccel
|
||||
: remaining;
|
||||
|
||||
s.velocity += mainDelta + maneuverDelta;
|
||||
|
||||
// ── Speed cap ─────────────────────────────────────────────────────
|
||||
const float speed = s.velocity.length();
|
||||
if (speed > s.maxSpeedPerTick)
|
||||
{
|
||||
s.velocity = s.velocity.normalized() * s.maxSpeedPerTick;
|
||||
}
|
||||
|
||||
// ── Snap to target or advance ─────────────────────────────────────
|
||||
if (dist <= s.velocity.length())
|
||||
{
|
||||
s.position = s.intent.target;
|
||||
s.velocity = QVector2D(0.0f, 0.0f);
|
||||
}
|
||||
else
|
||||
{
|
||||
s.velocity = delta.normalized() * s.speedPerTick;
|
||||
s.position += s.velocity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,13 +93,15 @@ TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to priority 0",
|
||||
// tickMovement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickMovement advances ship by speedPerTick toward target",
|
||||
// With facing=0 and target due east, main thrust drives the ship east. The test
|
||||
// config uses very high thrust so the ship reaches maxSpeedPerTick in one tick.
|
||||
TEST_CASE("BehaviorSystem: tickMovement advances ship by maxSpeedPerTick toward target",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
const float speed = f.ships.findShip(id)->speedPerTick;
|
||||
const float speed = f.ships.findShip(id)->maxSpeedPerTick;
|
||||
const QVector2D target(100.0f, 0.0f);
|
||||
|
||||
f.ships.forEach([&target](Ship& s) {
|
||||
@@ -112,6 +114,8 @@ TEST_CASE("BehaviorSystem: tickMovement advances ship by speedPerTick toward tar
|
||||
REQUIRE(s->position.y() == Approx(0.0f));
|
||||
}
|
||||
|
||||
// With very high maneuvering thrust the stopping distance is ~0, so desiredSpeed
|
||||
// still exceeds maxSpeedPerTick and the snap-to-target branch fires.
|
||||
TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoot",
|
||||
"[behavior]")
|
||||
{
|
||||
@@ -119,7 +123,7 @@ TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoo
|
||||
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
// Place target closer than one tick's travel.
|
||||
const float speed = f.ships.findShip(id)->speedPerTick;
|
||||
const float speed = f.ships.findShip(id)->maxSpeedPerTick;
|
||||
const QVector2D target(speed * 0.5f, 0.0f);
|
||||
|
||||
f.ships.forEach([&target](Ship& s) {
|
||||
|
||||
@@ -77,7 +77,7 @@ TEST_CASE("ShipSystem: interceptor level 5 hp matches formula", "[ship]")
|
||||
REQUIRE(ship->maxHp == Approx(65.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("ShipSystem: interceptor level 0 speedPerTick matches formula / kTickRateHz", "[ship]")
|
||||
TEST_CASE("ShipSystem: interceptor level 0 maxSpeedPerTick matches formula / kTickRateHz", "[ship]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
EntityId nextId = 1;
|
||||
@@ -86,9 +86,9 @@ TEST_CASE("ShipSystem: interceptor level 0 speedPerTick matches formula / kTickR
|
||||
const EntityId id = ss.spawn("interceptor", 0, QVector2D(0.0f, 0.0f));
|
||||
const Ship* ship = ss.findShip(id);
|
||||
|
||||
// speed_formula = "200 + 5*x" at x=0 → 200; speedPerTick = 200/30
|
||||
// speed_formula = "200 + 5*x" at x=0 → 200; maxSpeedPerTick = 200/30
|
||||
const float expected = 200.0f / static_cast<float>(kTickRateHz);
|
||||
REQUIRE(ship->speedPerTick == Approx(expected));
|
||||
REQUIRE(ship->maxSpeedPerTick == Approx(expected));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -810,10 +810,7 @@ void GameWorldView::drawShips(QPainter& painter)
|
||||
if (it == m_visuals->ships.end()) { continue; }
|
||||
|
||||
const QPointF center = worldToWidget(ship.position);
|
||||
const QVector2D vel = ship.velocity;
|
||||
const QVector2D dir = (vel.length() > 0.0001f)
|
||||
? vel.normalized()
|
||||
: QVector2D(1.0f, 0.0f);
|
||||
const QVector2D dir(std::cos(ship.facing), std::sin(ship.facing));
|
||||
const QVector2D perp(-dir.y(), dir.x());
|
||||
|
||||
const float fwd = tilePx() * 0.45f;
|
||||
|
||||
Reference in New Issue
Block a user