Compare commits

...

8 Commits

Author SHA1 Message Date
f5f4453e2c rename behavior components 2026-05-23 08:26:10 +02:00
b57299fd2a rename EntityId to BuildingId 2026-05-23 07:53:56 +02:00
dc4ebd5f2d fix crash 2026-05-22 22:11:56 +02:00
ea79d76953 unify Weapon and StationWeapon components 2026-05-22 22:06:30 +02:00
bd488db8ef remove unused building HP 2026-05-22 21:34:21 +02:00
4e3dc51981 fix stations were not drawn 2026-05-22 21:15:26 +02:00
9d20048705 cleanup 2026-05-22 20:45:10 +02:00
ca07cbaf0e switch to ECS architecture 2026-05-22 20:31:39 +02:00
47 changed files with 2357 additions and 2541 deletions

View File

@@ -10,6 +10,8 @@
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "CombatSystem.h" #include "CombatSystem.h"
#include "EcsComponents.h"
#include "EntityAdmin.h"
#include "MovementSystem.h" #include "MovementSystem.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include "Ship.h" #include "Ship.h"
@@ -25,10 +27,10 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
, m_arenaConfig(std::move(arenaConfig)) , m_arenaConfig(std::move(arenaConfig))
, m_rng(seed) , m_rng(seed)
, m_currentTick(0) , m_currentTick(0)
, m_nextId(1) , m_nextBuildingId(1)
, m_beltSystem(1.0) , m_beltSystem(1.0)
, m_team1HqId(kInvalidEntityId) , m_team1HqEntity(entt::null)
, m_team2HqId(kInvalidEntityId) , m_team2HqEntity(entt::null)
, m_finished(false) , m_finished(false)
, m_winnerTeam(-1) , m_winnerTeam(-1)
, m_stopRequested(false) , m_stopRequested(false)
@@ -36,18 +38,16 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
m_buildingSystem = std::make_unique<BuildingSystem>( m_buildingSystem = std::make_unique<BuildingSystem>(
m_gameConfig, m_gameConfig,
m_beltSystem, m_beltSystem,
[this]() { return allocateId(); }, [this]() { return allocateBuildingId(); },
[](int) {}, [](int) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
m_rng); m_rng);
m_shipSystem = std::make_unique<ShipSystem>( m_shipSystem = std::make_unique<ShipSystem>(m_gameConfig, m_admin);
m_gameConfig, [this]() { return allocateId(); });
m_aiSystem = std::make_unique<AiSystem>(); m_aiSystem = std::make_unique<AiSystem>();
m_movementSystem = std::make_unique<MovementSystem>(); m_movementSystem = std::make_unique<MovementSystem>();
m_combatSystem = std::make_unique<CombatSystem>(m_gameConfig); m_combatSystem = std::make_unique<CombatSystem>(m_gameConfig);
m_scrapSystem = std::make_unique<ScrapSystem>([this]() { return allocateId(); }); m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
placeStructures(); placeStructures();
spawnShips(); spawnShips();
@@ -59,9 +59,9 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
ArenaSimulation::~ArenaSimulation() = default; ArenaSimulation::~ArenaSimulation() = default;
EntityId ArenaSimulation::allocateId() BuildingId ArenaSimulation::allocateBuildingId()
{ {
return m_nextId++; return m_nextBuildingId++;
} }
void ArenaSimulation::placeStructures() void ArenaSimulation::placeStructures()
@@ -71,7 +71,7 @@ void ArenaSimulation::placeStructures()
+ m_arenaConfig.enemyBufferWidth; + m_arenaConfig.enemyBufferWidth;
const int midY = m_arenaConfig.heightTiles / 2; const int midY = m_arenaConfig.heightTiles / 2;
// Team 1 HQ at left edge, placed as Hq (enemy ships target Hq). // Team 1 HQ — ECS proxy entity, player faction (isEnemy=false).
{ {
const ParsedSurfaceMask hqParsed = const ParsedSurfaceMask hqParsed =
parseSurfaceMask(m_gameConfig.stations.hq.surfaceMask, Rotation::East); parseSurfaceMask(m_gameConfig.stations.hq.surfaceMask, Rotation::East);
@@ -79,16 +79,20 @@ void ArenaSimulation::placeStructures()
const int anchorY = midY - hqParsed.footprint.height() / 2; const int anchorY = midY - hqParsed.footprint.height() / 2;
const float hp = static_cast<float>( const float hp = static_cast<float>(
m_gameConfig.stations.hq.hpFormula.evaluate(1.0)); m_gameConfig.stations.hq.hpFormula.evaluate(1.0));
const QPoint anchor(anchorX, anchorY);
m_team1HqId = m_buildingSystem->placeImmediate( std::vector<QPoint> absCells;
BuildingType::Hq, for (const QPoint& rel : hqParsed.bodyCells)
m_gameConfig.stations.hq.surfaceMask, {
QPoint(anchorX, anchorY), absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
Rotation::East, hp, hp); }
const QVector2D center(anchorX + hqParsed.footprint.width() / 2.0f,
anchorY + hqParsed.footprint.height() / 2.0f);
m_team1HqEntity = m_admin.spawnStation(anchor, hqParsed.footprint, absCells,
hp, hp, false);
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
} }
// Team 2 HQ at right edge, placed as EnemyDefenceStation (player ships target these). // Team 2 HQ — ECS proxy entity, enemy faction (isEnemy=true). No weapon.
// No weapon — it's just a destructible target.
{ {
const ParsedSurfaceMask hqParsed = const ParsedSurfaceMask hqParsed =
parseSurfaceMask(m_gameConfig.stations.hq.surfaceMask, Rotation::West); parseSurfaceMask(m_gameConfig.stations.hq.surfaceMask, Rotation::West);
@@ -96,90 +100,72 @@ void ArenaSimulation::placeStructures()
const int anchorY = midY - hqParsed.footprint.height() / 2; const int anchorY = midY - hqParsed.footprint.height() / 2;
const float hp = static_cast<float>( const float hp = static_cast<float>(
m_gameConfig.stations.hq.hpFormula.evaluate(1.0)); m_gameConfig.stations.hq.hpFormula.evaluate(1.0));
const QPoint anchor(anchorX, anchorY);
m_team2HqId = m_buildingSystem->placeImmediate( std::vector<QPoint> absCells;
BuildingType::EnemyDefenceStation, for (const QPoint& rel : hqParsed.bodyCells)
m_gameConfig.stations.hq.surfaceMask, {
QPoint(anchorX, anchorY), absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
Rotation::West, hp, hp); }
m_team2HqEntity = m_admin.spawnStation(anchor, hqParsed.footprint, absCells,
hp, hp, true);
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
} }
// Team 1 defence stations (PlayerDefenceStation — targeted by team 2). auto placeArenaStation = [&](const ArenaStationEntry& entry, bool isEnemy)
{
float hp = 0.0f;
Weapon weapon;
weapon.cooldownTicks = 0.0f;
weapon.currentTarget = std::nullopt;
const double lv = static_cast<double>(entry.level);
const std::vector<std::string>& mask = isEnemy
? m_gameConfig.stations.enemyStation.surfaceMask
: m_gameConfig.stations.playerStation.surfaceMask;
if (entry.stationType == "player_station")
{
hp = static_cast<float>(
m_gameConfig.stations.playerStation.hpFormula.evaluate(lv));
weapon.damage = static_cast<float>(
m_gameConfig.stations.playerStation.damageFormula.evaluate(lv));
weapon.range = static_cast<float>(
m_gameConfig.stations.playerStation.rangeFormula.evaluate(lv));
weapon.fireRateHz = static_cast<float>(
m_gameConfig.stations.playerStation.fireRateFormula.evaluate(lv));
}
else
{
hp = static_cast<float>(
m_gameConfig.stations.enemyStation.hpFormula.evaluate(lv));
weapon.damage = static_cast<float>(
m_gameConfig.stations.enemyStation.damageFormula.evaluate(lv));
weapon.range = static_cast<float>(
m_gameConfig.stations.enemyStation.rangeFormula.evaluate(lv));
weapon.fireRateHz = static_cast<float>(
m_gameConfig.stations.enemyStation.fireRateFormula.evaluate(lv));
}
const ParsedSurfaceMask parsed = parseSurfaceMask(mask, Rotation::East);
const QPoint& anchor = entry.position;
std::vector<QPoint> absCells;
for (const QPoint& rel : parsed.bodyCells)
{
absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
}
const entt::entity stationEntity = m_admin.spawnStation(
anchor, parsed.footprint, absCells, hp, hp, isEnemy);
m_admin.addComponent<Weapon>(stationEntity, weapon);
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
};
for (const ArenaStationEntry& entry : m_arenaConfig.teams[0].stations) for (const ArenaStationEntry& entry : m_arenaConfig.teams[0].stations)
{ {
float hp = 0.0f; placeArenaStation(entry, false);
StationWeapon weapon;
weapon.cooldownTicks = 0.0f;
const double lv = static_cast<double>(entry.level);
if (entry.stationType == "player_station")
{
hp = static_cast<float>(
m_gameConfig.stations.playerStation.hpFormula.evaluate(lv));
weapon.damage = static_cast<float>(
m_gameConfig.stations.playerStation.damageFormula.evaluate(lv));
weapon.range = static_cast<float>(
m_gameConfig.stations.playerStation.rangeFormula.evaluate(lv));
weapon.fireRateHz = static_cast<float>(
m_gameConfig.stations.playerStation.fireRateFormula.evaluate(lv));
}
else
{
hp = static_cast<float>(
m_gameConfig.stations.enemyStation.hpFormula.evaluate(lv));
weapon.damage = static_cast<float>(
m_gameConfig.stations.enemyStation.damageFormula.evaluate(lv));
weapon.range = static_cast<float>(
m_gameConfig.stations.enemyStation.rangeFormula.evaluate(lv));
weapon.fireRateHz = static_cast<float>(
m_gameConfig.stations.enemyStation.fireRateFormula.evaluate(lv));
}
const EntityId stationId = m_buildingSystem->placeImmediate(
BuildingType::PlayerDefenceStation,
m_gameConfig.stations.playerStation.surfaceMask,
entry.position,
Rotation::East, hp, hp);
m_buildingSystem->initStationWeapon(stationId, weapon);
} }
// Team 2 defence stations (EnemyDefenceStation — targeted by team 1).
for (const ArenaStationEntry& entry : m_arenaConfig.teams[1].stations) for (const ArenaStationEntry& entry : m_arenaConfig.teams[1].stations)
{ {
float hp = 0.0f; placeArenaStation(entry, true);
StationWeapon weapon;
weapon.cooldownTicks = 0.0f;
const double lv = static_cast<double>(entry.level);
if (entry.stationType == "player_station")
{
hp = static_cast<float>(
m_gameConfig.stations.playerStation.hpFormula.evaluate(lv));
weapon.damage = static_cast<float>(
m_gameConfig.stations.playerStation.damageFormula.evaluate(lv));
weapon.range = static_cast<float>(
m_gameConfig.stations.playerStation.rangeFormula.evaluate(lv));
weapon.fireRateHz = static_cast<float>(
m_gameConfig.stations.playerStation.fireRateFormula.evaluate(lv));
}
else
{
hp = static_cast<float>(
m_gameConfig.stations.enemyStation.hpFormula.evaluate(lv));
weapon.damage = static_cast<float>(
m_gameConfig.stations.enemyStation.damageFormula.evaluate(lv));
weapon.range = static_cast<float>(
m_gameConfig.stations.enemyStation.rangeFormula.evaluate(lv));
weapon.fireRateHz = static_cast<float>(
m_gameConfig.stations.enemyStation.fireRateFormula.evaluate(lv));
}
const EntityId stationId = m_buildingSystem->placeImmediate(
BuildingType::EnemyDefenceStation,
m_gameConfig.stations.enemyStation.surfaceMask,
entry.position,
Rotation::East, hp, hp);
m_buildingSystem->initStationWeapon(stationId, weapon);
} }
} }
@@ -254,22 +240,22 @@ void ArenaSimulation::tick()
{ {
// Ship behavior systems (tick step 7). // Ship behavior systems (tick step 7).
m_shipSystem->clearMovementIntents(); m_shipSystem->clearMovementIntents();
m_aiSystem->tickHomeReturn(*m_shipSystem); m_aiSystem->tickHomeReturnBehavior(m_admin);
m_aiSystem->tickThreatResponse(*m_shipSystem, *m_buildingSystem); m_aiSystem->tickThreatResponseBehavior(m_admin, *m_buildingSystem);
m_aiSystem->tickRepairBehavior(*m_shipSystem, *m_buildingSystem); m_aiSystem->tickRepairBehavior(m_admin, *m_buildingSystem);
m_aiSystem->tickScrapCollector(*m_shipSystem, *m_scrapSystem, *m_buildingSystem); m_aiSystem->tickSalvageBehavior(m_admin, *m_scrapSystem, *m_buildingSystem);
// Combat resolution (tick step 8). // Combat resolution (tick step 8).
std::vector<FireEvent> fireEvents; std::vector<FireEvent> fireEvents;
m_combatSystem->tick(m_currentTick, *m_shipSystem, *m_buildingSystem, fireEvents); m_combatSystem->tick(m_currentTick, m_admin, *m_buildingSystem, fireEvents);
m_fireEvents.insert(m_fireEvents.end(), fireEvents.begin(), fireEvents.end()); m_fireEvents.insert(m_fireEvents.end(), fireEvents.begin(), fireEvents.end());
m_combatSystem->applyPendingDamage(m_currentTick, *m_shipSystem, *m_buildingSystem); m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
// Deaths (tick step 9, simplified). // Deaths (tick step 9, simplified).
tickDeaths(); tickDeaths();
// Movement (tick step 10). // Movement (tick step 10).
m_movementSystem->tick(*m_shipSystem); m_movementSystem->tick(m_admin);
// Scrap despawn (tick step 11). // Scrap despawn (tick step 11).
m_scrapSystem->tickDespawn(m_currentTick); m_scrapSystem->tickDespawn(m_currentTick);
@@ -285,58 +271,56 @@ void ArenaSimulation::tick()
void ArenaSimulation::tickDeaths() void ArenaSimulation::tickDeaths()
{ {
// Dead ships. // Dead ships.
std::vector<EntityId> deadShipIds; std::vector<entt::entity> deadShips;
m_shipSystem->forEach([&deadShipIds](Ship& s) m_admin.forEach<ShipIdentity, Health>(
{ [&deadShips](entt::entity e, const ShipIdentity& /*si*/, const Health& h)
if (s.hp <= 0.0f)
{ {
deadShipIds.push_back(s.id); if (h.hp <= 0.0f)
} {
}); deadShips.push_back(e);
}
});
for (EntityId deadId : deadShipIds) for (entt::entity deadEntity : deadShips)
{ {
const Ship* s = m_shipSystem->findShip(deadId); const ShipIdentity& si = m_admin.get<ShipIdentity>(deadEntity);
if (!s) const Position& pos = m_admin.get<Position>(deadEntity);
{
continue;
}
for (const ShipDef& def : m_gameConfig.ships.ships) for (const ShipDef& def : m_gameConfig.ships.ships)
{ {
if (def.id == s->schematicId && def.loot.scrapDrop > 0) if (def.id == si.schematicId && def.loot.scrapDrop > 0)
{ {
const Tick despawnAt = m_currentTick const Tick despawnAt = m_currentTick
+ secondsToTicks(m_gameConfig.world.scrapDespawnSeconds); + secondsToTicks(m_gameConfig.world.scrapDespawnSeconds);
m_scrapSystem->spawn(s->position, def.loot.scrapDrop, despawnAt); m_scrapSystem->spawn(pos.value, def.loot.scrapDrop, despawnAt);
break; break;
} }
} }
m_shipSystem->despawn(deadId); m_shipSystem->despawn(deadEntity);
} }
// Dead buildings (HQ and defence stations). // Dead stations.
std::vector<EntityId> deadBuildingIds; std::vector<entt::entity> deadStations;
for (const Building& b : m_buildingSystem->allBuildings()) m_admin.forEach<StationBody, Health>(
{ [&deadStations](entt::entity e, const StationBody& /*sb*/, const Health& h)
if (b.hp <= 0.0f
&& (b.type == BuildingType::Hq
|| b.type == BuildingType::PlayerDefenceStation
|| b.type == BuildingType::EnemyDefenceStation))
{ {
deadBuildingIds.push_back(b.id); if (h.hp <= 0.0f)
} {
} deadStations.push_back(e);
}
});
for (EntityId deadId : deadBuildingIds) for (entt::entity deadEntity : deadStations)
{ {
m_buildingSystem->removeBuilding(deadId); const StationBody& sb = m_admin.get<StationBody>(deadEntity);
m_buildingSystem->unregisterTileOccupancy(sb.bodyCells);
m_admin.destroy(deadEntity);
} }
// Check end conditions. // Check end conditions — HQ proxy entities.
const bool team1HqGone = const bool team1HqGone = !m_admin.isValid(m_team1HqEntity)
(m_buildingSystem->findBuilding(m_team1HqId) == nullptr); || m_admin.get<Health>(m_team1HqEntity).hp <= 0.0f;
const bool team2HqGone = const bool team2HqGone = !m_admin.isValid(m_team2HqEntity)
(m_buildingSystem->findBuilding(m_team2HqId) == nullptr); || m_admin.get<Health>(m_team2HqEntity).hp <= 0.0f;
if (team1HqGone || team2HqGone) if (team1HqGone || team2HqGone)
{ {
@@ -349,30 +333,23 @@ void ArenaSimulation::tickDeaths()
// Check if all ships and defence stations of one team are destroyed. // Check if all ships and defence stations of one team are destroyed.
bool team1HasUnits = false; bool team1HasUnits = false;
bool team2HasUnits = false; bool team2HasUnits = false;
m_shipSystem->forEach([&team1HasUnits, &team2HasUnits](Ship& s) m_admin.forEach<ShipIdentity, Faction>(
{ [&team1HasUnits, &team2HasUnits](entt::entity /*e*/,
if (s.isEnemy) const ShipIdentity& /*si*/,
const Faction& f)
{ {
team2HasUnits = true; if (f.isEnemy) { team2HasUnits = true; }
} else { team1HasUnits = true; }
else });
{
team1HasUnits = true;
}
});
for (const Building& b : m_buildingSystem->allBuildings()) m_admin.forEach<StationBody, Faction>(
{ [&team1HasUnits, &team2HasUnits](entt::entity /*e*/,
if (b.type == BuildingType::PlayerDefenceStation) const StationBody& /*sb*/,
const Faction& f)
{ {
team1HasUnits = true; if (f.isEnemy) { team2HasUnits = true; }
} else { team1HasUnits = true; }
else if (b.type == BuildingType::EnemyDefenceStation });
&& b.id != m_team2HqId)
{
team2HasUnits = true;
}
}
if (!team1HasUnits || !team2HasUnits) if (!team1HasUnits || !team2HasUnits)
{ {
@@ -433,6 +410,16 @@ const ScrapSystem& ArenaSimulation::scraps() const
return *m_scrapSystem; return *m_scrapSystem;
} }
EntityAdmin& ArenaSimulation::admin()
{
return m_admin;
}
const EntityAdmin& ArenaSimulation::admin() const
{
return m_admin;
}
void ArenaSimulation::updateStatus() void ArenaSimulation::updateStatus()
{ {
ArenaStatus newStatus; ArenaStatus newStatus;
@@ -450,8 +437,9 @@ void ArenaSimulation::updateStatus()
hqEntry.displayName = "HQ"; hqEntry.displayName = "HQ";
hqEntry.level = 1; hqEntry.level = 1;
hqEntry.total = 1; hqEntry.total = 1;
const EntityId hqId = (ti == 0) ? m_team1HqId : m_team2HqId; const entt::entity hqEntity = (ti == 0) ? m_team1HqEntity : m_team2HqEntity;
hqEntry.surviving = (m_buildingSystem->findBuilding(hqId) != nullptr) ? 1 : 0; hqEntry.surviving = (m_admin.isValid(hqEntity)
&& m_admin.get<Health>(hqEntity).hp > 0.0f) ? 1 : 0;
teamStatus.entries.push_back(hqEntry); teamStatus.entries.push_back(hqEntry);
} }
@@ -465,13 +453,14 @@ void ArenaSimulation::updateStatus()
int surviving = 0; int surviving = 0;
const bool isEnemyTeam = (ti == 1); const bool isEnemyTeam = (ti == 1);
m_shipSystem->forEach( m_admin.forEach<ShipIdentity, Faction, Health>(
[&surviving, &shipEntry, isEnemyTeam](Ship& s) [&surviving, &shipEntry, isEnemyTeam](entt::entity /*e*/,
const ShipIdentity& si, const Faction& f, const Health& h)
{ {
if (s.isEnemy == isEnemyTeam if (f.isEnemy == isEnemyTeam
&& s.schematicId == shipEntry.schematicId && si.schematicId == shipEntry.schematicId
&& s.level == shipEntry.level && si.level == shipEntry.level
&& s.hp > 0.0f) && h.hp > 0.0f)
{ {
++surviving; ++surviving;
} }
@@ -490,20 +479,19 @@ void ArenaSimulation::updateStatus()
entry.level = stationEntry.level; entry.level = stationEntry.level;
entry.total = 1; entry.total = 1;
// Count surviving stations of this team at this position.
const BuildingType expectedType = (ti == 0)
? BuildingType::PlayerDefenceStation
: BuildingType::EnemyDefenceStation;
int surviving = 0; int surviving = 0;
for (const Building& b : m_buildingSystem->allBuildings()) const bool isEnemyTeam = (ti == 1);
{ m_admin.forEach<StationBody, Faction, Health>(
if (b.type == expectedType && b.anchor == stationEntry.position) [&surviving, &stationEntry, isEnemyTeam](entt::entity /*e*/,
const StationBody& sb, const Faction& f, const Health& h)
{ {
surviving = 1; if (f.isEnemy == isEnemyTeam
break; && sb.anchor == stationEntry.position
} && h.hp > 0.0f)
} {
surviving = 1;
}
});
entry.surviving = surviving; entry.surviving = surviving;
teamStatus.entries.push_back(entry); teamStatus.entries.push_back(entry);
} }
@@ -512,3 +500,4 @@ void ArenaSimulation::updateStatus()
std::lock_guard<std::mutex> lock(m_statusMutex); std::lock_guard<std::mutex> lock(m_statusMutex);
m_status = newStatus; m_status = newStatus;
} }

View File

@@ -9,7 +9,11 @@
#include "BalancingConfig.h" #include "BalancingConfig.h"
#include "BeltSystem.h" #include "BeltSystem.h"
#include "EntityId.h" #include "EcsComponents.h"
#include "EntityAdmin.h"
#include "BuildingId.h"
#include "entt/entity/entity.hpp"
#include "FireEvent.h" #include "FireEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Tick.h" #include "Tick.h"
@@ -65,9 +69,11 @@ public:
const BuildingSystem& buildings() const; const BuildingSystem& buildings() const;
const ShipSystem& ships() const; const ShipSystem& ships() const;
const ScrapSystem& scraps() const; const ScrapSystem& scraps() const;
EntityAdmin& admin();
const EntityAdmin& admin() const;
private: private:
EntityId allocateId(); BuildingId allocateBuildingId();
void placeStructures(); void placeStructures();
void spawnShips(); void spawnShips();
void tick(); void tick();
@@ -79,8 +85,9 @@ private:
std::mt19937 m_rng; std::mt19937 m_rng;
Tick m_currentTick; Tick m_currentTick;
EntityId m_nextId; BuildingId m_nextBuildingId;
EntityAdmin m_admin;
BeltSystem m_beltSystem; BeltSystem m_beltSystem;
std::unique_ptr<BuildingSystem> m_buildingSystem; std::unique_ptr<BuildingSystem> m_buildingSystem;
std::unique_ptr<ShipSystem> m_shipSystem; std::unique_ptr<ShipSystem> m_shipSystem;
@@ -89,8 +96,8 @@ private:
std::unique_ptr<CombatSystem> m_combatSystem; std::unique_ptr<CombatSystem> m_combatSystem;
std::unique_ptr<ScrapSystem> m_scrapSystem; std::unique_ptr<ScrapSystem> m_scrapSystem;
EntityId m_team1HqId; entt::entity m_team1HqEntity;
EntityId m_team2HqId; entt::entity m_team2HqEntity;
bool m_finished; bool m_finished;
int m_winnerTeam; int m_winnerTeam;

View File

@@ -10,19 +10,18 @@
#include "ArenaSimulation.h" #include "ArenaSimulation.h"
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "Scrap.h" #include "EcsComponents.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include "Ship.h" #include "Ship.h"
#include "ShipSystem.h"
namespace namespace
{ {
ShipRole shipRole(const Ship& ship) ShipRole shipRoleFromComponents(bool isEnemy, bool hasCargo, bool hasRepairTool)
{ {
if (ship.isEnemy) { return ShipRole::Enemy; } if (isEnemy) { return ShipRole::Enemy; }
if (ship.cargo.has_value()) { return ShipRole::Salvage; } if (hasCargo) { return ShipRole::Salvage; }
if (ship.repairTool.has_value()) { return ShipRole::Repair; } if (hasRepairTool) { return ShipRole::Repair; }
return ShipRole::PlayerCombat; return ShipRole::PlayerCombat;
} }
@@ -64,6 +63,11 @@ double ArenaView::gameSpeed() const
return m_gameSpeedMultiplier; return m_gameSpeedMultiplier;
} }
void ArenaView::stopRendering()
{
m_renderTimer->stop();
}
void ArenaView::togglePause() void ArenaView::togglePause()
{ {
if (m_gameSpeedMultiplier < 0.001) if (m_gameSpeedMultiplier < 0.001)
@@ -95,11 +99,12 @@ void ArenaView::onFrame()
for (const FireEvent& fe : fires) for (const FireEvent& fe : fires)
{ {
float maxRadius = 0.125f; float maxRadius = 0.125f;
const Building* tBld = m_sim->buildings().findBuilding(fe.target); if (m_sim->admin().isValid(fe.target)
if (tBld) && m_sim->admin().hasAll<StationBody>(fe.target))
{ {
const int shorter = std::min(tBld->footprint.width(), const StationBody& sb = m_sim->admin().get<StationBody>(fe.target);
tBld->footprint.height()); const int shorter = std::min(sb.footprint.width(),
sb.footprint.height());
maxRadius = shorter / 2.0f; maxRadius = shorter / 2.0f;
} }
@@ -145,6 +150,7 @@ void ArenaView::paintGL()
drawTiles(painter); drawTiles(painter);
drawBuildings(painter); drawBuildings(painter);
drawStations(painter);
drawScrap(painter); drawScrap(painter);
drawShips(painter); drawShips(painter);
drawBeams(painter); drawBeams(painter);
@@ -188,23 +194,13 @@ QRectF ArenaView::tileRect(QPoint tile) const
static_cast<qreal>(tilePx()), static_cast<qreal>(tilePx())); static_cast<qreal>(tilePx()), static_cast<qreal>(tilePx()));
} }
std::optional<QVector2D> ArenaView::entityPosition(EntityId id) const std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
{ {
for (const Ship& ship : m_sim->ships().allShips()) if (!m_sim->admin().isValid(entity) || !m_sim->admin().hasAll<Position>(entity))
{ {
if (ship.id == id) return std::nullopt;
{
return ship.position;
}
} }
const Building* bldg = m_sim->buildings().findBuilding(id); return m_sim->admin().get<Position>(entity).value;
if (bldg)
{
return QVector2D(
bldg->anchor.x() + bldg->footprint.width() * 0.5f,
bldg->anchor.y() + bldg->footprint.height() * 0.5f);
}
return std::nullopt;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -264,7 +260,7 @@ void ArenaView::drawBuildings(QPainter& painter)
void ArenaView::drawScrap(QPainter& painter) void ArenaView::drawScrap(QPainter& painter)
{ {
const float r = tilePx() * 0.2f; const float r = tilePx() * 0.2f;
for (const Scrap& scrap : m_sim->scraps().allScraps()) for (const ScrapInfo& scrap : m_sim->scraps().allScrapInfo())
{ {
const QPointF center = worldToWidget(scrap.position); const QPointF center = worldToWidget(scrap.position);
painter.setBrush(QColor(128, 110, 90)); painter.setBrush(QColor(128, 110, 90));
@@ -274,37 +270,82 @@ void ArenaView::drawScrap(QPainter& painter)
} }
} }
void ArenaView::drawStations(QPainter& painter)
{
m_sim->admin().forEach<StationBody, Faction, Health>(
[&](entt::entity /*e*/, const StationBody& sb, const Faction& f, const Health& h)
{
const BuildingType visType = f.isEnemy
? BuildingType::EnemyDefenceStation
: BuildingType::PlayerDefenceStation;
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
m_visuals->buildings.find(visType);
if (it == m_visuals->buildings.end()) { return; }
const BuildingVisuals& bv = it->second;
painter.setPen(Qt::NoPen);
for (const QPoint& cell : sb.bodyCells)
{
painter.fillRect(tileRect(cell), bv.fill);
}
const QPointF tl = tileToWidget(sb.anchor);
const QRectF bboxRect(tl.x(), tl.y(),
sb.footprint.width() * static_cast<qreal>(tilePx()),
sb.footprint.height() * static_cast<qreal>(tilePx()));
painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush);
painter.drawRect(bboxRect);
if (h.maxHp > 0.0f)
{
const float fraction = std::max(0.0f, h.hp / h.maxHp);
const qreal barH = static_cast<qreal>(tilePx()) * 0.12;
const qreal barY = bboxRect.bottom() + 1.0;
const qreal barW = bboxRect.width();
painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH),
QColor(60, 60, 60));
painter.fillRect(QRectF(bboxRect.left(), barY, barW * static_cast<qreal>(fraction), barH),
f.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
}
});
}
void ArenaView::drawShips(QPainter& painter) void ArenaView::drawShips(QPainter& painter)
{ {
for (const Ship& ship : m_sim->ships().allShips()) m_sim->admin().forEach<ShipIdentity, Position, Velocity, Faction>(
{ [&](entt::entity e, const ShipIdentity& /*si*/, const Position& pos,
const ShipRole role = shipRole(ship); const Velocity& vel, const Faction& fac)
const std::map<ShipRole, ShipVisuals>::const_iterator it = {
m_visuals->ships.find(role); const bool hasCargo = m_sim->admin().hasAll<SalvageCargo>(e);
if (it == m_visuals->ships.end()) { continue; } const bool hasRepair = m_sim->admin().hasAll<RepairTool>(e);
const ShipRole role = shipRoleFromComponents(fac.isEnemy, hasCargo, hasRepair);
const std::map<ShipRole, ShipVisuals>::const_iterator it =
m_visuals->ships.find(role);
if (it == m_visuals->ships.end()) { return; }
const QPointF center = worldToWidget(ship.position); const QPointF center = worldToWidget(pos.value);
const QVector2D vel = ship.velocity; const QVector2D dir = (vel.value.length() > 0.0001f)
const QVector2D dir = (vel.length() > 0.0001f) ? vel.value.normalized()
? vel.normalized() : QVector2D(1.0f, 0.0f);
: QVector2D(1.0f, 0.0f); const QVector2D perp(-dir.y(), dir.x());
const QVector2D perp(-dir.y(), dir.x());
const float fwd = tilePx() * 0.45f; const float fwd = tilePx() * 0.45f;
const float side = tilePx() * 0.25f; const float side = tilePx() * 0.25f;
QPolygonF tri; QPolygonF tri;
tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd), tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd),
center.y() + static_cast<qreal>(dir.y() * fwd)) center.y() + static_cast<qreal>(dir.y() * fwd))
<< QPointF(center.x() + static_cast<qreal>(perp.x() * side - dir.x() * side), << QPointF(center.x() + static_cast<qreal>(perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(perp.y() * side - dir.y() * side)) center.y() + static_cast<qreal>(perp.y() * side - dir.y() * side))
<< QPointF(center.x() + static_cast<qreal>(-perp.x() * side - dir.x() * side), << QPointF(center.x() + static_cast<qreal>(-perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(-perp.y() * side - dir.y() * side)); center.y() + static_cast<qreal>(-perp.y() * side - dir.y() * side));
painter.setPen(QPen(it->second.outline, 1)); painter.setPen(QPen(it->second.outline, 1));
painter.setBrush(it->second.fill); painter.setBrush(it->second.fill);
painter.drawPolygon(tri); painter.drawPolygon(tri);
} });
} }
void ArenaView::drawBeams(QPainter& painter) void ArenaView::drawBeams(QPainter& painter)
@@ -319,3 +360,4 @@ void ArenaView::drawBeams(QPainter& painter)
worldToWidget(*targetPos + beam.targetOffset)); worldToWidget(*targetPos + beam.targetOffset));
} }
} }

View File

