implement ship behaviors
This commit is contained in:
@@ -11,7 +11,7 @@ Cross-references: `architecture.md` (design), `requirements.md` (REQ-* citations
|
||||
| 3 | Belt subsystem (placement, port interface, per-tile v1, splitter routing, clearTiles, visual iteration) | ✅ done |
|
||||
| 4 | Buildings + placement + belt↔building transport | ✅ done |
|
||||
| 5 | Scrap + ships skeleton (data + spawning, no AI) | ✅ done |
|
||||
| 6 | Ship behavior systems + movement arbitration | ⬜ next |
|
||||
| 6 | Ship behavior systems + movement arbitration | ✅ done |
|
||||
| 7 | Waves, threat accumulation, combat resolution, deaths & loot | ⬜ |
|
||||
| 8 | UI layer (GameWorldView, visuals.toml, panels, build/demolish, speed controls) | ⬜ |
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "BuildingSystem.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <limits>
|
||||
#include <random>
|
||||
#include <set>
|
||||
|
||||
@@ -659,3 +660,61 @@ bool BuildingSystem::isTileOccupied(QPoint tile) const
|
||||
{
|
||||
return m_tileOccupancy.count({tile.x(), tile.y()}) > 0;
|
||||
}
|
||||
|
||||
const Building* BuildingSystem::findNearestBuilding(QVector2D worldPos,
|
||||
BuildingType type) const
|
||||
{
|
||||
const Building* best = nullptr;
|
||||
float bestDist = std::numeric_limits<float>::max();
|
||||
for (const Building& b : m_buildings)
|
||||
{
|
||||
if (b.type != type)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QVector2D center(b.anchor.x() + b.footprint.width() / 2.0f,
|
||||
b.anchor.y() + b.footprint.height() / 2.0f);
|
||||
float dist = (center - worldPos).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
best = &b;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
bool BuildingSystem::deliverScrapToSalvageBay(EntityId bayId)
|
||||
{
|
||||
Building* bay = nullptr;
|
||||
for (Building& b : m_buildings)
|
||||
{
|
||||
if (b.id == bayId)
|
||||
{
|
||||
bay = &b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!bay || bay->type != BuildingType::SalvageBay)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (static_cast<int>(bay->outputBuffer.items.size()) >= bay->outputBuffer.capacity)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
bay->outputBuffer.items.push_back(Item{ItemType{"scrap"}});
|
||||
return true;
|
||||
}
|
||||
|
||||
void BuildingSystem::healBuilding(EntityId id, float amount)
|
||||
{
|
||||
for (Building& b : m_buildings)
|
||||
{
|
||||
if (b.id == id)
|
||||
{
|
||||
b.hp = std::min(b.hp + amount, b.maxHp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
#include <QVector2D>
|
||||
|
||||
#include "BeltSystem.h"
|
||||
#include "Building.h"
|
||||
@@ -60,6 +61,16 @@ public:
|
||||
std::vector<ConstructionSite> allSites() const;
|
||||
bool isTileOccupied(QPoint tile) const;
|
||||
|
||||
// Find nearest operational building of the given type; nullptr if none.
|
||||
const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const;
|
||||
|
||||
// Place one "scrap" item into a SalvageBay's output buffer.
|
||||
// Returns false if bay not found, wrong type, or output buffer is full.
|
||||
bool deliverScrapToSalvageBay(EntityId bayId);
|
||||
|
||||
// Increase a building's HP by amount, clamped to maxHp.
|
||||
void healBuilding(EntityId id, float amount);
|
||||
|
||||
private:
|
||||
struct BeltEntry
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "ScrapSystem.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <optional>
|
||||
|
||||
ScrapSystem::ScrapSystem(std::function<EntityId()> allocateId)
|
||||
: m_allocateId(std::move(allocateId))
|
||||
@@ -38,6 +39,20 @@ const Scrap* ScrapSystem::findScrap(EntityId id) const
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::optional<Scrap> ScrapSystem::consume(EntityId id)
|
||||
{
|
||||
for (auto it = m_scraps.begin(); it != m_scraps.end(); ++it)
|
||||
{
|
||||
if (it->id == id)
|
||||
{
|
||||
Scrap result = *it;
|
||||
m_scraps.erase(it);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<Scrap> ScrapSystem::allScraps() const
|
||||
{
|
||||
return m_scraps;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include <QVector2D>
|
||||
@@ -14,8 +15,9 @@ class ScrapSystem
|
||||
public:
|
||||
explicit ScrapSystem(std::function<EntityId()> allocateId);
|
||||
|
||||
EntityId spawn(QVector2D position, int amount, Tick despawnAt);
|
||||
void tickDespawn(Tick currentTick);
|
||||
EntityId spawn(QVector2D position, int amount, Tick despawnAt);
|
||||
void tickDespawn(Tick currentTick);
|
||||
std::optional<Scrap> consume(EntityId id); // removes and returns scrap, or nullopt
|
||||
|
||||
const Scrap* findScrap(EntityId id) const;
|
||||
std::vector<Scrap> allScraps() const;
|
||||
|
||||
@@ -23,8 +23,9 @@ struct Weapon
|
||||
|
||||
struct SalvageCargo
|
||||
{
|
||||
int capacity;
|
||||
int current;
|
||||
int capacity;
|
||||
int current;
|
||||
float collectionRange; // copy of ShipDef.salvage.collectionRange (tile units)
|
||||
};
|
||||
|
||||
struct RepairTool
|
||||
@@ -76,6 +77,8 @@ struct Ship
|
||||
int level;
|
||||
std::string blueprintId;
|
||||
|
||||
bool isEnemy = false; // true for enemy-faction ships (used by behavior systems)
|
||||
|
||||
std::optional<Weapon> weapon;
|
||||
std::optional<SalvageCargo> cargo;
|
||||
std::optional<RepairTool> repairTool;
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <limits>
|
||||
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "BuildingType.h"
|
||||
#include "Scrap.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "Tick.h"
|
||||
|
||||
ShipSystem::ShipSystem(const GameConfig& config,
|
||||
@@ -24,7 +30,8 @@ const ShipDef* ShipSystem::findShipDef(const std::string& blueprintId) const
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
EntityId ShipSystem::spawn(const std::string& blueprintId, int level, QVector2D position)
|
||||
EntityId ShipSystem::spawn(const std::string& blueprintId, int level, QVector2D position,
|
||||
bool isEnemy)
|
||||
{
|
||||
const ShipDef* def = findShipDef(blueprintId);
|
||||
assert(def != nullptr);
|
||||
@@ -42,6 +49,7 @@ EntityId ShipSystem::spawn(const std::string& blueprintId, int level, QVector2D
|
||||
/ static_cast<float>(kTickRateHz);
|
||||
ship.level = level;
|
||||
ship.blueprintId = blueprintId;
|
||||
ship.isEnemy = isEnemy;
|
||||
ship.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)};
|
||||
|
||||
if (def->combat)
|
||||
@@ -61,8 +69,9 @@ EntityId ShipSystem::spawn(const std::string& blueprintId, int level, QVector2D
|
||||
if (def->salvage)
|
||||
{
|
||||
SalvageCargo cargo;
|
||||
cargo.capacity = def->salvage->cargoCapacity;
|
||||
cargo.current = 0;
|
||||
cargo.capacity = def->salvage->cargoCapacity;
|
||||
cargo.current = 0;
|
||||
cargo.collectionRange = static_cast<float>(def->salvage->collectionRange);
|
||||
ship.cargo = cargo;
|
||||
|
||||
ScrapCollector sc;
|
||||
@@ -118,3 +127,526 @@ void ShipSystem::forEach(std::function<void(Ship&)> fn)
|
||||
fn(s);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static QVector2D buildingCenter(const Building& b)
|
||||
{
|
||||
return QVector2D(b.anchor.x() + b.footprint.width() / 2.0f,
|
||||
b.anchor.y() + b.footprint.height() / 2.0f);
|
||||
}
|
||||
|
||||
bool ShipSystem::isTargetValid(EntityId id, float range, const Ship& ship,
|
||||
const BuildingSystem& buildings) const
|
||||
{
|
||||
if (id == kInvalidEntityId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Check ship pool first.
|
||||
const Ship* target = findShip(id);
|
||||
if (target)
|
||||
{
|
||||
return (target->position - ship.position).length() <= range;
|
||||
}
|
||||
// Check building pool (HQ and defence stations are targetable).
|
||||
const Building* bld = buildings.findBuilding(id);
|
||||
if (bld)
|
||||
{
|
||||
return (buildingCenter(*bld) - ship.position).length() <= range;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ShipSystem::healShip(EntityId id, float amount)
|
||||
{
|
||||
for (Ship& s : m_ships)
|
||||
{
|
||||
if (s.id == id)
|
||||
{
|
||||
s.hp = std::min(s.hp + amount, s.maxHp);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clearMovementIntents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ShipSystem::clearMovementIntents()
|
||||
{
|
||||
for (Ship& s : m_ships)
|
||||
{
|
||||
s.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickHomeReturn (priority 4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ShipSystem::tickHomeReturn()
|
||||
{
|
||||
for (Ship& s : m_ships)
|
||||
{
|
||||
if (!s.homeReturn)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (s.hp / s.maxHp < s.homeReturn->retreatHpFraction)
|
||||
{
|
||||
if (4 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{4, s.homeReturn->homePos};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickThreatResponse (priority 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ShipSystem::tickThreatResponse(const BuildingSystem& buildings)
|
||||
{
|
||||
// Snapshot all buildings once (used for enemy targeting).
|
||||
const std::vector<Building> allBuildings = buildings.allBuildings();
|
||||
|
||||
for (Ship& s : m_ships)
|
||||
{
|
||||
if (!s.threatResponse)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const float range = s.threatResponse->engagementRange;
|
||||
|
||||
if (!s.isEnemy)
|
||||
{
|
||||
// Player combat ship: target nearest enemy ship.
|
||||
if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId),
|
||||
range, s, buildings))
|
||||
{
|
||||
s.threatResponse->currentTarget = std::nullopt;
|
||||
float bestDist = range;
|
||||
for (const Ship& candidate : m_ships)
|
||||
{
|
||||
if (!candidate.isEnemy)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float dist = (candidate.position - s.position).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
s.threatResponse->currentTarget = candidate.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (s.threatResponse->currentTarget)
|
||||
{
|
||||
const Ship* target = findShip(*s.threatResponse->currentTarget);
|
||||
if (target && 3 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{3, target->position};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No target: patrol rightward (aggressive).
|
||||
if (3 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{3, QVector2D(s.position.x() + 1000.0f,
|
||||
s.position.y())};
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Enemy ship: target nearest player ship or player building.
|
||||
if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId),
|
||||
range, s, buildings))
|
||||
{
|
||||
s.threatResponse->currentTarget = std::nullopt;
|
||||
float bestDist = range;
|
||||
|
||||
for (const Ship& candidate : m_ships)
|
||||
{
|
||||
if (candidate.isEnemy)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float dist = (candidate.position - s.position).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
s.threatResponse->currentTarget = candidate.id;
|
||||
}
|
||||
}
|
||||
|
||||
for (const Building& b : allBuildings)
|
||||
{
|
||||
if (b.type != BuildingType::PlayerDefenceStation
|
||||
&& b.type != BuildingType::Hq)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float dist = (buildingCenter(b) - s.position).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
s.threatResponse->currentTarget = b.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (s.threatResponse->currentTarget)
|
||||
{
|
||||
// Move toward target (building or ship).
|
||||
QVector2D dest;
|
||||
const Ship* tShip = findShip(*s.threatResponse->currentTarget);
|
||||
if (tShip)
|
||||
{
|
||||
dest = tShip->position;
|
||||
}
|
||||
else
|
||||
{
|
||||
const Building* tBld = buildings.findBuilding(
|
||||
*s.threatResponse->currentTarget);
|
||||
dest = tBld ? buildingCenter(*tBld) : s.position;
|
||||
}
|
||||
if (3 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{3, dest};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No target: move toward asteroid (leftward).
|
||||
if (3 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{3, QVector2D(-10000.0f, s.position.y())};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickRepairBehavior (priority 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ShipSystem::tickRepairBehavior(BuildingSystem& buildings)
|
||||
{
|
||||
const std::vector<Building> allBuildings = buildings.allBuildings();
|
||||
const float kPatrolRange = 3.0f; // scan radius relative to repair range
|
||||
|
||||
for (Ship& s : m_ships)
|
||||
{
|
||||
if (!s.repairBehavior || !s.repairTool)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const float repairRange = s.repairTool->range;
|
||||
|
||||
// Check for nearby enemies; if present, retreat.
|
||||
bool enemyNearby = false;
|
||||
for (const Ship& candidate : m_ships)
|
||||
{
|
||||
if (candidate.isEnemy
|
||||
&& (candidate.position - s.position).length() <= repairRange)
|
||||
{
|
||||
enemyNearby = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (enemyNearby)
|
||||
{
|
||||
if (2 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{2, QVector2D(-10000.0f, s.position.y())};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate current repair target.
|
||||
EntityId currentId = s.repairBehavior->currentTarget.value_or(kInvalidEntityId);
|
||||
bool targetValid = false;
|
||||
if (currentId != kInvalidEntityId)
|
||||
{
|
||||
const Ship* tShip = findShip(currentId);
|
||||
if (tShip && !tShip->isEnemy && tShip->hp < tShip->maxHp)
|
||||
{
|
||||
targetValid = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
const Building* tBld = buildings.findBuilding(currentId);
|
||||
if (tBld && tBld->type == BuildingType::PlayerDefenceStation
|
||||
&& tBld->hp < tBld->maxHp)
|
||||
{
|
||||
targetValid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetValid)
|
||||
{
|
||||
s.repairBehavior->currentTarget = std::nullopt;
|
||||
currentId = kInvalidEntityId;
|
||||
float bestDist = repairRange * kPatrolRange;
|
||||
|
||||
for (const Ship& candidate : m_ships)
|
||||
{
|
||||
if (candidate.isEnemy || candidate.id == s.id
|
||||
|| candidate.hp >= candidate.maxHp)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float dist = (candidate.position - s.position).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
s.repairBehavior->currentTarget = candidate.id;
|
||||
}
|
||||
}
|
||||
|
||||
for (const Building& b : allBuildings)
|
||||
{
|
||||
if (b.type != BuildingType::PlayerDefenceStation
|
||||
|| b.hp >= b.maxHp)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float dist = (buildingCenter(b) - s.position).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
s.repairBehavior->currentTarget = b.id;
|
||||
}
|
||||
}
|
||||
|
||||
currentId = s.repairBehavior->currentTarget.value_or(kInvalidEntityId);
|
||||
}
|
||||
|
||||
if (currentId == kInvalidEntityId)
|
||||
{
|
||||
// No target: patrol rightward.
|
||||
if (2 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{2, QVector2D(s.position.x() + 1000.0f,
|
||||
s.position.y())};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute target position and whether we are in repair range.
|
||||
QVector2D targetPos;
|
||||
bool isShipTarget = false;
|
||||
const Ship* tShip = findShip(currentId);
|
||||
if (tShip)
|
||||
{
|
||||
targetPos = tShip->position;
|
||||
isShipTarget = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
const Building* tBld = buildings.findBuilding(currentId);
|
||||
targetPos = tBld ? buildingCenter(*tBld) : s.position;
|
||||
}
|
||||
|
||||
float distToTarget = (targetPos - s.position).length();
|
||||
if (distToTarget <= repairRange)
|
||||
{
|
||||
if (isShipTarget)
|
||||
{
|
||||
healShip(currentId, s.repairTool->ratePerTick);
|
||||
}
|
||||
else
|
||||
{
|
||||
buildings.healBuilding(currentId, s.repairTool->ratePerTick);
|
||||
}
|
||||
}
|
||||
|
||||
if (2 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{2, targetPos};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickScrapCollector (priority 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ShipSystem::tickScrapCollector(ScrapSystem& scraps, const BuildingSystem& buildings)
|
||||
{
|
||||
for (Ship& s : m_ships)
|
||||
{
|
||||
if (!s.scrapCollector || !s.cargo)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const float collectRange = s.cargo->collectionRange;
|
||||
|
||||
// Assign delivery bay if not yet set.
|
||||
if (s.scrapCollector->deliveryBay == kInvalidEntityId)
|
||||
{
|
||||
const Building* bay = buildings.findNearestBuilding(s.position,
|
||||
BuildingType::SalvageBay);
|
||||
if (bay)
|
||||
{
|
||||
s.scrapCollector->deliveryBay = bay->id;
|
||||
}
|
||||
}
|
||||
|
||||
const EntityId bayId = s.scrapCollector->deliveryBay;
|
||||
|
||||
// Compute bay position for movement.
|
||||
QVector2D bayPos = s.position;
|
||||
if (bayId != kInvalidEntityId)
|
||||
{
|
||||
const Building* bay = buildings.findBuilding(bayId);
|
||||
if (bay)
|
||||
{
|
||||
bayPos = buildingCenter(*bay);
|
||||
}
|
||||
}
|
||||
|
||||
const bool cargoFull = (s.cargo->current >= s.cargo->capacity);
|
||||
|
||||
if (cargoFull)
|
||||
{
|
||||
// Return to bay and deliver.
|
||||
if (1 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{1, bayPos};
|
||||
}
|
||||
if (bayId != kInvalidEntityId
|
||||
&& (s.position - bayPos).length() <= 1.0f)
|
||||
{
|
||||
// Deliver one item per tick.
|
||||
if (const_cast<BuildingSystem&>(buildings).deliverScrapToSalvageBay(bayId))
|
||||
{
|
||||
--s.cargo->current;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Retreat from enemies when not carrying cargo.
|
||||
bool retreating = false;
|
||||
if (s.cargo->current == 0)
|
||||
{
|
||||
for (const Ship& candidate : m_ships)
|
||||
{
|
||||
if (candidate.isEnemy
|
||||
&& (candidate.position - s.position).length() <= collectRange)
|
||||
{
|
||||
if (1 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{1,
|
||||
QVector2D(-10000.0f, s.position.y())};
|
||||
}
|
||||
retreating = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (retreating)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect scrap if within range.
|
||||
for (const Scrap& sc : scraps.allScraps())
|
||||
{
|
||||
if ((sc.position - s.position).length() <= collectRange)
|
||||
{
|
||||
if (scraps.consume(sc.id))
|
||||
{
|
||||
++s.cargo->current;
|
||||
s.scrapCollector->scrapTarget = std::nullopt;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (s.scrapCollector->scrapTarget)
|
||||
{
|
||||
// Move toward known scrap target.
|
||||
if (1 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{1, *s.scrapCollector->scrapTarget};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Scan for nearest scrap within sensor range.
|
||||
const float sensorRange = collectRange * 5.0f;
|
||||
float bestDist = sensorRange;
|
||||
std::optional<QVector2D> bestPos;
|
||||
for (const Scrap& sc : scraps.allScraps())
|
||||
{
|
||||
float dist = (sc.position - s.position).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
bestPos = sc.position;
|
||||
}
|
||||
}
|
||||
if (bestPos)
|
||||
{
|
||||
s.scrapCollector->scrapTarget = bestPos;
|
||||
if (1 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{1, *bestPos};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No scrap in range: patrol rightward.
|
||||
if (1 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{1, QVector2D(s.position.x() + 1000.0f,
|
||||
s.position.y())};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickMovement (tick-order step 10)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ShipSystem::tickMovement()
|
||||
{
|
||||
for (Ship& s : m_ships)
|
||||
{
|
||||
if (s.intent.priority == 0)
|
||||
{
|
||||
s.velocity = QVector2D(0.0f, 0.0f);
|
||||
continue;
|
||||
}
|
||||
QVector2D delta = s.intent.target - s.position;
|
||||
float dist = delta.length();
|
||||
if (dist <= s.speedPerTick)
|
||||
{
|
||||
s.position = s.intent.target;
|
||||
s.velocity = QVector2D(0.0f, 0.0f);
|
||||
}
|
||||
else
|
||||
{
|
||||
s.velocity = delta.normalized() * s.speedPerTick;
|
||||
s.position += s.velocity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,22 +9,56 @@
|
||||
#include "GameConfig.h"
|
||||
#include "Ship.h"
|
||||
|
||||
class BuildingSystem;
|
||||
class ScrapSystem;
|
||||
|
||||
class ShipSystem
|
||||
{
|
||||
public:
|
||||
ShipSystem(const GameConfig& config,
|
||||
std::function<EntityId()> allocateId);
|
||||
|
||||
EntityId spawn(const std::string& blueprintId, int level, QVector2D position);
|
||||
// isEnemy defaults to false; set true for enemy-faction ships (step 7 wave spawning).
|
||||
EntityId spawn(const std::string& blueprintId, int level, QVector2D position,
|
||||
bool isEnemy = false);
|
||||
void despawn(EntityId id);
|
||||
|
||||
const Ship* findShip(EntityId id) const;
|
||||
std::vector<Ship> allShips() const;
|
||||
void forEach(std::function<void(Ship&)> fn);
|
||||
|
||||
// -- Behavior tick methods (tick-order step 7) ---------------------------
|
||||
|
||||
// Reset all movement intents to priority 0 before behavior systems run.
|
||||
void clearMovementIntents();
|
||||
|
||||
// Priority 4: low-HP ships retreat to homePos.
|
||||
void tickHomeReturn();
|
||||
|
||||
// Priority 3: combat ships acquire targets and advance toward them.
|
||||
void tickThreatResponse(const BuildingSystem& buildings);
|
||||
|
||||
// Priority 2: repair ships find and heal damaged friendly ships/stations.
|
||||
void tickRepairBehavior(BuildingSystem& buildings);
|
||||
|
||||
// Priority 1: salvage ships collect scrap and deliver it.
|
||||
void tickScrapCollector(ScrapSystem& scraps, const BuildingSystem& buildings);
|
||||
|
||||
// -- Movement (tick-order step 10) ---------------------------------------
|
||||
void tickMovement();
|
||||
|
||||
private:
|
||||
const ShipDef* findShipDef(const std::string& blueprintId) const;
|
||||
|
||||
// True if the entity identified by id is alive and within range of ship.
|
||||
// Searches both the ship list and (for buildings) the supplied BuildingSystem.
|
||||
bool isTargetValid(EntityId id, float range, const Ship& ship,
|
||||
const BuildingSystem& buildings) const;
|
||||
|
||||
// Heal the ship with the given id by amount, clamped to maxHp.
|
||||
// Returns false if the ship is not found.
|
||||
bool healShip(EntityId id, float amount);
|
||||
|
||||
const GameConfig& m_config;
|
||||
std::function<EntityId()> m_allocateId;
|
||||
std::vector<Ship> m_ships;
|
||||
|
||||
@@ -27,11 +27,24 @@ Simulation::~Simulation() = default;
|
||||
void Simulation::tick()
|
||||
{
|
||||
m_buildingSystem->tickConstruction(m_currentTick);
|
||||
m_buildingSystem->tickBeltPull(); // tick order step 3
|
||||
m_buildingSystem->tickProduction(m_currentTick); // step 4
|
||||
m_buildingSystem->tickBeltPush(); // step 5
|
||||
m_beltSystem.tick(); // step 6
|
||||
m_scrapSystem->tickDespawn(m_currentTick); // step 11
|
||||
m_buildingSystem->tickBeltPull(); // tick order step 3
|
||||
m_buildingSystem->tickProduction(m_currentTick); // step 4
|
||||
m_buildingSystem->tickBeltPush(); // step 5
|
||||
m_beltSystem.tick(); // step 6
|
||||
|
||||
// Step 7: ship behavior systems (movement arbitration via intent priority).
|
||||
m_shipSystem->clearMovementIntents();
|
||||
m_shipSystem->tickHomeReturn(); // priority 4
|
||||
m_shipSystem->tickThreatResponse(*m_buildingSystem); // priority 3
|
||||
m_shipSystem->tickRepairBehavior(*m_buildingSystem); // priority 2
|
||||
m_shipSystem->tickScrapCollector(*m_scrapSystem, *m_buildingSystem); // priority 1
|
||||
|
||||
// Steps 8 & 9: combat resolution and deaths — added in implementation step 7.
|
||||
|
||||
// Step 10: advance ship positions from winning intents.
|
||||
m_shipSystem->tickMovement();
|
||||
|
||||
m_scrapSystem->tickDespawn(m_currentTick); // step 11
|
||||
|
||||
++m_currentTick;
|
||||
}
|
||||
|
||||
440
src/test/BehaviorSystemTest.cpp
Normal file
440
src/test/BehaviorSystemTest.cpp
Normal file
@@ -0,0 +1,440 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <random>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#include "BeltSystem.h"
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "BuildingType.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "Rotation.h"
|
||||
#include "Scrap.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "Ship.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "Tick.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
|
||||
}
|
||||
|
||||
struct Fixture
|
||||
{
|
||||
GameConfig cfg;
|
||||
BeltSystem belts;
|
||||
EntityId nextId;
|
||||
int stock;
|
||||
std::mt19937 rng;
|
||||
BuildingSystem buildings;
|
||||
ShipSystem ships;
|
||||
ScrapSystem scraps;
|
||||
Tick tick;
|
||||
|
||||
explicit Fixture()
|
||||
: cfg(loadConfig())
|
||||
, belts(cfg.world.beltSpeedTilesPerSecond)
|
||||
, nextId(1)
|
||||
, stock(0)
|
||||
, rng(42)
|
||||
, buildings(cfg, belts,
|
||||
[this]() { return nextId++; },
|
||||
[this](int n) { stock += n; },
|
||||
rng)
|
||||
, ships(cfg, [this]() { return nextId++; })
|
||||
, scraps([this]() { return nextId++; })
|
||||
, tick(0)
|
||||
{
|
||||
}
|
||||
|
||||
// Run one full behavior+movement tick (steps 7 and 10).
|
||||
void runBehaviorTick()
|
||||
{
|
||||
ships.clearMovementIntents();
|
||||
ships.tickHomeReturn();
|
||||
ships.tickThreatResponse(buildings);
|
||||
ships.tickRepairBehavior(buildings);
|
||||
ships.tickScrapCollector(scraps, buildings);
|
||||
ships.tickMovement();
|
||||
++tick;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clearMovementIntents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to priority 0",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
// Manually write a non-zero intent.
|
||||
f.ships.forEach([](Ship& s) {
|
||||
s.intent = MovementIntent{3, QVector2D(10.0f, 0.0f)};
|
||||
});
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
|
||||
const Ship* s = f.ships.findShip(id);
|
||||
REQUIRE(s != nullptr);
|
||||
REQUIRE(s->intent.priority == 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickMovement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickMovement advances ship by speedPerTick 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 QVector2D target(100.0f, 0.0f);
|
||||
|
||||
f.ships.forEach([&target](Ship& s) {
|
||||
s.intent = MovementIntent{1, target};
|
||||
});
|
||||
f.ships.tickMovement();
|
||||
|
||||
const Ship* s = f.ships.findShip(id);
|
||||
REQUIRE(s->position.x() == Approx(speed));
|
||||
REQUIRE(s->position.y() == Approx(0.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoot",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
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 QVector2D target(speed * 0.5f, 0.0f);
|
||||
|
||||
f.ships.forEach([&target](Ship& s) {
|
||||
s.intent = MovementIntent{1, target};
|
||||
});
|
||||
f.ships.tickMovement();
|
||||
|
||||
const Ship* s = f.ships.findShip(id);
|
||||
REQUIRE(s->position.x() == Approx(target.x()));
|
||||
REQUIRE(s->position.y() == Approx(target.y()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickHomeReturn
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickHomeReturn does nothing when HP is above threshold",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
f.ships.forEach([](Ship& s) {
|
||||
s.homeReturn = HomeReturn{0.3f, QVector2D(-10.0f, 0.0f)};
|
||||
s.hp = s.maxHp; // full HP — above threshold
|
||||
});
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickHomeReturn();
|
||||
|
||||
REQUIRE(f.ships.findShip(id)->intent.priority == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickHomeReturn writes priority-4 intent toward homePos when HP is low",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const QVector2D homePos(-10.0f, 0.0f);
|
||||
|
||||
f.ships.forEach([&homePos](Ship& s) {
|
||||
s.homeReturn = HomeReturn{0.5f, homePos};
|
||||
s.hp = s.maxHp * 0.2f; // below 50% threshold
|
||||
});
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickHomeReturn();
|
||||
|
||||
const Ship* s = f.ships.findShip(id);
|
||||
REQUIRE(s->intent.priority == 4);
|
||||
REQUIRE(s->intent.target.x() == Approx(homePos.x()));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickHomeReturn priority-4 beats tickThreatResponse priority-3",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
// Player ship with both homeReturn (low HP) and an enemy in range.
|
||||
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
const QVector2D homePos(-50.0f, 0.0f);
|
||||
f.ships.forEach([&homePos, playerId](Ship& s) {
|
||||
if (s.id == playerId)
|
||||
{
|
||||
s.homeReturn = HomeReturn{0.5f, homePos};
|
||||
s.hp = s.maxHp * 0.1f;
|
||||
}
|
||||
});
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickHomeReturn();
|
||||
f.ships.tickThreatResponse(f.buildings);
|
||||
|
||||
const Ship* s = f.ships.findShip(playerId);
|
||||
REQUIRE(s->intent.priority == 4);
|
||||
REQUIRE(s->intent.target.x() == Approx(homePos.x()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickThreatResponse — player ships
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in range",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
// Spawn enemy within attack range (150 tile units).
|
||||
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickThreatResponse(f.buildings);
|
||||
|
||||
const Ship* player = f.ships.findShip(playerId);
|
||||
REQUIRE(player->threatResponse.has_value());
|
||||
REQUIRE(player->threatResponse->currentTarget.has_value());
|
||||
REQUIRE(*player->threatResponse->currentTarget == enemyId);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId id1 = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player (isEnemy=false)
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickThreatResponse(f.buildings);
|
||||
|
||||
const Ship* s = f.ships.findShip(id1);
|
||||
REQUIRE(s->threatResponse.has_value());
|
||||
REQUIRE_FALSE(s->threatResponse->currentTarget.has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
// Place enemy far beyond engagement range (150 tile units).
|
||||
f.ships.spawn("interceptor", 1, QVector2D(500.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickThreatResponse(f.buildings);
|
||||
|
||||
const Ship* s = f.ships.findShip(playerId);
|
||||
REQUIRE_FALSE(s->threatResponse->currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickThreatResponse — enemy ships
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickThreatResponse(f.buildings);
|
||||
|
||||
const Ship* enemy = f.ships.findShip(enemyId);
|
||||
REQUIRE(enemy->threatResponse.has_value());
|
||||
REQUIRE(enemy->threatResponse->currentTarget.has_value());
|
||||
REQUIRE(*enemy->threatResponse->currentTarget == playerId);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: enemy ship with no target writes leftward movement intent",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickThreatResponse(f.buildings);
|
||||
|
||||
const Ship* enemy = f.ships.findShip(enemyId);
|
||||
REQUIRE(enemy->intent.priority == 3);
|
||||
REQUIRE(enemy->intent.target.x() < 0.0f); // moving leftward (toward asteroid)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickRepairBehavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly ship",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f));
|
||||
|
||||
// Damage the friendly ship.
|
||||
f.ships.forEach([friendlyId](Ship& s) {
|
||||
if (s.id == friendlyId)
|
||||
{
|
||||
s.hp = s.maxHp * 0.5f;
|
||||
}
|
||||
});
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickRepairBehavior(f.buildings);
|
||||
|
||||
const Ship* repair = f.ships.findShip(repairId);
|
||||
REQUIRE(repair->intent.priority == 2);
|
||||
REQUIRE(repair->intent.target.x() == Approx(5.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
// Repair range = 80 tile units; place ships close together.
|
||||
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
|
||||
|
||||
const float initialHp = f.ships.findShip(friendlyId)->maxHp * 0.5f;
|
||||
f.ships.forEach([friendlyId, initialHp](Ship& s) {
|
||||
if (s.id == friendlyId)
|
||||
{
|
||||
s.hp = initialHp;
|
||||
}
|
||||
});
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickRepairBehavior(f.buildings);
|
||||
|
||||
// repair_rate_formula = "5 + x" at x=1 → 6; hp should have increased.
|
||||
const Ship* friendly = f.ships.findShip(friendlyId);
|
||||
REQUIRE(friendly->hp > initialHp);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
|
||||
|
||||
// Nearly full HP — one repair tick must not exceed maxHp.
|
||||
f.ships.forEach([friendlyId](Ship& s) {
|
||||
if (s.id == friendlyId)
|
||||
{
|
||||
s.hp = s.maxHp - 0.001f;
|
||||
}
|
||||
});
|
||||
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickRepairBehavior(f.buildings);
|
||||
}
|
||||
|
||||
const Ship* friendly = f.ships.findShip(friendlyId);
|
||||
REQUIRE(friendly->hp <= friendly->maxHp);
|
||||
REQUIRE(friendly->hp == Approx(friendly->maxHp));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickScrapCollector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
// Scrap beyond collectionRange (50) but within sensorRange (250).
|
||||
const QVector2D scrapPos(100.0f, 0.0f);
|
||||
const Tick farFuture = 100000;
|
||||
f.scraps.spawn(scrapPos, 1, farFuture);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickScrapCollector(f.scraps, f.buildings);
|
||||
|
||||
const Ship* s = f.ships.findShip(shipId);
|
||||
REQUIRE(s->intent.priority == 1);
|
||||
REQUIRE(s->intent.target.x() == Approx(scrapPos.x()));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
// Place scrap exactly at ship position so it is within collectionRange immediately.
|
||||
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const Tick farFuture = 100000;
|
||||
const EntityId scrapId = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, farFuture);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickScrapCollector(f.scraps, f.buildings);
|
||||
|
||||
const Ship* s = f.ships.findShip(shipId);
|
||||
REQUIRE(s->cargo->current == 1);
|
||||
REQUIRE(f.scraps.findScrap(scrapId) == nullptr); // consumed
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
|
||||
// Place a SalvageBay building so the ship has somewhere to deliver.
|
||||
// The SalvageBay occupies asteroid tiles (x < 0 convention); use negative coords.
|
||||
// We bypass construction time by ticking until it is operational.
|
||||
const EntityId bayId = f.buildings.place(BuildingType::SalvageBay,
|
||||
QPoint(-4, 0), Rotation::East, 0);
|
||||
Tick tick = 0;
|
||||
// SalvageBay construction_time_seconds = 15 → 450 ticks; run 500 to be safe.
|
||||
for (int i = 0; i < 500; ++i)
|
||||
{
|
||||
f.buildings.tickConstruction(tick++);
|
||||
if (f.buildings.findBuilding(bayId) != nullptr)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(f.buildings.findBuilding(bayId) != nullptr);
|
||||
|
||||
// Spawn salvage ship and fill its cargo.
|
||||
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f));
|
||||
f.ships.forEach([](Ship& s) {
|
||||
if (s.cargo)
|
||||
{
|
||||
s.cargo->current = s.cargo->capacity; // full cargo
|
||||
}
|
||||
});
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ships.tickScrapCollector(f.scraps, f.buildings);
|
||||
|
||||
// Intent should point toward the bay (x < 0 area), not rightward.
|
||||
const Ship* s = f.ships.findShip(shipId);
|
||||
REQUIRE(s->intent.priority == 1);
|
||||
REQUIRE(s->intent.target.x() < s->position.x());
|
||||
}
|
||||
@@ -11,4 +11,5 @@ add_files(
|
||||
BuildingTest.cpp
|
||||
ShipTest.cpp
|
||||
ScrapTest.cpp
|
||||
BehaviorSystemTest.cpp
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user