@@ -8,8 +8,9 @@
#include <QTimer> #include <QTimer>
#include <QVector2D> #include <QVector2D>
#include "EntityId.h"
#include "FireEvent.h" #include "FireEvent.h"
#include "entt/entity/entity.hpp"
#include "Tick.h" #include "Tick.h"
#include "TickDriver.h" #include "TickDriver.h"
#include "VisualsConfig.h" #include "VisualsConfig.h"
@@ -28,6 +29,7 @@ public:
void setGameSpeed(double multiplier); void setGameSpeed(double multiplier);
double gameSpeed() const; double gameSpeed() const;
void togglePause(); void togglePause();
void stopRendering();
signals: signals:
void speedChanged(double multiplier); void speedChanged(double multiplier);
@@ -42,6 +44,7 @@ private slots:
private: private:
void drawTiles(QPainter& painter); void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter); void drawBuildings(QPainter& painter);
void drawStations(QPainter& painter);
void drawScrap(QPainter& painter); void drawScrap(QPainter& painter);
void drawShips(QPainter& painter); void drawShips(QPainter& painter);
void drawBeams(QPainter& painter); void drawBeams(QPainter& painter);
@@ -51,7 +54,7 @@ private:
QPointF tileToWidget(QPoint tile) const; QPointF tileToWidget(QPoint tile) const;
QRectF tileRect(QPoint tile) const; QRectF tileRect(QPoint tile) const;
std::optional<QVector2D> entityPosition(EntityId id) const; std::optional<QVector2D> entityPosition(entt::entity entity) const;
struct ActiveBeam struct ActiveBeam
{ {

View File

@@ -112,6 +112,7 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const VisualsConfig* visuals,
void InspectWindow::closeEvent(QCloseEvent* event) void InspectWindow::closeEvent(QCloseEvent* event)
{ {
m_arenaView->stopRendering();
m_pollTimer->stop(); m_pollTimer->stop();
emit closed(); emit closed();
event->accept(); event->accept();

View File

@@ -0,0 +1,9 @@
#pragma once
// Stable id for factory buildings, construction sites, and belts.
// Ships, stations, scrap, and the HQ proxy use entt::entity instead.
// Ids are allocated centrally by the Simulation, strictly increasing, never
// reused. 0 is reserved as the invalid id.
using BuildingId = long long;
constexpr BuildingId kInvalidBuildingId = 0;

View File

@@ -1,10 +1,10 @@
SET(HDRS SET(HDRS
${HDRS} ${HDRS}
${CMAKE_CURRENT_SOURCE_DIR}/Tick.h ${CMAKE_CURRENT_SOURCE_DIR}/Tick.h
${CMAKE_CURRENT_SOURCE_DIR}/EntityId.h
${CMAKE_CURRENT_SOURCE_DIR}/Rotation.h ${CMAKE_CURRENT_SOURCE_DIR}/Rotation.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.h
${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.h ${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.h
${CMAKE_CURRENT_SOURCE_DIR}/EcsComponents.h
${CMAKE_CURRENT_SOURCE_DIR}/Blueprint.h ${CMAKE_CURRENT_SOURCE_DIR}/Blueprint.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h
${CMAKE_CURRENT_SOURCE_DIR}/Item.h ${CMAKE_CURRENT_SOURCE_DIR}/Item.h

View File

@@ -0,0 +1,106 @@
#pragma once
#include <string>
#include <vector>
#include <QPoint>
#include <QSize>
#include <QVector2D>
#include "Tick.h"
#include "entt/entity/entity.hpp"
// ---------------------------------------------------------------------------
// Shared components (used by ships, stations, scrap, HQ proxy)
// ---------------------------------------------------------------------------
struct Position
{
QVector2D value;
};
struct Health
{
float hp;
float maxHp;
};
struct Faction
{
bool isEnemy;
};
// ---------------------------------------------------------------------------
// Ship components (always present on every ship)
// ---------------------------------------------------------------------------
struct Velocity
{
QVector2D value;
};
struct Facing
{
float radians;
float rotationSpeed;
};
struct ShipDynamics
{
float maxSpeedPerTick;
float mainAccelerationPerTick;
float maneuveringAccelerationPerTick;
float angularAccelerationPerTick;
float maxRotationSpeedPerTick;
};
struct SensorRange
{
float value;
};
struct ShipIdentity
{
int level;
std::string schematicId;
};
// ---------------------------------------------------------------------------
// Ship optional components (hardware + behavior, in Ship.h)
// ---------------------------------------------------------------------------
// Weapon, SalvageCargo, RepairTool, ThreatResponseBehavior, SalvageBehavior,
// RepairBehavior, HomeReturnBehavior, RallyBehavior remain defined in Ship.h.
// ---------------------------------------------------------------------------
// Station components
// ---------------------------------------------------------------------------
struct StationBody
{
QPoint anchor;
QSize footprint;
std::vector<QPoint> bodyCells;
};
// ---------------------------------------------------------------------------
// Scrap components
// ---------------------------------------------------------------------------
struct ScrapData
{
int amount;
};
struct DespawnAt
{
Tick tick;
};
// ---------------------------------------------------------------------------
// HQ proxy (empty tag)
// ---------------------------------------------------------------------------
struct HqProxy { char unused = 0; };

View File

@@ -1,11 +1,14 @@
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "EcsComponents.h"
#include "MovementIntent.h"
entt::entity EntityAdmin::createEntity() entt::entity EntityAdmin::createEntity()
{ {
return m_registry.create(); return m_registry.create();
} }
bool EntityAdmin::isValid(entt::entity entity) bool EntityAdmin::isValid(entt::entity entity) const
{ {
return m_registry.valid(entity); return m_registry.valid(entity);
} }
@@ -19,3 +22,57 @@ void EntityAdmin::clear()
{ {
m_registry.clear(); m_registry.clear();
} }
entt::entity EntityAdmin::spawnShip(QVector2D position, float hp, float maxHp,
float maxSpeedPerTick, float mainAccelPerTick,
float maneuveringAccelPerTick, float angularAccelPerTick,
float maxRotationSpeedPerTick, float sensorRange,
int level, const std::string& schematicId, bool isEnemy)
{
entt::entity entity = createEntity();
add<Position>(entity, Position{position});
add<Health>(entity, Health{hp, maxHp});
add<Faction>(entity, Faction{isEnemy});
add<Velocity>(entity, Velocity{QVector2D(0.0f, 0.0f)});
add<Facing>(entity, Facing{0.0f, 0.0f});
add<ShipDynamics>(entity, ShipDynamics{
maxSpeedPerTick, mainAccelPerTick, maneuveringAccelPerTick,
angularAccelPerTick, maxRotationSpeedPerTick});
add<SensorRange>(entity, SensorRange{sensorRange});
add<ShipIdentity>(entity, ShipIdentity{level, schematicId});
add<MovementIntent>(entity, MovementIntent{0, QVector2D(0.0f, 0.0f)});
return entity;
}
entt::entity EntityAdmin::spawnStation(QPoint anchor, QSize footprint,
const std::vector<QPoint>& bodyCells,
float hp, float maxHp, bool isEnemy)
{
entt::entity entity = createEntity();
QVector2D center(anchor.x() + footprint.width() / 2.0f,
anchor.y() + footprint.height() / 2.0f);
add<Position>(entity, Position{center});
add<Health>(entity, Health{hp, maxHp});
add<Faction>(entity, Faction{isEnemy});
add<StationBody>(entity, StationBody{anchor, footprint, bodyCells});
return entity;
}
entt::entity EntityAdmin::spawnScrap(QVector2D position, int amount, Tick despawnAt)
{
entt::entity entity = createEntity();
add<Position>(entity, Position{position});
add<ScrapData>(entity, ScrapData{amount});
add<DespawnAt>(entity, DespawnAt{despawnAt});
return entity;
}
entt::entity EntityAdmin::spawnHqProxy(QVector2D position, float hp, float maxHp)
{
entt::entity entity = createEntity();
add<Position>(entity, Position{position});
add<Health>(entity, Health{hp, maxHp});
add<Faction>(entity, Faction{false});
add<HqProxy>(entity);
return entity;
}

View File

@@ -1,6 +1,15 @@
#ifndef ENTITY_ADMIN_H #ifndef ENTITY_ADMIN_H
#define ENTITY_ADMIN_H #define ENTITY_ADMIN_H
#include <string>
#include <vector>
#include <QPoint>
#include <QSize>
#include <QVector2D>
#include "Tick.h"
#include "entt/entity/registry.hpp" #include "entt/entity/registry.hpp"
class EntityAdmin class EntityAdmin
@@ -10,36 +19,84 @@ public:
EntityAdmin(const EntityAdmin&) = delete; EntityAdmin(const EntityAdmin&) = delete;
EntityAdmin& operator=(const EntityAdmin&) = delete; EntityAdmin& operator=(const EntityAdmin&) = delete;
// -- Queries / iteration ------------------------------------------------
template <typename... Ts, typename Func> template <typename... Ts, typename Func>
void forEach(Func&& f); void forEach(Func&& f);
template <typename... Ts, typename Func>
void forEach(Func&& f) const;
template <typename... Ts> template <typename... Ts>
bool hasAll(entt::entity entity); bool hasAll(entt::entity entity);
template <typename T> template <typename T>
T& get(entt::entity entity); T& get(entt::entity entity);
bool isValid(entt::entity entity); template <typename T>
const T& get(entt::entity entity) const;
bool isValid(entt::entity entity) const;
void destroy(entt::entity entity); void destroy(entt::entity entity);
void clear(); void clear();
/* // -- Public component attachment ----------------------------------------
factory methods (like spawnShip, spawnScrap, etc shall go here) // Used by systems (e.g. ShipSystem) to attach optional components after
*/ // a factory method has created the base entity.
template <typename T, typename... Args>
void addComponent(entt::entity entity, Args&&... args);
template <typename T>
void removeComponent(entt::entity entity);
// -- Factory methods ----------------------------------------------------
entt::entity spawnShip(QVector2D position, float hp, float maxHp,
float maxSpeedPerTick, float mainAccelPerTick,
float maneuveringAccelPerTick, float angularAccelPerTick,
float maxRotationSpeedPerTick, float sensorRange,
int level, const std::string& schematicId, bool isEnemy);
entt::entity spawnStation(QPoint anchor, QSize footprint,
const std::vector<QPoint>& bodyCells,
float hp, float maxHp, bool isEnemy);
entt::entity spawnScrap(QVector2D position, int amount, Tick despawnAt);
entt::entity spawnHqProxy(QVector2D position, float hp, float maxHp);
private: private:
entt::entity createEntity(); entt::entity createEntity();
template <typename T, typename... Args> template <typename T, typename... Args>
T& add(entt::entity entity, Args&&... args); void add(entt::entity entity, Args&&... args);
entt::registry m_registry; entt::registry m_registry;
}; };
// ---------------------------------------------------------------------------
// Template implementations
// ---------------------------------------------------------------------------
template <typename... Ts, typename Func> template <typename... Ts, typename Func>
void EntityAdmin::forEach(Func&& f) void EntityAdmin::forEach(Func&& f)
{ {
m_registry.view<Ts...>().each(std::forward<Func>(f)); // Avoid view.each() — MSVC 2017 ICEs on the extended_storage_iterator it instantiates.
for (entt::entity entity : m_registry.view<Ts...>())
{
f(entity, m_registry.get<Ts>(entity)...);
}
}
template <typename... Ts, typename Func>
void EntityAdmin::forEach(Func&& f) const
{
entt::registry& reg = const_cast<entt::registry&>(m_registry);
for (entt::entity entity : reg.view<Ts...>())
{
f(entity, reg.get<Ts>(entity)...);
}
} }
template <typename... Ts> template <typename... Ts>
@@ -54,10 +111,28 @@ T& EntityAdmin::get(entt::entity entity)
return m_registry.get<T>(entity); return m_registry.get<T>(entity);
} }
template <typename T, typename... Args> template <typename T>
T& EntityAdmin::add(entt::entity entity, Args&&... args) const T& EntityAdmin::get(entt::entity entity) const
{ {
return m_registry.emplace<T>(entity, std::forward<Args>(args)...); return m_registry.get<T>(entity);
}
template <typename T, typename... Args>
void EntityAdmin::addComponent(entt::entity entity, Args&&... args)
{
m_registry.emplace<T>(entity, std::forward<Args>(args)...);
}
template <typename T>
void EntityAdmin::removeComponent(entt::entity entity)
{
m_registry.remove<T>(entity);
}
template <typename T, typename... Args>
void EntityAdmin::add(entt::entity entity, Args&&... args)
{
m_registry.emplace<T>(entity, std::forward<Args>(args)...);
} }
#endif // ENTITY_ADMIN_H #endif // ENTITY_ADMIN_H

View File

@@ -1,9 +0,0 @@
#pragma once
// Canonical reference to every targetable entity in the simulation: ships,
// scrap drops, and buildings (including HQ and defence stations).
// Ids are allocated centrally by the Simulation, strictly increasing, never
// reused. 0 is reserved as the invalid id.
using EntityId = long long;
constexpr EntityId kInvalidEntityId = 0;

View File

@@ -1,14 +1,15 @@
#pragma once #pragma once
#include "EntityId.h"
#include "Tick.h" #include "Tick.h"
#include "entt/entity/entity.hpp"
// Transient record emitted each time a weapon fires (REQ-SHP-FIRING, // Transient record emitted each time a weapon fires (REQ-SHP-FIRING,
// REQ-SHP-FIRING-BEAM). Buffered in a sim-owned queue and drained by the // REQ-SHP-FIRING-BEAM). Buffered in a sim-owned queue and drained by the
// renderer each frame to draw the 0.3-second laser beam. // renderer each frame to draw the 0.3-second laser beam.
struct FireEvent struct FireEvent
{ {
EntityId shooter; entt::entity shooter;
EntityId target; entt::entity target;
Tick emittedAt; Tick emittedAt;
}; };

View File

@@ -8,456 +8,439 @@
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "EntityId.h" #include "EcsComponents.h"
#include "EntityAdmin.h"
#include "BuildingId.h"
#include "MovementIntent.h" #include "MovementIntent.h"
#include "Scrap.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include "Ship.h" #include "Ship.h"
#include "ShipSystem.h"
static QVector2D buildingCenter(const Building& b)
{
return QVector2D(b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
}
static bool isTargetValid(EntityId id, float range, const Ship& ship,
const ShipSystem& ships,
const BuildingSystem& buildings)
{
if (id == kInvalidEntityId) { return false; }
const Ship* target = ships.findShip(id);
if (target) { return (target->position - ship.position).length() <= range; }
const Building* bld = buildings.findBuilding(id);
if (bld) { return (buildingCenter(*bld) - ship.position).length() <= range; }
return false;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tickHomeReturn (priority 4) // tickHomeReturnBehavior (priority 4)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void AiSystem::tickHomeReturn(ShipSystem& ships) void AiSystem::tickHomeReturnBehavior(EntityAdmin& admin)
{ {
ships.forEach([&](Ship& s) admin.forEach<HomeReturnBehavior, Health, MovementIntent>(
{ [](entt::entity /*e*/, const HomeReturnBehavior& homeReturnBehavior, const Health& h, MovementIntent& intent)
if (!s.homeReturn) { return; }
if (s.hp / s.maxHp < s.homeReturn->retreatHpFraction)
{ {
if (4 > s.intent.priority) if (h.hp / h.maxHp < homeReturnBehavior.retreatHpFraction)
{ {
s.intent = MovementIntent{4, s.homeReturn->homePos}; if (4 > intent.priority)
{
intent = MovementIntent{4, homeReturnBehavior.homePos};
}
} }
} });
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tickThreatResponse (priority 3) // tickThreatResponseBehavior (priority 3)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void AiSystem::tickThreatResponse(ShipSystem& ships, const BuildingSystem& buildings) void AiSystem::tickThreatResponseBehavior(EntityAdmin& admin, const BuildingSystem& buildings)
{ {
const std::vector<Building> allBuildings = buildings.allBuildings(); // Snapshot all combatant entities for target acquisition.
const std::vector<Ship> allShips = ships.allShips(); struct CombatantInfo
ships.forEach([&](Ship& s)
{ {
if (!s.threatResponse) { return; } entt::entity entity;
QVector2D position;
bool isEnemy;
bool isStation;
};
std::vector<CombatantInfo> combatants;
const float range = s.sensorRange; admin.forEach<Position, Faction, ShipIdentity>(
[&combatants](entt::entity e, const Position& pos, const Faction& f, const ShipIdentity& /*si*/)
if (!s.isEnemy)
{ {
if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId), combatants.push_back({e, pos.value, f.isEnemy, false});
range, s, ships, buildings)) });
admin.forEach<Position, Faction, StationBody>(
[&combatants](entt::entity e, const Position& pos, const Faction& f, const StationBody& /*sb*/)
{
combatants.push_back({e, pos.value, f.isEnemy, true});
});
admin.forEach<Position, Faction, HqProxy>(
[&combatants](entt::entity e, const Position& pos, const Faction& f, const HqProxy& /*hq*/)
{
combatants.push_back({e, pos.value, f.isEnemy, true});
});
admin.forEach<ThreatResponseBehavior, Position, Faction, SensorRange, MovementIntent>(
[&](entt::entity e, ThreatResponseBehavior& threatResponseBehavior, Position& pos, Faction& faction,
SensorRange& sensor, MovementIntent& intent)
{
const float range = sensor.value;
// Validate current target.
bool targetValid = false;
if (threatResponseBehavior.currentTarget)
{ {
s.threatResponse->currentTarget = std::nullopt; const entt::entity t = *threatResponseBehavior.currentTarget;
if (admin.isValid(t) && admin.hasAll<Position>(t))
{
const float dist = (admin.get<Position>(t).value - pos.value).length();
if (dist <= range)
{
targetValid = true;
}
}
}
if (!targetValid)
{
threatResponseBehavior.currentTarget = std::nullopt;
float bestDist = range; float bestDist = range;
for (const Ship& candidate : allShips)
{
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) for (const CombatantInfo& c : combatants)
{ {
if (b.type != BuildingType::EnemyDefenceStation) { continue; } if (c.entity == e) { continue; }
float dist = (buildingCenter(b) - s.position).length();
if (dist < bestDist)
{
bestDist = dist;
s.threatResponse->currentTarget = b.id;
}
}
}
if (s.threatResponse->currentTarget) bool isValidTarget = false;
{ if (!faction.isEnemy)
QVector2D dest;
const Ship* tShip = ships.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
{
if (3 > s.intent.priority)
{
if (s.rallyBehavior)
{ {
s.intent = MovementIntent{3, s.rallyBehavior->rallyPoint}; isValidTarget = c.isEnemy;
} }
else else
{ {
s.intent = MovementIntent{3, QVector2D(s.position.x() + 1000.0f, isValidTarget = !c.isEnemy;
s.position.y())};
} }
} if (!isValidTarget) { continue; }
}
}
else
{
if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId),
range, s, ships, buildings))
{
s.threatResponse->currentTarget = std::nullopt;
float bestDist = range;
for (const Ship& candidate : allShips) const float dist = (c.position - pos.value).length();
{
if (candidate.isEnemy) { continue; }
float dist = (candidate.position - s.position).length();
if (dist < bestDist) if (dist < bestDist)
{ {
bestDist = dist; bestDist = dist;
s.threatResponse->currentTarget = candidate.id; threatResponseBehavior.currentTarget = c.entity;
}
}
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) if (threatResponseBehavior.currentTarget)
{ {
QVector2D dest; const entt::entity t = *threatResponseBehavior.currentTarget;
const Ship* tShip = ships.findShip(*s.threatResponse->currentTarget); QVector2D dest = pos.value;
if (tShip) if (admin.isValid(t) && admin.hasAll<Position>(t))
{ {
dest = tShip->position; dest = admin.get<Position>(t).value;
} }
else if (3 > intent.priority)
{ {
const Building* tBld = buildings.findBuilding( intent = MovementIntent{3, dest};
*s.threatResponse->currentTarget);
dest = tBld ? buildingCenter(*tBld) : s.position;
}
if (3 > s.intent.priority)
{
s.intent = MovementIntent{3, dest};
} }
} }
else else
{ {
if (3 > s.intent.priority) if (3 > intent.priority)
{ {
s.intent = MovementIntent{3, QVector2D(-10000.0f, s.position.y())}; if (admin.hasAll<RallyBehavior>(e))
{
intent = MovementIntent{3, admin.get<RallyBehavior>(e).rallyPoint};
}
else if (!faction.isEnemy)
{
intent = MovementIntent{3, QVector2D(pos.value.x() + 1000.0f,
pos.value.y())};
}
else
{
intent = MovementIntent{3, QVector2D(-10000.0f, pos.value.y())};
}
} }
} }
} });
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tickRepairBehavior (priority 2) // tickRepairBehavior (priority 2)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void AiSystem::tickRepairBehavior(ShipSystem& ships, BuildingSystem& buildings) void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings)
{ {
const std::vector<Building> allBuildings = buildings.allBuildings(); // Snapshot all entities with health for repair targeting.
const std::vector<Ship> allShips = ships.allShips(); struct RepairableInfo
ships.forEach([&](Ship& s)
{ {
if (!s.repairBehavior || !s.repairTool) { return; } entt::entity entity;
QVector2D position;
bool isEnemy;
bool isShip;
float hp;
float maxHp;
};
std::vector<RepairableInfo> repairables;
const float repairRange = s.repairTool->range; admin.forEach<ShipIdentity, Position, Faction, Health>(
[&repairables](entt::entity e, const ShipIdentity& /*si*/,
bool enemyNearby = false; const Position& pos, const Faction& f, const Health& h)
for (const Ship& candidate : allShips)
{ {
if (candidate.isEnemy repairables.push_back({e, pos.value, f.isEnemy, true, h.hp, h.maxHp});
&& (candidate.position - s.position).length() <= s.sensorRange) });
{
enemyNearby = true; admin.forEach<StationBody, Position, Faction, Health>(
break; [&repairables](entt::entity e, const StationBody& /*sb*/,
} const Position& pos, const Faction& f, const Health& h)
}
if (enemyNearby)
{ {
if (2 > s.intent.priority) repairables.push_back({e, pos.value, f.isEnemy, false, h.hp, h.maxHp});
{ });
s.intent = MovementIntent{2, QVector2D(-10000.0f, s.position.y())};
}
return;
}
EntityId currentId = s.repairBehavior->currentTarget.value_or(kInvalidEntityId); // Snapshot enemy ships for threat detection.
bool targetValid = false; struct EnemyInfo
if (currentId != kInvalidEntityId)
{
const Ship* tShip = ships.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 = s.sensorRange;
for (const Ship& candidate : allShips)
{
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)
{
if (2 > s.intent.priority)
{
s.intent = MovementIntent{2, QVector2D(s.position.x() + 1000.0f,
s.position.y())};
}
return;
}
QVector2D targetPos;
bool isShipTarget = false;
const Ship* tShip = ships.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)
{
ships.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 AiSystem::tickScrapCollector(ShipSystem& ships, ScrapSystem& scraps,
BuildingSystem& buildings)
{
const std::vector<Ship> allShips = ships.allShips();
ships.forEach([&](Ship& s)
{ {
if (!s.scrapCollector || !s.cargo) { return; } QVector2D position;
};
const float collectRange = s.cargo->collectionRange; std::vector<EnemyInfo> enemies;
admin.forEach<ShipIdentity, Position, Faction>(
if (s.scrapCollector->deliveryBay == kInvalidEntityId) [&enemies](entt::entity /*e*/, const ShipIdentity& /*si*/,
const Position& pos, const Faction& f)
{ {
const Building* bay = buildings.findNearestBuilding(s.position, if (f.isEnemy)
BuildingType::SalvageBay);
if (bay)
{ {
s.scrapCollector->deliveryBay = bay->id; enemies.push_back({pos.value});
} }
} });
const EntityId bayId = s.scrapCollector->deliveryBay; admin.forEach<RepairBehavior, RepairTool, Position, Faction, SensorRange, MovementIntent>(
[&](entt::entity e, RepairBehavior& rb, RepairTool& rt, Position& pos,
QVector2D bayPos = s.position; Faction& /*faction*/, SensorRange& sensor, MovementIntent& intent)
if (bayId != kInvalidEntityId)
{ {
const Building* bay = buildings.findBuilding(bayId); const float repairRange = rt.range;
if (bay)
{
bayPos = buildingCenter(*bay);
}
}
const bool cargoFull = (s.cargo->current >= s.cargo->capacity); // Flee if enemy nearby.
bool enemyNearby = false;
if (cargoFull) for (const EnemyInfo& enemy : enemies)
{
if (1 > s.intent.priority)
{ {
s.intent = MovementIntent{1, bayPos}; if ((enemy.position - pos.value).length() <= sensor.value)
}
if (bayId != kInvalidEntityId
&& (s.position - bayPos).length() <= 1.0f)
{
if (buildings.deliverScrapToSalvageBay(bayId))
{ {
--s.cargo->current; enemyNearby = true;
}
}
return;
}
bool retreating = false;
if (s.cargo->current == 0)
{
for (const Ship& candidate : allShips)
{
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; break;
} }
} }
} if (enemyNearby)
if (retreating) { return; }
for (const Scrap& sc : scraps.allScraps())
{
if ((sc.position - s.position).length() <= collectRange)
{ {
if (scraps.consume(sc.id)) if (2 > intent.priority)
{ {
++s.cargo->current; intent = MovementIntent{2, QVector2D(-10000.0f, pos.value.y())};
s.scrapCollector->scrapTarget = std::nullopt;
} }
break; return;
} }
}
if (s.scrapCollector->scrapTarget) // Validate current target.
{ bool targetValid = false;
if (1 > s.intent.priority) if (rb.currentTarget)
{ {
s.intent = MovementIntent{1, *s.scrapCollector->scrapTarget}; const entt::entity t = *rb.currentTarget;
} if (admin.isValid(t) && admin.hasAll<Health>(t))
}
else
{
float bestDist = s.sensorRange;
std::optional<QVector2D> bestPos;
for (const Scrap& sc : scraps.allScraps())
{
float dist = (sc.position - s.position).length();
if (dist < bestDist)
{ {
bestDist = dist; const Health& th = admin.get<Health>(t);
bestPos = sc.position; if (th.hp > 0.0f && th.hp < th.maxHp)
{
targetValid = true;
}
} }
} }
if (bestPos)
if (!targetValid)
{ {
s.scrapCollector->scrapTarget = bestPos; rb.currentTarget = std::nullopt;
if (1 > s.intent.priority) float bestDist = sensor.value;
for (const RepairableInfo& r : repairables)
{ {
s.intent = MovementIntent{1, *bestPos}; if (r.entity == e) { continue; }
if (r.isEnemy) { continue; }
if (r.hp >= r.maxHp) { continue; }
const float dist = (r.position - pos.value).length();
if (dist < bestDist)
{
bestDist = dist;
rb.currentTarget = r.entity;
}
}
}
if (!rb.currentTarget)
{
if (2 > intent.priority)
{
intent = MovementIntent{2, QVector2D(pos.value.x() + 1000.0f,
pos.value.y())};
}
return;
}
const entt::entity target = *rb.currentTarget;
QVector2D targetPos = pos.value;
bool isShipTarget = false;
if (admin.isValid(target) && admin.hasAll<Position>(target))
{
targetPos = admin.get<Position>(target).value;
isShipTarget = admin.hasAll<ShipIdentity>(target);
}
const float distToTarget = (targetPos - pos.value).length();
if (distToTarget <= repairRange)
{
if (admin.isValid(target) && admin.hasAll<Health>(target))
{
Health& targetHealth = admin.get<Health>(target);
targetHealth.hp = std::min(targetHealth.hp + rt.ratePerTick,
targetHealth.maxHp);
}
}
if (2 > intent.priority)
{
intent = MovementIntent{2, targetPos};
}
});
}
// ---------------------------------------------------------------------------
// tickSalvageBehavior (priority 1)
// ---------------------------------------------------------------------------
void AiSystem::tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps,
BuildingSystem& buildings)
{
// Snapshot enemy ships for threat detection.
struct EnemyShipPos
{
QVector2D position;
};
std::vector<EnemyShipPos> enemyShips;
admin.forEach<ShipIdentity, Position, Faction>(
[&enemyShips](entt::entity /*e*/, const ShipIdentity& /*si*/,
const Position& pos, const Faction& f)
{
if (f.isEnemy)
{
enemyShips.push_back({pos.value});
}
});
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
admin.forEach<SalvageBehavior, SalvageCargo, Position, SensorRange, MovementIntent>(
[&](entt::entity /*e*/, SalvageBehavior& salvageBehavior, SalvageCargo& cargo,
Position& pos, SensorRange& sensor, MovementIntent& intent)
{
const float collectRange = cargo.collectionRange;
// Assign nearest SalvageBay if needed.
if (salvageBehavior.deliveryBay == kInvalidBuildingId)
{
const Building* bay = buildings.findNearestBuilding(pos.value,
BuildingType::SalvageBay);
if (bay)
{
salvageBehavior.deliveryBay = bay->id;
}
}
const BuildingId bayId = salvageBehavior.deliveryBay;
QVector2D bayPos = pos.value;
if (bayId != kInvalidBuildingId)
{
const Building* bay = buildings.findBuilding(bayId);
if (bay)
{
bayPos = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,
bay->anchor.y() + bay->footprint.height() / 2.0f);
}
}
const bool cargoFull = (cargo.current >= cargo.capacity);
if (cargoFull)
{
if (1 > intent.priority)
{
intent = MovementIntent{1, bayPos};
}
if (bayId != kInvalidBuildingId
&& (pos.value - bayPos).length() <= 1.0f)
{
if (buildings.deliverScrapToSalvageBay(bayId))
{
--cargo.current;
}
}
return;
}
// Retreat if enemy near and cargo empty.
bool retreating = false;
if (cargo.current == 0)
{
for (const EnemyShipPos& enemy : enemyShips)
{
if ((enemy.position - pos.value).length() <= collectRange)
{
if (1 > intent.priority)
{
intent = MovementIntent{1, QVector2D(-10000.0f, pos.value.y())};
}
retreating = true;
break;
}
}
}
if (retreating) { return; }
// Collect nearby scrap.
for (const ScrapInfo& si : allScrap)
{
if ((si.position - pos.value).length() <= collectRange)
{
if (scraps.consume(si.entity))
{
++cargo.current;
salvageBehavior.scrapTarget = std::nullopt;
}
break;
}
}
// Move toward scrap target or find a new one.
if (salvageBehavior.scrapTarget)
{
if (1 > intent.priority)
{
intent = MovementIntent{1, *salvageBehavior.scrapTarget};
} }
} }
else else
{ {
if (1 > s.intent.priority) float bestDist = sensor.value;
std::optional<QVector2D> bestPos;
for (const ScrapInfo& si : allScrap)
{ {
s.intent = MovementIntent{1, QVector2D(s.position.x() + 1000.0f, const float dist = (si.position - pos.value).length();
s.position.y())}; if (dist < bestDist)
{
bestDist = dist;
bestPos = si.position;
}
}
if (bestPos)
{
salvageBehavior.scrapTarget = bestPos;
if (1 > intent.priority)
{
intent = MovementIntent{1, *bestPos};
}
}
else
{
if (1 > intent.priority)
{
intent = MovementIntent{1, QVector2D(pos.value.x() + 1000.0f,
pos.value.y())};
}
} }
} }
} });
});
} }

View File

@@ -1,14 +1,14 @@
#pragma once #pragma once
class BuildingSystem; class BuildingSystem;
class EntityAdmin;
class ScrapSystem; class ScrapSystem;
class ShipSystem;
class AiSystem class AiSystem
{ {
public: public:
void tickHomeReturn(ShipSystem& ships); void tickHomeReturnBehavior(EntityAdmin& admin);
void tickThreatResponse(ShipSystem& ships, const BuildingSystem& buildings); void tickThreatResponseBehavior(EntityAdmin& admin, const BuildingSystem& buildings);
void tickRepairBehavior(ShipSystem& ships, BuildingSystem& buildings); void tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings);
void tickScrapCollector(ShipSystem& ships, ScrapSystem& scraps, BuildingSystem& buildings); void tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps, BuildingSystem& buildings);
}; };

View File

@@ -9,7 +9,9 @@
#include <QSize> #include <QSize>
#include "BuildingType.h" #include "BuildingType.h"
#include "EntityId.h" #include "BuildingId.h"
#include "entt/entity/entity.hpp"
#include "Item.h" #include "Item.h"
#include "ItemType.h" #include "ItemType.h"
#include "Port.h" #include "Port.h"
@@ -43,7 +45,7 @@ struct Production
// Occupies tiles but does not produce. // Occupies tiles but does not produce.
struct ConstructionSite struct ConstructionSite
{ {
EntityId id = kInvalidEntityId; BuildingId id = kInvalidBuildingId;
QPoint anchor; // top-left of body bounding box QPoint anchor; // top-left of body bounding box
QSize footprint; QSize footprint;
std::vector<QPoint> bodyCells; // absolute world tile coordinates std::vector<QPoint> bodyCells; // absolute world tile coordinates
@@ -54,27 +56,14 @@ struct ConstructionSite
std::optional<ShipLayoutConfig> shipLayout; std::optional<ShipLayoutConfig> shipLayout;
}; };
// Weapon state for stationary structures (defence stations).
// Distinct from Ship::Weapon; stations have no movement intent.
struct StationWeapon
{
float damage;
float range;
float fireRateHz;
float cooldownTicks;
std::optional<EntityId> currentTarget;
};
// A fully constructed, operational building. // A fully constructed, operational building.
struct Building struct Building
{ {
EntityId id = kInvalidEntityId; BuildingId id = kInvalidBuildingId;
QPoint anchor; // top-left of body bounding box QPoint anchor; // top-left of body bounding box
QSize footprint; QSize footprint;
Rotation rotation = Rotation::East; Rotation rotation = Rotation::East;
BuildingType type = BuildingType::Miner; BuildingType type = BuildingType::Miner;
float hp = 0.0f;
float maxHp = 0.0f;
std::string recipeId; // empty = none selected std::string recipeId; // empty = none selected
InputBuffer inputBuffer; InputBuffer inputBuffer;
@@ -89,7 +78,5 @@ struct Building
// Module layout for shipyards (REQ-MOD-LAYOUT). // Module layout for shipyards (REQ-MOD-LAYOUT).
std::optional<ShipLayoutConfig> shipLayout; std::optional<ShipLayoutConfig> shipLayout;
// Set only for defence stations; nullopt for all other building types.
std::optional<StationWeapon> weapon;
}; };

View File

@@ -9,14 +9,14 @@
BuildingSystem::BuildingSystem(const GameConfig& config, BuildingSystem::BuildingSystem(const GameConfig& config,
BeltSystem& belts, BeltSystem& belts,
std::function<EntityId()> allocateId, std::function<BuildingId()> allocateBuildingId,
std::function<void(int)> addBuildingBlocks, std::function<void(int)> addBuildingBlocks,
std::function<void(const std::string&, QVector2D, std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip, const std::optional<ShipLayoutConfig>&)> spawnShip,
std::mt19937& rng) std::mt19937& rng)
: m_config(config) : m_config(config)
, m_belts(belts) , m_belts(belts)
, m_allocateId(std::move(allocateId)) , m_allocateBuildingId(std::move(allocateBuildingId))
, m_addBuildingBlocks(std::move(addBuildingBlocks)) , m_addBuildingBlocks(std::move(addBuildingBlocks))
, m_spawnShip(std::move(spawnShip)) , m_spawnShip(std::move(spawnShip))
, m_rng(rng) , m_rng(rng)
@@ -227,10 +227,10 @@ std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe
// Placement // Placement
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
EntityId BuildingSystem::place(BuildingType type, QPoint anchor, BuildingId BuildingSystem::place(BuildingType type, QPoint anchor,
Rotation rotation, Tick currentTick) Rotation rotation, Tick currentTick)
{ {
const EntityId id = m_allocateId(); const BuildingId id = m_allocateBuildingId();
const BuildingDef* def = findBuildingDef(type); const BuildingDef* def = findBuildingDef(type);
assert(def != nullptr); assert(def != nullptr);
@@ -269,7 +269,7 @@ EntityId BuildingSystem::place(BuildingType type, QPoint anchor,
// Demolish // Demolish
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
int BuildingSystem::demolish(EntityId id) int BuildingSystem::demolish(BuildingId id)
{ {
// Construction queue? // Construction queue?
for (std::deque<ConstructionSite>::iterator it = m_constructionQueue.begin(); for (std::deque<ConstructionSite>::iterator it = m_constructionQueue.begin();
@@ -325,7 +325,7 @@ int BuildingSystem::demolish(EntityId id)
// Set recipe // Set recipe
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void BuildingSystem::setRecipe(EntityId id, const std::string& recipeId) void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
{ {
// Construction site: store recipe for when building completes. // Construction site: store recipe for when building completes.
for (ConstructionSite& site : m_constructionQueue) for (ConstructionSite& site : m_constructionQueue)
@@ -371,7 +371,7 @@ void BuildingSystem::setRecipe(EntityId id, const std::string& recipeId)
} }
} }
void BuildingSystem::setShipLayout(EntityId id, const ShipLayoutConfig& layout) void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout)
{ {
for (ConstructionSite& site : m_constructionQueue) for (ConstructionSite& site : m_constructionQueue)
{ {
@@ -445,8 +445,6 @@ void BuildingSystem::tickConstruction(Tick currentTick)
building.footprint = front.footprint; building.footprint = front.footprint;
building.rotation = front.rotation; building.rotation = front.rotation;
building.type = front.type; building.type = front.type;
building.hp = 100.0f;
building.maxHp = 100.0f;
building.recipeId = front.recipeId; building.recipeId = front.recipeId;
building.shipLayout = front.shipLayout; building.shipLayout = front.shipLayout;
@@ -600,9 +598,7 @@ void BuildingSystem::tickProduction(Tick currentTick)
building.type == BuildingType::Splitter || building.type == BuildingType::Splitter ||
building.type == BuildingType::Shipyard || building.type == BuildingType::Shipyard ||
building.type == BuildingType::SalvageBay || building.type == BuildingType::SalvageBay ||
building.type == BuildingType::Hq || building.type == BuildingType::Hq)
building.type == BuildingType::PlayerDefenceStation ||
building.type == BuildingType::EnemyDefenceStation)
{ {
continue; continue;
} }
@@ -825,7 +821,7 @@ void BuildingSystem::tickBeltPush()
// Queries // Queries
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const Building* BuildingSystem::findBuilding(EntityId id) const const Building* BuildingSystem::findBuilding(BuildingId id) const
{ {
for (const Building& building : m_buildings) for (const Building& building : m_buildings)
{ {
@@ -837,7 +833,7 @@ const Building* BuildingSystem::findBuilding(EntityId id) const
return nullptr; return nullptr;
} }
const ConstructionSite* BuildingSystem::findSite(EntityId id) const const ConstructionSite* BuildingSystem::findSite(BuildingId id) const
{ {
for (const ConstructionSite& site : m_constructionQueue) for (const ConstructionSite& site : m_constructionQueue)
{ {
@@ -870,7 +866,7 @@ std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::allBeltTiles() const
continue; continue;
} }
BeltTileInfo info; BeltTileInfo info;
info.id = b.id; info.buildingId = b.id;
info.tile = b.bodyCells.empty() ? b.anchor : b.bodyCells[0]; info.tile = b.bodyCells.empty() ? b.anchor : b.bodyCells[0];
info.type = b.type; info.type = b.type;
if (!b.outputPorts.empty()) if (!b.outputPorts.empty())
@@ -897,7 +893,7 @@ bool BuildingSystem::isTileOccupied(QPoint tile) const
return m_tileOccupancy.count({tile.x(), tile.y()}) > 0; return m_tileOccupancy.count({tile.x(), tile.y()}) > 0;
} }
std::optional<EntityId> BuildingSystem::findRotateInPlaceTarget( std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget(
BuildingType type, QPoint anchor, Rotation rot) const BuildingType type, QPoint anchor, Rotation rot) const
{ {
const BuildingDef* def = findBuildingDef(type); const BuildingDef* def = findBuildingDef(type);
@@ -910,7 +906,7 @@ std::optional<EntityId> BuildingSystem::findRotateInPlaceTarget(
const QPoint firstAbs = anchor + mask.bodyCells[0]; const QPoint firstAbs = anchor + mask.bodyCells[0];
const auto firstIt = m_tileOccupancy.find({firstAbs.x(), firstAbs.y()}); const auto firstIt = m_tileOccupancy.find({firstAbs.x(), firstAbs.y()});
if (firstIt == m_tileOccupancy.end()) { return std::nullopt; } if (firstIt == m_tileOccupancy.end()) { return std::nullopt; }
const EntityId candidateId = firstIt->second; const BuildingId candidateId = firstIt->second;
for (const QPoint& rel : mask.bodyCells) for (const QPoint& rel : mask.bodyCells)
{ {
@@ -941,7 +937,7 @@ std::optional<EntityId> BuildingSystem::findRotateInPlaceTarget(
return std::nullopt; return std::nullopt;
} }
void BuildingSystem::rotateInPlace(EntityId id, Rotation newRotation) void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
{ {
// Construction site path — just update rotation; no ports to recompute. // Construction site path — just update rotation; no ports to recompute.
for (ConstructionSite& site : m_constructionQueue) for (ConstructionSite& site : m_constructionQueue)
@@ -1026,7 +1022,7 @@ const Building* BuildingSystem::findNearestBuilding(QVector2D worldPos,
return best; return best;
} }
bool BuildingSystem::deliverScrapToSalvageBay(EntityId bayId) bool BuildingSystem::deliverScrapToSalvageBay(BuildingId bayId)
{ {
Building* bay = nullptr; Building* bay = nullptr;
for (Building& b : m_buildings) for (Building& b : m_buildings)
@@ -1049,36 +1045,11 @@ bool BuildingSystem::deliverScrapToSalvageBay(EntityId bayId)
return true; return true;
} }
void BuildingSystem::healBuilding(EntityId id, float amount) BuildingId BuildingSystem::placeImmediate(BuildingType type,
{
for (Building& b : m_buildings)
{
if (b.id == id)
{
b.hp = std::min(b.hp + amount, b.maxHp);
return;
}
}
}
void BuildingSystem::damageBuilding(EntityId id, float amount)
{
for (Building& b : m_buildings)
{
if (b.id == id)
{
b.hp -= amount;
return;
}
}
}
EntityId BuildingSystem::placeImmediate(BuildingType type,
const std::vector<std::string>& surfaceMask, const std::vector<std::string>& surfaceMask,
QPoint anchor, Rotation rotation, QPoint anchor, Rotation rotation)
float hp, float maxHp)
{ {
const EntityId id = m_allocateId(); const BuildingId id = m_allocateBuildingId();
const ParsedSurfaceMask mask = parseSurfaceMask(surfaceMask, rotation); const ParsedSurfaceMask mask = parseSurfaceMask(surfaceMask, rotation);
Building building; Building building;
@@ -1087,8 +1058,6 @@ EntityId BuildingSystem::placeImmediate(BuildingType type,
building.footprint = mask.footprint; building.footprint = mask.footprint;
building.rotation = rotation; building.rotation = rotation;
building.type = type; building.type = type;
building.hp = hp;
building.maxHp = maxHp;
for (const QPoint& cell : mask.bodyCells) for (const QPoint& cell : mask.bodyCells)
{ {
@@ -1109,7 +1078,7 @@ EntityId BuildingSystem::placeImmediate(BuildingType type,
return id; return id;
} }
bool BuildingSystem::removeBuilding(EntityId id) bool BuildingSystem::removeBuilding(BuildingId id)
{ {
for (std::vector<Building>::iterator it = m_buildings.begin(); for (std::vector<Building>::iterator it = m_buildings.begin();
it != m_buildings.end(); it != m_buildings.end();
@@ -1133,18 +1102,6 @@ bool BuildingSystem::removeBuilding(EntityId id)
return false; return false;
} }
void BuildingSystem::initStationWeapon(EntityId id, const StationWeapon& weapon)
{
for (Building& b : m_buildings)
{
if (b.id == id)
{
b.weapon = weapon;
return;
}
}
}
void BuildingSystem::forEachBuilding(std::function<void(Building&)> fn) void BuildingSystem::forEachBuilding(std::function<void(Building&)> fn)
{ {
for (Building& b : m_buildings) for (Building& b : m_buildings)
@@ -1152,3 +1109,20 @@ void BuildingSystem::forEachBuilding(std::function<void(Building&)> fn)
fn(b); fn(b);
} }
} }
void BuildingSystem::registerTileOccupancy(const std::vector<QPoint>& cells,
BuildingId ownerPlaceholder)
{
for (const QPoint& cell : cells)
{
m_tileOccupancy[{cell.x(), cell.y()}] = ownerPlaceholder;
}
}
void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells)
{
for (const QPoint& cell : cells)
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
}

View File

@@ -15,7 +15,7 @@
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "EntityId.h" #include "BuildingId.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Rotation.h" #include "Rotation.h"
#include "ModulesConfig.h" #include "ModulesConfig.h"
@@ -32,7 +32,7 @@ class BuildingSystem
public: public:
BuildingSystem(const GameConfig& config, BuildingSystem(const GameConfig& config,
BeltSystem& belts, BeltSystem& belts,
std::function<EntityId()> allocateId, std::function<BuildingId()> allocateBuildingId,
std::function<void(int)> addBuildingBlocks, std::function<void(int)> addBuildingBlocks,
std::function<void(const std::string&, QVector2D, std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip, const std::optional<ShipLayoutConfig>&)> spawnShip,
@@ -41,21 +41,21 @@ public:
// -- Placement / demolish ------------------------------------------------ // -- Placement / demolish ------------------------------------------------
// Returns the new entity id. Belt and Splitter register with BeltSystem // Returns the new entity id. Belt and Splitter register with BeltSystem
// directly; other types enter the construction queue. // directly; other types enter the construction queue.
EntityId place(BuildingType type, QPoint anchor, Rotation rotation, BuildingId place(BuildingType type, QPoint anchor, Rotation rotation,
Tick currentTick); Tick currentTick);
// Remove a building or construction site by id. Returns the refund in // Remove a building or construction site by id. Returns the refund in
// building blocks (floor(cost * refundPercentage / 100)). Returns 0 for // building blocks (floor(cost * refundPercentage / 100)). Returns 0 for
// unknown ids. // unknown ids.
int demolish(EntityId id); int demolish(BuildingId id);
// Set the recipe (or schematic id for shipyard) on a building or queued // Set the recipe (or schematic id for shipyard) on a building or queued
// construction site. Clears both buffers on an operational building. // construction site. Clears both buffers on an operational building.
void setRecipe(EntityId id, const std::string& recipeId); void setRecipe(BuildingId id, const std::string& recipeId);
// Set the module layout for a shipyard. Cancels in-progress production // Set the module layout for a shipyard. Cancels in-progress production
// (materials discarded) and reinitializes input buffers (REQ-BLD-SHIPYARD). // (materials discarded) and reinitializes input buffers (REQ-BLD-SHIPYARD).
void setShipLayout(EntityId id, const ShipLayoutConfig& layout); void setShipLayout(BuildingId id, const ShipLayoutConfig& layout);
// -- Tick hooks (called from Simulation::tick in the documented order) --- // -- Tick hooks (called from Simulation::tick in the documented order) ---
void tickConstruction(Tick currentTick); void tickConstruction(Tick currentTick);
@@ -67,15 +67,15 @@ public:
// -- Queries ------------------------------------------------------------- // -- Queries -------------------------------------------------------------
struct BeltTileInfo struct BeltTileInfo
{ {
EntityId id; BuildingId buildingId;
QPoint tile; QPoint tile;
BuildingType type; // Belt or Splitter BuildingType type; // Belt or Splitter
Rotation directionA; // Belt: its direction; Splitter: first output Rotation directionA; // Belt: its direction; Splitter: first output
Rotation directionB; // Splitter: second output; Belt: same as directionA Rotation directionB; // Splitter: second output; Belt: same as directionA
}; };
const Building* findBuilding(EntityId id) const; const Building* findBuilding(BuildingId id) const;
const ConstructionSite* findSite(EntityId id) const; const ConstructionSite* findSite(BuildingId id) const;
std::vector<Building> allBuildings() const; std::vector<Building> allBuildings() const;
std::vector<ConstructionSite> allSites() const; std::vector<ConstructionSite> allSites() const;
std::vector<BeltTileInfo> allBeltTiles() const; std::vector<BeltTileInfo> allBeltTiles() const;
@@ -84,44 +84,38 @@ public:
// Returns the entity id of the building or construction site whose footprint // Returns the entity id of the building or construction site whose footprint
// exactly coincides with the ghost (type, anchor, rot) and is of the same // exactly coincides with the ghost (type, anchor, rot) and is of the same
// building type. Returns nullopt otherwise. // building type. Returns nullopt otherwise.
std::optional<EntityId> findRotateInPlaceTarget(BuildingType type, std::optional<BuildingId> findRotateInPlaceTarget(BuildingType type,
QPoint anchor, QPoint anchor,
Rotation rot) const; Rotation rot) const;
// Rotate an existing building or construction site to newRotation in place. // Rotate an existing building or construction site to newRotation in place.
// For belt-type operational buildings, re-registers with BeltSystem (items // For belt-type operational buildings, re-registers with BeltSystem (items
// currently on the tile are discarded by BeltSystem::removeTile). // currently on the tile are discarded by BeltSystem::removeTile).
void rotateInPlace(EntityId id, Rotation newRotation); void rotateInPlace(BuildingId id, Rotation newRotation);
// Find nearest operational building of the given type; nullptr if none. // Find nearest operational building of the given type; nullptr if none.
const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const; const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const;
// Register / unregister tile occupancy for ECS station entities.
void registerTileOccupancy(const std::vector<QPoint>& cells, BuildingId ownerPlaceholder);
void unregisterTileOccupancy(const std::vector<QPoint>& cells);
// Place one "scrap" item into a SalvageBay's output buffer. // Place one "scrap" item into a SalvageBay's output buffer.
// Returns false if bay not found, wrong type, or output buffer is full. // Returns false if bay not found, wrong type, or output buffer is full.
bool deliverScrapToSalvageBay(EntityId bayId); bool deliverScrapToSalvageBay(BuildingId bayId);
// Increase a building's HP by amount, clamped to maxHp.
void healBuilding(EntityId id, float amount);
// Reduce a building's HP by amount; hp may go below 0 (step 9 processes deaths).
void damageBuilding(EntityId id, float amount);
// Bypass the construction queue and create a fully-operational Building // Bypass the construction queue and create a fully-operational Building
// immediately. Used for pre-placed structures (HQ, defence stations). // immediately. Used for pre-placed structures (HQ, defence stations).
// surfaceMask comes from the relevant config struct. // surfaceMask comes from the relevant config struct.
EntityId placeImmediate(BuildingType type, BuildingId placeImmediate(BuildingType type,
const std::vector<std::string>& surfaceMask, const std::vector<std::string>& surfaceMask,
QPoint anchor, Rotation rotation, QPoint anchor, Rotation rotation);
float hp, float maxHp);
// Remove an operational building by id without refund (used for deaths). // Remove an operational building by id without refund (used for deaths).
// Returns true if found and removed. // Returns true if found and removed.
bool removeBuilding(EntityId id); bool removeBuilding(BuildingId id);
// Set the weapon component on an already-placed defence station. // Mutable iteration over all operational buildings.
void initStationWeapon(EntityId id, const StationWeapon& weapon);
// Mutable iteration over all operational buildings (used by CombatSystem).
void forEachBuilding(std::function<void(Building&)> fn); void forEachBuilding(std::function<void(Building&)> fn);
private: private:
@@ -136,7 +130,7 @@ private:
const GameConfig& m_config; const GameConfig& m_config;
BeltSystem& m_belts; BeltSystem& m_belts;
std::function<EntityId()> m_allocateId; std::function<BuildingId()> m_allocateBuildingId;
std::function<void(int)> m_addBuildingBlocks; std::function<void(int)> m_addBuildingBlocks;
std::function<void(const std::string&, QVector2D, std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> m_spawnShip; const std::optional<ShipLayoutConfig>&)> m_spawnShip;
@@ -146,5 +140,5 @@ private:
std::deque<ConstructionSite> m_constructionQueue; std::deque<ConstructionSite> m_constructionQueue;
// Maps every occupied body-cell coordinate to the entity that owns it. // Maps every occupied body-cell coordinate to the entity that owns it.
std::map<std::pair<int, int>, EntityId> m_tileOccupancy; std::map<std::pair<int, int>, BuildingId> m_tileOccupancy;
}; };

View File

@@ -5,7 +5,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Building.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Scrap.h
${CMAKE_CURRENT_SOURCE_DIR}/Ship.h ${CMAKE_CURRENT_SOURCE_DIR}/Ship.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h

View File

@@ -1,11 +1,9 @@
#include "CombatSystem.h" #include "CombatSystem.h"
#include "BuildingSystem.h" #include "EcsComponents.h"
#include "BuildingType.h" #include "EntityAdmin.h"
#include "Ship.h"
#include "ShipSystem.h"
static constexpr Tick kWeaponImpactDelayTicks = 5; // 0.15 s × 30 Hz, rounded to nearest static constexpr Tick kWeaponImpactDelayTicks = 5;
CombatSystem::CombatSystem(const GameConfig& config) CombatSystem::CombatSystem(const GameConfig& config)
: m_config(config) : m_config(config)
@@ -13,199 +11,114 @@ CombatSystem::CombatSystem(const GameConfig& config)
} }
void CombatSystem::tick(Tick currentTick, void CombatSystem::tick(Tick currentTick,
ShipSystem& ships, EntityAdmin& admin,
BuildingSystem& buildings, BuildingSystem& /*buildings*/,
std::vector<FireEvent>& outFireEvents) std::vector<FireEvent>& outFireEvents)
{ {
// Ships: iterate and resolve weapon for each combat ship. // Ship weapons.
ships.forEach([&](Ship& ship) admin.forEach<Weapon, ThreatResponseBehavior, Position, Faction>(
{ [&](entt::entity e, Weapon& weapon, ThreatResponseBehavior& threatResponseBehavior, Position& pos, Faction& faction)
resolveShipWeapon(ship, currentTick, ships, buildings, outFireEvents);
});
// Defence stations: acquire targets and fire.
buildings.forEachBuilding([&](Building& building)
{
if (building.type == BuildingType::PlayerDefenceStation ||
building.type == BuildingType::EnemyDefenceStation)
{ {
resolveStationWeapon(building, currentTick, ships, buildings, outFireEvents); weapon.currentTarget = threatResponseBehavior.currentTarget;
} resolveWeapon(e, weapon, pos, faction, currentTick, admin, outFireEvents);
}); });
// Station weapons.
admin.forEach<Weapon, Position, Faction>(
[&](entt::entity e, Weapon& weapon, Position& pos, Faction& faction)
{
resolveWeapon(e, weapon, pos, faction, currentTick, admin, outFireEvents);
});
} }
void CombatSystem::resolveShipWeapon(Ship& ship, Tick currentTick, void CombatSystem::resolveWeapon(
ShipSystem& ships, entt::entity shipEntity,
BuildingSystem& buildings, Weapon& weapon,
std::vector<FireEvent>& out) const Position& ownPos,
const Faction& ownFaction,
Tick currentTick,
EntityAdmin& admin,
std::vector<FireEvent>& out)
{ {
if (!ship.weapon || !ship.threatResponse || if (weapon.cooldownTicks > 0.0f)
!ship.threatResponse->currentTarget) {
weapon.cooldownTicks -= 1.0f;
}
if (weapon.cooldownTicks > 0.0f)
{
return;
}
// Validate or clear existing target.
if (weapon.currentTarget)
{
const entt::entity t = *weapon.currentTarget;
if (!admin.isValid(t) || !admin.hasAll<Position>(t))
{
weapon.currentTarget = std::nullopt;
}
else
{
const float distanceSquared = (ownPos.value - admin.get<Position>(t).value).lengthSquared();
if (distanceSquared > weapon.range * weapon.range)
{
weapon.currentTarget = std::nullopt;
}
}
}
// Acquire a new target if needed (nearest opposing-faction ship).
if (!weapon.currentTarget)
{
float bestDistanceSquared = weapon.range * weapon.range;
admin.forEach<ShipIdentity, Position, Faction>(
[&](entt::entity candidate, const ShipIdentity& /*si*/,
const Position& candidatePos, const Faction& candidateFaction)
{
const bool isValidTarget = ownFaction.isEnemy
? !candidateFaction.isEnemy
: candidateFaction.isEnemy;
if (!isValidTarget)
{
return;
}
const float distanceSquared = (candidatePos.value - ownPos.value).lengthSquared();
if (distanceSquared < bestDistanceSquared)
{
bestDistanceSquared = distanceSquared;
weapon.currentTarget = candidate;
}
});
}
if (!weapon.currentTarget)
{ {
return; return;
} }
Weapon& w = *ship.weapon; const entt::entity targetEntity = *weapon.currentTarget;
m_pendingDamage.push_back({targetEntity, weapon.damage,
// Decrement cooldown toward zero. currentTick + kWeaponImpactDelayTicks});
if (w.cooldownTicks > 0.0f)
{
w.cooldownTicks -= 1.0f;
}
if (w.cooldownTicks > 0.0f)
{
return;
}
const EntityId targetId = *ship.threatResponse->currentTarget;
const std::optional<QVector2D> tPos = targetPosition(targetId, ships, buildings);
if (!tPos)
{
ship.threatResponse->currentTarget = std::nullopt;
return;
}
const float dist = (ship.position - *tPos).length();
if (dist > w.range)
{
return;
}
m_pendingDamage.push_back({targetId, w.damage, currentTick + kWeaponImpactDelayTicks});
FireEvent evt; FireEvent evt;
evt.shooter = ship.id; evt.shooter = shipEntity;
evt.target = targetId; evt.target = targetEntity;
evt.emittedAt = currentTick; evt.emittedAt = currentTick;
out.push_back(evt); out.push_back(evt);
w.cooldownTicks = static_cast<float>(kTickRateHz) / w.fireRateHz; weapon.cooldownTicks = static_cast<float>(kTickRateHz) / weapon.fireRateHz;
} }
void CombatSystem::resolveStationWeapon(Building& station, Tick currentTick, void CombatSystem::applyPendingDamage(Tick currentTick, EntityAdmin& admin)
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& out)
{ {
if (!station.weapon) std::vector<PendingDamage>::iterator it = m_pendingDamage.begin();
{
return;
}
StationWeapon& w = *station.weapon;
const bool stationIsEnemy = (station.type == BuildingType::EnemyDefenceStation);
const QVector2D stationCenter(
station.anchor.x() + station.footprint.width() / 2.0f,
station.anchor.y() + station.footprint.height() / 2.0f);
// Validate or clear existing target.
if (w.currentTarget)
{
const std::optional<QVector2D> tPos =
targetPosition(*w.currentTarget, ships, buildings);
if (!tPos || (stationCenter - *tPos).length() > w.range)
{
w.currentTarget = std::nullopt;
}
}
// Acquire a new target if needed.
if (!w.currentTarget)
{
w.currentTarget = acquireStationTarget(station, stationIsEnemy, ships);
}
if (!w.currentTarget)
{
return;
}
// Decrement cooldown.
if (w.cooldownTicks > 0.0f)
{
w.cooldownTicks -= 1.0f;
}
if (w.cooldownTicks > 0.0f)
{
return;
}
const EntityId targetId = *w.currentTarget;
const std::optional<QVector2D> tPos = targetPosition(targetId, ships, buildings);
if (!tPos)
{
w.currentTarget = std::nullopt;
return;
}
if ((stationCenter - *tPos).length() > w.range)
{
return;
}
m_pendingDamage.push_back({targetId, w.damage, currentTick + kWeaponImpactDelayTicks});
FireEvent evt;
evt.shooter = station.id;
evt.target = targetId;
evt.emittedAt = currentTick;
out.push_back(evt);
w.cooldownTicks = static_cast<float>(kTickRateHz) / w.fireRateHz;
}
std::optional<EntityId> CombatSystem::acquireStationTarget(
const Building& station, bool stationIsEnemy,
const ShipSystem& ships) const
{
const QVector2D stationCenter(
station.anchor.x() + station.footprint.width() / 2.0f,
station.anchor.y() + station.footprint.height() / 2.0f);
const float range = station.weapon->range;
std::optional<EntityId> best;
float bestDist = range;
// Scan ships for valid targets.
for (const Ship& candidate : ships.allShips())
{
const bool isValidTarget = stationIsEnemy ? !candidate.isEnemy
: candidate.isEnemy;
if (!isValidTarget)
{
continue;
}
const float dist = (candidate.position - stationCenter).length();
if (dist < bestDist)
{
bestDist = dist;
best = candidate.id;
}
}
return best;
}
void CombatSystem::applyPendingDamage(Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings)
{
auto it = m_pendingDamage.begin();
while (it != m_pendingDamage.end()) while (it != m_pendingDamage.end())
{ {
if (it->appliesAt <= currentTick) if (it->appliesAt <= currentTick)
{ {
if (ships.findShip(it->target)) if (admin.isValid(it->target) && admin.hasAll<Health>(it->target))
{ {
ships.damageShip(it->target, it->amount); admin.get<Health>(it->target).hp -= it->amount;
}
else if (buildings.findBuilding(it->target))
{
buildings.damageBuilding(it->target, it->amount);
} }
it = m_pendingDamage.erase(it); it = m_pendingDamage.erase(it);
} }
@@ -216,21 +129,3 @@ void CombatSystem::applyPendingDamage(Tick currentTick,
} }
} }
std::optional<QVector2D> CombatSystem::targetPosition(
EntityId id,
const ShipSystem& ships,
const BuildingSystem& buildings) const
{
const Ship* ship = ships.findShip(id);
if (ship)
{
return ship->position;
}
const Building* bld = buildings.findBuilding(id);
if (bld)
{
return QVector2D(bld->anchor.x() + bld->footprint.width() / 2.0f,
bld->anchor.y() + bld->footprint.height() / 2.0f);
}
return std::nullopt;
}

View File

@@ -6,67 +6,48 @@
#include <QVector2D> #include <QVector2D>
#include "Building.h" #include "Building.h"
#include "EntityId.h" #include "EcsComponents.h"
#include "FireEvent.h" #include "FireEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Ship.h" #include "Ship.h"
#include "Tick.h" #include "Tick.h"
class BuildingSystem; #include "entt/entity/entity.hpp"
class ShipSystem;
class BuildingSystem;
class EntityAdmin;
// Resolves all weapon fire for ships and defence stations (tick-order step 8).
// REQ-SHP-FIRING, REQ-DEF-PLAYER-FIRE, REQ-DEF-ENEMY-FIRE.
class CombatSystem class CombatSystem
{ {
public: public:
explicit CombatSystem(const GameConfig& config); explicit CombatSystem(const GameConfig& config);
// Advance weapon cooldowns, acquire targets for stations, fire when ready,
// queue deferred damage, and append FireEvents. Call applyPendingDamage()
// after tick() (step 8b) and before death processing (step 9).
void tick(Tick currentTick, void tick(Tick currentTick,
ShipSystem& ships, EntityAdmin& admin,
BuildingSystem& buildings, BuildingSystem& buildings,
std::vector<FireEvent>& outFireEvents); std::vector<FireEvent>& outFireEvents);
// Apply any queued damage whose impact tick has arrived. Silently drops void applyPendingDamage(Tick currentTick, EntityAdmin& admin);
// damage if the target no longer exists.
void applyPendingDamage(Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings);
private: private:
struct PendingDamage struct PendingDamage
{ {
EntityId target; entt::entity target;
float amount; float amount;
Tick appliesAt; Tick appliesAt;
}; };
std::vector<PendingDamage> m_pendingDamage; std::vector<PendingDamage> m_pendingDamage;
// Process one ship's weapon for this tick.
void resolveShipWeapon(Ship& ship, Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& out);
// Process one defence-station's weapon for this tick. void resolveWeapon(
void resolveStationWeapon(Building& station, Tick currentTick, entt::entity shipEntity,
ShipSystem& ships, Weapon& weapon,
BuildingSystem& buildings, const Position& ownPos,
std::vector<FireEvent>& out); const Faction& ownFaction,
Tick currentTick,
// Find the nearest valid target for a defence station within its range. EntityAdmin& admin,
// Both enemy and player stations target ships of the opposing faction only. std::vector<FireEvent>& out);
std::optional<EntityId> acquireStationTarget(
const Building& station, bool stationIsEnemy,
const ShipSystem& ships) const;
// Return the world position of the entity, or nullopt if it no longer exists.
std::optional<QVector2D> targetPosition(EntityId id,
const ShipSystem& ships,
const BuildingSystem& buildings) const;
const GameConfig& m_config; const GameConfig& m_config;
}; };

View File

@@ -5,8 +5,9 @@
#include <QVector2D> #include <QVector2D>
#include "Ship.h" #include "EcsComponents.h"
#include "ShipSystem.h" #include "EntityAdmin.h"
#include "MovementIntent.h"
static float wrapAngle(float a) static float wrapAngle(float a)
{ {
@@ -17,86 +18,88 @@ static float wrapAngle(float a)
return a; return a;
} }
void MovementSystem::tick(ShipSystem& ships) void MovementSystem::tick(EntityAdmin& admin)
{ {
ships.forEach([&](Ship& s) admin.forEach<Position, Velocity, Facing, ShipDynamics, MovementIntent>(
{ [](entt::entity /*e*/, Position& pos, Velocity& vel, Facing& facing,
if (s.intent.priority == 0) ShipDynamics& dynamics, MovementIntent& intent)
{ {
s.velocity = QVector2D(0.0f, 0.0f); if (intent.priority == 0)
s.rotationSpeed = 0.0f; {
return; vel.value = QVector2D(0.0f, 0.0f);
} facing.rotationSpeed = 0.0f;
return;
}
const QVector2D delta = s.intent.target - s.position; const QVector2D delta = intent.target - pos.value;
const float dist = delta.length(); const float dist = delta.length();
if (dist < 0.001f) if (dist < 0.001f)
{ {
s.velocity = QVector2D(0.0f, 0.0f); vel.value = QVector2D(0.0f, 0.0f);
return; return;
} }
// ── Rotate toward target ────────────────────────────────────────── // Rotate toward target.
const float desiredAngle = std::atan2(delta.y(), delta.x()); const float desiredAngle = std::atan2(delta.y(), delta.x());
const float angleDiff = wrapAngle(desiredAngle - s.facing); const float angleDiff = wrapAngle(desiredAngle - facing.radians);
const float rotDelta = std::max(-s.angularAccelerationPerTick, const float rotDelta = std::max(-dynamics.angularAccelerationPerTick,
std::min(angleDiff, s.angularAccelerationPerTick)); std::min(angleDiff, dynamics.angularAccelerationPerTick));
s.rotationSpeed += rotDelta; facing.rotationSpeed += rotDelta;
s.rotationSpeed = std::max(-s.maxRotationSpeedPerTick, facing.rotationSpeed = std::max(-dynamics.maxRotationSpeedPerTick,
std::min(s.rotationSpeed, s.maxRotationSpeedPerTick)); std::min(facing.rotationSpeed, dynamics.maxRotationSpeedPerTick));
const bool sameSign = (s.rotationSpeed >= 0.0f) == (angleDiff >= 0.0f); const bool sameSign = (facing.rotationSpeed >= 0.0f) == (angleDiff >= 0.0f);
if (sameSign && std::abs(s.rotationSpeed) > std::abs(angleDiff)) if (sameSign && std::abs(facing.rotationSpeed) > std::abs(angleDiff))
{ {
s.rotationSpeed = angleDiff; facing.rotationSpeed = angleDiff;
} }
s.facing = wrapAngle(s.facing + s.rotationSpeed); facing.radians = wrapAngle(facing.radians + facing.rotationSpeed);
// ── Desired velocity (with braking near target) ─────────────────── // Desired velocity (with braking near target).
const float manAccel = s.maneuveringAccelerationPerTick; const float manAccel = dynamics.maneuveringAccelerationPerTick;
const float stoppingDist = (s.maxSpeedPerTick * s.maxSpeedPerTick) const float stoppingDist = (dynamics.maxSpeedPerTick * dynamics.maxSpeedPerTick)
/ (2.0f * manAccel); / (2.0f * manAccel);
const float desiredSpeed = (dist <= stoppingDist) const float desiredSpeed = (dist <= stoppingDist)
? std::sqrt(2.0f * manAccel * dist) ? std::sqrt(2.0f * manAccel * dist)
: s.maxSpeedPerTick; : dynamics.maxSpeedPerTick;
const QVector2D desiredVel = delta.normalized() * desiredSpeed; const QVector2D desiredVel = delta.normalized() * desiredSpeed;
const QVector2D velError = desiredVel - s.velocity; const QVector2D velError = desiredVel - vel.value;
// ── Main acceleration: forward only, along facing ───────────────── // Main acceleration: forward only, along facing.
const QVector2D facingVec(std::cos(s.facing), std::sin(s.facing)); const QVector2D facingVec(std::cos(facing.radians), std::sin(facing.radians));
const float mainAligned = std::max(0.0f, const float mainAligned = std::max(0.0f,
QVector2D::dotProduct(velError, facingVec)); QVector2D::dotProduct(velError, facingVec));
const float mainApplied = std::min(mainAligned, s.mainAccelerationPerTick); const float mainApplied = std::min(mainAligned, dynamics.mainAccelerationPerTick);
const QVector2D mainDelta = facingVec * mainApplied; const QVector2D mainDelta = facingVec * mainApplied;
// ── Maneuvering acceleration: any direction, handles the remainder // Maneuvering acceleration: any direction, handles the remainder.
const QVector2D remaining = velError - mainDelta; const QVector2D remaining = velError - mainDelta;
const float remainLen = remaining.length(); const float remainLen = remaining.length();
const QVector2D maneuverDelta = (remainLen > manAccel) const QVector2D maneuverDelta = (remainLen > manAccel)
? remaining.normalized() * manAccel ? remaining.normalized() * manAccel
: remaining; : remaining;
s.velocity += mainDelta + maneuverDelta; vel.value += mainDelta + maneuverDelta;
// ── Speed cap ───────────────────────────────────────────────────── // Speed cap.
const float speed = s.velocity.length(); const float speed = vel.value.length();
if (speed > s.maxSpeedPerTick) if (speed > dynamics.maxSpeedPerTick)
{ {
s.velocity = s.velocity.normalized() * s.maxSpeedPerTick; vel.value = vel.value.normalized() * dynamics.maxSpeedPerTick;
} }
// ── Snap to target or advance ───────────────────────────────────── // Snap to target or advance.
if (dist <= s.velocity.length()) if (dist <= vel.value.length())
{ {
s.position = s.intent.target; pos.value = intent.target;
s.velocity = QVector2D(0.0f, 0.0f); vel.value = QVector2D(0.0f, 0.0f);
} }
else else
{ {
s.position += s.velocity; pos.value += vel.value;
} }
}); });
} }

View File

@@ -1,9 +1,9 @@
#pragma once #pragma once
class ShipSystem; class EntityAdmin;
class MovementSystem class MovementSystem
{ {
public: public:
void tick(ShipSystem& ships); void tick(EntityAdmin& admin);
}; };

View File

@@ -1,14 +0,0 @@
#pragma once
#include <QVector2D>
#include "EntityId.h"
#include "Tick.h"
struct Scrap
{
EntityId id;
QVector2D position;
int amount;
Tick despawnAt;
};

View File

@@ -1,59 +1,53 @@
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include <algorithm> #include "EntityAdmin.h"
#include <optional>
ScrapSystem::ScrapSystem(std::function<EntityId()> allocateId) ScrapSystem::ScrapSystem(EntityAdmin& admin)
: m_allocateId(std::move(allocateId)) : m_admin(admin)
{ {
} }
EntityId ScrapSystem::spawn(QVector2D position, int amount, Tick despawnAt) entt::entity ScrapSystem::spawn(QVector2D position, int amount, Tick despawnAt)
{ {
Scrap s; return m_admin.spawnScrap(position, amount, despawnAt);
s.id = m_allocateId();
s.position = position;
s.amount = amount;
s.despawnAt = despawnAt;
m_scraps.push_back(s);
return s.id;
} }
void ScrapSystem::tickDespawn(Tick currentTick) void ScrapSystem::tickDespawn(Tick currentTick)
{ {
m_scraps.erase( std::vector<entt::entity> expired;
std::remove_if(m_scraps.begin(), m_scraps.end(), m_admin.forEach<DespawnAt>(
[currentTick](const Scrap& s) { return s.despawnAt <= currentTick; }), [&expired, currentTick](entt::entity e, DespawnAt& d)
m_scraps.end());
}
const Scrap* ScrapSystem::findScrap(EntityId id) const
{
for (const Scrap& s : m_scraps)
{
if (s.id == id)
{ {
return &s; if (d.tick <= currentTick)
} {
} expired.push_back(e);
return nullptr; }
} });
std::optional<Scrap> ScrapSystem::consume(EntityId id) for (entt::entity e : expired)
{
for (auto it = m_scraps.begin(); it != m_scraps.end(); ++it)
{ {
if (it->id == id) m_admin.destroy(e);
{
Scrap result = *it;
m_scraps.erase(it);
return result;
}
} }
return std::nullopt;
} }
std::vector<Scrap> ScrapSystem::allScraps() const std::optional<int> ScrapSystem::consume(entt::entity entity)
{ {
return m_scraps; if (!m_admin.isValid(entity) || !m_admin.hasAll<ScrapData>(entity))
{
return std::nullopt;
}
int amount = m_admin.get<ScrapData>(entity).amount;
m_admin.destroy(entity);
return amount;
}
std::vector<ScrapInfo> ScrapSystem::allScrapInfo() const
{
std::vector<ScrapInfo> result;
m_admin.forEach<ScrapData>(
[&result, this](entt::entity e, const ScrapData& /*sd*/)
{
result.push_back(ScrapInfo{e, m_admin.get<Position>(e).value});
});
return result;
} }

View File

@@ -1,28 +1,37 @@
#pragma once #pragma once
#include <functional>
#include <optional> #include <optional>
#include <vector> #include <vector>
#include <QVector2D> #include <QVector2D>
#include "EntityId.h" #include "EcsComponents.h"
#include "Scrap.h"
#include "Tick.h" #include "Tick.h"
#include "entt/entity/entity.hpp"
class EntityAdmin;
struct ScrapInfo
{
entt::entity entity;
QVector2D position;
};
class ScrapSystem class ScrapSystem
{ {
public: public:
explicit ScrapSystem(std::function<EntityId()> allocateId); explicit ScrapSystem(EntityAdmin& admin);
EntityId spawn(QVector2D position, int amount, Tick despawnAt); entt::entity spawn(QVector2D position, int amount, Tick despawnAt);
void tickDespawn(Tick currentTick); void tickDespawn(Tick currentTick);
std::optional<Scrap> consume(EntityId id); // removes and returns scrap, or nullopt
const Scrap* findScrap(EntityId id) const; // Removes the scrap and returns its amount, or nullopt if not found.
std::vector<Scrap> allScraps() const; std::optional<int> consume(entt::entity entity);
// Lightweight snapshot for callers that need to iterate all scrap.
std::vector<ScrapInfo> allScrapInfo() const;
private: private:
std::function<EntityId()> m_allocateId; EntityAdmin& m_admin;
std::vector<Scrap> m_scraps;
}; };

View File

@@ -5,9 +5,11 @@
#include <QVector2D> #include <QVector2D>
#include "EntityId.h" #include "BuildingId.h"
#include "MovementIntent.h" #include "MovementIntent.h"
#include "entt/entity/entity.hpp"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Hardware components — derived from config at spawn, stored on ship // Hardware components — derived from config at spawn, stored on ship
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -18,44 +20,44 @@ struct Weapon
float range; float range;
float fireRateHz; float fireRateHz;
float cooldownTicks; float cooldownTicks;
std::optional<EntityId> currentTarget; std::optional<entt::entity> currentTarget;
}; };
struct SalvageCargo struct SalvageCargo
{ {
int capacity; int capacity;
int current; int current;
float collectionRange; // copy of ShipDef.salvage.collectionRange (tile units) float collectionRange;
}; };
struct RepairTool struct RepairTool
{ {
float ratePerTick; float ratePerTick;
float range; float range;
std::optional<EntityId> currentTarget; std::optional<entt::entity> currentTarget;
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Behavior components — AI state consumed by step-6 behavior systems // Behavior components — AI state consumed by step-6 behavior systems
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
struct ThreatResponse struct ThreatResponseBehavior
{ {
std::optional<EntityId> currentTarget; std::optional<entt::entity> currentTarget;
}; };
struct ScrapCollector struct SalvageBehavior
{ {
std::optional<QVector2D> scrapTarget; std::optional<QVector2D> scrapTarget;
EntityId deliveryBay; // kInvalidEntityId until assigned at a salvage bay BuildingId deliveryBay; // kInvalidBuildingId until assigned at a salvage bay
}; };
struct RepairBehavior struct RepairBehavior
{ {
std::optional<EntityId> currentTarget; std::optional<entt::entity> currentTarget;
}; };
struct HomeReturn struct HomeReturnBehavior
{ {
float retreatHpFraction; float retreatHpFraction;
QVector2D homePos; QVector2D homePos;
@@ -65,41 +67,3 @@ struct RallyBehavior
{ {
QVector2D rallyPoint; QVector2D rallyPoint;
}; };
// ---------------------------------------------------------------------------
// Ship
// ---------------------------------------------------------------------------
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 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;
bool isEnemy = false; // true for enemy-faction ships (used by behavior systems)
std::optional<Weapon> weapon;
std::optional<SalvageCargo> cargo;
std::optional<RepairTool> repairTool;
std::optional<ThreatResponse> threatResponse;
std::optional<ScrapCollector> scrapCollector;
std::optional<RepairBehavior> repairBehavior;
std::optional<HomeReturn> homeReturn;
std::optional<RallyBehavior> rallyBehavior;
// Cleared at the start of the behavior step each tick; the highest-priority
// write from behavior systems wins (architecture.md §Movement Arbitration).
MovementIntent intent;
};

View File

@@ -1,17 +1,17 @@
#include "ShipSystem.h" #include "ShipSystem.h"
#include <algorithm>
#include <cassert> #include <cassert>
#include <map> #include <map>
#include <utility> #include <utility>
#include "EcsComponents.h"
#include "EntityAdmin.h"
#include "ModulesConfig.h" #include "ModulesConfig.h"
#include "Tick.h" #include "Tick.h"
ShipSystem::ShipSystem(const GameConfig& config, ShipSystem::ShipSystem(const GameConfig& config, EntityAdmin& admin)
std::function<EntityId()> allocateId)
: m_config(config) : m_config(config)
, m_allocateId(std::move(allocateId)) , m_admin(admin)
{ {
} }
@@ -39,54 +39,47 @@ const ModuleDef* ShipSystem::findModuleDef(const std::string& id) const
return nullptr; return nullptr;
} }
EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D position, entt::entity ShipSystem::spawn(const std::string& schematicId, int level, QVector2D position,
bool isEnemy, bool isEnemy,
const std::optional<ShipLayoutConfig>& layout) const std::optional<ShipLayoutConfig>& layout)
{ {
const ShipDef* def = findShipDef(schematicId); const ShipDef* def = findShipDef(schematicId);
assert(def != nullptr); assert(def != nullptr);
const double x = static_cast<double>(level); const double x = static_cast<double>(level);
Ship ship;
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); 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)};
float hp = static_cast<float>(def->health.hpFormula.evaluate(x));
float maxHp = hp;
float maxSpeedPerTick = static_cast<float>(def->movement.speedFormula.evaluate(x)) / tickRate;
float mainAccelPerTick = static_cast<float>(def->movement.mainAccelerationFormula.evaluate(x)) / tickRate;
float maneuveringAccelPerTick = static_cast<float>(def->movement.maneuveringAccelerationFormula.evaluate(x)) / tickRate;
float angularAccelPerTick = static_cast<float>(def->movement.angularAccelerationFormula.evaluate(x)) / tickRate;
float maxRotationSpeedPerTick = static_cast<float>(def->movement.maxRotationSpeedFormula.evaluate(x)) / tickRate;
float sensorRange = static_cast<float>(def->sensor.sensorRangeFormula.evaluate(x));
entt::entity entity = m_admin.spawnShip(
position, hp, maxHp,
maxSpeedPerTick, mainAccelPerTick, maneuveringAccelPerTick,
angularAccelPerTick, maxRotationSpeedPerTick, sensorRange,
level, schematicId, isEnemy);
// Optional components based on ship role.
if (def->combat) if (def->combat)
{ {
Weapon w; Weapon w;
w.damage = static_cast<float>(def->combat->damageFormula.evaluate(x)); w.damage = static_cast<float>(def->combat->damageFormula.evaluate(x));
w.range = static_cast<float>(def->combat->attackRangeFormula.evaluate(x)); w.range = static_cast<float>(def->combat->attackRangeFormula.evaluate(x));
w.fireRateHz = static_cast<float>(def->combat->attackRateFormula.evaluate(x)); w.fireRateHz = static_cast<float>(def->combat->attackRateFormula.evaluate(x));
w.cooldownTicks = 0.0f; w.cooldownTicks = 0.0f;
ship.weapon = w; w.currentTarget = std::nullopt;
m_admin.addComponent<Weapon>(entity, w);
ship.threatResponse = ThreatResponse{}; m_admin.addComponent<ThreatResponseBehavior>(entity, ThreatResponseBehavior{});
if (!isEnemy) if (!isEnemy)
{ {
ship.rallyBehavior = RallyBehavior{m_rallyPoint}; m_admin.addComponent<RallyBehavior>(entity, RallyBehavior{m_rallyPoint});
} }
} }
@@ -96,23 +89,23 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
cargo.capacity = def->salvage->cargoCapacity; cargo.capacity = def->salvage->cargoCapacity;
cargo.current = 0; cargo.current = 0;
cargo.collectionRange = static_cast<float>(def->salvage->collectionRange); cargo.collectionRange = static_cast<float>(def->salvage->collectionRange);
ship.cargo = cargo; m_admin.addComponent<SalvageCargo>(entity, cargo);
ScrapCollector sc; SalvageBehavior salvageBehavior;
sc.scrapTarget = std::nullopt; salvageBehavior.scrapTarget = std::nullopt;
sc.deliveryBay = kInvalidEntityId; salvageBehavior.deliveryBay = kInvalidBuildingId;
ship.scrapCollector = sc; m_admin.addComponent<SalvageBehavior>(entity, salvageBehavior);
} }
if (def->repair) if (def->repair)
{ {
RepairTool rt; RepairTool rt;
rt.ratePerTick = static_cast<float>(def->repair->repairRateFormula.evaluate(x)); rt.ratePerTick = static_cast<float>(def->repair->repairRateFormula.evaluate(x));
rt.range = static_cast<float>(def->repair->repairRangeFormula.evaluate(x)); rt.range = static_cast<float>(def->repair->repairRangeFormula.evaluate(x));
ship.repairTool = rt; rt.currentTarget = std::nullopt;
m_admin.addComponent<RepairTool>(entity, rt);
RepairBehavior rb; m_admin.addComponent<RepairBehavior>(entity, RepairBehavior{});
ship.repairBehavior = rb;
} }
// Apply module stat modifiers (REQ-MOD-STAT-CALC). // Apply module stat modifiers (REQ-MOD-STAT-CALC).
@@ -152,106 +145,50 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
} }
}; };
applyMod(ship.maxHp, "hp"); Health& health = m_admin.get<Health>(entity);
ship.hp = ship.maxHp; ShipDynamics& dynamics = m_admin.get<ShipDynamics>(entity);
applyMod(ship.maxSpeedPerTick, "speed"); SensorRange& sensor = m_admin.get<SensorRange>(entity);
applyMod(ship.mainAccelerationPerTick, "main_acceleration");
applyMod(ship.maneuveringAccelerationPerTick, "maneuvering_acceleration"); applyMod(health.maxHp, "hp");
applyMod(ship.angularAccelerationPerTick, "angular_acceleration"); health.hp = health.maxHp;
applyMod(ship.maxRotationSpeedPerTick, "max_rotation_speed"); applyMod(dynamics.maxSpeedPerTick, "speed");
applyMod(ship.sensorRange, "sensor_range"); applyMod(dynamics.mainAccelerationPerTick, "main_acceleration");
if (ship.weapon.has_value()) applyMod(dynamics.maneuveringAccelerationPerTick, "maneuvering_acceleration");
applyMod(dynamics.angularAccelerationPerTick, "angular_acceleration");
applyMod(dynamics.maxRotationSpeedPerTick, "max_rotation_speed");
applyMod(sensor.value, "sensor_range");
if (m_admin.hasAll<Weapon>(entity))
{ {
applyMod(ship.weapon->damage, "damage"); Weapon& weapon = m_admin.get<Weapon>(entity);
applyMod(ship.weapon->range, "attack_range"); applyMod(weapon.damage, "damage");
applyMod(ship.weapon->fireRateHz, "attack_rate"); applyMod(weapon.range, "attack_range");
applyMod(weapon.fireRateHz, "attack_rate");
} }
if (ship.repairTool.has_value()) if (m_admin.hasAll<RepairTool>(entity))
{ {
applyMod(ship.repairTool->ratePerTick, "repair_rate"); RepairTool& repairTool = m_admin.get<RepairTool>(entity);
applyMod(ship.repairTool->range, "repair_range"); applyMod(repairTool.ratePerTick, "repair_rate");
applyMod(repairTool.range, "repair_range");
} }
} }
m_ships.push_back(ship); return entity;
return ship.id;
} }
void ShipSystem::despawn(EntityId id) void ShipSystem::despawn(entt::entity entity)
{ {
m_ships.erase( m_admin.destroy(entity);
std::remove_if(m_ships.begin(), m_ships.end(),
[id](const Ship& s) { return s.id == id; }),
m_ships.end());
} }
const Ship* ShipSystem::findShip(EntityId id) const
{
for (const Ship& s : m_ships)
{
if (s.id == id)
{
return &s;
}
}
return nullptr;
}
std::vector<Ship> ShipSystem::allShips() const
{
return m_ships;
}
void ShipSystem::forEach(std::function<void(Ship&)> fn)
{
for (Ship& s : m_ships)
{
fn(s);
}
}
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;
}
bool ShipSystem::damageShip(EntityId id, float amount)
{
for (Ship& s : m_ships)
{
if (s.id == id)
{
s.hp -= amount;
return true;
}
}
return false;
}
// ---------------------------------------------------------------------------
// clearMovementIntents
// ---------------------------------------------------------------------------
void ShipSystem::clearMovementIntents() void ShipSystem::clearMovementIntents()
{ {
for (Ship& s : m_ships) m_admin.forEach<MovementIntent>([](entt::entity /*e*/, MovementIntent& i)
{ {
s.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)}; i = MovementIntent{0, QVector2D(0.0f, 0.0f)};
} });
} }
// ---------------------------------------------------------------------------
// Rally point management (REQ-SHP-RALLY)
// ---------------------------------------------------------------------------
void ShipSystem::setRallyPoint(QVector2D point) void ShipSystem::setRallyPoint(QVector2D point)
{ {
m_rallyPoint = point; m_rallyPoint = point;
@@ -259,11 +196,17 @@ void ShipSystem::setRallyPoint(QVector2D point)
void ShipSystem::triggerRallyDeparture() void ShipSystem::triggerRallyDeparture()
{ {
for (Ship& s : m_ships) std::vector<entt::entity> toRemove;
{ m_admin.forEach<RallyBehavior, Faction>(
if (!s.isEnemy) [&toRemove](entt::entity e, const RallyBehavior& /*rb*/, const Faction& f)
{ {
s.rallyBehavior = std::nullopt; if (!f.isEnemy)
} {
toRemove.push_back(e);
}
});
for (entt::entity e : toRemove)
{
m_admin.removeComponent<RallyBehavior>(e);
} }
} }

View File

@@ -1,30 +1,27 @@
#pragma once #pragma once
#include <functional> #include <optional>
#include <vector> #include <string>
#include <QVector2D> #include <QVector2D>
#include "EntityId.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Ship.h" #include "Ship.h"
#include "ShipLayout.h" #include "ShipLayout.h"
#include "entt/entity/entity.hpp"
class EntityAdmin;
class ShipSystem class ShipSystem
{ {
public: public:
ShipSystem(const GameConfig& config, ShipSystem(const GameConfig& config, EntityAdmin& admin);
std::function<EntityId()> allocateId);
// isEnemy defaults to false; set true for enemy-faction ships (step 7 wave spawning). entt::entity spawn(const std::string& schematicId, int level, QVector2D position,
EntityId spawn(const std::string& schematicId, int level, QVector2D position, bool isEnemy = false,
bool isEnemy = false, const std::optional<ShipLayoutConfig>& layout = std::nullopt);
const std::optional<ShipLayoutConfig>& layout = std::nullopt); void despawn(entt::entity entity);
void despawn(EntityId id);
const Ship* findShip(EntityId id) const;
std::vector<Ship> allShips() const;
void forEach(std::function<void(Ship&)> fn);
// Reset all movement intents to priority 0 before behavior systems run. // Reset all movement intents to priority 0 before behavior systems run.
void clearMovementIntents(); void clearMovementIntents();
@@ -35,20 +32,11 @@ public:
// Release all gathered player combat ships to advance toward the enemy. // Release all gathered player combat ships to advance toward the enemy.
void triggerRallyDeparture(); void triggerRallyDeparture();
// Reduce ship HP by amount. Does not remove the ship; step 9 handles death.
// Returns false if ship not found.
bool damageShip(EntityId id, float amount);
// 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);
private: private:
const ShipDef* findShipDef(const std::string& schematicId) const; const ShipDef* findShipDef(const std::string& schematicId) const;
const ModuleDef* findModuleDef(const std::string& id) const; const ModuleDef* findModuleDef(const std::string& id) const;
const GameConfig& m_config; const GameConfig& m_config;
std::function<EntityId()> m_allocateId; EntityAdmin& m_admin;
std::vector<Ship> m_ships; QVector2D m_rallyPoint;
QVector2D m_rallyPoint;
}; };

View File

@@ -4,6 +4,7 @@
#include "AiSystem.h" #include "AiSystem.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "EcsComponents.h"
#include "CombatSystem.h" #include "CombatSystem.h"
#include "MovementSystem.h" #include "MovementSystem.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
@@ -16,21 +17,22 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
, m_rng(seed) , m_rng(seed)
, m_currentTick(0) , m_currentTick(0)
, m_nextDepartureTick(secondsToTicks(m_config.world.departureIntervalSeconds)) , m_nextDepartureTick(secondsToTicks(m_config.world.departureIntervalSeconds))
, m_nextId(1) , m_nextBuildingId(1)
, m_buildingBlocksStock(m_config.world.startingBuildingBlocks) , m_buildingBlocksStock(m_config.world.startingBuildingBlocks)
, m_gameOver(false) , m_gameOver(false)
, m_hqId(kInvalidEntityId) , m_hqBuildingId(kInvalidBuildingId)
, m_playerStation1Id(kInvalidEntityId) , m_hqProxyEntity(entt::null)
, m_playerStation2Id(kInvalidEntityId) , m_playerStation1Entity(entt::null)
, m_playerStation2Entity(entt::null)
, m_beltSystem(m_config.world.beltSpeedTilesPerSecond) , m_beltSystem(m_config.world.beltSpeedTilesPerSecond)
{ {
m_currentEnemyStationIds[0] = kInvalidEntityId; m_currentEnemyStationEntities[0] = entt::null;
m_currentEnemyStationIds[1] = kInvalidEntityId; m_currentEnemyStationEntities[1] = entt::null;
m_buildingSystem = std::make_unique<BuildingSystem>( m_buildingSystem = std::make_unique<BuildingSystem>(
m_config, m_config,
m_beltSystem, m_beltSystem,
[this]() { return allocateId(); }, [this]() { return allocateBuildingId(); },
[this](int amount) { m_buildingBlocksStock += amount; }, [this](int amount) { m_buildingBlocksStock += amount; },
[this](const std::string& id, QVector2D pos, [this](const std::string& id, QVector2D pos,
const std::optional<ShipLayoutConfig>& layout) { const std::optional<ShipLayoutConfig>& layout) {
@@ -43,10 +45,10 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout); m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout);
}, },
m_rng); m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, [this]() { return allocateId(); }); m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>(); m_aiSystem = std::make_unique<AiSystem>();
m_movementSystem = std::make_unique<MovementSystem>(); m_movementSystem = std::make_unique<MovementSystem>();
m_scrapSystem = std::make_unique<ScrapSystem>([this]() { return allocateId(); }); m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng); m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config); m_combatSystem = std::make_unique<CombatSystem>(m_config);
@@ -80,22 +82,24 @@ void Simulation::reset(unsigned int seed)
m_rng.seed(seed); m_rng.seed(seed);
m_currentTick = 0; m_currentTick = 0;
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds); m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
m_nextId = 1; m_nextBuildingId = 1;
m_buildingBlocksStock = m_config.world.startingBuildingBlocks; m_buildingBlocksStock = m_config.world.startingBuildingBlocks;
m_gameOver = false; m_gameOver = false;
m_hqId = kInvalidEntityId; m_hqBuildingId = kInvalidBuildingId;
m_playerStation1Id = kInvalidEntityId; m_hqProxyEntity = entt::null;
m_playerStation2Id = kInvalidEntityId; m_playerStation1Entity = entt::null;
m_currentEnemyStationIds[0] = kInvalidEntityId; m_playerStation2Entity = entt::null;
m_currentEnemyStationIds[1] = kInvalidEntityId; m_currentEnemyStationEntities[0] = entt::null;
m_currentEnemyStationEntities[1] = entt::null;
m_fireEvents.clear(); m_fireEvents.clear();
m_schematicDropEvents.clear(); m_schematicDropEvents.clear();
m_admin.clear();
m_beltSystem = BeltSystem(m_config.world.beltSpeedTilesPerSecond); m_beltSystem = BeltSystem(m_config.world.beltSpeedTilesPerSecond);
m_buildingSystem = std::make_unique<BuildingSystem>( m_buildingSystem = std::make_unique<BuildingSystem>(
m_config, m_config,
m_beltSystem, m_beltSystem,
[this]() { return allocateId(); }, [this]() { return allocateBuildingId(); },
[this](int amount) { m_buildingBlocksStock += amount; }, [this](int amount) { m_buildingBlocksStock += amount; },
[this](const std::string& id, QVector2D pos, [this](const std::string& id, QVector2D pos,
const std::optional<ShipLayoutConfig>& layout) { const std::optional<ShipLayoutConfig>& layout) {
@@ -108,10 +112,10 @@ void Simulation::reset(unsigned int seed)
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout); m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout);
}, },
m_rng); m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, [this]() { return allocateId(); }); m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>(); m_aiSystem = std::make_unique<AiSystem>();
m_movementSystem = std::make_unique<MovementSystem>(); m_movementSystem = std::make_unique<MovementSystem>();
m_scrapSystem = std::make_unique<ScrapSystem>([this]() { return allocateId(); }); m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng); m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config); m_combatSystem = std::make_unique<CombatSystem>(m_config);
@@ -158,17 +162,17 @@ void Simulation::tick()
} }
m_shipSystem->clearMovementIntents(); m_shipSystem->clearMovementIntents();
m_aiSystem->tickHomeReturn(*m_shipSystem); // priority 4 m_aiSystem->tickHomeReturnBehavior(m_admin); // priority 4
m_aiSystem->tickThreatResponse(*m_shipSystem, *m_buildingSystem); // priority 3 m_aiSystem->tickThreatResponseBehavior(m_admin, *m_buildingSystem); // priority 3
m_aiSystem->tickRepairBehavior(*m_shipSystem, *m_buildingSystem); // priority 2 m_aiSystem->tickRepairBehavior(m_admin, *m_buildingSystem); // priority 2
m_aiSystem->tickScrapCollector(*m_shipSystem, *m_scrapSystem, *m_buildingSystem); // priority 1 m_aiSystem->tickSalvageBehavior(m_admin, *m_scrapSystem, *m_buildingSystem); // priority 1
// Step 8: combat resolution // Step 8: combat resolution
m_combatSystem->tick(m_currentTick, *m_shipSystem, m_combatSystem->tick(m_currentTick, m_admin,
*m_buildingSystem, m_fireEvents); *m_buildingSystem, m_fireEvents);
// Step 8b: deferred damage whose impact tick has arrived // Step 8b: deferred damage whose impact tick has arrived
m_combatSystem->applyPendingDamage(m_currentTick, *m_shipSystem, *m_buildingSystem); m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
// Step 9: deaths & loot // Step 9: deaths & loot
if (!m_gameOver) if (!m_gameOver)
@@ -177,7 +181,7 @@ void Simulation::tick()
} }
// Step 10: advance ship positions // Step 10: advance ship positions
m_movementSystem->tick(*m_shipSystem); m_movementSystem->tick(m_admin);
// Step 11: scrap despawn // Step 11: scrap despawn
m_scrapSystem->tickDespawn(m_currentTick); m_scrapSystem->tickDespawn(m_currentTick);
@@ -192,6 +196,7 @@ void Simulation::tick()
void Simulation::placeInitialStructures() void Simulation::placeInitialStructures()
{ {
// HQ — right edge of asteroid (rightmost asteroid tile is x = -1). // HQ — right edge of asteroid (rightmost asteroid tile is x = -1).
// Placed as a Building (for belt input) plus an ECS proxy (for HP/targeting).
const ParsedSurfaceMask hqParsed = const ParsedSurfaceMask hqParsed =
parseSurfaceMask(m_config.stations.hq.surfaceMask, Rotation::East); parseSurfaceMask(m_config.stations.hq.surfaceMask, Rotation::East);
const int hqAnchorX = -hqParsed.footprint.width(); const int hqAnchorX = -hqParsed.footprint.width();
@@ -199,13 +204,18 @@ void Simulation::placeInitialStructures()
(m_config.world.heightTiles - hqParsed.footprint.height()) / 2; (m_config.world.heightTiles - hqParsed.footprint.height()) / 2;
const float hqHp = const float hqHp =
static_cast<float>(m_config.stations.hq.hpFormula.evaluate(0.0)); static_cast<float>(m_config.stations.hq.hpFormula.evaluate(0.0));
m_hqId = m_buildingSystem->placeImmediate( m_hqBuildingId = m_buildingSystem->placeImmediate(
BuildingType::Hq, BuildingType::Hq,
m_config.stations.hq.surfaceMask, m_config.stations.hq.surfaceMask,
QPoint(hqAnchorX, hqAnchorY), QPoint(hqAnchorX, hqAnchorY),
Rotation::East, hqHp, hqHp); Rotation::East);
// Player defence stations — right edge of player buffer zone. const QVector2D hqCenter(
hqAnchorX + hqParsed.footprint.width() / 2.0f,
hqAnchorY + hqParsed.footprint.height() / 2.0f);
m_hqProxyEntity = m_admin.spawnHqProxy(hqCenter, hqHp, hqHp);
// Player defence stations — ECS entities with tile occupancy.
const ParsedSurfaceMask psParsed = const ParsedSurfaceMask psParsed =
parseSurfaceMask(m_config.stations.playerStation.surfaceMask, Rotation::East); parseSurfaceMask(m_config.stations.playerStation.surfaceMask, Rotation::East);
const int psAnchorX = const int psAnchorX =
@@ -214,7 +224,7 @@ void Simulation::placeInitialStructures()
const float psHp = static_cast<float>( const float psHp = static_cast<float>(
m_config.stations.playerStation.hpFormula.evaluate(psLevel)); m_config.stations.playerStation.hpFormula.evaluate(psLevel));
StationWeapon psWeapon; Weapon psWeapon;
psWeapon.damage = static_cast<float>( psWeapon.damage = static_cast<float>(
m_config.stations.playerStation.damageFormula.evaluate(psLevel)); m_config.stations.playerStation.damageFormula.evaluate(psLevel));
psWeapon.range = static_cast<float>( psWeapon.range = static_cast<float>(
@@ -222,21 +232,35 @@ void Simulation::placeInitialStructures()
psWeapon.fireRateHz = static_cast<float>( psWeapon.fireRateHz = static_cast<float>(
m_config.stations.playerStation.fireRateFormula.evaluate(psLevel)); m_config.stations.playerStation.fireRateFormula.evaluate(psLevel));
psWeapon.cooldownTicks = 0.0f; psWeapon.cooldownTicks = 0.0f;
psWeapon.currentTarget = std::nullopt;
const int ps1Y = m_config.world.heightTiles / 4; const int ps1Y = m_config.world.heightTiles / 4;
const int ps2Y = 3 * m_config.world.heightTiles / 4; const int ps2Y = 3 * m_config.world.heightTiles / 4;
m_playerStation1Id = m_buildingSystem->placeImmediate( {
BuildingType::PlayerDefenceStation, const QPoint anchor(psAnchorX, ps1Y);
m_config.stations.playerStation.surfaceMask, std::vector<QPoint> absCells;
QPoint(psAnchorX, ps1Y), Rotation::East, psHp, psHp); for (const QPoint& rel : psParsed.bodyCells)
m_buildingSystem->initStationWeapon(m_playerStation1Id, psWeapon); {
absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
m_playerStation2Id = m_buildingSystem->placeImmediate( }
BuildingType::PlayerDefenceStation, m_playerStation1Entity = m_admin.spawnStation(
m_config.stations.playerStation.surfaceMask, anchor, psParsed.footprint, absCells, psHp, psHp, false);
QPoint(psAnchorX, ps2Y), Rotation::East, psHp, psHp); m_admin.addComponent<Weapon>(m_playerStation1Entity, psWeapon);
m_buildingSystem->initStationWeapon(m_playerStation2Id, psWeapon); m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
}
{
const QPoint anchor(psAnchorX, ps2Y);
std::vector<QPoint> absCells;
for (const QPoint& rel : psParsed.bodyCells)
{
absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
}
m_playerStation2Entity = m_admin.spawnStation(
anchor, psParsed.footprint, absCells, psHp, psHp, false);
m_admin.addComponent<Weapon>(m_playerStation2Entity, psWeapon);
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
}
// Rally point: center of the player defence stations' X column, world vertical midpoint. // Rally point: center of the player defence stations' X column, world vertical midpoint.
const float rallyX = static_cast<float>(psAnchorX) + psParsed.footprint.width() / 2.0f; const float rallyX = static_cast<float>(psAnchorX) + psParsed.footprint.width() / 2.0f;
@@ -252,7 +276,6 @@ void Simulation::placeEnemyStationSet(int generation)
const ParsedSurfaceMask esParsed = const ParsedSurfaceMask esParsed =
parseSurfaceMask(m_config.stations.enemyStation.surfaceMask, Rotation::East); parseSurfaceMask(m_config.stations.enemyStation.surfaceMask, Rotation::East);
// Right edge of contest zone, shifted right by (generation * pushExpandColumns).
const int rightEdgeX = m_config.world.regions.playerBufferWidth const int rightEdgeX = m_config.world.regions.playerBufferWidth
+ m_config.world.regions.contestZoneWidth + m_config.world.regions.contestZoneWidth
+ generation * m_config.world.push.pushExpandColumns; + generation * m_config.world.push.pushExpandColumns;
@@ -262,7 +285,7 @@ void Simulation::placeEnemyStationSet(int generation)
const float esHp = static_cast<float>( const float esHp = static_cast<float>(
m_config.stations.enemyStation.hpFormula.evaluate(genD)); m_config.stations.enemyStation.hpFormula.evaluate(genD));
StationWeapon esWeapon; Weapon esWeapon;
esWeapon.damage = static_cast<float>( esWeapon.damage = static_cast<float>(
m_config.stations.enemyStation.damageFormula.evaluate(genD)); m_config.stations.enemyStation.damageFormula.evaluate(genD));
esWeapon.range = static_cast<float>( esWeapon.range = static_cast<float>(
@@ -270,24 +293,35 @@ void Simulation::placeEnemyStationSet(int generation)
esWeapon.fireRateHz = static_cast<float>( esWeapon.fireRateHz = static_cast<float>(
m_config.stations.enemyStation.fireRateFormula.evaluate(genD)); m_config.stations.enemyStation.fireRateFormula.evaluate(genD));
esWeapon.cooldownTicks = 0.0f; esWeapon.cooldownTicks = 0.0f;
esWeapon.currentTarget = std::nullopt;
const int y1 = m_config.world.heightTiles / 4; const int y1 = m_config.world.heightTiles / 4;
const int y2 = 3 * m_config.world.heightTiles / 4; const int y2 = 3 * m_config.world.heightTiles / 4;
const EntityId id1 = m_buildingSystem->placeImmediate( {
BuildingType::EnemyDefenceStation, const QPoint anchor(anchorX, y1);
m_config.stations.enemyStation.surfaceMask, std::vector<QPoint> absCells;
QPoint(anchorX, y1), Rotation::East, esHp, esHp); for (const QPoint& rel : esParsed.bodyCells)
m_buildingSystem->initStationWeapon(id1, esWeapon); {
absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
const EntityId id2 = m_buildingSystem->placeImmediate( }
BuildingType::EnemyDefenceStation, m_currentEnemyStationEntities[0] = m_admin.spawnStation(
m_config.stations.enemyStation.surfaceMask, anchor, esParsed.footprint, absCells, esHp, esHp, true);
QPoint(anchorX, y2), Rotation::East, esHp, esHp); m_admin.addComponent<Weapon>(m_currentEnemyStationEntities[0], esWeapon);
m_buildingSystem->initStationWeapon(id2, esWeapon); m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
}
m_currentEnemyStationIds[0] = id1; {
m_currentEnemyStationIds[1] = id2; const QPoint anchor(anchorX, y2);
std::vector<QPoint> absCells;
for (const QPoint& rel : esParsed.bodyCells)
{
absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
}
m_currentEnemyStationEntities[1] = m_admin.spawnStation(
anchor, esParsed.footprint, absCells, esHp, esHp, true);
m_admin.addComponent<Weapon>(m_currentEnemyStationEntities[1], esWeapon);
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -297,98 +331,92 @@ void Simulation::placeEnemyStationSet(int generation)
void Simulation::tickDeathsAndLoot() void Simulation::tickDeathsAndLoot()
{ {
// --- Dead ships --- // --- Dead ships ---
std::vector<EntityId> deadShipIds; std::vector<entt::entity> deadShips;
m_shipSystem->forEach([&deadShipIds](Ship& s) m_admin.forEach<ShipIdentity, Health>(
{ [&deadShips](entt::entity e, const ShipIdentity& /*si*/, const Health& h)
if (s.hp <= 0.0f)
{ {
deadShipIds.push_back(s.id); if (h.hp <= 0.0f)
} {
}); deadShips.push_back(e);
}
});
for (EntityId deadId : deadShipIds) for (entt::entity deadEntity : deadShips)
{ {
const Ship* s = m_shipSystem->findShip(deadId); const ShipIdentity& si = m_admin.get<ShipIdentity>(deadEntity);
if (!s) const Position& pos = m_admin.get<Position>(deadEntity);
{
continue;
}
// Look up scrap drop amount from config.
for (const ShipDef& def : m_config.ships.ships) for (const ShipDef& def : m_config.ships.ships)
{ {
if (def.id == s->schematicId && def.loot.scrapDrop > 0) if (def.id == si.schematicId && def.loot.scrapDrop > 0)
{ {
const Tick despawnAt = m_currentTick const Tick despawnAt = m_currentTick
+ secondsToTicks(m_config.world.scrapDespawnSeconds); + secondsToTicks(m_config.world.scrapDespawnSeconds);
m_scrapSystem->spawn(s->position, def.loot.scrapDrop, despawnAt); m_scrapSystem->spawn(pos.value, def.loot.scrapDrop, despawnAt);
break; break;
} }
} }
m_shipSystem->despawn(deadId); m_shipSystem->despawn(deadEntity);
} }
// --- Dead buildings (HQ, player/enemy defence stations) --- // --- Dead stations ---
std::vector<EntityId> deadBuildingIds; std::vector<entt::entity> deadStations;
for (const Building& b : m_buildingSystem->allBuildings()) m_admin.forEach<StationBody, Health>(
{ [&deadStations](entt::entity e, const StationBody& /*sb*/, const Health& h)
if (b.hp <= 0.0f &&
(b.type == BuildingType::Hq ||
b.type == BuildingType::PlayerDefenceStation ||
b.type == BuildingType::EnemyDefenceStation))
{ {
deadBuildingIds.push_back(b.id); if (h.hp <= 0.0f)
} {
} deadStations.push_back(e);
}
});
for (EntityId deadId : deadBuildingIds) for (entt::entity deadEntity : deadStations)
{ {
const Building* b = m_buildingSystem->findBuilding(deadId); const StationBody& sb = m_admin.get<StationBody>(deadEntity);
if (!b) const Position& pos = m_admin.get<Position>(deadEntity);
{ const Faction& fac = m_admin.get<Faction>(deadEntity);
continue;
}
if (b->type == BuildingType::Hq) const Tick despawnAt = m_currentTick
+ secondsToTicks(m_config.world.scrapDespawnSeconds);
int scrap = 0;
if (!fac.isEnemy)
{ {
m_gameOver = true; const double lv = static_cast<double>(
m_config.stations.playerStation.level);
scrap = static_cast<int>(
m_config.stations.playerStation.scrapDropFormula.evaluate(lv));
} }
else else
{ {
const QVector2D center( const double genD = static_cast<double>(m_waveSystem->generation());
b->anchor.x() + b->footprint.width() / 2.0f, scrap = static_cast<int>(
b->anchor.y() + b->footprint.height() / 2.0f); m_config.stations.enemyStation.scrapDropFormula.evaluate(genD));
const Tick despawnAt = m_currentTick }
+ secondsToTicks(m_config.world.scrapDespawnSeconds); if (scrap > 0)
int scrap = 0; {
if (b->type == BuildingType::PlayerDefenceStation) m_scrapSystem->spawn(pos.value, scrap, despawnAt);
{ }
const double lv = static_cast<double>( m_buildingSystem->unregisterTileOccupancy(sb.bodyCells);
m_config.stations.playerStation.level); m_admin.destroy(deadEntity);
scrap = static_cast<int>( }
m_config.stations.playerStation.scrapDropFormula.evaluate(lv));
} // --- HQ death check ---
else if (b->type == BuildingType::EnemyDefenceStation) if (m_admin.isValid(m_hqProxyEntity))
{ {
const double genD = static_cast<double>(m_waveSystem->generation()); const Health& hqHealth = m_admin.get<Health>(m_hqProxyEntity);
scrap = static_cast<int>( if (hqHealth.hp <= 0.0f)
m_config.stations.enemyStation.scrapDropFormula.evaluate(genD)); {
} m_gameOver = true;
if (scrap > 0)
{
m_scrapSystem->spawn(center, scrap, despawnAt);
}
} }
m_buildingSystem->removeBuilding(deadId);
} }
// --- Push check: if both current enemy stations are gone, trigger push --- // --- Push check: if both current enemy stations are gone, trigger push ---
const bool es0Gone = const bool es0Gone = !m_admin.isValid(m_currentEnemyStationEntities[0])
(m_buildingSystem->findBuilding(m_currentEnemyStationIds[0]) == nullptr); || m_admin.get<Health>(m_currentEnemyStationEntities[0]).hp <= 0.0f;
const bool es1Gone = const bool es1Gone = !m_admin.isValid(m_currentEnemyStationEntities[1])
(m_buildingSystem->findBuilding(m_currentEnemyStationIds[1]) == nullptr); || m_admin.get<Health>(m_currentEnemyStationEntities[1]).hp <= 0.0f;
if (es0Gone && es1Gone && if (es0Gone && es1Gone &&
m_currentEnemyStationIds[0] != kInvalidEntityId) m_currentEnemyStationEntities[0] != entt::null)
{ {
m_waveSystem->applyPush(); m_waveSystem->applyPush();
placeEnemyStationSet(m_waveSystem->generation()); placeEnemyStationSet(m_waveSystem->generation());
@@ -484,7 +512,7 @@ bool Simulation::isSchematicUnlocked(const std::string& shipId) const
return it->second.unlocked; return it->second.unlocked;
} }
EntityId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation) BuildingId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{ {
int cost = 0; int cost = 0;
for (const BuildingDef& def : m_config.buildings.buildings) for (const BuildingDef& def : m_config.buildings.buildings)
@@ -497,13 +525,13 @@ EntityId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation
} }
if (m_buildingBlocksStock < cost) if (m_buildingBlocksStock < cost)
{ {
return kInvalidEntityId; return kInvalidBuildingId;
} }
m_buildingBlocksStock -= cost; m_buildingBlocksStock -= cost;
return m_buildingSystem->place(type, anchor, rotation, m_currentTick); return m_buildingSystem->place(type, anchor, rotation, m_currentTick);
} }
void Simulation::demolish(EntityId id) void Simulation::demolish(BuildingId id)
{ {
m_buildingBlocksStock += m_buildingSystem->demolish(id); m_buildingBlocksStock += m_buildingSystem->demolish(id);
} }
@@ -548,7 +576,17 @@ const ScrapSystem& Simulation::scraps() const
return *m_scrapSystem; return *m_scrapSystem;
} }
EntityId Simulation::allocateId() EntityAdmin& Simulation::admin()
{ {
return m_nextId++; return m_admin;
}
const EntityAdmin& Simulation::admin() const
{
return m_admin;
}
BuildingId Simulation::allocateBuildingId()
{
return m_nextBuildingId++;
} }

View File

@@ -9,9 +9,12 @@
#include <QPoint> #include <QPoint>
#include "BeltSystem.h" #include "BeltSystem.h"
#include "EntityAdmin.h"
#include "entt/entity/entity.hpp"
#include "SchematicDropEvent.h" #include "SchematicDropEvent.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "EntityId.h" #include "BuildingId.h"
#include "FireEvent.h" #include "FireEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Rotation.h" #include "Rotation.h"
@@ -59,11 +62,11 @@ public:
bool isSchematicUnlocked(const std::string& shipId) const; bool isSchematicUnlocked(const std::string& shipId) const;
// Checks affordability, deducts building blocks, and places the building. // Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidEntityId if blocks are insufficient. // Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
EntityId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation); BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// Demolishes the building with the given id and refunds building blocks. // Demolishes the building with the given id and refunds building blocks.
void demolish(EntityId id); void demolish(BuildingId id);
BuildingSystem& buildings(); BuildingSystem& buildings();
const BuildingSystem& buildings() const; const BuildingSystem& buildings() const;
@@ -73,9 +76,11 @@ public:
const ShipSystem& ships() const; const ShipSystem& ships() const;
ScrapSystem& scraps(); ScrapSystem& scraps();
const ScrapSystem& scraps() const; const ScrapSystem& scraps() const;
EntityAdmin& admin();
const EntityAdmin& admin() const;
private: private:
EntityId allocateId(); // Strictly increasing; never returns kInvalidEntityId. BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId.
// Populate HQ, player defence stations, and the first enemy station set. // Populate HQ, player defence stations, and the first enemy station set.
void placeInitialStructures(); void placeInitialStructures();
@@ -95,15 +100,16 @@ private:
Tick m_currentTick; Tick m_currentTick;
Tick m_nextDepartureTick; Tick m_nextDepartureTick;
EntityId m_nextId; BuildingId m_nextBuildingId;
int m_buildingBlocksStock; int m_buildingBlocksStock;
bool m_gameOver = false; bool m_gameOver = false;
// Pre-placed structure IDs. // Pre-placed structure IDs.
EntityId m_hqId; BuildingId m_hqBuildingId; // Building id (for belt integration)
EntityId m_playerStation1Id; entt::entity m_hqProxyEntity; // ECS entity (HP, targeting)
EntityId m_playerStation2Id; entt::entity m_playerStation1Entity;
EntityId m_currentEnemyStationIds[2]; entt::entity m_playerStation2Entity;
entt::entity m_currentEnemyStationEntities[2];
// Schematic unlock state (REQ-DEF-SCHEMATIC-DROP). // Schematic unlock state (REQ-DEF-SCHEMATIC-DROP).
struct SchematicState struct SchematicState
@@ -113,6 +119,7 @@ private:
}; };
std::map<std::string, SchematicState> m_schematicLevels; std::map<std::string, SchematicState> m_schematicLevels;
EntityAdmin m_admin;
BeltSystem m_beltSystem; BeltSystem m_beltSystem;
std::unique_ptr<BuildingSystem> m_buildingSystem; std::unique_ptr<BuildingSystem> m_buildingSystem;
std::unique_ptr<ShipSystem> m_shipSystem; std::unique_ptr<ShipSystem> m_shipSystem;

View File

@@ -10,9 +10,10 @@
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "EcsComponents.h"
#include "EntityAdmin.h"
#include "MovementSystem.h" #include "MovementSystem.h"
#include "Rotation.h" #include "Rotation.h"
#include "Scrap.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include "Ship.h" #include "Ship.h"
#include "ShipSystem.h" #include "ShipSystem.h"
@@ -31,9 +32,10 @@ struct Fixture
{ {
GameConfig cfg; GameConfig cfg;
BeltSystem belts; BeltSystem belts;
EntityId nextId; BuildingId nextBuildingId;
int stock; int stock;
std::mt19937 rng; std::mt19937 rng;
EntityAdmin admin;
BuildingSystem buildings; BuildingSystem buildings;
ShipSystem ships; ShipSystem ships;
AiSystem ai; AiSystem ai;
@@ -44,16 +46,16 @@ struct Fixture
explicit Fixture() explicit Fixture()
: cfg(loadConfig()) : cfg(loadConfig())
, belts(cfg.world.beltSpeedTilesPerSecond) , belts(cfg.world.beltSpeedTilesPerSecond)
, nextId(1) , nextBuildingId(1)
, stock(0) , stock(0)
, rng(42) , rng(42)
, buildings(cfg, belts, , buildings(cfg, belts,
[this]() { return nextId++; }, [this]() { return nextBuildingId++; },
[this](int n) { stock += n; }, [this](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng) rng)
, ships(cfg, [this]() { return nextId++; }) , ships(cfg, admin)
, scraps([this]() { return nextId++; }) , scraps(admin)
, tick(0) , tick(0)
{ {
} }
@@ -62,15 +64,31 @@ struct Fixture
void runBehaviorTick() void runBehaviorTick()
{ {
ships.clearMovementIntents(); ships.clearMovementIntents();
ai.tickHomeReturn(ships); ai.tickHomeReturnBehavior(admin);
ai.tickThreatResponse(ships, buildings); ai.tickThreatResponseBehavior(admin, buildings);
ai.tickRepairBehavior(ships, buildings); ai.tickRepairBehavior(admin, buildings);
ai.tickScrapCollector(ships, scraps, buildings); ai.tickSalvageBehavior(admin, scraps, buildings);
movement.tick(ships); movement.tick(admin);
++tick; ++tick;
} }
}; };
// Helpers to read ECS data for a ship entity.
static const MovementIntent& intent(EntityAdmin& a, entt::entity e)
{
return a.get<MovementIntent>(e);
}
static const Health& health(EntityAdmin& a, entt::entity e)
{
return a.get<Health>(e);
}
static const Position& pos(EntityAdmin& a, entt::entity e)
{
return a.get<Position>(e);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// clearMovementIntents // clearMovementIntents
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -79,220 +97,181 @@ TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to priority 0",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = 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.admin.get<MovementIntent>(e) = MovementIntent{3, QVector2D(10.0f, 0.0f)};
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
const Ship* s = f.ships.findShip(id); REQUIRE(intent(f.admin, e).priority == 0);
REQUIRE(s != nullptr);
REQUIRE(s->intent.priority == 0);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tickMovement // tickMovement
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 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", TEST_CASE("BehaviorSystem: tickMovement advances ship by maxSpeedPerTick toward target",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const float speed = f.ships.findShip(id)->maxSpeedPerTick; const float speed = f.admin.get<ShipDynamics>(e).maxSpeedPerTick;
const QVector2D target(100.0f, 0.0f); f.admin.get<MovementIntent>(e) = MovementIntent{1, QVector2D(100.0f, 0.0f)};
f.movement.tick(f.admin);
f.ships.forEach([&target](Ship& s) { REQUIRE(pos(f.admin, e).value.x() == Approx(speed));
s.intent = MovementIntent{1, target}; REQUIRE(pos(f.admin, e).value.y() == Approx(0.0f));
});
f.movement.tick(f.ships);
const Ship* s = f.ships.findShip(id);
REQUIRE(s->position.x() == Approx(speed));
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", TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoot",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
// Place target closer than one tick's travel. const float speed = f.admin.get<ShipDynamics>(e).maxSpeedPerTick;
const float speed = f.ships.findShip(id)->maxSpeedPerTick;
const QVector2D target(speed * 0.5f, 0.0f); const QVector2D target(speed * 0.5f, 0.0f);
f.admin.get<MovementIntent>(e) = MovementIntent{1, target};
f.movement.tick(f.admin);
f.ships.forEach([&target](Ship& s) { REQUIRE(pos(f.admin, e).value.x() == Approx(target.x()));
s.intent = MovementIntent{1, target}; REQUIRE(pos(f.admin, e).value.y() == Approx(target.y()));
});
f.movement.tick(f.ships);
const Ship* s = f.ships.findShip(id);
REQUIRE(s->position.x() == Approx(target.x()));
REQUIRE(s->position.y() == Approx(target.y()));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tickHomeReturn // tickHomeReturnBehavior
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: tickHomeReturn does nothing when HP is above threshold", TEST_CASE("BehaviorSystem: tickHomeReturnBehavior does nothing when HP is above threshold",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
f.admin.addComponent<HomeReturnBehavior>(e, HomeReturnBehavior{0.3f, QVector2D(-10.0f, 0.0f)});
f.ships.forEach([](Ship& s) { f.admin.get<Health>(e).hp = f.admin.get<Health>(e).maxHp; // full HP
s.homeReturn = HomeReturn{0.3f, QVector2D(-10.0f, 0.0f)};
s.hp = s.maxHp; // full HP — above threshold
});
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickHomeReturn(f.ships); f.ai.tickHomeReturnBehavior(f.admin);
REQUIRE(f.ships.findShip(id)->intent.priority == 0); REQUIRE(intent(f.admin, e).priority == 0);
} }
TEST_CASE("BehaviorSystem: tickHomeReturn writes priority-4 intent toward homePos when HP is low", TEST_CASE("BehaviorSystem: tickHomeReturnBehavior writes priority-4 intent toward homePos when HP is low",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const QVector2D homePos(-10.0f, 0.0f); const QVector2D homePos(-10.0f, 0.0f);
f.admin.addComponent<HomeReturnBehavior>(e, HomeReturnBehavior{0.5f, homePos});
f.ships.forEach([&homePos](Ship& s) { f.admin.get<Health>(e).hp = f.admin.get<Health>(e).maxHp * 0.2f; // below threshold
s.homeReturn = HomeReturn{0.5f, homePos};
s.hp = s.maxHp * 0.2f; // below 50% threshold
});
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickHomeReturn(f.ships); f.ai.tickHomeReturnBehavior(f.admin);
const Ship* s = f.ships.findShip(id); REQUIRE(intent(f.admin, e).priority == 4);
REQUIRE(s->intent.priority == 4); REQUIRE(intent(f.admin, e).target.x() == Approx(homePos.x()));
REQUIRE(s->intent.target.x() == Approx(homePos.x()));
} }
TEST_CASE("BehaviorSystem: tickHomeReturn priority-4 beats tickThreatResponse priority-3", TEST_CASE("BehaviorSystem: tickHomeReturnBehavior priority-4 beats tickThreatResponseBehavior priority-3",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
// Player ship with both homeReturn (low HP) and an enemy in range. const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
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); f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f), /*isEnemy=*/true);
const QVector2D homePos(-50.0f, 0.0f); const QVector2D homePos(-50.0f, 0.0f);
f.ships.forEach([&homePos, playerId](Ship& s) { f.admin.addComponent<HomeReturnBehavior>(player, HomeReturnBehavior{0.5f, homePos});
if (s.id == playerId) f.admin.get<Health>(player).hp = f.admin.get<Health>(player).maxHp * 0.1f;
{
s.homeReturn = HomeReturn{0.5f, homePos};
s.hp = s.maxHp * 0.1f;
}
});
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickHomeReturn(f.ships); f.ai.tickHomeReturnBehavior(f.admin);
f.ai.tickThreatResponse(f.ships, f.buildings); f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
const Ship* s = f.ships.findShip(playerId); REQUIRE(intent(f.admin, player).priority == 4);
REQUIRE(s->intent.priority == 4); REQUIRE(intent(f.admin, player).target.x() == Approx(homePos.x()));
REQUIRE(s->intent.target.x() == Approx(homePos.x()));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tickThreatResponse — player ships // tickThreatResponseBehavior — player ships
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in range", TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in range",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
// Spawn enemy within attack range (150 tile units). const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f), /*isEnemy=*/true);
/*isEnemy=*/true);
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickThreatResponse(f.ships, f.buildings); f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
const Ship* player = f.ships.findShip(playerId); REQUIRE(f.admin.hasAll<ThreatResponseBehavior>(player));
REQUIRE(player->threatResponse.has_value()); const ThreatResponseBehavior& threatResponseBehavior = f.admin.get<ThreatResponseBehavior>(player);
REQUIRE(player->threatResponse->currentTarget.has_value()); REQUIRE(threatResponseBehavior.currentTarget.has_value());
REQUIRE(*player->threatResponse->currentTarget == enemyId); REQUIRE(*threatResponseBehavior.currentTarget == enemy);
} }
TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships", TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const EntityId id1 = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e1 = 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.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickThreatResponse(f.ships, f.buildings); f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
const Ship* s = f.ships.findShip(id1); REQUIRE(f.admin.hasAll<ThreatResponseBehavior>(e1));
REQUIRE(s->threatResponse.has_value()); REQUIRE_FALSE(f.admin.get<ThreatResponseBehavior>(e1).currentTarget.has_value());
REQUIRE_FALSE(s->threatResponse->currentTarget.has_value());
} }
TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range", TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = 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.spawn("interceptor", 1, QVector2D(500.0f, 0.0f), /*isEnemy=*/true);
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickThreatResponse(f.ships, f.buildings); f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
const Ship* s = f.ships.findShip(playerId); REQUIRE_FALSE(f.admin.get<ThreatResponseBehavior>(player).currentTarget.has_value());
REQUIRE_FALSE(s->threatResponse->currentTarget.has_value());
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tickThreatResponse — enemy ships // tickThreatResponseBehavior — enemy ships
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range", TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f), const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickThreatResponse(f.ships, f.buildings); f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
const Ship* enemy = f.ships.findShip(enemyId); REQUIRE(f.admin.hasAll<ThreatResponseBehavior>(enemy));
REQUIRE(enemy->threatResponse.has_value()); const ThreatResponseBehavior& threatResponseBehavior = f.admin.get<ThreatResponseBehavior>(enemy);
REQUIRE(enemy->threatResponse->currentTarget.has_value()); REQUIRE(threatResponseBehavior.currentTarget.has_value());
REQUIRE(*enemy->threatResponse->currentTarget == playerId); REQUIRE(*threatResponseBehavior.currentTarget == player);
} }
TEST_CASE("BehaviorSystem: enemy ship with no target writes leftward movement intent", TEST_CASE("BehaviorSystem: enemy ship with no target writes leftward movement intent",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f), const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickThreatResponse(f.ships, f.buildings); f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
const Ship* enemy = f.ships.findShip(enemyId); REQUIRE(intent(f.admin, enemy).priority == 3);
REQUIRE(enemy->intent.priority == 3); REQUIRE(intent(f.admin, enemy).target.x() < 0.0f);
REQUIRE(enemy->intent.target.x() < 0.0f); // moving leftward (toward asteroid)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -303,126 +282,95 @@ TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly shi
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f));
// Damage the friendly ship. f.admin.get<Health>(friendly).hp = f.admin.get<Health>(friendly).maxHp * 0.5f;
f.ships.forEach([friendlyId](Ship& s) {
if (s.id == friendlyId)
{
s.hp = s.maxHp * 0.5f;
}
});
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickRepairBehavior(f.ships, f.buildings); f.ai.tickRepairBehavior(f.admin, f.buildings);
const Ship* repair = f.ships.findShip(repairId); REQUIRE(intent(f.admin, repairShip).priority == 2);
REQUIRE(repair->intent.priority == 2); REQUIRE(intent(f.admin, repairShip).target.x() == Approx(5.0f));
REQUIRE(repair->intent.target.x() == Approx(5.0f));
} }
TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range", TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
// Repair range = 80 tile units; place ships close together. const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.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; const float initialHp = f.admin.get<Health>(friendly).maxHp * 0.5f;
f.ships.forEach([friendlyId, initialHp](Ship& s) { f.admin.get<Health>(friendly).hp = initialHp;
if (s.id == friendlyId)
{
s.hp = initialHp;
}
});
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickRepairBehavior(f.ships, f.buildings); f.ai.tickRepairBehavior(f.admin, f.buildings);
// repair_rate_formula = "5 + x" at x=1 → 6; hp should have increased. REQUIRE(health(f.admin, friendly).hp > initialHp);
const Ship* friendly = f.ships.findShip(friendlyId);
REQUIRE(friendly->hp > initialHp);
} }
TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]") TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
{ {
Fixture f; Fixture f;
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); 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 entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
// Nearly full HP — one repair tick must not exceed maxHp. f.admin.get<Health>(friendly).hp = f.admin.get<Health>(friendly).maxHp - 0.001f;
f.ships.forEach([friendlyId](Ship& s) {
if (s.id == friendlyId)
{
s.hp = s.maxHp - 0.001f;
}
});
for (int i = 0; i < 5; ++i) for (int i = 0; i < 5; ++i)
{ {
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickRepairBehavior(f.ships, f.buildings); f.ai.tickRepairBehavior(f.admin, f.buildings);
} }
const Ship* friendly = f.ships.findShip(friendlyId); const Health& h = health(f.admin, friendly);
REQUIRE(friendly->hp <= friendly->maxHp); REQUIRE(h.hp <= h.maxHp);
REQUIRE(friendly->hp == Approx(friendly->maxHp)); REQUIRE(h.hp == Approx(h.maxHp));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tickScrapCollector // tickSalvageBehavior
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[behavior]") TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[behavior]")
{ {
Fixture f; Fixture f;
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); const entt::entity ship = 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 QVector2D scrapPos(100.0f, 0.0f);
const Tick farFuture = 100000; f.scraps.spawn(scrapPos, 1, 100000);
f.scraps.spawn(scrapPos, 1, farFuture);
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
const Ship* s = f.ships.findShip(shipId); REQUIRE(intent(f.admin, ship).priority == 1);
REQUIRE(s->intent.priority == 1); REQUIRE(intent(f.admin, ship).target.x() == Approx(scrapPos.x()));
REQUIRE(s->intent.target.x() == Approx(scrapPos.x()));
} }
TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]") TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]")
{ {
Fixture f; Fixture f;
// Place scrap exactly at ship position so it is within collectionRange immediately. const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
const Tick farFuture = 100000;
const EntityId scrapId = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, farFuture);
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
const Ship* s = f.ships.findShip(shipId); REQUIRE(f.admin.get<SalvageCargo>(ship).current == 1);
REQUIRE(s->cargo->current == 1); REQUIRE_FALSE(f.admin.isValid(scrapEntity));
REQUIRE(f.scraps.findScrap(scrapId) == nullptr); // consumed
} }
TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[behavior]") TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[behavior]")
{ {
Fixture f; Fixture f;
// Place a SalvageBay building so the ship has somewhere to deliver. const BuildingId bayId = f.buildings.place(BuildingType::SalvageBay,
// 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); QPoint(-4, 0), Rotation::East, 0);
Tick tick = 0; Tick t = 0;
// SalvageBay construction_time_seconds = 15 → 450 ticks; run 500 to be safe.
for (int i = 0; i < 500; ++i) for (int i = 0; i < 500; ++i)
{ {
f.buildings.tickConstruction(tick++); f.buildings.tickConstruction(t++);
if (f.buildings.findBuilding(bayId) != nullptr) if (f.buildings.findBuilding(bayId) != nullptr)
{ {
break; break;
@@ -430,22 +378,16 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
} }
REQUIRE(f.buildings.findBuilding(bayId) != nullptr); REQUIRE(f.buildings.findBuilding(bayId) != nullptr);
// Spawn salvage ship and fill its cargo. const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f));
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f)); SalvageCargo& cargo = f.admin.get<SalvageCargo>(ship);
f.ships.forEach([](Ship& s) { cargo.current = cargo.capacity; // full cargo
if (s.cargo)
{
s.cargo->current = s.cargo->capacity; // full cargo
}
});
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
// Intent should point toward the bay (x < 0 area), not rightward. const MovementIntent& i = intent(f.admin, ship);
const Ship* s = f.ships.findShip(shipId); REQUIRE(i.priority == 1);
REQUIRE(s->intent.priority == 1); REQUIRE(i.target.x() < pos(f.admin, ship).value.x());
REQUIRE(s->intent.target.x() < s->position.x());
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -455,57 +397,50 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn", "[sensor]") TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn", "[sensor]")
{ {
Fixture f; Fixture f;
// interceptor sensor_range_formula = "200" (test config); verify at level 1. const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); REQUIRE(f.admin.get<SensorRange>(e).value == Approx(200.0f));
REQUIRE(f.ships.findShip(id)->sensorRange == Approx(200.0f));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sensor range — tickThreatResponse // Sensor range — tickThreatResponseBehavior
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]") TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]")
{ {
Fixture f; Fixture f;
// interceptor sensor_range = 200 (test config); enemy at 190 tiles. const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f),
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f), /*isEnemy=*/true);
/*isEnemy=*/true);
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickThreatResponse(f.ships, f.buildings); f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
const Ship* player = f.ships.findShip(playerId); REQUIRE(f.admin.get<ThreatResponseBehavior>(player).currentTarget == enemy);
REQUIRE(player->threatResponse->currentTarget == enemyId);
} }
TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]") TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]")
{ {
Fixture f; Fixture f;
// interceptor sensor_range = 200 (test config); enemy at 210 tiles. const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), /*isEnemy=*/true); f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), /*isEnemy=*/true);
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickThreatResponse(f.ships, f.buildings); f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
const Ship* player = f.ships.findShip(playerId); REQUIRE_FALSE(f.admin.get<ThreatResponseBehavior>(player).currentTarget.has_value());
REQUIRE_FALSE(player->threatResponse->currentTarget.has_value());
} }
TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]") TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]")
{ {
Fixture f; Fixture f;
// interceptor sensor_range = 200 (test config); player at 210 tiles from enemy.
f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickThreatResponse(f.ships, f.buildings); f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
const Ship* enemy = f.ships.findShip(enemyId); REQUIRE_FALSE(f.admin.get<ThreatResponseBehavior>(enemy).currentTarget.has_value());
REQUIRE_FALSE(enemy->threatResponse->currentTarget.has_value());
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -515,64 +450,54 @@ TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[
TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]") TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]")
{ {
Fixture f; Fixture f;
// repair_ship sensor_range = 250; enemy at 200 tiles. const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true); f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true);
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickRepairBehavior(f.ships, f.buildings); f.ai.tickRepairBehavior(f.admin, f.buildings);
const Ship* repair = f.ships.findShip(repairId); REQUIRE(intent(f.admin, repairShip).priority == 2);
REQUIRE(repair->intent.priority == 2); REQUIRE(intent(f.admin, repairShip).target.x() < 0.0f);
REQUIRE(repair->intent.target.x() < 0.0f); // retreating leftward
} }
TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]") TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]")
{ {
Fixture f; Fixture f;
// repair_ship sensor_range = 250; enemy at 300 tiles. const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true); f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true);
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickRepairBehavior(f.ships, f.buildings); f.ai.tickRepairBehavior(f.admin, f.buildings);
// Enemy outside sensor range → repair ship patrols rightward instead of retreating. REQUIRE(intent(f.admin, repairShip).target.x() > pos(f.admin, repairShip).value.x());
const Ship* repair = f.ships.findShip(repairId);
REQUIRE(repair->intent.target.x() > repair->position.x());
} }
TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor range", "[sensor]") TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor range", "[sensor]")
{ {
Fixture f; Fixture f;
// repair_ship sensor_range = 250; damaged friendly at 300 tiles. const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f));
const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f)); f.admin.get<Health>(friendly).hp = f.admin.get<Health>(friendly).maxHp * 0.5f;
f.ships.forEach([friendlyId](Ship& s) {
if (s.id == friendlyId) { s.hp = s.maxHp * 0.5f; }
});
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickRepairBehavior(f.ships, f.buildings); f.ai.tickRepairBehavior(f.admin, f.buildings);
REQUIRE_FALSE(f.ships.findShip(repairId)->repairBehavior->currentTarget.has_value()); REQUIRE_FALSE(f.admin.get<RepairBehavior>(repairShip).currentTarget.has_value());
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sensor range — tickScrapCollector // Sensor range — tickSalvageBehavior
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]") TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]")
{ {
Fixture f; Fixture f;
// salvage_ship sensor_range = 250; scrap at 300 tiles. const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000);
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
const Ship* s = f.ships.findShip(shipId); REQUIRE_FALSE(f.admin.get<SalvageBehavior>(ship).scrapTarget.has_value());
REQUIRE(s->scrapCollector->scrapTarget == std::nullopt); REQUIRE(intent(f.admin, ship).target.x() > pos(f.admin, ship).value.x());
REQUIRE(s->intent.target.x() > s->position.x()); // patrolling rightward
} }

View File

@@ -12,7 +12,7 @@
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "EntityId.h" #include "BuildingId.h"
#include "Rotation.h" #include "Rotation.h"
#include "Simulation.h" #include "Simulation.h"
#include "SurfaceMask.h" #include "SurfaceMask.h"
@@ -521,13 +521,13 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
const QPoint offsetA(-1, 0); const QPoint offsetA(-1, 0);
const QPoint offsetB( 1, 0); const QPoint offsetB( 1, 0);
const EntityId idA = sim.tryPlaceBuilding( const BuildingId idA = sim.tryPlaceBuilding(
BuildingType::Belt, cursor + offsetA, Rotation::East); BuildingType::Belt, cursor + offsetA, Rotation::East);
const EntityId idB = sim.tryPlaceBuilding( const BuildingId idB = sim.tryPlaceBuilding(
BuildingType::Belt, cursor + offsetB, Rotation::East); BuildingType::Belt, cursor + offsetB, Rotation::East);
REQUIRE(idA != kInvalidEntityId); REQUIRE(idA != kInvalidBuildingId);
REQUIRE(idB != kInvalidEntityId); REQUIRE(idB != kInvalidBuildingId);
REQUIRE(sim.buildings().isTileOccupied(cursor + offsetA)); // (-6, 0) REQUIRE(sim.buildings().isTileOccupied(cursor + offsetA)); // (-6, 0)
REQUIRE(sim.buildings().isTileOccupied(cursor + offsetB)); // (-4, 0) REQUIRE(sim.buildings().isTileOccupied(cursor + offsetB)); // (-4, 0)
REQUIRE_FALSE(sim.buildings().isTileOccupied(cursor)); // center not occupied REQUIRE_FALSE(sim.buildings().isTileOccupied(cursor)); // center not occupied
@@ -555,7 +555,7 @@ TEST_CASE("Blueprint placement: cost is deducted for each building in sequence",
REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost); REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost);
} }
TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidEntityId and deducts nothing", TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId and deducts nothing",
"[blueprint]") "[blueprint]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadConfig());
@@ -578,11 +578,11 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidEntityId and
} }
const int blocksBeforeAttempt = sim.buildingBlocksStock(); const int blocksBeforeAttempt = sim.buildingBlocksStock();
const EntityId id = sim.tryPlaceBuilding( const BuildingId id = sim.tryPlaceBuilding(
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East); BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
// Placement must fail and leave the stock unchanged. // Placement must fail and leave the stock unchanged.
REQUIRE(id == kInvalidEntityId); REQUIRE(id == kInvalidBuildingId);
REQUIRE(sim.buildingBlocksStock() == blocksBeforeAttempt); REQUIRE(sim.buildingBlocksStock() == blocksBeforeAttempt);
} }
@@ -618,8 +618,8 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
Simulation sim(loadConfig()); Simulation sim(loadConfig());
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid. // Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
const EntityId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East); const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidEntityId); REQUIRE(id != kInvalidBuildingId);
sim.buildings().setRecipe(id, "mine_iron_ore"); sim.buildings().setRecipe(id, "mine_iron_ore");
@@ -633,8 +633,8 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
{ {
Simulation sim(loadConfig()); Simulation sim(loadConfig());
const EntityId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East); const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidEntityId); REQUIRE(id != kInvalidBuildingId);
sim.buildings().setRecipe(id, "mine_copper_ore"); sim.buildings().setRecipe(id, "mine_copper_ore");
// Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300. // Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300.

View File

@@ -74,15 +74,15 @@ TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
REQUIRE(id != kInvalidEntityId); REQUIRE(id != kInvalidBuildingId);
// Miner mask ["AA","A>"] with East rotation → body at (0,0),(1,0),(0,1). // Miner mask ["AA","A>"] with East rotation → body at (0,0),(1,0),(0,1).
REQUIRE(bs.isTileOccupied(QPoint(0, 0))); REQUIRE(bs.isTileOccupied(QPoint(0, 0)));
@@ -99,9 +99,9 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
@@ -127,14 +127,14 @@ TEST_CASE("BuildingSystem: placed building enters construction queue", "[buildin
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
REQUIRE(bs.allSites().size() == 1); REQUIRE(bs.allSites().size() == 1);
REQUIRE(bs.allBuildings().empty()); REQUIRE(bs.allBuildings().empty());
@@ -147,14 +147,14 @@ TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building]
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
// Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300. // Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300.
// We need to process tick 300 itself, so run 301 ticks (ticks 0..300). // We need to process tick 300 itself, so run 301 ticks (ticks 0..300).
@@ -180,9 +180,9 @@ TEST_CASE("BuildingSystem: first queued building starts construction immediately
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
@@ -197,9 +197,9 @@ TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[b
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
@@ -218,14 +218,14 @@ TEST_CASE("BuildingSystem: construction completes after configured duration", "[
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
// Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300. // Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300.
// We need to process tick 300 itself, so run 301 ticks (ticks 0..300). // We need to process tick 300 itself, so run 301 ticks (ticks 0..300).
@@ -242,15 +242,15 @@ TEST_CASE("BuildingSystem: second building starts after first completes", "[buil
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const EntityId id2 = bs.place(BuildingType::Miner, QPoint(5, 5), Rotation::East, 0); const BuildingId id2 = bs.place(BuildingType::Miner, QPoint(5, 5), Rotation::East, 0);
// Process through tick 300 to complete first miner's construction. // Process through tick 300 to complete first miner's construction.
Tick tick = 0; Tick tick = 0;
@@ -271,14 +271,14 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "mine_iron_ore"); bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0; Tick tick = 0;
@@ -300,14 +300,14 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "mine_iron_ore"); bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0; Tick tick = 0;
@@ -339,16 +339,16 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
BeltSystem belts(static_cast<double>(kTickRateHz)); BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1). // Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
// Output port (2,1) East. Input port example: (2,0) West. // Output port (2,1) East. Input port example: (2,0) West.
const EntityId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0); const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(sid, "iron_ingot"); bs.setRecipe(sid, "iron_ingot");
// Complete construction (15s → tick 450+1 = 451 ticks). // Complete construction (15s → tick 450+1 = 451 ticks).
@@ -380,14 +380,14 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
BeltSystem belts(static_cast<double>(kTickRateHz)); BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "mine_iron_ore"); bs.setRecipe(id, "mine_iron_ore");
// Belt at the miner's output port tile (1,1) flowing East. // Belt at the miner's output port tile (1,1) flowing East.
@@ -419,14 +419,14 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
BeltSystem belts(static_cast<double>(kTickRateHz)); BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "mine_iron_ore"); bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0; Tick tick = 0;
@@ -459,14 +459,14 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::ReprocessingPlant, const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0); QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "reprocessing_cycle"); bs.setRecipe(id, "reprocessing_cycle");
@@ -489,14 +489,14 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
int stock = 0; int stock = 0;
// Seed chosen so first roll produces 2-item output (iron_ingot), filling buffer. // Seed chosen so first roll produces 2-item output (iron_ingot), filling buffer.
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::ReprocessingPlant, const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0); QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "reprocessing_cycle"); bs.setRecipe(id, "reprocessing_cycle");
@@ -547,9 +547,9 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
@@ -565,16 +565,16 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a que
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const std::optional<EntityId> result = const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::North); bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::North);
REQUIRE(result.has_value()); REQUIRE(result.has_value());
REQUIRE(*result == id); REQUIRE(*result == id);
@@ -587,20 +587,20 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0; Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick); runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(bs.allSites().empty()); REQUIRE(bs.allSites().empty());
const std::optional<EntityId> result = const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::South); bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::South);
REQUIRE(result.has_value()); REQUIRE(result.has_value());
REQUIRE(*result == id); REQUIRE(*result == id);
@@ -613,9 +613,9 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
@@ -634,9 +634,9 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprin
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
@@ -657,18 +657,18 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
// Smelter is a fully filled 2×2 footprint — rotating the ghost produces the // Smelter is a fully filled 2×2 footprint — rotating the ghost produces the
// same four body tiles, so findRotateInPlaceTarget must still return the id. // same four body tiles, so findRotateInPlaceTarget must still return the id.
const EntityId id = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const std::optional<EntityId> result = const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(0, 0), Rotation::North); bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(0, 0), Rotation::North);
REQUIRE(result.has_value()); REQUIRE(result.has_value());
REQUIRE(*result == id); REQUIRE(*result == id);
@@ -685,14 +685,14 @@ TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a constru
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
REQUIRE(bs.findSite(id)->rotation == Rotation::East); REQUIRE(bs.findSite(id)->rotation == Rotation::East);
bs.rotateInPlace(id, Rotation::North); bs.rotateInPlace(id, Rotation::North);
@@ -707,14 +707,14 @@ TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const Tick completesAt = bs.findSite(id)->completesAt; const Tick completesAt = bs.findSite(id)->completesAt;
REQUIRE(completesAt > 0); REQUIRE(completesAt > 0);
@@ -730,14 +730,14 @@ TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direct
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0; Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick); runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
@@ -760,14 +760,14 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0; int stock = 0;
std::mt19937 rng(0); std::mt19937 rng(0);
EntityId nextId = 1; BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts, BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng); rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0; Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick); runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);

View File

@@ -8,6 +8,8 @@
#include "BuildingType.h" #include "BuildingType.h"
#include "CombatSystem.h" #include "CombatSystem.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "EcsComponents.h"
#include "EntityAdmin.h"
#include "FireEvent.h" #include "FireEvent.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include "Ship.h" #include "Ship.h"
@@ -20,7 +22,6 @@ static GameConfig loadConfig()
return ConfigLoader::loadFromDirectory(CONFIG_DIR); return ConfigLoader::loadFromDirectory(CONFIG_DIR);
} }
// Find the first ShipDef with a combat component.
static const ShipDef* findCombatShip(const GameConfig& cfg) static const ShipDef* findCombatShip(const GameConfig& cfg)
{ {
for (const ShipDef& def : cfg.ships.ships) for (const ShipDef& def : cfg.ships.ships)
@@ -33,159 +34,104 @@ static const ShipDef* findCombatShip(const GameConfig& cfg)
return nullptr; return nullptr;
} }
// Helper fixture for unit tests that need ships + combat but not a full Simulation.
struct CombatFixture
{
GameConfig cfg;
std::mt19937 rng;
EntityAdmin admin;
BuildingId nextBuildingId;
BeltSystem belts;
ShipSystem ships;
BuildingSystem buildings;
CombatSystem combat;
explicit CombatFixture()
: cfg(loadConfig())
, rng(42)
, nextBuildingId(1)
, belts(cfg.world.beltSpeedTilesPerSecond)
, ships(cfg, admin)
, buildings(cfg, belts,
[this]() { return nextBuildingId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng)
, combat(cfg)
{
}
void wireEnemyTarget(entt::entity enemy, entt::entity playerTarget)
{
if (admin.hasAll<Weapon>(enemy))
{
admin.get<Weapon>(enemy).currentTarget = playerTarget;
admin.get<Weapon>(enemy).cooldownTicks = 0.0f;
}
if (admin.hasAll<ThreatResponseBehavior>(enemy))
{
admin.get<ThreatResponseBehavior>(enemy).currentTarget = playerTarget;
}
}
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Ship weapon firing // Ship weapon firing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[combat]") TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[combat]")
{ {
const GameConfig cfg = loadConfig(); CombatFixture f;
std::mt19937 rng(42); const ShipDef* combatDef = findCombatShip(f.cfg);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
EntityId nextShipId = 1; const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
EntityId nextBldId = 100; f.wireEnemyTarget(enemy, player);
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
// Spawn an enemy combat ship close to the player side. const float hpBefore = f.admin.get<Health>(player).hp;
const EntityId enemyId = ships.spawn(combatDef->id, 1,
QVector2D(5.0f, 5.0f), /*isEnemy=*/true);
// Spawn a player combat ship in front of the enemy.
const EntityId playerId = ships.spawn(combatDef->id, 1,
QVector2D(4.0f, 5.0f), /*isEnemy=*/false);
// Wire the enemy's weapon target manually.
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
// Record player HP before combat.
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events; std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
combat.applyPendingDamage(5, ships, buildings); f.combat.applyPendingDamage(5, f.admin);
float hpAfter = 0.0f; REQUIRE(f.admin.get<Health>(player).hp < hpBefore);
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpAfter = s.hp; }
}
REQUIRE(hpAfter < hpBefore);
REQUIRE(events.size() >= 1); REQUIRE(events.size() >= 1);
} }
TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]") TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]")
{ {
const GameConfig cfg = loadConfig(); CombatFixture f;
std::mt19937 rng(42); const ShipDef* combatDef = findCombatShip(f.cfg);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
EntityId nextShipId = 1; const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); f.wireEnemyTarget(enemy, player);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); f.admin.get<Weapon>(enemy).cooldownTicks = 3.0f; // override to 3
// Set cooldown to 3 so it won't fire on tick 0 or 1 or 2.
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 3.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
CombatSystem combat(cfg);
std::vector<FireEvent> events; std::vector<FireEvent> events;
f.combat.tick(0, f.admin, f.buildings, events);
// Ticks 0 and 1: cooldown still > 0 after decrement → no fire. f.combat.tick(1, f.admin, f.buildings, events);
combat.tick(0, ships, buildings, events);
combat.tick(1, ships, buildings, events);
REQUIRE(events.empty()); REQUIRE(events.empty());
// Tick 2: cooldown reaches 0 → fires. f.combat.tick(2, f.admin, f.buildings, events);
combat.tick(2, ships, buildings, events);
REQUIRE(events.size() == 1); REQUIRE(events.size() == 1);
} }
TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]") TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
{ {
const GameConfig cfg = loadConfig(); CombatFixture f;
std::mt19937 rng(42); const ShipDef* combatDef = findCombatShip(f.cfg);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(0.0f, 0.0f), true);
EntityId nextShipId = 1; const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false);
EntityId nextBldId = 100; f.wireEnemyTarget(enemy, player);
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(0.0f, 0.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false);
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
CombatSystem combat(cfg);
std::vector<FireEvent> events; std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
REQUIRE(events.empty()); REQUIRE(events.empty());
} }
@@ -197,49 +143,37 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadConfig(), 42);
// Find the player defence station. // Find the player station entity via ECS.
EntityId stationId = kInvalidEntityId; entt::entity stationEntity = entt::null;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::PlayerDefenceStation)
{
stationId = b.id;
break;
}
}
REQUIRE(stationId != kInvalidEntityId);
// Place an enemy ship close to the player station.
QVector2D stationCenter; QVector2D stationCenter;
for (const Building& b : sim.buildings().allBuildings()) sim.admin().forEach<StationBody, Faction>(
{ [&](entt::entity e, const StationBody& sb, const Faction& f)
if (b.id == stationId)
{ {
stationCenter = QVector2D( if (!f.isEnemy && stationEntity == entt::null)
b.anchor.x() + b.footprint.width() / 2.0f, {
b.anchor.y() + b.footprint.height() / 2.0f); stationEntity = e;
break; stationCenter = QVector2D(
} sb.anchor.x() + sb.footprint.width() / 2.0f,
} sb.anchor.y() + sb.footprint.height() / 2.0f);
}
});
REQUIRE(sim.admin().isValid(stationEntity));
// Find a combat ship schematic for the enemy.
const ShipDef* combatDef = findCombatShip(sim.config()); const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
const EntityId enemyId = sim.ships().spawn( const entt::entity enemyShip = sim.ships().spawn(
combatDef->id, 1, combatDef->id, 1,
QVector2D(stationCenter.x() + 1.0f, stationCenter.y()), QVector2D(stationCenter.x() + 1.0f, stationCenter.y()),
/*isEnemy=*/true); /*isEnemy=*/true);
// Tick to let station auto-acquire and fire.
sim.tick(); sim.tick();
// Check that a fire event was emitted with stationId as shooter.
const std::vector<FireEvent> events = sim.drainFireEvents(); const std::vector<FireEvent> events = sim.drainFireEvents();
bool stationFired = false; bool stationFired = false;
for (const FireEvent& e : events) for (const FireEvent& evt : events)
{ {
if (e.shooter == stationId) { stationFired = true; } if (evt.shooter == stationEntity) { stationFired = true; }
} }
REQUIRE(stationFired); REQUIRE(stationFired);
} }
@@ -248,26 +182,24 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadConfig(), 42);
// Find the enemy defence station. entt::entity stationEntity = entt::null;
EntityId stationId = kInvalidEntityId;
QVector2D stationCenter; QVector2D stationCenter;
for (const Building& b : sim.buildings().allBuildings()) sim.admin().forEach<StationBody, Faction>(
{ [&](entt::entity e, const StationBody& sb, const Faction& f)
if (b.type == BuildingType::EnemyDefenceStation)
{ {
stationId = b.id; if (f.isEnemy && stationEntity == entt::null)
stationCenter = QVector2D( {
b.anchor.x() + b.footprint.width() / 2.0f, stationEntity = e;
b.anchor.y() + b.footprint.height() / 2.0f); stationCenter = QVector2D(
break; sb.anchor.x() + sb.footprint.width() / 2.0f,
} sb.anchor.y() + sb.footprint.height() / 2.0f);
} }
REQUIRE(stationId != kInvalidEntityId); });
REQUIRE(sim.admin().isValid(stationEntity));
const ShipDef* combatDef = findCombatShip(sim.config()); const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
// Spawn a player ship right next to the enemy station.
sim.ships().spawn( sim.ships().spawn(
combatDef->id, 1, combatDef->id, 1,
QVector2D(stationCenter.x() - 1.0f, stationCenter.y()), QVector2D(stationCenter.x() - 1.0f, stationCenter.y()),
@@ -277,9 +209,9 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
const std::vector<FireEvent> events = sim.drainFireEvents(); const std::vector<FireEvent> events = sim.drainFireEvents();
bool stationFired = false; bool stationFired = false;
for (const FireEvent& e : events) for (const FireEvent& evt : events)
{ {
if (e.shooter == stationId) { stationFired = true; } if (evt.shooter == stationEntity) { stationFired = true; }
} }
REQUIRE(stationFired); REQUIRE(stationFired);
} }
@@ -288,25 +220,25 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadConfig(), 42);
EntityId stationId = kInvalidEntityId; entt::entity stationEntity = entt::null;
QVector2D stationCenter; QVector2D stationCenter;
for (const Building& b : sim.buildings().allBuildings()) sim.admin().forEach<StationBody, Faction>(
{ [&](entt::entity e, const StationBody& sb, const Faction& f)
if (b.type == BuildingType::EnemyDefenceStation)
{ {
stationId = b.id; if (f.isEnemy && stationEntity == entt::null)
stationCenter = QVector2D( {
b.anchor.x() + b.footprint.width() / 2.0f, stationEntity = e;
b.anchor.y() + b.footprint.height() / 2.0f); stationCenter = QVector2D(
break; sb.anchor.x() + sb.footprint.width() / 2.0f,
} sb.anchor.y() + sb.footprint.height() / 2.0f);
} }
REQUIRE(stationId != kInvalidEntityId); });
REQUIRE(sim.admin().isValid(stationEntity));
const ShipDef* combatDef = findCombatShip(sim.config()); const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
const EntityId playerId = sim.ships().spawn( const entt::entity playerShip = sim.ships().spawn(
combatDef->id, 1, combatDef->id, 1,
QVector2D(stationCenter.x() - 1.0f, stationCenter.y()), QVector2D(stationCenter.x() - 1.0f, stationCenter.y()),
/*isEnemy=*/false); /*isEnemy=*/false);
@@ -315,9 +247,9 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]
const std::vector<FireEvent> events = sim.drainFireEvents(); const std::vector<FireEvent> events = sim.drainFireEvents();
bool playerFiredAtStation = false; bool playerFiredAtStation = false;
for (const FireEvent& e : events) for (const FireEvent& evt : events)
{ {
if (e.shooter == playerId && e.target == stationId) if (evt.shooter == playerShip && evt.target == stationEntity)
{ {
playerFiredAtStation = true; playerFiredAtStation = true;
} }
@@ -331,219 +263,86 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]
TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]") TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]")
{ {
const GameConfig cfg = loadConfig(); CombatFixture f;
std::mt19937 rng(42); const ShipDef* combatDef = findCombatShip(f.cfg);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
EntityId nextShipId = 1; const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
EntityId nextBldId = 100; f.wireEnemyTarget(enemy, player);
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); const float hpBefore = f.admin.get<Health>(player).hp;
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events; std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
// Ticks 1-4: damage must not have arrived yet.
for (Tick t = 1; t < 5; ++t) for (Tick t = 1; t < 5; ++t)
{ {
combat.applyPendingDamage(t, ships, buildings); f.combat.applyPendingDamage(t, f.admin);
float hp = 0.0f; REQUIRE(f.admin.get<Health>(player).hp == Approx(hpBefore));
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hp = s.hp; }
}
REQUIRE(hp == Approx(hpBefore));
} }
} }
TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]") TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]")
{ {
const GameConfig cfg = loadConfig(); CombatFixture f;
std::mt19937 rng(42); const ShipDef* combatDef = findCombatShip(f.cfg);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
EntityId nextShipId = 1; const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
EntityId nextBldId = 100; f.wireEnemyTarget(enemy, player);
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); const float hpBefore = f.admin.get<Health>(player).hp;
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events; std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
combat.applyPendingDamage(5, ships, buildings); f.combat.applyPendingDamage(5, f.admin);
float hpAfter = 0.0f; REQUIRE(f.admin.get<Health>(player).hp < hpBefore);
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpAfter = s.hp; }
}
REQUIRE(hpAfter < hpBefore);
} }
TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[combat]") TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[combat]")
{ {
const GameConfig cfg = loadConfig(); CombatFixture f;
std::mt19937 rng(42); const ShipDef* combatDef = findCombatShip(f.cfg);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
EntityId nextShipId = 1; const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
EntityId nextBldId = 100; f.wireEnemyTarget(enemy, player);
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
CombatSystem combat(cfg);
std::vector<FireEvent> events; std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
// Target is removed before impact. f.ships.despawn(player);
ships.despawn(playerId);
// Should not crash; damage is silently dropped. // Should not crash.
combat.applyPendingDamage(5, ships, buildings); f.combat.applyPendingDamage(5, f.admin);
REQUIRE(ships.findShip(playerId) == nullptr); REQUIRE_FALSE(f.admin.isValid(player));
} }
TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat]") TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat]")
{ {
const GameConfig cfg = loadConfig(); CombatFixture f;
std::mt19937 rng(42); const ShipDef* combatDef = findCombatShip(f.cfg);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
EntityId nextShipId = 1; const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
EntityId nextBldId = 100; f.wireEnemyTarget(enemy, player);
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); const float hpBefore = f.admin.get<Health>(player).hp;
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events; std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
// Shooter is removed before impact. f.ships.despawn(enemy);
ships.despawn(enemyId);
// Damage must still land on the target. f.combat.applyPendingDamage(5, f.admin);
combat.applyPendingDamage(5, ships, buildings);
float hpAfter = 0.0f; REQUIRE(f.admin.get<Health>(player).hp < hpBefore);
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpAfter = s.hp; }
}
REQUIRE(hpAfter < hpBefore);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -557,22 +356,20 @@ TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]")
const ShipDef* combatDef = findCombatShip(sim.config()); const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
const EntityId shipId = sim.ships().spawn(combatDef->id, 1, const entt::entity ship = sim.ships().spawn(combatDef->id, 1,
QVector2D(10.0f, 10.0f)); QVector2D(10.0f, 10.0f));
// Set hp to lethal. sim.admin().get<Health>(ship).hp = -1.0f;
sim.ships().damageShip(shipId, 9999.0f);
sim.tick(); sim.tick();
REQUIRE(sim.ships().findShip(shipId) == nullptr); REQUIRE_FALSE(sim.admin().isValid(ship));
} }
TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]") TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadConfig(), 42);
// Find a ship def that drops scrap.
const ShipDef* droppingDef = nullptr; const ShipDef* droppingDef = nullptr;
for (const ShipDef& def : sim.config().ships.ships) for (const ShipDef& def : sim.config().ships.ships)
{ {
@@ -584,27 +381,25 @@ TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
} }
REQUIRE(droppingDef != nullptr); REQUIRE(droppingDef != nullptr);
const EntityId shipId = sim.ships().spawn(droppingDef->id, 1, const entt::entity ship = sim.ships().spawn(droppingDef->id, 1,
QVector2D(10.0f, 10.0f)); QVector2D(10.0f, 10.0f));
sim.ships().damageShip(shipId, 9999.0f); sim.admin().get<Health>(ship).hp = -1.0f;
sim.tick(); sim.tick();
// At least one scrap entity should now exist. REQUIRE(!sim.scraps().allScrapInfo().empty());
REQUIRE(!sim.scraps().allScraps().empty());
} }
TEST_CASE("CombatSystem: HQ death sets game over", "[combat]") TEST_CASE("CombatSystem: HQ death sets game over", "[combat]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadConfig(), 42);
sim.buildings().forEachBuilding([](Building& b) // Damage the HQ proxy entity (has HqProxy + Health).
{ sim.admin().forEach<HqProxy, Health>(
if (b.type == BuildingType::Hq) [](entt::entity /*e*/, const HqProxy& /*hq*/, Health& h)
{ {
b.hp = -1.0f; h.hp = -1.0f;
} });
});
sim.tick(); sim.tick();

View File

@@ -2,25 +2,23 @@
#include <QVector2D> #include <QVector2D>
#include "EntityId.h" #include "EntityAdmin.h"
#include "Scrap.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Spawn // Spawn
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("ScrapSystem: spawn returns a findable scrap with correct fields", "[scrap]") TEST_CASE("ScrapSystem: spawn returns a valid entity with correct scrap data", "[scrap]")
{ {
EntityId nextId = 1; EntityAdmin admin;
ScrapSystem ss([&nextId]() { return nextId++; }); ScrapSystem ss(admin);
const EntityId id = ss.spawn(QVector2D(3.0f, 4.0f), 5, 100); const entt::entity e = ss.spawn(QVector2D(3.0f, 4.0f), 5, 100);
const Scrap* s = ss.findScrap(id);
REQUIRE(s != nullptr); REQUIRE(admin.isValid(e));
REQUIRE(s->amount == 5); REQUIRE(admin.get<ScrapData>(e).amount == 5);
REQUIRE(s->despawnAt == 100); REQUIRE(admin.get<DespawnAt>(e).tick == 100);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -29,24 +27,24 @@ TEST_CASE("ScrapSystem: spawn returns a findable scrap with correct fields", "[s
TEST_CASE("ScrapSystem: scrap still present one tick before despawnAt", "[scrap]") TEST_CASE("ScrapSystem: scrap still present one tick before despawnAt", "[scrap]")
{ {
EntityId nextId = 1; EntityAdmin admin;
ScrapSystem ss([&nextId]() { return nextId++; }); ScrapSystem ss(admin);
const EntityId id = ss.spawn(QVector2D(0.0f, 0.0f), 1, 50); const entt::entity e = ss.spawn(QVector2D(0.0f, 0.0f), 1, 50);
ss.tickDespawn(49); ss.tickDespawn(49);
REQUIRE(ss.findScrap(id) != nullptr); REQUIRE(admin.isValid(e));
} }
TEST_CASE("ScrapSystem: scrap removed at despawnAt tick", "[scrap]") TEST_CASE("ScrapSystem: scrap removed at despawnAt tick", "[scrap]")
{ {
EntityId nextId = 1; EntityAdmin admin;
ScrapSystem ss([&nextId]() { return nextId++; }); ScrapSystem ss(admin);
const EntityId id = ss.spawn(QVector2D(0.0f, 0.0f), 1, 50); const entt::entity e = ss.spawn(QVector2D(0.0f, 0.0f), 1, 50);
ss.tickDespawn(50); ss.tickDespawn(50);
REQUIRE(ss.findScrap(id) == nullptr); REQUIRE_FALSE(admin.isValid(e));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -55,29 +53,56 @@ TEST_CASE("ScrapSystem: scrap removed at despawnAt tick", "[scrap]")
TEST_CASE("ScrapSystem: tickDespawn removes only expired scraps", "[scrap]") TEST_CASE("ScrapSystem: tickDespawn removes only expired scraps", "[scrap]")
{ {
EntityId nextId = 1; EntityAdmin admin;
ScrapSystem ss([&nextId]() { return nextId++; }); ScrapSystem ss(admin);
const EntityId earlyId = ss.spawn(QVector2D(0.0f, 0.0f), 1, 30); const entt::entity earlyE = ss.spawn(QVector2D(0.0f, 0.0f), 1, 30);
const EntityId lateId = ss.spawn(QVector2D(1.0f, 0.0f), 2, 60); const entt::entity lateE = ss.spawn(QVector2D(1.0f, 0.0f), 2, 60);
ss.tickDespawn(30); ss.tickDespawn(30);
REQUIRE(ss.findScrap(earlyId) == nullptr); REQUIRE_FALSE(admin.isValid(earlyE));
REQUIRE(ss.findScrap(lateId) != nullptr); REQUIRE(admin.isValid(lateE));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Entity ids // Consume
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("ScrapSystem: spawned scraps receive strictly increasing entity ids", "[scrap]") TEST_CASE("ScrapSystem: consume returns amount and destroys entity", "[scrap]")
{ {
EntityId nextId = 1; EntityAdmin admin;
ScrapSystem ss([&nextId]() { return nextId++; }); ScrapSystem ss(admin);
const EntityId id1 = ss.spawn(QVector2D(0.0f, 0.0f), 1, 100); const entt::entity e = ss.spawn(QVector2D(0.0f, 0.0f), 7, 100);
const EntityId id2 = ss.spawn(QVector2D(1.0f, 0.0f), 2, 200);
REQUIRE(id2 > id1); const std::optional<int> amount = ss.consume(e);
REQUIRE(amount.has_value());
REQUIRE(*amount == 7);
REQUIRE_FALSE(admin.isValid(e));
}
TEST_CASE("ScrapSystem: consume returns nullopt for invalid entity", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
const std::optional<int> amount = ss.consume(entt::null);
REQUIRE_FALSE(amount.has_value());
}
// ---------------------------------------------------------------------------
// allScrapInfo
// ---------------------------------------------------------------------------
TEST_CASE("ScrapSystem: allScrapInfo returns all spawned scrap", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
ss.spawn(QVector2D(1.0f, 2.0f), 3, 100);
ss.spawn(QVector2D(4.0f, 5.0f), 6, 200);
const std::vector<ScrapInfo> info = ss.allScrapInfo();
REQUIRE(info.size() == 2);
} }

View File

@@ -4,6 +4,8 @@
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "EcsComponents.h"
#include "EntityAdmin.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "ItemType.h" #include "ItemType.h"
#include "ModulesConfig.h" #include "ModulesConfig.h"
@@ -43,17 +45,16 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
return nullptr; return nullptr;
} }
static EntityId placeShipyard(Simulation& sim, const BuildingDef& yardDef) static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
{ {
return sim.buildings().placeImmediate( return sim.buildings().placeImmediate(
BuildingType::Shipyard, BuildingType::Shipyard,
yardDef.surfaceMask, yardDef.surfaceMask,
QPoint(0, 0), QPoint(0, 0),
Rotation::East, Rotation::East);
100.0f, 100.0f);
} }
static void fillMaterials(Simulation& sim, EntityId yardId, static void fillMaterials(Simulation& sim, BuildingId yardId,
const ShipDef& def, const ShipDef& def,
const ShipLayoutConfig& layout) const ShipLayoutConfig& layout)
{ {
@@ -96,13 +97,12 @@ TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]")
const double x = static_cast<double>(def->schematic.playerProductionLevel); const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float expectedHp = static_cast<float>(def->health.hpFormula.evaluate(x)); const float expectedHp = static_cast<float>(def->health.hpFormula.evaluate(x));
const EntityId id = sim.ships().spawn("interceptor", const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel, def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, std::nullopt); QVector2D(5.0f, 5.0f), false, std::nullopt);
const Ship* ship = sim.ships().findShip(id); REQUIRE(sim.admin().isValid(e));
REQUIRE(ship != nullptr); CHECK(sim.admin().get<Health>(e).maxHp == Approx(expectedHp));
CHECK(ship->maxHp == Approx(expectedHp));
} }
TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]") TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
@@ -121,16 +121,15 @@ TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
pm.rotation = Rotation::East; pm.rotation = Rotation::East;
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
const EntityId id = sim.ships().spawn("interceptor", const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel, def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout); QVector2D(5.0f, 5.0f), false, layout);
const Ship* ship = sim.ships().findShip(id); REQUIRE(sim.admin().isValid(e));
REQUIRE(ship != nullptr);
// armor_plate has multiplied_hp_formula = "1.5" // armor_plate has multiplied_hp_formula = "1.5"
// final = base * (1 + (1.5 - 1)) + 0 = base * 1.5 // final = base * (1 + (1.5 - 1)) + 0 = base * 1.5
CHECK(ship->maxHp == Approx(baseHp * 1.5f)); CHECK(sim.admin().get<Health>(e).maxHp == Approx(baseHp * 1.5f));
CHECK(ship->hp == ship->maxHp); CHECK(sim.admin().get<Health>(e).hp == sim.admin().get<Health>(e).maxHp);
} }
TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]") TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
@@ -149,15 +148,14 @@ TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
pm.rotation = Rotation::East; pm.rotation = Rotation::East;
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
const EntityId id = sim.ships().spawn("interceptor", const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel, def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout); QVector2D(5.0f, 5.0f), false, layout);
const Ship* ship = sim.ships().findShip(id); REQUIRE(sim.admin().isValid(e));
REQUIRE(ship != nullptr);
// sensor_booster has added_sensor_range_formula = "10" // sensor_booster has added_sensor_range_formula = "10"
// final = base * 1.0 + 10 = base + 10 // final = base * 1.0 + 10 = base + 10
CHECK(ship->sensorRange == Approx(baseRange + 10.0f)); CHECK(sim.admin().get<SensorRange>(e).value == Approx(baseRange + 10.0f));
} }
TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]") TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
@@ -179,16 +177,15 @@ TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
} }
const EntityId id = sim.ships().spawn("interceptor", const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel, def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout); QVector2D(5.0f, 5.0f), false, layout);
const Ship* ship = sim.ships().findShip(id); REQUIRE(sim.admin().isValid(e));
REQUIRE(ship != nullptr);
// Two armor_plates: each 1.5 multiplier // Two armor_plates: each 1.5 multiplier
// total_mult = 1 + (1.5 - 1) + (1.5 - 1) = 2.0 // total_mult = 1 + (1.5 - 1) + (1.5 - 1) = 2.0
// final = base * 2.0 // final = base * 2.0
CHECK(ship->maxHp == Approx(baseHp * 2.0f)); CHECK(sim.admin().get<Health>(e).maxHp == Approx(baseHp * 2.0f));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -202,7 +199,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
const BuildingDef* yardDef = findShipyardDef(sim.config()); const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const EntityId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, "interceptor"); sim.buildings().setRecipe(yardId, "interceptor");
ShipLayoutConfig layout; ShipLayoutConfig layout;
@@ -231,7 +228,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
const BuildingDef* yardDef = findShipyardDef(sim.config()); const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const EntityId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, "interceptor"); sim.buildings().setRecipe(yardId, "interceptor");
// Fill materials and tick to start production. // Fill materials and tick to start production.
@@ -264,7 +261,7 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
const BuildingDef* yardDef = findShipyardDef(sim.config()); const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const EntityId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, "interceptor"); sim.buildings().setRecipe(yardId, "interceptor");
ShipLayoutConfig layout; ShipLayoutConfig layout;

View File

@@ -1,14 +1,14 @@
#include "catch.hpp" #include "catch.hpp"
#include <cmath> #include <cmath>
#include <functional>
#include <string> #include <string>
#include <vector>
#include <QVector2D> #include <QVector2D>
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "EntityId.h" #include "EcsComponents.h"
#include "EntityAdmin.h"
#include "BuildingId.h"
#include "Ship.h" #include "Ship.h"
#include "ShipSystem.h" #include "ShipSystem.h"
#include "Tick.h" #include "Tick.h"
@@ -25,70 +25,65 @@ static GameConfig loadConfig()
TEST_CASE("ShipSystem: interceptor spawn has weapon and threatResponse, no cargo or repair", TEST_CASE("ShipSystem: interceptor spawn has weapon and threatResponse, no cargo or repair",
"[ship]") "[ship]")
{ {
EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
EntityId nextId = 1; ShipSystem ss(cfg, admin);
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
REQUIRE(ship != nullptr); REQUIRE(admin.isValid(e));
REQUIRE(ship->weapon.has_value()); REQUIRE(admin.hasAll<Weapon>(e));
REQUIRE(ship->threatResponse.has_value()); REQUIRE(admin.hasAll<ThreatResponseBehavior>(e));
REQUIRE_FALSE(ship->cargo.has_value()); REQUIRE_FALSE(admin.hasAll<SalvageCargo>(e));
REQUIRE_FALSE(ship->repairTool.has_value()); REQUIRE_FALSE(admin.hasAll<RepairTool>(e));
REQUIRE_FALSE(ship->repairBehavior.has_value()); REQUIRE_FALSE(admin.hasAll<RepairBehavior>(e));
REQUIRE_FALSE(ship->scrapCollector.has_value()); REQUIRE_FALSE(admin.hasAll<SalvageBehavior>(e));
} }
TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]") TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]")
{ {
EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
EntityId nextId = 1; ShipSystem ss(cfg, admin);
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
REQUIRE(ship != nullptr);
// hp_formula = "40 + 5*x" at x=1 → 45 // hp_formula = "40 + 5*x" at x=1 → 45
REQUIRE(ship->maxHp == Approx(45.0f)); REQUIRE(admin.get<Health>(e).maxHp == Approx(45.0f));
REQUIRE(ship->hp == Approx(45.0f)); REQUIRE(admin.get<Health>(e).hp == Approx(45.0f));
// damage_formula = "10 + 2*x" at x=1 → 12 // damage_formula = "10 + 2*x" at x=1 → 12
REQUIRE(ship->weapon->damage == Approx(12.0f)); REQUIRE(admin.get<Weapon>(e).damage == Approx(12.0f));
// attack_range_formula = "150" // attack_range_formula = "150"
REQUIRE(ship->weapon->range == Approx(150.0f)); REQUIRE(admin.get<Weapon>(e).range == Approx(150.0f));
// sensor_range_formula = "200" // sensor_range_formula = "200"
REQUIRE(ship->sensorRange == Approx(200.0f)); REQUIRE(admin.get<SensorRange>(e).value == Approx(200.0f));
// cooldownTicks starts at 0 // cooldownTicks starts at 0
REQUIRE(ship->weapon->cooldownTicks == Approx(0.0f)); REQUIRE(admin.get<Weapon>(e).cooldownTicks == Approx(0.0f));
} }
TEST_CASE("ShipSystem: interceptor level 5 hp matches formula", "[ship]") TEST_CASE("ShipSystem: interceptor level 5 hp matches formula", "[ship]")
{ {
EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
EntityId nextId = 1; ShipSystem ss(cfg, admin);
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("interceptor", 5, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", 5, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
// hp_formula = "40 + 5*x" at x=5 → 65 // hp_formula = "40 + 5*x" at x=5 → 65
REQUIRE(ship->maxHp == Approx(65.0f)); REQUIRE(admin.get<Health>(e).maxHp == Approx(65.0f));
} }
TEST_CASE("ShipSystem: interceptor level 0 maxSpeedPerTick matches formula / kTickRateHz", "[ship]") TEST_CASE("ShipSystem: interceptor level 0 maxSpeedPerTick matches formula / kTickRateHz", "[ship]")
{ {
EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
EntityId nextId = 1; ShipSystem ss(cfg, admin);
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("interceptor", 0, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", 0, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
// speed_formula = "200 + 5*x" at x=0 → 200; maxSpeedPerTick = 200/30 // speed_formula = "200 + 5*x" at x=0 → 200; maxSpeedPerTick = 200/30
const float expected = 200.0f / static_cast<float>(kTickRateHz); const float expected = 200.0f / static_cast<float>(kTickRateHz);
REQUIRE(ship->maxSpeedPerTick == Approx(expected)); REQUIRE(admin.get<ShipDynamics>(e).maxSpeedPerTick == Approx(expected));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -98,34 +93,31 @@ TEST_CASE("ShipSystem: interceptor level 0 maxSpeedPerTick matches formula / kTi
TEST_CASE("ShipSystem: salvage_ship spawn has cargo and scrapCollector, no weapon", TEST_CASE("ShipSystem: salvage_ship spawn has cargo and scrapCollector, no weapon",
"[ship]") "[ship]")
{ {
EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
EntityId nextId = 1; ShipSystem ss(cfg, admin);
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
REQUIRE(ship != nullptr); REQUIRE(admin.hasAll<SalvageCargo>(e));
REQUIRE(ship->cargo.has_value()); REQUIRE(admin.hasAll<SalvageBehavior>(e));
REQUIRE(ship->scrapCollector.has_value()); REQUIRE_FALSE(admin.hasAll<Weapon>(e));
REQUIRE_FALSE(ship->weapon.has_value()); REQUIRE_FALSE(admin.hasAll<RepairTool>(e));
REQUIRE_FALSE(ship->repairTool.has_value());
} }
TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]") TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
{ {
EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
EntityId nextId = 1; ShipSystem ss(cfg, admin);
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
// cargo_capacity = 10 // cargo_capacity = 10
REQUIRE(ship->cargo->capacity == 10); REQUIRE(admin.get<SalvageCargo>(e).capacity == 10);
REQUIRE(ship->cargo->current == 0); REQUIRE(admin.get<SalvageCargo>(e).current == 0);
REQUIRE(ship->scrapCollector->deliveryBay == kInvalidEntityId); REQUIRE(admin.get<SalvageBehavior>(e).deliveryBay == kInvalidBuildingId);
REQUIRE_FALSE(ship->scrapCollector->scrapTarget.has_value()); REQUIRE_FALSE(admin.get<SalvageBehavior>(e).scrapTarget.has_value());
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -135,61 +127,59 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
TEST_CASE("ShipSystem: repair_ship spawn has repairTool and repairBehavior, no weapon", TEST_CASE("ShipSystem: repair_ship spawn has repairTool and repairBehavior, no weapon",
"[ship]") "[ship]")
{ {
EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
EntityId nextId = 1; ShipSystem ss(cfg, admin);
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
REQUIRE(ship != nullptr); REQUIRE(admin.hasAll<RepairTool>(e));
REQUIRE(ship->repairTool.has_value()); REQUIRE(admin.hasAll<RepairBehavior>(e));
REQUIRE(ship->repairBehavior.has_value()); REQUIRE_FALSE(admin.hasAll<Weapon>(e));
REQUIRE_FALSE(ship->weapon.has_value()); REQUIRE_FALSE(admin.hasAll<SalvageCargo>(e));
REQUIRE_FALSE(ship->cargo.has_value());
} }
TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas", "[ship]") TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas", "[ship]")
{ {
EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
EntityId nextId = 1; ShipSystem ss(cfg, admin);
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
// repair_rate_formula = "5 + x" at x=1 → 6 // repair_rate_formula = "5 + x" at x=1 → 6
REQUIRE(ship->repairTool->ratePerTick == Approx(6.0f)); REQUIRE(admin.get<RepairTool>(e).ratePerTick == Approx(6.0f));
// repair_range_formula = "80" // repair_range_formula = "80"
REQUIRE(ship->repairTool->range == Approx(80.0f)); REQUIRE(admin.get<RepairTool>(e).range == Approx(80.0f));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Entity ids and removal // Entity ids and removal
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("ShipSystem: spawned ships receive strictly increasing entity ids", "[ship]") TEST_CASE("ShipSystem: spawned ships are valid entities", "[ship]")
{ {
EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
EntityId nextId = 1; ShipSystem ss(cfg, admin);
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id1 = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e1 = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const EntityId id2 = ss.spawn("salvage_ship", 1, QVector2D(1.0f, 0.0f)); const entt::entity e2 = ss.spawn("salvage_ship", 1, QVector2D(1.0f, 0.0f));
REQUIRE(id2 > id1); REQUIRE(admin.isValid(e1));
REQUIRE(admin.isValid(e2));
REQUIRE(e1 != e2);
} }
TEST_CASE("ShipSystem: despawn removes the ship", "[ship]") TEST_CASE("ShipSystem: despawn removes the ship", "[ship]")
{ {
EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
EntityId nextId = 1; ShipSystem ss(cfg, admin);
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
REQUIRE(ss.findShip(id) != nullptr); REQUIRE(admin.isValid(e));
ss.despawn(id); ss.despawn(e);
REQUIRE(ss.findShip(id) == nullptr); REQUIRE_FALSE(admin.isValid(e));
} }

View File

@@ -4,6 +4,8 @@
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "EcsComponents.h"
#include "EntityAdmin.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "ItemType.h" #include "ItemType.h"
#include "Rotation.h" #include "Rotation.h"
@@ -41,17 +43,24 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
return nullptr; return nullptr;
} }
static EntityId placeShipyard(Simulation& sim, const BuildingDef& yardDef) static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
{ {
return sim.buildings().placeImmediate( return sim.buildings().placeImmediate(
BuildingType::Shipyard, BuildingType::Shipyard,
yardDef.surfaceMask, yardDef.surfaceMask,
QPoint(0, 0), QPoint(0, 0),
Rotation::East, Rotation::East);
100.0f, 100.0f);
} }
static void fillMaterials(Simulation& sim, EntityId yardId, const ShipDef& def) static int countShips(Simulation& sim)
{
int n = 0;
sim.admin().forEach<ShipIdentity>(
[&n](entt::entity /*e*/, const ShipIdentity& /*si*/) { ++n; });
return n;
}
static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def)
{ {
sim.buildings().forEachBuilding([&](Building& b) sim.buildings().forEachBuilding([&](Building& b)
{ {
@@ -80,17 +89,17 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
const BuildingDef* yardDef = findShipyardDef(sim.config()); const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const int shipsBefore = static_cast<int>(sim.ships().allShips().size()); const int shipsBefore = countShips(sim);
const EntityId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
REQUIRE(yardId != kInvalidEntityId); REQUIRE(yardId != kInvalidBuildingId);
sim.buildings().setRecipe(yardId, def->id); sim.buildings().setRecipe(yardId, def->id);
fillMaterials(sim, yardId, *def); fillMaterials(sim, yardId, *def);
// First tick: materials consumed, production cycle starts — no ship yet. // First tick: materials consumed, production cycle starts — no ship yet.
sim.tick(); sim.tick();
REQUIRE(static_cast<int>(sim.ships().allShips().size()) == shipsBefore); REQUIRE(countShips(sim) == shipsBefore);
// Tick until the cycle completes. // Tick until the cycle completes.
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds); const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
@@ -98,21 +107,21 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
{ {
sim.tick(); sim.tick();
} }
REQUIRE(static_cast<int>(sim.ships().allShips().size()) == shipsBefore); REQUIRE(countShips(sim) == shipsBefore);
// Final tick: cycle completes, ship spawns. // Final tick: cycle completes, ship spawns.
sim.tick(); sim.tick();
REQUIRE(static_cast<int>(sim.ships().allShips().size()) == shipsBefore + 1); REQUIRE(countShips(sim) == shipsBefore + 1);
bool foundPlayerShip = false; bool foundPlayerShip = false;
for (const Ship& ship : sim.ships().allShips()) sim.admin().forEach<ShipIdentity, Faction>(
{ [&](entt::entity /*e*/, const ShipIdentity& si, const Faction& f)
if (!ship.isEnemy && ship.schematicId == def->id)
{ {
foundPlayerShip = true; if (!f.isEnemy && si.schematicId == def->id)
break; {
} foundPlayerShip = true;
} }
});
REQUIRE(foundPlayerShip); REQUIRE(foundPlayerShip);
} }
@@ -123,13 +132,13 @@ TEST_CASE("Shipyard: does not spawn without a schematic set", "[shipyard]")
const BuildingDef* yardDef = findShipyardDef(sim.config()); const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const int shipsBefore = static_cast<int>(sim.ships().allShips().size()); const int shipsBefore = countShips(sim);
placeShipyard(sim, *yardDef); placeShipyard(sim, *yardDef);
sim.tick(); sim.tick();
REQUIRE(static_cast<int>(sim.ships().allShips().size()) == shipsBefore); REQUIRE(countShips(sim) == shipsBefore);
} }
TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]") TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
@@ -141,9 +150,9 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
const BuildingDef* yardDef = findShipyardDef(sim.config()); const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const int shipsBefore = static_cast<int>(sim.ships().allShips().size()); const int shipsBefore = countShips(sim);
const EntityId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, def->id); sim.buildings().setRecipe(yardId, def->id);
// Materials remain at zero (default after setRecipe); no cycle starts. // Materials remain at zero (default after setRecipe); no cycle starts.
@@ -153,7 +162,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
sim.tick(); sim.tick();
} }
REQUIRE(static_cast<int>(sim.ships().allShips().size()) == shipsBefore); REQUIRE(countShips(sim) == shipsBefore);
} }
TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipyard]") TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipyard]")
@@ -165,7 +174,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
const BuildingDef* yardDef = findShipyardDef(sim.config()); const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const EntityId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, def->id); sim.buildings().setRecipe(yardId, def->id);
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds); const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
@@ -176,7 +185,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
{ {
sim.tick(); sim.tick();
} }
const int after1 = static_cast<int>(sim.ships().allShips().size()); const int after1 = countShips(sim);
// Second cycle: capture count immediately after the next spawn tick. // Second cycle: capture count immediately after the next spawn tick.
fillMaterials(sim, yardId, *def); fillMaterials(sim, yardId, *def);
@@ -184,7 +193,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
{ {
sim.tick(); sim.tick();
} }
const int after2 = static_cast<int>(sim.ships().allShips().size()); const int after2 = countShips(sim);
// After each cycle one ship was added; ships from prior cycles may have died // After each cycle one ship was added; ships from prior cycles may have died
// from enemy fire, so we only assert the most-recent spawn is still present. // from enemy fire, so we only assert the most-recent spawn is still present.

View File

@@ -6,6 +6,8 @@
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "EcsComponents.h"
#include "EntityAdmin.h"
#include "Rotation.h" #include "Rotation.h"
#include "Ship.h" #include "Ship.h"
#include "ShipSystem.h" #include "ShipSystem.h"
@@ -100,16 +102,23 @@ TEST_CASE("WaveSystem: Simulation pre-places HQ + 2 player + 2 enemy stations",
{ {
const Simulation sim(loadConfig(), 42); const Simulation sim(loadConfig(), 42);
int hqCount = 0; // HQ is still a Building (for belt integration).
int playerCount = 0; int hqCount = 0;
int enemyCount = 0;
for (const Building& b : sim.buildings().allBuildings()) for (const Building& b : sim.buildings().allBuildings())
{ {
if (b.type == BuildingType::Hq) { ++hqCount; } if (b.type == BuildingType::Hq) { ++hqCount; }
else if (b.type == BuildingType::PlayerDefenceStation) { ++playerCount; }
else if (b.type == BuildingType::EnemyDefenceStation) { ++enemyCount; }
} }
// Stations are ECS entities.
int playerCount = 0;
int enemyCount = 0;
sim.admin().forEach<StationBody, Faction>(
[&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f)
{
if (f.isEnemy) { ++enemyCount; }
else { ++playerCount; }
});
REQUIRE(hqCount == 1); REQUIRE(hqCount == 1);
REQUIRE(playerCount == 2); REQUIRE(playerCount == 2);
REQUIRE(enemyCount == 2); REQUIRE(enemyCount == 2);
@@ -123,15 +132,12 @@ TEST_CASE("WaveSystem: HQ has correct initial HP from config", "[wave]")
static_cast<float>(sim.config().stations.hq.hpFormula.evaluate(0.0)); static_cast<float>(sim.config().stations.hq.hpFormula.evaluate(0.0));
bool found = false; bool found = false;
float actualHp = 0.0f; float actualHp = 0.0f;
for (const Building& b : sim.buildings().allBuildings()) sim.admin().forEach<HqProxy, Health>(
{ [&](entt::entity /*e*/, const HqProxy& /*hq*/, const Health& h)
if (b.type == BuildingType::Hq)
{ {
found = true; found = true;
actualHp = b.hp; actualHp = h.hp;
break; });
}
}
REQUIRE(found); REQUIRE(found);
REQUIRE(actualHp == Approx(expectedHp)); REQUIRE(actualHp == Approx(expectedHp));
@@ -159,16 +165,18 @@ TEST_CASE("WaveSystem: player stations have weapon set", "[wave]")
const Simulation sim(loadConfig(), 42); const Simulation sim(loadConfig(), 42);
int armedPlayerStations = 0; int armedPlayerStations = 0;
for (const Building& b : sim.buildings().allBuildings()) sim.admin().forEach<StationBody, Faction, Weapon>(
{ [&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f,
if (b.type == BuildingType::PlayerDefenceStation && b.weapon) const Weapon& w)
{ {
++armedPlayerStations; if (!f.isEnemy)
REQUIRE(b.weapon->damage > 0.0f); {
REQUIRE(b.weapon->range > 0.0f); ++armedPlayerStations;
REQUIRE(b.weapon->fireRateHz > 0.0f); REQUIRE(w.damage > 0.0f);
} REQUIRE(w.range > 0.0f);
} REQUIRE(w.fireRateHz > 0.0f);
}
});
REQUIRE(armedPlayerStations == 2); REQUIRE(armedPlayerStations == 2);
} }
@@ -177,16 +185,18 @@ TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]")
const Simulation sim(loadConfig(), 42); const Simulation sim(loadConfig(), 42);
int armedEnemyStations = 0; int armedEnemyStations = 0;
for (const Building& b : sim.buildings().allBuildings()) sim.admin().forEach<StationBody, Faction, Weapon>(
{ [&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f,
if (b.type == BuildingType::EnemyDefenceStation && b.weapon) const Weapon& w)
{ {
++armedEnemyStations; if (f.isEnemy)
REQUIRE(b.weapon->damage > 0.0f); {
REQUIRE(b.weapon->range > 0.0f); ++armedEnemyStations;
REQUIRE(b.weapon->fireRateHz > 0.0f); REQUIRE(w.damage > 0.0f);
} REQUIRE(w.range > 0.0f);
} REQUIRE(w.fireRateHz > 0.0f);
}
});
REQUIRE(armedEnemyStations == 2); REQUIRE(armedEnemyStations == 2);
} }
@@ -207,14 +217,11 @@ TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]
} }
bool foundEnemyShip = false; bool foundEnemyShip = false;
for (const Ship& s : sim.ships().allShips()) sim.admin().forEach<ShipIdentity, Faction>(
{ [&](entt::entity /*e*/, const ShipIdentity& /*si*/, const Faction& f)
if (s.isEnemy)
{ {
foundEnemyShip = true; if (f.isEnemy) { foundEnemyShip = true; }
break; });
}
}
REQUIRE(foundEnemyShip); REQUIRE(foundEnemyShip);
} }
@@ -229,13 +236,14 @@ TEST_CASE("WaveSystem: only eligible ships (cost > 0) appear in waves", "[wave]"
sim.tick(); sim.tick();
} }
for (const Ship& s : sim.ships().allShips()) sim.admin().forEach<ShipIdentity, Faction>(
{ [&](entt::entity /*e*/, const ShipIdentity& si, const Faction& f)
if (!s.isEnemy) { continue; } {
// salvage_ship and repair_ship have cost_formula = "0" and must not spawn. if (!f.isEnemy) { return; }
REQUIRE(s.schematicId != "salvage_ship"); // salvage_ship and repair_ship have cost_formula = "0" and must not spawn.
REQUIRE(s.schematicId != "repair_ship"); REQUIRE(si.schematicId != "salvage_ship");
} REQUIRE(si.schematicId != "repair_ship");
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -247,22 +255,21 @@ TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]"
Simulation sim(loadConfig(), 42); Simulation sim(loadConfig(), 42);
// Damage both enemy stations to 0. // Damage both enemy stations to 0.
sim.buildings().forEachBuilding([](Building& b) sim.admin().forEach<StationBody, Faction, Health>(
{ [](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
if (b.type == BuildingType::EnemyDefenceStation)
{ {
b.hp = -1.0f; if (f.isEnemy) { h.hp = -1.0f; }
} });
});
sim.tick(); sim.tick();
// After push: should have 2 new enemy stations. // After push: should have 2 new enemy stations.
int enemyCount = 0; int enemyCount = 0;
for (const Building& b : sim.buildings().allBuildings()) sim.admin().forEach<StationBody, Faction>(
{ [&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f)
if (b.type == BuildingType::EnemyDefenceStation) { ++enemyCount; } {
} if (f.isEnemy) { ++enemyCount; }
});
REQUIRE(enemyCount == 2); REQUIRE(enemyCount == 2);
} }
@@ -270,13 +277,11 @@ TEST_CASE("WaveSystem: push emits exactly one SchematicDropEvent", "[wave]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadConfig(), 42);
sim.buildings().forEachBuilding([](Building& b) sim.admin().forEach<StationBody, Faction, Health>(
{ [](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
if (b.type == BuildingType::EnemyDefenceStation)
{ {
b.hp = -1.0f; if (f.isEnemy) { h.hp = -1.0f; }
} });
});
sim.tick(); sim.tick();
@@ -288,13 +293,11 @@ TEST_CASE("WaveSystem: push schematic drop awards a known ship id", "[wave]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadConfig(), 42);
sim.buildings().forEachBuilding([](Building& b) sim.admin().forEach<StationBody, Faction, Health>(
{ [](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
if (b.type == BuildingType::EnemyDefenceStation)
{ {
b.hp = -1.0f; if (f.isEnemy) { h.hp = -1.0f; }
} });
});
sim.tick(); sim.tick();
const std::vector<SchematicDropEvent> events = sim.drainSchematicDropEvents(); const std::vector<SchematicDropEvent> events = sim.drainSchematicDropEvents();
@@ -319,28 +322,31 @@ TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")
// Record the X position of the initial enemy stations. // Record the X position of the initial enemy stations.
int initialX = std::numeric_limits<int>::min(); int initialX = std::numeric_limits<int>::min();
for (const Building& b : sim.buildings().allBuildings()) sim.admin().forEach<StationBody, Faction>(
{ [&](entt::entity /*e*/, const StationBody& sb, const Faction& f)
if (b.type == BuildingType::EnemyDefenceStation)
{ {
if (b.anchor.x() > initialX) { initialX = b.anchor.x(); } if (f.isEnemy && sb.anchor.x() > initialX)
} {
} initialX = sb.anchor.x();
}
});
sim.buildings().forEachBuilding([](Building& b) sim.admin().forEach<StationBody, Faction, Health>(
{ [](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
if (b.type == BuildingType::EnemyDefenceStation) { b.hp = -1.0f; } {
}); if (f.isEnemy) { h.hp = -1.0f; }
});
sim.tick(); sim.tick();
int newX = std::numeric_limits<int>::min(); int newX = std::numeric_limits<int>::min();
for (const Building& b : sim.buildings().allBuildings()) sim.admin().forEach<StationBody, Faction>(
{ [&](entt::entity /*e*/, const StationBody& sb, const Faction& f)
if (b.type == BuildingType::EnemyDefenceStation)
{ {
if (b.anchor.x() > newX) { newX = b.anchor.x(); } if (f.isEnemy && sb.anchor.x() > newX)
} {
} newX = sb.anchor.x();
}
});
REQUIRE(newX > initialX); REQUIRE(newX > initialX);
} }

View File

@@ -62,9 +62,9 @@ BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidge
connect(m_loadBtn, &QPushButton::clicked, this, &BlueprintPanel::onLoadClicked); connect(m_loadBtn, &QPushButton::clicked, this, &BlueprintPanel::onLoadClicked);
} }
void BlueprintPanel::onSelectionChanged(const std::vector<EntityId>& ids) void BlueprintPanel::onSelectionChanged(const std::vector<BuildingId>& ids)
{ {
m_selectedIds = ids; m_selectedBuildingIds = ids;
refreshButtonStates(); refreshButtonStates();
} }
@@ -86,7 +86,7 @@ void BlueprintPanel::clearActiveBlueprintButton()
void BlueprintPanel::onCreateClicked() void BlueprintPanel::onCreateClicked()
{ {
if (m_selectedIds.empty()) { return; } if (m_selectedBuildingIds.empty()) { return; }
Blueprint bp = createBlueprintFromSelection(); Blueprint bp = createBlueprintFromSelection();
if (bp.buildings.empty()) { return; } if (bp.buildings.empty()) { return; }
@@ -144,9 +144,9 @@ Blueprint BlueprintPanel::createBlueprintFromSelection() const
const Building* building; const Building* building;
}; };
std::vector<Entry> entries; std::vector<Entry> entries;
entries.reserve(m_selectedIds.size()); entries.reserve(m_selectedBuildingIds.size());
for (const EntityId id : m_selectedIds) for (const BuildingId id : m_selectedBuildingIds)
{ {
const Building* b = m_sim->buildings().findBuilding(id); const Building* b = m_sim->buildings().findBuilding(id);
if (!b) { continue; } if (!b) { continue; }
@@ -320,7 +320,7 @@ void BlueprintPanel::onLoadClicked()
void BlueprintPanel::refreshButtonStates() void BlueprintPanel::refreshButtonStates()
{ {
const bool anyPlaceable = [&]() { const bool anyPlaceable = [&]() {
for (const EntityId id : m_selectedIds) for (const BuildingId id : m_selectedBuildingIds)
{ {
const Building* b = m_sim->buildings().findBuilding(id); const Building* b = m_sim->buildings().findBuilding(id);
if (!b) { continue; } if (!b) { continue; }

View File

@@ -5,7 +5,7 @@
#include <QWidget> #include <QWidget>
#include "Blueprint.h" #include "Blueprint.h"
#include "EntityId.h" #include "BuildingId.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Tick.h" #include "Tick.h"
@@ -22,7 +22,7 @@ public:
BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent = nullptr); BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent = nullptr);
public slots: public slots:
void onSelectionChanged(const std::vector<EntityId>& ids); void onSelectionChanged(const std::vector<BuildingId>& ids);
void onStateUpdated(Tick tick, int blocks, double speed); void onStateUpdated(Tick tick, int blocks, double speed);
void clearActiveBlueprintButton(); void clearActiveBlueprintButton();
@@ -45,7 +45,7 @@ private:
Simulation* m_sim; Simulation* m_sim;
const GameConfig* m_config; const GameConfig* m_config;
std::vector<EntityId> m_selectedIds; std::vector<BuildingId> m_selectedBuildingIds;
int m_currentBlocks; int m_currentBlocks;
int m_activeIndex; int m_activeIndex;
std::vector<Blueprint> m_blueprints; std::vector<Blueprint> m_blueprints;

View File

@@ -20,7 +20,7 @@
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Scrap.h" #include "EcsComponents.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include "Ship.h" #include "Ship.h"
#include "ShipSystem.h" #include "ShipSystem.h"
@@ -55,11 +55,11 @@ Rotation rotateCounterClockwise(Rotation r)
return Rotation::East; return Rotation::East;
} }
ShipRole shipRole(const Ship& ship) ShipRole shipRoleFromComponents(bool isEnemy, bool hasCargo, bool hasRepairTool)
{ {
if (ship.isEnemy) { return ShipRole::Enemy; } if (isEnemy) { return ShipRole::Enemy; }
if (ship.cargo.has_value()) { return ShipRole::Salvage; } if (hasCargo) { return ShipRole::Salvage; }
if (ship.repairTool.has_value()) { return ShipRole::Repair; } if (hasRepairTool){ return ShipRole::Repair; }
return ShipRole::PlayerCombat; return ShipRole::PlayerCombat;
} }
@@ -116,7 +116,7 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
, m_ghostValid(false) , m_ghostValid(false)
, m_dragging(false) , m_dragging(false)
, m_demolishMode(false) , m_demolishMode(false)
, m_demolishHoverId(kInvalidEntityId) , m_demolishHoverBuildingId(kInvalidBuildingId)
, m_debugDraw(false) , m_debugDraw(false)
, m_rng(std::random_device{}()) , m_rng(std::random_device{}())
, m_boxSelecting(false) , m_boxSelecting(false)
@@ -160,11 +160,11 @@ void GameWorldView::onFrame()
for (const FireEvent& fe : fires) for (const FireEvent& fe : fires)
{ {
float maxRadius = 0.125f; float maxRadius = 0.125f;
const Building* tBld = m_sim->buildings().findBuilding(fe.target); if (m_sim->admin().isValid(fe.target)
if (tBld) && m_sim->admin().hasAll<StationBody>(fe.target))
{ {
const int shorter = std::min(tBld->footprint.width(), const StationBody& sb = m_sim->admin().get<StationBody>(fe.target);
tBld->footprint.height()); const int shorter = std::min(sb.footprint.width(), sb.footprint.height());
maxRadius = shorter / 2.0f; maxRadius = shorter / 2.0f;
} }
@@ -262,6 +262,7 @@ void GameWorldView::paintGL()
drawTiles(painter); drawTiles(painter);
drawBuildings(painter); drawBuildings(painter);
drawStations(painter);
drawBeltItems(painter); drawBeltItems(painter);
drawScrap(painter); drawScrap(painter);
if (m_debugDraw) { drawDebugSensorRanges(painter); } if (m_debugDraw) { drawDebugSensorRanges(painter); }
@@ -342,17 +343,16 @@ float GameWorldView::enemyStationRightEdge() const
{ {
float rightX = static_cast<float>(m_config->world.regions.playerBufferWidth float rightX = static_cast<float>(m_config->world.regions.playerBufferWidth
+ m_config->world.regions.contestZoneWidth); + m_config->world.regions.contestZoneWidth);
for (const Building& b : m_sim->buildings().allBuildings()) m_sim->admin().forEach<StationBody, Faction>(
{ [&rightX](entt::entity /*e*/, const StationBody& sb, const Faction& f)
if (b.type == BuildingType::EnemyDefenceStation)
{ {
for (const QPoint& cell : b.bodyCells) if (!f.isEnemy) { return; }
for (const QPoint& cell : sb.bodyCells)
{ {
const float cx = static_cast<float>(cell.x() + 1); const float cx = static_cast<float>(cell.x() + 1);
if (cx > rightX) { rightX = cx; } if (cx > rightX) { rightX = cx; }
} }
} });
}
return rightX; return rightX;
} }
@@ -412,7 +412,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
return true; return true;
} }
EntityId GameWorldView::buildingAtTile(QPoint tile) const BuildingId GameWorldView::buildingAtTile(QPoint tile) const
{ {
for (const Building& b : m_sim->buildings().allBuildings()) for (const Building& b : m_sim->buildings().allBuildings())
{ {
@@ -424,10 +424,10 @@ EntityId GameWorldView::buildingAtTile(QPoint tile) const
} }
} }
} }
return kInvalidEntityId; return kInvalidBuildingId;
} }
EntityId GameWorldView::siteAtTile(QPoint tile) const BuildingId GameWorldView::siteAtTile(QPoint tile) const
{ {
for (const ConstructionSite& s : m_sim->buildings().allSites()) for (const ConstructionSite& s : m_sim->buildings().allSites())
{ {
@@ -439,25 +439,17 @@ EntityId GameWorldView::siteAtTile(QPoint tile) const
} }
} }
} }
return kInvalidEntityId; return kInvalidBuildingId;
} }
std::optional<QVector2D> GameWorldView::entityPosition(EntityId id) const std::optional<QVector2D> GameWorldView::entityPosition(entt::entity entity) const
{ {
const Ship* ship = m_sim->ships().findShip(id); if (!m_sim->admin().isValid(entity) || !m_sim->admin().hasAll<Position>(entity))
if (ship)
{ {
return ship->position; return std::nullopt;
} }
const Building* bldg = m_sim->buildings().findBuilding(id); return m_sim->admin().get<Position>(entity).value;
if (bldg)
{
return QVector2D(
bldg->anchor.x() + bldg->footprint.width() * 0.5f,
bldg->anchor.y() + bldg->footprint.height() * 0.5f);
}
return std::nullopt;
} }
void GameWorldView::stepSpeed(int delta) void GameWorldView::stepSpeed(int delta)
@@ -503,7 +495,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
for (const BlueprintBuilding& bb : bp.buildings) for (const BlueprintBuilding& bb : bp.buildings)
{ {
const QPoint anchor = center + bb.offset; const QPoint anchor = center + bb.offset;
const std::optional<EntityId> rotateTarget = const std::optional<BuildingId> rotateTarget =
m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation); m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
if (rotateTarget.has_value()) if (rotateTarget.has_value())
{ {
@@ -511,8 +503,8 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
continue; continue;
} }
const EntityId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation); const BuildingId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation);
if (id == kInvalidEntityId || bb.recipeId.empty()) { continue; } if (id == kInvalidBuildingId || bb.recipeId.empty()) { continue; }
if (bb.type == BuildingType::Shipyard) if (bb.type == BuildingType::Shipyard)
{ {
@@ -541,7 +533,7 @@ void GameWorldView::placeAtTile(QPoint tile)
return; return;
} }
const std::optional<EntityId> rotateTarget = const std::optional<BuildingId> rotateTarget =
m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation); m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
if (rotateTarget.has_value()) if (rotateTarget.has_value())
{ {
@@ -557,9 +549,9 @@ void GameWorldView::placeAtTile(QPoint tile)
} }
if (!m_sim->buildings().isTileOccupied(tile)) if (!m_sim->buildings().isTileOccupied(tile))
{ {
const EntityId id = m_sim->tryPlaceBuilding( const BuildingId id = m_sim->tryPlaceBuilding(
type, tile, m_ghostRotation); type, tile, m_ghostRotation);
if (id != kInvalidEntityId) if (id != kInvalidBuildingId)
{ {
m_beltDragTiles.insert(tile); m_beltDragTiles.insert(tile);
} }
@@ -571,8 +563,8 @@ void GameWorldView::placeAtTile(QPoint tile)
{ {
if (!m_sim->buildings().isTileOccupied(tile)) if (!m_sim->buildings().isTileOccupied(tile))
{ {
const EntityId id = m_sim->tryPlaceBuilding(type, tile, m_ghostRotation); const BuildingId id = m_sim->tryPlaceBuilding(type, tile, m_ghostRotation);
if (id != kInvalidEntityId) if (id != kInvalidBuildingId)
{ {
if (type == BuildingType::TunnelEntry) if (type == BuildingType::TunnelEntry)
{ {
@@ -685,7 +677,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
} }
bool selected = false; bool selected = false;
for (EntityId selId : m_selectedIds) for (BuildingId selId : m_selectedBuildingIds)
{ {
if (selId == b.id) { selected = true; break; } if (selId == b.id) { selected = true; break; }
} }
@@ -790,7 +782,7 @@ void GameWorldView::drawBeltItems(QPainter& painter)
void GameWorldView::drawScrap(QPainter& painter) void GameWorldView::drawScrap(QPainter& painter)
{ {
const float r = tilePx() * 0.2f; const float r = tilePx() * 0.2f;
for (const Scrap& scrap : m_sim->scraps().allScraps()) for (const ScrapInfo& scrap : m_sim->scraps().allScrapInfo())
{ {
const QPointF center = worldToWidget(scrap.position); const QPointF center = worldToWidget(scrap.position);
painter.setBrush(QColor(128, 110, 90)); painter.setBrush(QColor(128, 110, 90));
@@ -800,54 +792,103 @@ void GameWorldView::drawScrap(QPainter& painter)
} }
} }
void GameWorldView::drawStations(QPainter& painter)
{
m_sim->admin().forEach<StationBody, Faction, Health>(
[&](entt::entity /*e*/, const StationBody& sb, const Faction& f, const Health& h)
{
const BuildingType visType = f.isEnemy
? BuildingType::EnemyDefenceStation
: BuildingType::PlayerDefenceStation;
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
m_visuals->buildings.find(visType);
if (it == m_visuals->buildings.end()) { return; }
const BuildingVisuals& bv = it->second;
painter.setPen(Qt::NoPen);
for (const QPoint& cell : sb.bodyCells)
{
painter.fillRect(tileRect(cell), bv.fill);
}
const QPointF tl = tileToWidget(QPoint(sb.anchor.x(), sb.anchor.y()));
const QRectF bboxRect(tl.x(), tl.y(),
sb.footprint.width() * static_cast<qreal>(tilePx()),
sb.footprint.height() * static_cast<qreal>(tilePx()));
painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush);
painter.drawRect(bboxRect);
// HP bar below footprint.
if (h.maxHp > 0.0f)
{
const float fraction = std::max(0.0f, h.hp / h.maxHp);
const qreal barH = static_cast<qreal>(tilePx()) * 0.12;
const qreal barY = bboxRect.bottom() + 1.0;
const qreal barW = bboxRect.width();
painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH),
QColor(60, 60, 60));
painter.fillRect(QRectF(bboxRect.left(), barY, barW * static_cast<qreal>(fraction), barH),
f.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
}
});
}
void GameWorldView::drawShips(QPainter& painter) void GameWorldView::drawShips(QPainter& painter)
{ {
for (const Ship& ship : m_sim->ships().allShips()) m_sim->admin().forEach<ShipIdentity, Position, Facing, Faction>(
{ [&](entt::entity e, const ShipIdentity& /*si*/, const Position& pos,
const ShipRole role = shipRole(ship); const Facing& facing, const Faction& fac)
const std::map<ShipRole, ShipVisuals>::const_iterator it = {
m_visuals->ships.find(role); const bool hasCargo = m_sim->admin().hasAll<SalvageCargo>(e);
if (it == m_visuals->ships.end()) { continue; } const bool hasRepair = m_sim->admin().hasAll<RepairTool>(e);
const ShipRole role = shipRoleFromComponents(fac.isEnemy, hasCargo, hasRepair);
const std::map<ShipRole, ShipVisuals>::const_iterator it =
m_visuals->ships.find(role);
if (it == m_visuals->ships.end()) { return; }
const QPointF center = worldToWidget(ship.position); const QPointF center = worldToWidget(pos.value);
const QVector2D dir(std::cos(ship.facing), std::sin(ship.facing)); const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians));
const QVector2D perp(-dir.y(), dir.x()); const QVector2D perp(-dir.y(), dir.x());
const float fwd = tilePx() * 0.45f; const float fwd = tilePx() * 0.45f;
const float side = tilePx() * 0.25f; const float side = tilePx() * 0.25f;
QPolygonF tri; QPolygonF tri;
tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd), tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd),
center.y() + static_cast<qreal>(dir.y() * fwd)) center.y() + static_cast<qreal>(dir.y() * fwd))
<< QPointF(center.x() + static_cast<qreal>(perp.x() * side - dir.x() * side), << QPointF(center.x() + static_cast<qreal>(perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(perp.y() * side - dir.y() * side)) center.y() + static_cast<qreal>(perp.y() * side - dir.y() * side))
<< QPointF(center.x() + static_cast<qreal>(-perp.x() * side - dir.x() * side), << QPointF(center.x() + static_cast<qreal>(-perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(-perp.y() * side - dir.y() * side)); center.y() + static_cast<qreal>(-perp.y() * side - dir.y() * side));
painter.setPen(QPen(it->second.outline, 1)); painter.setPen(QPen(it->second.outline, 1));
painter.setBrush(it->second.fill); painter.setBrush(it->second.fill);
painter.drawPolygon(tri); painter.drawPolygon(tri);
} });
} }
void GameWorldView::drawDebugSensorRanges(QPainter& painter) void GameWorldView::drawDebugSensorRanges(QPainter& painter)
{ {
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
for (const Ship& ship : m_sim->ships().allShips()) m_sim->admin().forEach<ShipIdentity, Position, Facing, Faction, SensorRange>(
{ [&](entt::entity e, const ShipIdentity& /*si*/, const Position& pos,
const ShipRole role = shipRole(ship); const Facing& /*facing*/, const Faction& fac, const SensorRange& sensor)
const std::map<ShipRole, ShipVisuals>::const_iterator it = {
m_visuals->ships.find(role); const bool hasCargo = m_sim->admin().hasAll<SalvageCargo>(e);
if (it == m_visuals->ships.end()) { continue; } const bool hasRepair = m_sim->admin().hasAll<RepairTool>(e);
const ShipRole role = shipRoleFromComponents(fac.isEnemy, hasCargo, hasRepair);
const std::map<ShipRole, ShipVisuals>::const_iterator it =
m_visuals->ships.find(role);
if (it == m_visuals->ships.end()) { return; }
const float range = ship.sensorRange; const QPointF center = worldToWidget(pos.value);
const qreal radiusPx = static_cast<qreal>(sensor.value)
const QPointF center = worldToWidget(ship.position); * static_cast<qreal>(tilePx());
const qreal radiusPx = static_cast<qreal>(range) painter.setPen(QPen(it->second.outline, 1));
* static_cast<qreal>(tilePx()); painter.drawEllipse(center, radiusPx, radiusPx);
painter.setPen(QPen(it->second.outline, 1)); });
painter.drawEllipse(center, radiusPx, radiusPx);
}
} }
void GameWorldView::drawBeams(QPainter& painter) void GameWorldView::drawBeams(QPainter& painter)
@@ -916,9 +957,9 @@ void GameWorldView::drawOverlays(QPainter& painter)
} }
// Demolish hover tint // Demolish hover tint
if (m_demolishMode && m_demolishHoverId != kInvalidEntityId) if (m_demolishMode && m_demolishHoverBuildingId != kInvalidBuildingId)
{ {
const Building* b = m_sim->buildings().findBuilding(m_demolishHoverId); const Building* b = m_sim->buildings().findBuilding(m_demolishHoverBuildingId);
if (b) if (b)
{ {
for (const QPoint& cell : b->bodyCells) for (const QPoint& cell : b->bodyCells)
@@ -1127,56 +1168,55 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
} }
else if (m_demolishMode) else if (m_demolishMode)
{ {
EntityId hovered = buildingAtTile(tile); BuildingId hovered = buildingAtTile(tile);
if (hovered == kInvalidEntityId) if (hovered == kInvalidBuildingId)
{ {
hovered = siteAtTile(tile); hovered = siteAtTile(tile);
} }
if (hovered != kInvalidEntityId) if (hovered != kInvalidBuildingId)
{ {
const Building* b = m_sim->buildings().findBuilding(hovered); const Building* b = m_sim->buildings().findBuilding(hovered);
const bool isProtected = b && (b->type == BuildingType::Hq const bool isProtected = b && b->type == BuildingType::Hq;
|| b->type == BuildingType::PlayerDefenceStation);
if (!isProtected) if (!isProtected)
{ {
m_sim->demolish(hovered); m_sim->demolish(hovered);
m_demolishHoverId = kInvalidEntityId; m_demolishHoverBuildingId = kInvalidBuildingId;
} }
} }
} }
else else
{ {
EntityId id = buildingAtTile(tile); BuildingId id = buildingAtTile(tile);
if (id == kInvalidEntityId) if (id == kInvalidBuildingId)
{ {
id = siteAtTile(tile); id = siteAtTile(tile);
} }
if (id != kInvalidEntityId) if (id != kInvalidBuildingId)
{ {
if (event->modifiers() & Qt::ControlModifier) if (event->modifiers() & Qt::ControlModifier)
{ {
bool found = false; bool found = false;
std::vector<EntityId> newSel; std::vector<BuildingId> newSel;
for (EntityId sel : m_selectedIds) for (BuildingId sel : m_selectedBuildingIds)
{ {
if (sel == id) { found = true; } if (sel == id) { found = true; }
else { newSel.push_back(sel); } else { newSel.push_back(sel); }
} }
if (!found) { newSel.push_back(id); } if (!found) { newSel.push_back(id); }
m_selectedIds = newSel; m_selectedBuildingIds = newSel;
} }
else else
{ {
m_selectedIds = { id }; m_selectedBuildingIds = { id };
} }
emit selectionChanged(m_selectedIds); emit selectionChanged(m_selectedBuildingIds);
} }
else else
{ {
if (!(event->modifiers() & Qt::ControlModifier)) if (!(event->modifiers() & Qt::ControlModifier))
{ {
m_selectedIds.clear(); m_selectedBuildingIds.clear();
emit selectionChanged(m_selectedIds); emit selectionChanged(m_selectedBuildingIds);
} }
m_boxSelecting = true; m_boxSelecting = true;
m_boxStartTile = tile; m_boxStartTile = tile;
@@ -1205,7 +1245,7 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event)
} }
else if (m_demolishMode) else if (m_demolishMode)
{ {
m_demolishHoverId = buildingAtTile(tile); m_demolishHoverBuildingId = buildingAtTile(tile);
} }
else if (m_boxSelecting) else if (m_boxSelecting)
{ {
@@ -1232,7 +1272,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
const int x1 = std::max(m_boxStartTile.x(), m_boxCurrentTile.x()); const int x1 = std::max(m_boxStartTile.x(), m_boxCurrentTile.x());
const int y1 = std::max(m_boxStartTile.y(), m_boxCurrentTile.y()); const int y1 = std::max(m_boxStartTile.y(), m_boxCurrentTile.y());
std::vector<EntityId> boxSel; std::vector<BuildingId> boxSel;
for (const Building& b : m_sim->buildings().allBuildings()) for (const Building& b : m_sim->buildings().allBuildings())
{ {
for (const QPoint& cell : b.bodyCells) for (const QPoint& cell : b.bodyCells)
@@ -1260,21 +1300,21 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
if (!(event->modifiers() & Qt::ControlModifier)) if (!(event->modifiers() & Qt::ControlModifier))
{ {
m_selectedIds = boxSel; m_selectedBuildingIds = boxSel;
} }
else else
{ {
for (EntityId id : boxSel) for (BuildingId id : boxSel)
{ {
bool found = false; bool found = false;
for (EntityId sel : m_selectedIds) for (BuildingId sel : m_selectedBuildingIds)
{ {
if (sel == id) { found = true; break; } if (sel == id) { found = true; break; }
} }
if (!found) { m_selectedIds.push_back(id); } if (!found) { m_selectedBuildingIds.push_back(id); }
} }
} }
emit selectionChanged(m_selectedIds); emit selectionChanged(m_selectedBuildingIds);
} }
} }
@@ -1287,7 +1327,7 @@ void GameWorldView::toggleDemolishMode()
if (m_demolishMode) if (m_demolishMode)
{ {
m_demolishMode = false; m_demolishMode = false;
m_demolishHoverId = kInvalidEntityId; m_demolishHoverBuildingId = kInvalidBuildingId;
} }
else else
{ {
@@ -1353,9 +1393,9 @@ void GameWorldView::resetForNewGame()
m_ghostRotation = Rotation::East; m_ghostRotation = Rotation::East;
m_ghostValid = false; m_ghostValid = false;
m_demolishMode = false; m_demolishMode = false;
m_demolishHoverId = kInvalidEntityId; m_demolishHoverBuildingId = kInvalidBuildingId;
emit demolishModeChanged(false); emit demolishModeChanged(false);
m_selectedIds.clear(); m_selectedBuildingIds.clear();
m_boxSelecting = false; m_boxSelecting = false;
m_scrollXTiles = 0.0f; m_scrollXTiles = 0.0f;
m_scrollLeft = false; m_scrollLeft = false;

View File

@@ -15,8 +15,10 @@
#include "Blueprint.h" #include "Blueprint.h"
#include "SchematicDropEvent.h" #include "SchematicDropEvent.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "EntityId.h" #include "BuildingId.h"
#include "FireEvent.h" #include "FireEvent.h"
#include "entt/entity/entity.hpp"
#include "GameConfig.h" #include "GameConfig.h"
#include "Rotation.h" #include "Rotation.h"
#include "Tick.h" #include "Tick.h"
@@ -44,7 +46,7 @@ public:
const VisualsConfig* visuals, QWidget* parent = nullptr); const VisualsConfig* visuals, QWidget* parent = nullptr);
signals: signals:
void selectionChanged(const std::vector<EntityId>& ids); void selectionChanged(const std::vector<BuildingId>& ids);
void stateUpdated(Tick tick, int blocks, double speed); void stateUpdated(Tick tick, int blocks, double speed);
void gameOver(); void gameOver();
void builderModeExited(); void builderModeExited();
@@ -79,6 +81,7 @@ private slots:
private: private:
void drawTiles(QPainter& painter); void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter); void drawBuildings(QPainter& painter);
void drawStations(QPainter& painter);
void drawBeltItems(QPainter& painter); void drawBeltItems(QPainter& painter);
void drawScrap(QPainter& painter); void drawScrap(QPainter& painter);
void drawShips(QPainter& painter); void drawShips(QPainter& painter);
@@ -101,15 +104,15 @@ private:
bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const; bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const;
const BuildingDef* findBuildingDef(BuildingType type) const; const BuildingDef* findBuildingDef(BuildingType type) const;
EntityId buildingAtTile(QPoint tile) const; BuildingId buildingAtTile(QPoint tile) const;
EntityId siteAtTile(QPoint tile) const; BuildingId siteAtTile(QPoint tile) const;
void drawPortGlyph(QPainter& painter, QPoint bodyTile, void drawPortGlyph(QPainter& painter, QPoint bodyTile,
Rotation direction, const QColor& color); Rotation direction, const QColor& color);
void placeBlueprintAtTile(QPoint center); void placeBlueprintAtTile(QPoint center);
std::optional<QVector2D> entityPosition(EntityId id) const; std::optional<QVector2D> entityPosition(entt::entity entity) const;
void stepSpeed(int delta); void stepSpeed(int delta);
void placeAtTile(QPoint tile); void placeAtTile(QPoint tile);
@@ -159,10 +162,10 @@ private:
QPoint m_blueprintGhostTile; QPoint m_blueprintGhostTile;
bool m_demolishMode; bool m_demolishMode;
EntityId m_demolishHoverId; BuildingId m_demolishHoverBuildingId;
bool m_debugDraw; bool m_debugDraw;
std::vector<EntityId> m_selectedIds; std::vector<BuildingId> m_selectedBuildingIds;
bool m_boxSelecting; bool m_boxSelecting;
QPoint m_boxStartTile; QPoint m_boxStartTile;
QPoint m_boxCurrentTile; QPoint m_boxCurrentTile;

View File

@@ -219,7 +219,7 @@ void MainWindow::onEscapeMenuRequested()
} }
} }
void MainWindow::onLayoutDialogRequested(EntityId shipyardId) void MainWindow::onLayoutDialogRequested(BuildingId shipyardId)
{ {
const double prevSpeed = m_gameWorldView->gameSpeed(); const double prevSpeed = m_gameWorldView->gameSpeed();
m_gameWorldView->setGameSpeed(0.0); m_gameWorldView->setGameSpeed(0.0);

View File

@@ -5,7 +5,7 @@
#include <QWidget> #include <QWidget>
#include "EntityId.h" #include "BuildingId.h"
#include "ShipLayoutBlueprint.h" #include "ShipLayoutBlueprint.h"
#include "Tick.h" #include "Tick.h"
#include "VisualsConfig.h" #include "VisualsConfig.h"
@@ -34,7 +34,7 @@ private slots:
void onGameOver(); void onGameOver();
void onStateUpdated(Tick tick, int blocks, double speed); void onStateUpdated(Tick tick, int blocks, double speed);
void onEscapeMenuRequested(); void onEscapeMenuRequested();
void onLayoutDialogRequested(EntityId shipyardId); void onLayoutDialogRequested(BuildingId shipyardId);
private: private:
void layoutPanels(); void layoutPanels();

View File

@@ -86,7 +86,7 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
: QWidget(parent) : QWidget(parent)
, m_sim(sim) , m_sim(sim)
, m_config(config) , m_config(config)
, m_singleId(kInvalidEntityId) , m_singleBuildingId(kInvalidBuildingId)
, m_splitterTile(0, 0) , m_splitterTile(0, 0)
{ {
m_layout = new QVBoxLayout(this); m_layout = new QVBoxLayout(this);
@@ -125,9 +125,9 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
connect(m_clearBeltBtn, &QPushButton::clicked, connect(m_clearBeltBtn, &QPushButton::clicked,
this, &SelectedBuildingPanel::onClearBelt); this, &SelectedBuildingPanel::onClearBelt);
connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() { connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() {
if (m_singleId != kInvalidEntityId) if (m_singleBuildingId != kInvalidBuildingId)
{ {
emit layoutDialogRequested(m_singleId); emit layoutDialogRequested(m_singleBuildingId);
} }
}); });
connect(m_filterAList, &QListWidget::itemChanged, connect(m_filterAList, &QListWidget::itemChanged,
@@ -138,31 +138,31 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
buildEmpty(); buildEmpty();
} }
void SelectedBuildingPanel::onSelectionChanged(const std::vector<EntityId>& ids) void SelectedBuildingPanel::onSelectionChanged(const std::vector<BuildingId>& ids)
{ {
m_selection = ids; m_selectedBuildingIds = ids;
rebuild(); rebuild();
} }
void SelectedBuildingPanel::rebuild() void SelectedBuildingPanel::rebuild()
{ {
if (m_selection.empty()) if (m_selectedBuildingIds.empty())
{ {
buildEmpty(); buildEmpty();
} }
else if (m_selection.size() == 1) else if (m_selectedBuildingIds.size() == 1)
{ {
buildSingle(m_selection[0]); buildSingle(m_selectedBuildingIds[0]);
} }
else else
{ {
buildMulti(m_selection); buildMulti(m_selectedBuildingIds);
} }
} }
void SelectedBuildingPanel::buildEmpty() void SelectedBuildingPanel::buildEmpty()
{ {
m_singleId = kInvalidEntityId; m_singleBuildingId = kInvalidBuildingId;
m_titleLabel->hide(); m_titleLabel->hide();
m_recipeCombo->hide(); m_recipeCombo->hide();
m_layoutPreview->hide(); m_layoutPreview->hide();
@@ -175,9 +175,9 @@ void SelectedBuildingPanel::buildEmpty()
m_buffersLabel->hide(); m_buffersLabel->hide();
} }
void SelectedBuildingPanel::buildSingle(EntityId id) void SelectedBuildingPanel::buildSingle(BuildingId id)
{ {
m_singleId = id; m_singleBuildingId = id;
const Building* b = m_sim->buildings().findBuilding(id); const Building* b = m_sim->buildings().findBuilding(id);
if (!b) if (!b)
@@ -490,8 +490,8 @@ const ShipDef* SelectedBuildingPanel::findShipDef(const std::string& id) const
void SelectedBuildingPanel::onStateUpdated(Tick /*tick*/, int /*blocks*/, double /*speed*/) void SelectedBuildingPanel::onStateUpdated(Tick /*tick*/, int /*blocks*/, double /*speed*/)
{ {
if (m_singleId == kInvalidEntityId) { return; } if (m_singleBuildingId == kInvalidBuildingId) { return; }
const Building* b = m_sim->buildings().findBuilding(m_singleId); const Building* b = m_sim->buildings().findBuilding(m_singleBuildingId);
if (b) if (b)
{ {
// If the panel was last showing this id as a construction site, the // If the panel was last showing this id as a construction site, the
@@ -506,7 +506,7 @@ void SelectedBuildingPanel::onStateUpdated(Tick /*tick*/, int /*blocks*/, double
} }
return; return;
} }
const ConstructionSite* s = m_sim->buildings().findSite(m_singleId); const ConstructionSite* s = m_sim->buildings().findSite(m_singleBuildingId);
if (s) if (s)
{ {
rebuild(); rebuild();
@@ -515,9 +515,9 @@ void SelectedBuildingPanel::onStateUpdated(Tick /*tick*/, int /*blocks*/, double
buildEmpty(); buildEmpty();
} }
void SelectedBuildingPanel::buildMulti(const std::vector<EntityId>& ids) void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
{ {
m_singleId = kInvalidEntityId; m_singleBuildingId = kInvalidBuildingId;
m_recipeCombo->hide(); m_recipeCombo->hide();
m_clearBeltBtn->hide(); m_clearBeltBtn->hide();
m_filterALabel->hide(); m_filterALabel->hide();
@@ -527,7 +527,7 @@ void SelectedBuildingPanel::buildMulti(const std::vector<EntityId>& ids)
m_buffersLabel->hide(); m_buffersLabel->hide();
std::map<BuildingType, int> counts; std::map<BuildingType, int> counts;
for (EntityId id : ids) for (BuildingId id : ids)
{ {
const Building* b = m_sim->buildings().findBuilding(id); const Building* b = m_sim->buildings().findBuilding(id);
if (b) if (b)
@@ -564,12 +564,12 @@ void SelectedBuildingPanel::buildMulti(const std::vector<EntityId>& ids)
void SelectedBuildingPanel::onRecipeChanged(int comboIndex) void SelectedBuildingPanel::onRecipeChanged(int comboIndex)
{ {
if (m_singleId == kInvalidEntityId) if (m_singleBuildingId == kInvalidBuildingId)
{ {
return; return;
} }
const QString recipeId = m_recipeCombo->itemData(comboIndex).toString(); const QString recipeId = m_recipeCombo->itemData(comboIndex).toString();
m_sim->buildings().setRecipe(m_singleId, recipeId.toStdString()); m_sim->buildings().setRecipe(m_singleBuildingId, recipeId.toStdString());
rebuild(); rebuild();
} }
@@ -619,7 +619,7 @@ void SelectedBuildingPanel::buildSplitterFilters(QPoint splitterTile)
void SelectedBuildingPanel::onSplitterFilterChanged() void SelectedBuildingPanel::onSplitterFilterChanged()
{ {
if (m_singleId == kInvalidEntityId) if (m_singleBuildingId == kInvalidBuildingId)
{ {
return; return;
} }
@@ -664,7 +664,7 @@ std::vector<std::string> SelectedBuildingPanel::allItemIds() const
void SelectedBuildingPanel::onClearBelt() void SelectedBuildingPanel::onClearBelt()
{ {
std::vector<QPoint> tiles; std::vector<QPoint> tiles;
for (EntityId id : m_selection) for (BuildingId id : m_selectedBuildingIds)
{ {
const Building* b = m_sim->buildings().findBuilding(id); const Building* b = m_sim->buildings().findBuilding(id);
if (b && isBeltLike(b->type)) if (b && isBeltLike(b->type))

View File

@@ -7,7 +7,7 @@
#include <QWidget> #include <QWidget>
#include "Building.h" #include "Building.h"
#include "EntityId.h" #include "BuildingId.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "RecipesConfig.h" #include "RecipesConfig.h"
#include "ShipLayout.h" #include "ShipLayout.h"
@@ -31,10 +31,10 @@ public:
QWidget* parent = nullptr); QWidget* parent = nullptr);
signals: signals:
void layoutDialogRequested(EntityId shipyardId); void layoutDialogRequested(BuildingId shipyardId);
public slots: public slots:
void onSelectionChanged(const std::vector<EntityId>& ids); void onSelectionChanged(const std::vector<BuildingId>& ids);
void onStateUpdated(Tick tick, int blocks, double speed); void onStateUpdated(Tick tick, int blocks, double speed);
private slots: private slots:
@@ -46,8 +46,8 @@ private:
void rebuild(); void rebuild();
void clearContent(); void clearContent();
void buildEmpty(); void buildEmpty();
void buildSingle(EntityId id); void buildSingle(BuildingId id);
void buildMulti(const std::vector<EntityId>& ids); void buildMulti(const std::vector<BuildingId>& ids);
void refreshBuffers(const Building* b); void refreshBuffers(const Building* b);
void buildSplitterFilters(QPoint splitterTile); void buildSplitterFilters(QPoint splitterTile);
const RecipeDef* findRecipe(const Building* b) const; const RecipeDef* findRecipe(const Building* b) const;
@@ -56,7 +56,7 @@ private:
Simulation* m_sim; Simulation* m_sim;
const GameConfig* m_config; const GameConfig* m_config;
std::vector<EntityId> m_selection; std::vector<BuildingId> m_selectedBuildingIds;
QVBoxLayout* m_layout; QVBoxLayout* m_layout;
QLabel* m_titleLabel; QLabel* m_titleLabel;
@@ -71,7 +71,7 @@ private:
ShipLayoutPreview* m_layoutPreview; ShipLayoutPreview* m_layoutPreview;
QPushButton* m_configureLayoutBtn; QPushButton* m_configureLayoutBtn;
EntityId m_singleId; BuildingId m_singleBuildingId;
QPoint m_splitterTile; QPoint m_splitterTile;
std::string m_currentRecipeId; std::string m_currentRecipeId;
}; };