From ca07cbaf0ec664d9350d5bc2f1b83b6ad2a97b10 Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Fri, 22 May 2026 20:31:39 +0200 Subject: [PATCH] switch to ECS architecture --- src/balancing/ArenaSimulation.cpp | 336 ++++++++-------- src/balancing/ArenaSimulation.h | 11 +- src/balancing/ArenaView.cpp | 93 ++--- src/balancing/ArenaView.h | 5 +- src/lib/core/CMakeLists.txt | 1 + src/lib/core/EcsComponents.h | 107 +++++ src/lib/core/EntityAdmin.cpp | 59 ++- src/lib/core/EntityAdmin.h | 93 ++++- src/lib/core/FireEvent.h | 7 +- src/lib/sim/AiSystem.cpp | 632 +++++++++++++++--------------- src/lib/sim/AiSystem.h | 10 +- src/lib/sim/Building.h | 4 +- src/lib/sim/BuildingSystem.cpp | 17 + src/lib/sim/BuildingSystem.h | 4 + src/lib/sim/CombatSystem.cpp | 243 +++++------- src/lib/sim/CombatSystem.h | 57 +-- src/lib/sim/MovementSystem.cpp | 143 +++---- src/lib/sim/MovementSystem.h | 4 +- src/lib/sim/ScrapSystem.cpp | 74 ++-- src/lib/sim/ScrapSystem.h | 31 +- src/lib/sim/Ship.h | 50 +-- src/lib/sim/ShipSystem.cpp | 209 ++++------ src/lib/sim/ShipSystem.h | 40 +- src/lib/sim/Simulation.cpp | 268 +++++++------ src/lib/sim/Simulation.h | 15 +- src/test/BehaviorSystemTest.cpp | 387 ++++++++---------- src/test/CombatSystemTest.cpp | 527 ++++++++----------------- src/test/ScrapTest.cpp | 87 ++-- src/test/ShipModuleTest.cpp | 32 +- src/test/ShipTest.cpp | 148 ++++--- src/test/ShipyardTest.cpp | 44 ++- src/test/WaveSystemTest.cpp | 161 ++++---- src/ui/GameWorldView.cpp | 114 +++--- src/ui/GameWorldView.h | 4 +- 34 files changed, 1943 insertions(+), 2074 deletions(-) create mode 100644 src/lib/core/EcsComponents.h diff --git a/src/balancing/ArenaSimulation.cpp b/src/balancing/ArenaSimulation.cpp index 791ce5b..c9fa9a6 100644 --- a/src/balancing/ArenaSimulation.cpp +++ b/src/balancing/ArenaSimulation.cpp @@ -10,6 +10,8 @@ #include "BuildingSystem.h" #include "BuildingType.h" #include "CombatSystem.h" +#include "EcsComponents.h" +#include "EntityAdmin.h" #include "MovementSystem.h" #include "ScrapSystem.h" #include "Ship.h" @@ -27,8 +29,8 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig, , m_currentTick(0) , m_nextId(1) , m_beltSystem(1.0) - , m_team1HqId(kInvalidEntityId) - , m_team2HqId(kInvalidEntityId) + , m_team1HqEntity(entt::null) + , m_team2HqEntity(entt::null) , m_finished(false) , m_winnerTeam(-1) , m_stopRequested(false) @@ -41,13 +43,11 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig, [](const std::string&, QVector2D, const std::optional&) {}, m_rng); - m_shipSystem = std::make_unique( - m_gameConfig, [this]() { return allocateId(); }); - + m_shipSystem = std::make_unique(m_gameConfig, m_admin); m_aiSystem = std::make_unique(); m_movementSystem = std::make_unique(); m_combatSystem = std::make_unique(m_gameConfig); - m_scrapSystem = std::make_unique([this]() { return allocateId(); }); + m_scrapSystem = std::make_unique(m_admin); placeStructures(); spawnShips(); @@ -71,7 +71,7 @@ void ArenaSimulation::placeStructures() + m_arenaConfig.enemyBufferWidth; 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 = parseSurfaceMask(m_gameConfig.stations.hq.surfaceMask, Rotation::East); @@ -79,16 +79,20 @@ void ArenaSimulation::placeStructures() const int anchorY = midY - hqParsed.footprint.height() / 2; const float hp = static_cast( m_gameConfig.stations.hq.hpFormula.evaluate(1.0)); - - m_team1HqId = m_buildingSystem->placeImmediate( - BuildingType::Hq, - m_gameConfig.stations.hq.surfaceMask, - QPoint(anchorX, anchorY), - Rotation::East, hp, hp); + const QPoint anchor(anchorX, anchorY); + std::vector absCells; + for (const QPoint& rel : hqParsed.bodyCells) + { + absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y())); + } + 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, allocateId()); } - // Team 2 HQ at right edge, placed as EnemyDefenceStation (player ships target these). - // No weapon — it's just a destructible target. + // Team 2 HQ — ECS proxy entity, enemy faction (isEnemy=true). No weapon. { const ParsedSurfaceMask hqParsed = parseSurfaceMask(m_gameConfig.stations.hq.surfaceMask, Rotation::West); @@ -96,90 +100,72 @@ void ArenaSimulation::placeStructures() const int anchorY = midY - hqParsed.footprint.height() / 2; const float hp = static_cast( m_gameConfig.stations.hq.hpFormula.evaluate(1.0)); - - m_team2HqId = m_buildingSystem->placeImmediate( - BuildingType::EnemyDefenceStation, - m_gameConfig.stations.hq.surfaceMask, - QPoint(anchorX, anchorY), - Rotation::West, hp, hp); + const QPoint anchor(anchorX, anchorY); + std::vector absCells; + for (const QPoint& rel : hqParsed.bodyCells) + { + absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y())); + } + m_team2HqEntity = m_admin.spawnStation(anchor, hqParsed.footprint, absCells, + hp, hp, true); + m_buildingSystem->registerTileOccupancy(absCells, allocateId()); } - // Team 1 defence stations (PlayerDefenceStation — targeted by team 2). + auto placeArenaStation = [&](const ArenaStationEntry& entry, bool isEnemy) + { + float hp = 0.0f; + StationWeapon weapon; + weapon.cooldownTicks = 0.0f; + weapon.currentTarget = std::nullopt; + const double lv = static_cast(entry.level); + + const std::vector& mask = isEnemy + ? m_gameConfig.stations.enemyStation.surfaceMask + : m_gameConfig.stations.playerStation.surfaceMask; + + if (entry.stationType == "player_station") + { + hp = static_cast( + m_gameConfig.stations.playerStation.hpFormula.evaluate(lv)); + weapon.damage = static_cast( + m_gameConfig.stations.playerStation.damageFormula.evaluate(lv)); + weapon.range = static_cast( + m_gameConfig.stations.playerStation.rangeFormula.evaluate(lv)); + weapon.fireRateHz = static_cast( + m_gameConfig.stations.playerStation.fireRateFormula.evaluate(lv)); + } + else + { + hp = static_cast( + m_gameConfig.stations.enemyStation.hpFormula.evaluate(lv)); + weapon.damage = static_cast( + m_gameConfig.stations.enemyStation.damageFormula.evaluate(lv)); + weapon.range = static_cast( + m_gameConfig.stations.enemyStation.rangeFormula.evaluate(lv)); + weapon.fireRateHz = static_cast( + m_gameConfig.stations.enemyStation.fireRateFormula.evaluate(lv)); + } + + const ParsedSurfaceMask parsed = parseSurfaceMask(mask, Rotation::East); + const QPoint& anchor = entry.position; + std::vector 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(stationEntity, weapon); + m_buildingSystem->registerTileOccupancy(absCells, allocateId()); + }; + for (const ArenaStationEntry& entry : m_arenaConfig.teams[0].stations) { - float hp = 0.0f; - StationWeapon weapon; - weapon.cooldownTicks = 0.0f; - const double lv = static_cast(entry.level); - - if (entry.stationType == "player_station") - { - hp = static_cast( - m_gameConfig.stations.playerStation.hpFormula.evaluate(lv)); - weapon.damage = static_cast( - m_gameConfig.stations.playerStation.damageFormula.evaluate(lv)); - weapon.range = static_cast( - m_gameConfig.stations.playerStation.rangeFormula.evaluate(lv)); - weapon.fireRateHz = static_cast( - m_gameConfig.stations.playerStation.fireRateFormula.evaluate(lv)); - } - else - { - hp = static_cast( - m_gameConfig.stations.enemyStation.hpFormula.evaluate(lv)); - weapon.damage = static_cast( - m_gameConfig.stations.enemyStation.damageFormula.evaluate(lv)); - weapon.range = static_cast( - m_gameConfig.stations.enemyStation.rangeFormula.evaluate(lv)); - weapon.fireRateHz = static_cast( - 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); + placeArenaStation(entry, false); } - - // Team 2 defence stations (EnemyDefenceStation — targeted by team 1). for (const ArenaStationEntry& entry : m_arenaConfig.teams[1].stations) { - float hp = 0.0f; - StationWeapon weapon; - weapon.cooldownTicks = 0.0f; - const double lv = static_cast(entry.level); - - if (entry.stationType == "player_station") - { - hp = static_cast( - m_gameConfig.stations.playerStation.hpFormula.evaluate(lv)); - weapon.damage = static_cast( - m_gameConfig.stations.playerStation.damageFormula.evaluate(lv)); - weapon.range = static_cast( - m_gameConfig.stations.playerStation.rangeFormula.evaluate(lv)); - weapon.fireRateHz = static_cast( - m_gameConfig.stations.playerStation.fireRateFormula.evaluate(lv)); - } - else - { - hp = static_cast( - m_gameConfig.stations.enemyStation.hpFormula.evaluate(lv)); - weapon.damage = static_cast( - m_gameConfig.stations.enemyStation.damageFormula.evaluate(lv)); - weapon.range = static_cast( - m_gameConfig.stations.enemyStation.rangeFormula.evaluate(lv)); - weapon.fireRateHz = static_cast( - 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); + placeArenaStation(entry, true); } } @@ -254,22 +240,22 @@ void ArenaSimulation::tick() { // Ship behavior systems (tick step 7). m_shipSystem->clearMovementIntents(); - m_aiSystem->tickHomeReturn(*m_shipSystem); - m_aiSystem->tickThreatResponse(*m_shipSystem, *m_buildingSystem); - m_aiSystem->tickRepairBehavior(*m_shipSystem, *m_buildingSystem); - m_aiSystem->tickScrapCollector(*m_shipSystem, *m_scrapSystem, *m_buildingSystem); + m_aiSystem->tickHomeReturn(m_admin); + m_aiSystem->tickThreatResponse(m_admin, *m_buildingSystem); + m_aiSystem->tickRepairBehavior(m_admin, *m_buildingSystem); + m_aiSystem->tickScrapCollector(m_admin, *m_scrapSystem, *m_buildingSystem); // Combat resolution (tick step 8). std::vector 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_combatSystem->applyPendingDamage(m_currentTick, *m_shipSystem, *m_buildingSystem); + m_combatSystem->applyPendingDamage(m_currentTick, m_admin); // Deaths (tick step 9, simplified). tickDeaths(); // Movement (tick step 10). - m_movementSystem->tick(*m_shipSystem); + m_movementSystem->tick(m_admin); // Scrap despawn (tick step 11). m_scrapSystem->tickDespawn(m_currentTick); @@ -285,58 +271,56 @@ void ArenaSimulation::tick() void ArenaSimulation::tickDeaths() { // Dead ships. - std::vector deadShipIds; - m_shipSystem->forEach([&deadShipIds](Ship& s) - { - if (s.hp <= 0.0f) + std::vector deadShips; + m_admin.forEach( + [&deadShips](entt::entity e, const ShipIdentity& /*si*/, const Health& h) { - 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); - if (!s) - { - continue; - } + const ShipIdentity& si = m_admin.get(deadEntity); + const Position& pos = m_admin.get(deadEntity); 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 + secondsToTicks(m_gameConfig.world.scrapDespawnSeconds); - m_scrapSystem->spawn(s->position, def.loot.scrapDrop, despawnAt); + m_scrapSystem->spawn(pos.value, def.loot.scrapDrop, despawnAt); break; } } - m_shipSystem->despawn(deadId); + m_shipSystem->despawn(deadEntity); } - // Dead buildings (HQ and defence stations). - std::vector deadBuildingIds; - for (const Building& b : m_buildingSystem->allBuildings()) - { - if (b.hp <= 0.0f - && (b.type == BuildingType::Hq - || b.type == BuildingType::PlayerDefenceStation - || b.type == BuildingType::EnemyDefenceStation)) + // Dead stations. + std::vector deadStations; + m_admin.forEach( + [&deadStations](entt::entity e, const StationBody& /*sb*/, const Health& h) { - 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(deadEntity); + m_buildingSystem->unregisterTileOccupancy(sb.bodyCells); + m_admin.destroy(deadEntity); } - // Check end conditions. - const bool team1HqGone = - (m_buildingSystem->findBuilding(m_team1HqId) == nullptr); - const bool team2HqGone = - (m_buildingSystem->findBuilding(m_team2HqId) == nullptr); + // Check end conditions — HQ proxy entities. + const bool team1HqGone = !m_admin.isValid(m_team1HqEntity) + || m_admin.get(m_team1HqEntity).hp <= 0.0f; + const bool team2HqGone = !m_admin.isValid(m_team2HqEntity) + || m_admin.get(m_team2HqEntity).hp <= 0.0f; if (team1HqGone || team2HqGone) { @@ -349,30 +333,23 @@ void ArenaSimulation::tickDeaths() // Check if all ships and defence stations of one team are destroyed. bool team1HasUnits = false; bool team2HasUnits = false; - m_shipSystem->forEach([&team1HasUnits, &team2HasUnits](Ship& s) - { - if (s.isEnemy) + m_admin.forEach( + [&team1HasUnits, &team2HasUnits](entt::entity /*e*/, + const ShipIdentity& /*si*/, + const Faction& f) { - team2HasUnits = true; - } - else - { - team1HasUnits = true; - } - }); + if (f.isEnemy) { team2HasUnits = true; } + else { team1HasUnits = true; } + }); - for (const Building& b : m_buildingSystem->allBuildings()) - { - if (b.type == BuildingType::PlayerDefenceStation) + m_admin.forEach( + [&team1HasUnits, &team2HasUnits](entt::entity /*e*/, + const StationBody& /*sb*/, + const Faction& f) { - team1HasUnits = true; - } - else if (b.type == BuildingType::EnemyDefenceStation - && b.id != m_team2HqId) - { - team2HasUnits = true; - } - } + if (f.isEnemy) { team2HasUnits = true; } + else { team1HasUnits = true; } + }); if (!team1HasUnits || !team2HasUnits) { @@ -433,6 +410,16 @@ const ScrapSystem& ArenaSimulation::scraps() const return *m_scrapSystem; } +EntityAdmin& ArenaSimulation::admin() +{ + return m_admin; +} + +const EntityAdmin& ArenaSimulation::admin() const +{ + return m_admin; +} + void ArenaSimulation::updateStatus() { ArenaStatus newStatus; @@ -450,8 +437,9 @@ void ArenaSimulation::updateStatus() hqEntry.displayName = "HQ"; hqEntry.level = 1; hqEntry.total = 1; - const EntityId hqId = (ti == 0) ? m_team1HqId : m_team2HqId; - hqEntry.surviving = (m_buildingSystem->findBuilding(hqId) != nullptr) ? 1 : 0; + const entt::entity hqEntity = (ti == 0) ? m_team1HqEntity : m_team2HqEntity; + hqEntry.surviving = (m_admin.isValid(hqEntity) + && m_admin.get(hqEntity).hp > 0.0f) ? 1 : 0; teamStatus.entries.push_back(hqEntry); } @@ -465,13 +453,14 @@ void ArenaSimulation::updateStatus() int surviving = 0; const bool isEnemyTeam = (ti == 1); - m_shipSystem->forEach( - [&surviving, &shipEntry, isEnemyTeam](Ship& s) + m_admin.forEach( + [&surviving, &shipEntry, isEnemyTeam](entt::entity /*e*/, + const ShipIdentity& si, const Faction& f, const Health& h) { - if (s.isEnemy == isEnemyTeam - && s.schematicId == shipEntry.schematicId - && s.level == shipEntry.level - && s.hp > 0.0f) + if (f.isEnemy == isEnemyTeam + && si.schematicId == shipEntry.schematicId + && si.level == shipEntry.level + && h.hp > 0.0f) { ++surviving; } @@ -490,20 +479,19 @@ void ArenaSimulation::updateStatus() entry.level = stationEntry.level; entry.total = 1; - // Count surviving stations of this team at this position. - const BuildingType expectedType = (ti == 0) - ? BuildingType::PlayerDefenceStation - : BuildingType::EnemyDefenceStation; - int surviving = 0; - for (const Building& b : m_buildingSystem->allBuildings()) - { - if (b.type == expectedType && b.anchor == stationEntry.position) + const bool isEnemyTeam = (ti == 1); + m_admin.forEach( + [&surviving, &stationEntry, isEnemyTeam](entt::entity /*e*/, + const StationBody& sb, const Faction& f, const Health& h) { - surviving = 1; - break; - } - } + if (f.isEnemy == isEnemyTeam + && sb.anchor == stationEntry.position + && h.hp > 0.0f) + { + surviving = 1; + } + }); entry.surviving = surviving; teamStatus.entries.push_back(entry); } diff --git a/src/balancing/ArenaSimulation.h b/src/balancing/ArenaSimulation.h index c322d7c..856fcb3 100644 --- a/src/balancing/ArenaSimulation.h +++ b/src/balancing/ArenaSimulation.h @@ -9,7 +9,11 @@ #include "BalancingConfig.h" #include "BeltSystem.h" +#include "EcsComponents.h" +#include "EntityAdmin.h" #include "EntityId.h" + +#include "entt/entity/entity.hpp" #include "FireEvent.h" #include "GameConfig.h" #include "Tick.h" @@ -65,6 +69,8 @@ public: const BuildingSystem& buildings() const; const ShipSystem& ships() const; const ScrapSystem& scraps() const; + EntityAdmin& admin(); + const EntityAdmin& admin() const; private: EntityId allocateId(); @@ -81,6 +87,7 @@ private: Tick m_currentTick; EntityId m_nextId; + EntityAdmin m_admin; BeltSystem m_beltSystem; std::unique_ptr m_buildingSystem; std::unique_ptr m_shipSystem; @@ -89,8 +96,8 @@ private: std::unique_ptr m_combatSystem; std::unique_ptr m_scrapSystem; - EntityId m_team1HqId; - EntityId m_team2HqId; + entt::entity m_team1HqEntity; + entt::entity m_team2HqEntity; bool m_finished; int m_winnerTeam; diff --git a/src/balancing/ArenaView.cpp b/src/balancing/ArenaView.cpp index 93c7bcb..6cb3cf2 100644 --- a/src/balancing/ArenaView.cpp +++ b/src/balancing/ArenaView.cpp @@ -10,19 +10,18 @@ #include "ArenaSimulation.h" #include "Building.h" #include "BuildingSystem.h" -#include "Scrap.h" +#include "EcsComponents.h" #include "ScrapSystem.h" #include "Ship.h" -#include "ShipSystem.h" namespace { -ShipRole shipRole(const Ship& ship) +ShipRole shipRoleFromComponents(bool isEnemy, bool hasCargo, bool hasRepairTool) { - if (ship.isEnemy) { return ShipRole::Enemy; } - if (ship.cargo.has_value()) { return ShipRole::Salvage; } - if (ship.repairTool.has_value()) { return ShipRole::Repair; } + if (isEnemy) { return ShipRole::Enemy; } + if (hasCargo) { return ShipRole::Salvage; } + if (hasRepairTool) { return ShipRole::Repair; } return ShipRole::PlayerCombat; } @@ -95,11 +94,12 @@ void ArenaView::onFrame() for (const FireEvent& fe : fires) { float maxRadius = 0.125f; - const Building* tBld = m_sim->buildings().findBuilding(fe.target); - if (tBld) + if (m_sim->admin().isValid(fe.target) + && m_sim->admin().hasAll(fe.target)) { - const int shorter = std::min(tBld->footprint.width(), - tBld->footprint.height()); + const StationBody& sb = m_sim->admin().get(fe.target); + const int shorter = std::min(sb.footprint.width(), + sb.footprint.height()); maxRadius = shorter / 2.0f; } @@ -188,23 +188,13 @@ QRectF ArenaView::tileRect(QPoint tile) const static_cast(tilePx()), static_cast(tilePx())); } -std::optional ArenaView::entityPosition(EntityId id) const +std::optional ArenaView::entityPosition(entt::entity entity) const { - for (const Ship& ship : m_sim->ships().allShips()) + if (!m_sim->admin().isValid(entity) || !m_sim->admin().hasAll(entity)) { - if (ship.id == id) - { - return ship.position; - } + return std::nullopt; } - const Building* bldg = m_sim->buildings().findBuilding(id); - if (bldg) - { - return QVector2D( - bldg->anchor.x() + bldg->footprint.width() * 0.5f, - bldg->anchor.y() + bldg->footprint.height() * 0.5f); - } - return std::nullopt; + return m_sim->admin().get(entity).value; } // --------------------------------------------------------------------------- @@ -264,7 +254,7 @@ void ArenaView::drawBuildings(QPainter& painter) void ArenaView::drawScrap(QPainter& painter) { 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); painter.setBrush(QColor(128, 110, 90)); @@ -276,35 +266,38 @@ void ArenaView::drawScrap(QPainter& painter) void ArenaView::drawShips(QPainter& painter) { - for (const Ship& ship : m_sim->ships().allShips()) - { - const ShipRole role = shipRole(ship); - const std::map::const_iterator it = - m_visuals->ships.find(role); - if (it == m_visuals->ships.end()) { continue; } + m_sim->admin().forEach( + [&](entt::entity e, const ShipIdentity& /*si*/, const Position& pos, + const Velocity& vel, const Faction& fac) + { + const bool hasCargo = m_sim->admin().hasAll(e); + const bool hasRepair = m_sim->admin().hasAll(e); + const ShipRole role = shipRoleFromComponents(fac.isEnemy, hasCargo, hasRepair); + const std::map::const_iterator it = + m_visuals->ships.find(role); + if (it == m_visuals->ships.end()) { return; } - const QPointF center = worldToWidget(ship.position); - const QVector2D vel = ship.velocity; - const QVector2D dir = (vel.length() > 0.0001f) - ? vel.normalized() - : QVector2D(1.0f, 0.0f); - const QVector2D perp(-dir.y(), dir.x()); + const QPointF center = worldToWidget(pos.value); + const QVector2D dir = (vel.value.length() > 0.0001f) + ? vel.value.normalized() + : QVector2D(1.0f, 0.0f); + const QVector2D perp(-dir.y(), dir.x()); - const float fwd = tilePx() * 0.45f; - const float side = tilePx() * 0.25f; + const float fwd = tilePx() * 0.45f; + const float side = tilePx() * 0.25f; - QPolygonF tri; - tri << QPointF(center.x() + static_cast(dir.x() * fwd), - center.y() + static_cast(dir.y() * fwd)) - << QPointF(center.x() + static_cast(perp.x() * side - dir.x() * side), - center.y() + static_cast(perp.y() * side - dir.y() * side)) - << QPointF(center.x() + static_cast(-perp.x() * side - dir.x() * side), - center.y() + static_cast(-perp.y() * side - dir.y() * side)); + QPolygonF tri; + tri << QPointF(center.x() + static_cast(dir.x() * fwd), + center.y() + static_cast(dir.y() * fwd)) + << QPointF(center.x() + static_cast(perp.x() * side - dir.x() * side), + center.y() + static_cast(perp.y() * side - dir.y() * side)) + << QPointF(center.x() + static_cast(-perp.x() * side - dir.x() * side), + center.y() + static_cast(-perp.y() * side - dir.y() * side)); - painter.setPen(QPen(it->second.outline, 1)); - painter.setBrush(it->second.fill); - painter.drawPolygon(tri); - } + painter.setPen(QPen(it->second.outline, 1)); + painter.setBrush(it->second.fill); + painter.drawPolygon(tri); + }); } void ArenaView::drawBeams(QPainter& painter) diff --git a/src/balancing/ArenaView.h b/src/balancing/ArenaView.h index 523d666..1bf8859 100644 --- a/src/balancing/ArenaView.h +++ b/src/balancing/ArenaView.h @@ -8,8 +8,9 @@ #include #include -#include "EntityId.h" #include "FireEvent.h" + +#include "entt/entity/entity.hpp" #include "Tick.h" #include "TickDriver.h" #include "VisualsConfig.h" @@ -51,7 +52,7 @@ private: QPointF tileToWidget(QPoint tile) const; QRectF tileRect(QPoint tile) const; - std::optional entityPosition(EntityId id) const; + std::optional entityPosition(entt::entity entity) const; struct ActiveBeam { diff --git a/src/lib/core/CMakeLists.txt b/src/lib/core/CMakeLists.txt index b74c37d..fc91e85 100644 --- a/src/lib/core/CMakeLists.txt +++ b/src/lib/core/CMakeLists.txt @@ -5,6 +5,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/Rotation.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.h ${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.h + ${CMAKE_CURRENT_SOURCE_DIR}/EcsComponents.h ${CMAKE_CURRENT_SOURCE_DIR}/Blueprint.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h ${CMAKE_CURRENT_SOURCE_DIR}/Item.h diff --git a/src/lib/core/EcsComponents.h b/src/lib/core/EcsComponents.h new file mode 100644 index 0000000..431593a --- /dev/null +++ b/src/lib/core/EcsComponents.h @@ -0,0 +1,107 @@ +#pragma once + +#include +#include + +#include +#include +#include + +#include "EntityId.h" +#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, ThreatResponse, ScrapCollector, +// RepairBehavior, HomeReturn, RallyBehavior remain defined in Ship.h. + +// --------------------------------------------------------------------------- +// Station components +// --------------------------------------------------------------------------- + +struct StationBody +{ + QPoint anchor; + QSize footprint; + std::vector bodyCells; +}; + +// StationWeapon remains defined in Building.h. + +// --------------------------------------------------------------------------- +// Scrap components +// --------------------------------------------------------------------------- + +struct ScrapData +{ + int amount; +}; + +struct DespawnAt +{ + Tick tick; +}; + +// --------------------------------------------------------------------------- +// HQ proxy (empty tag) +// --------------------------------------------------------------------------- + +struct HqProxy { char unused = 0; }; diff --git a/src/lib/core/EntityAdmin.cpp b/src/lib/core/EntityAdmin.cpp index 29bda65..d4f6b77 100644 --- a/src/lib/core/EntityAdmin.cpp +++ b/src/lib/core/EntityAdmin.cpp @@ -1,11 +1,14 @@ #include "EntityAdmin.h" +#include "EcsComponents.h" +#include "MovementIntent.h" + entt::entity EntityAdmin::createEntity() { return m_registry.create(); } -bool EntityAdmin::isValid(entt::entity entity) +bool EntityAdmin::isValid(entt::entity entity) const { return m_registry.valid(entity); } @@ -19,3 +22,57 @@ void EntityAdmin::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(entity, Position{position}); + add(entity, Health{hp, maxHp}); + add(entity, Faction{isEnemy}); + add(entity, Velocity{QVector2D(0.0f, 0.0f)}); + add(entity, Facing{0.0f, 0.0f}); + add(entity, ShipDynamics{ + maxSpeedPerTick, mainAccelPerTick, maneuveringAccelPerTick, + angularAccelPerTick, maxRotationSpeedPerTick}); + add(entity, SensorRange{sensorRange}); + add(entity, ShipIdentity{level, schematicId}); + add(entity, MovementIntent{0, QVector2D(0.0f, 0.0f)}); + return entity; +} + +entt::entity EntityAdmin::spawnStation(QPoint anchor, QSize footprint, + const std::vector& 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(entity, Position{center}); + add(entity, Health{hp, maxHp}); + add(entity, Faction{isEnemy}); + add(entity, StationBody{anchor, footprint, bodyCells}); + return entity; +} + +entt::entity EntityAdmin::spawnScrap(QVector2D position, int amount, Tick despawnAt) +{ + entt::entity entity = createEntity(); + add(entity, Position{position}); + add(entity, ScrapData{amount}); + add(entity, DespawnAt{despawnAt}); + return entity; +} + +entt::entity EntityAdmin::spawnHqProxy(QVector2D position, float hp, float maxHp) +{ + entt::entity entity = createEntity(); + add(entity, Position{position}); + add(entity, Health{hp, maxHp}); + add(entity, Faction{false}); + add(entity); + return entity; +} diff --git a/src/lib/core/EntityAdmin.h b/src/lib/core/EntityAdmin.h index 973a5fb..dc16e53 100644 --- a/src/lib/core/EntityAdmin.h +++ b/src/lib/core/EntityAdmin.h @@ -1,6 +1,15 @@ #ifndef ENTITY_ADMIN_H #define ENTITY_ADMIN_H +#include +#include + +#include +#include +#include + +#include "Tick.h" + #include "entt/entity/registry.hpp" class EntityAdmin @@ -10,36 +19,84 @@ public: EntityAdmin(const EntityAdmin&) = delete; EntityAdmin& operator=(const EntityAdmin&) = delete; + // -- Queries / iteration ------------------------------------------------ + template void forEach(Func&& f); + template + void forEach(Func&& f) const; + template bool hasAll(entt::entity entity); template T& get(entt::entity entity); - bool isValid(entt::entity entity); + template + const T& get(entt::entity entity) const; + + bool isValid(entt::entity entity) const; void destroy(entt::entity entity); void clear(); - /* - factory methods (like spawnShip, spawnScrap, etc shall go here) - */ + // -- Public component attachment ---------------------------------------- + // Used by systems (e.g. ShipSystem) to attach optional components after + // a factory method has created the base entity. + + template + void addComponent(entt::entity entity, Args&&... args); + + template + 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& 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: entt::entity createEntity(); template - T& add(entt::entity entity, Args&&... args); + void add(entt::entity entity, Args&&... args); entt::registry m_registry; }; +// --------------------------------------------------------------------------- +// Template implementations +// --------------------------------------------------------------------------- + template void EntityAdmin::forEach(Func&& f) { - m_registry.view().each(std::forward(f)); + // Avoid view.each() — MSVC 2017 ICEs on the extended_storage_iterator it instantiates. + for (entt::entity entity : m_registry.view()) + { + f(entity, m_registry.get(entity)...); + } +} + +template +void EntityAdmin::forEach(Func&& f) const +{ + entt::registry& reg = const_cast(m_registry); + for (entt::entity entity : reg.view()) + { + f(entity, reg.get(entity)...); + } } template @@ -54,10 +111,28 @@ T& EntityAdmin::get(entt::entity entity) return m_registry.get(entity); } -template -T& EntityAdmin::add(entt::entity entity, Args&&... args) +template +const T& EntityAdmin::get(entt::entity entity) const { - return m_registry.emplace(entity, std::forward(args)...); + return m_registry.get(entity); +} + +template +void EntityAdmin::addComponent(entt::entity entity, Args&&... args) +{ + m_registry.emplace(entity, std::forward(args)...); +} + +template +void EntityAdmin::removeComponent(entt::entity entity) +{ + m_registry.remove(entity); +} + +template +void EntityAdmin::add(entt::entity entity, Args&&... args) +{ + m_registry.emplace(entity, std::forward(args)...); } #endif // ENTITY_ADMIN_H diff --git a/src/lib/core/FireEvent.h b/src/lib/core/FireEvent.h index 43cf0ed..cea1324 100644 --- a/src/lib/core/FireEvent.h +++ b/src/lib/core/FireEvent.h @@ -1,14 +1,15 @@ #pragma once -#include "EntityId.h" #include "Tick.h" +#include "entt/entity/entity.hpp" + // 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 // renderer each frame to draw the 0.3-second laser beam. struct FireEvent { - EntityId shooter; - EntityId target; + entt::entity shooter; + entt::entity target; Tick emittedAt; }; diff --git a/src/lib/sim/AiSystem.cpp b/src/lib/sim/AiSystem.cpp index 537142f..7900dc7 100644 --- a/src/lib/sim/AiSystem.cpp +++ b/src/lib/sim/AiSystem.cpp @@ -8,456 +8,440 @@ #include "Building.h" #include "BuildingSystem.h" #include "BuildingType.h" +#include "EcsComponents.h" +#include "EntityAdmin.h" #include "EntityId.h" #include "MovementIntent.h" #include "Scrap.h" #include "ScrapSystem.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) // --------------------------------------------------------------------------- -void AiSystem::tickHomeReturn(ShipSystem& ships) +void AiSystem::tickHomeReturn(EntityAdmin& admin) { - ships.forEach([&](Ship& s) - { - if (!s.homeReturn) { return; } - if (s.hp / s.maxHp < s.homeReturn->retreatHpFraction) + admin.forEach( + [](entt::entity /*e*/, const HomeReturn& hr, const Health& h, MovementIntent& intent) { - if (4 > s.intent.priority) + if (h.hp / h.maxHp < hr.retreatHpFraction) { - s.intent = MovementIntent{4, s.homeReturn->homePos}; + if (4 > intent.priority) + { + intent = MovementIntent{4, hr.homePos}; + } } - } - }); + }); } // --------------------------------------------------------------------------- // tickThreatResponse (priority 3) // --------------------------------------------------------------------------- -void AiSystem::tickThreatResponse(ShipSystem& ships, const BuildingSystem& buildings) +void AiSystem::tickThreatResponse(EntityAdmin& admin, const BuildingSystem& buildings) { - const std::vector allBuildings = buildings.allBuildings(); - const std::vector allShips = ships.allShips(); - - ships.forEach([&](Ship& s) + // Snapshot all combatant entities for target acquisition. + struct CombatantInfo { - if (!s.threatResponse) { return; } + entt::entity entity; + QVector2D position; + bool isEnemy; + bool isStation; + }; + std::vector combatants; - const float range = s.sensorRange; - - if (!s.isEnemy) + admin.forEach( + [&combatants](entt::entity e, const Position& pos, const Faction& f, const ShipIdentity& /*si*/) { - if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId), - range, s, ships, buildings)) + combatants.push_back({e, pos.value, f.isEnemy, false}); + }); + + admin.forEach( + [&combatants](entt::entity e, const Position& pos, const Faction& f, const StationBody& /*sb*/) + { + combatants.push_back({e, pos.value, f.isEnemy, true}); + }); + + admin.forEach( + [&combatants](entt::entity e, const Position& pos, const Faction& f, const HqProxy& /*hq*/) + { + combatants.push_back({e, pos.value, f.isEnemy, true}); + }); + + admin.forEach( + [&](entt::entity e, ThreatResponse& threat, Position& pos, Faction& faction, + SensorRange& sensor, MovementIntent& intent) + { + const float range = sensor.value; + + // Validate current target. + bool targetValid = false; + if (threat.currentTarget) { - s.threatResponse->currentTarget = std::nullopt; + const entt::entity t = *threat.currentTarget; + if (admin.isValid(t) && admin.hasAll(t)) + { + const float dist = (admin.get(t).value - pos.value).length(); + if (dist <= range) + { + targetValid = true; + } + } + } + + if (!targetValid) + { + threat.currentTarget = std::nullopt; 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; } - float dist = (buildingCenter(b) - s.position).length(); - if (dist < bestDist) - { - bestDist = dist; - s.threatResponse->currentTarget = b.id; - } - } - } + if (c.entity == e) { continue; } - if (s.threatResponse->currentTarget) - { - 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) + bool isValidTarget = false; + if (!faction.isEnemy) { - s.intent = MovementIntent{3, s.rallyBehavior->rallyPoint}; + isValidTarget = c.isEnemy; } else { - s.intent = MovementIntent{3, QVector2D(s.position.x() + 1000.0f, - s.position.y())}; + isValidTarget = !c.isEnemy; } - } - } - } - else - { - if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId), - range, s, ships, buildings)) - { - s.threatResponse->currentTarget = std::nullopt; - float bestDist = range; + if (!isValidTarget) { continue; } - for (const Ship& candidate : allShips) - { - if (candidate.isEnemy) { continue; } - float dist = (candidate.position - s.position).length(); + const float dist = (c.position - pos.value).length(); if (dist < bestDist) { bestDist = dist; - s.threatResponse->currentTarget = candidate.id; - } - } - - for (const Building& b : allBuildings) - { - if (b.type != BuildingType::PlayerDefenceStation - && b.type != BuildingType::Hq) - { - continue; - } - float dist = (buildingCenter(b) - s.position).length(); - if (dist < bestDist) - { - bestDist = dist; - s.threatResponse->currentTarget = b.id; + threat.currentTarget = c.entity; } } } - if (s.threatResponse->currentTarget) + if (threat.currentTarget) { - QVector2D dest; - const Ship* tShip = ships.findShip(*s.threatResponse->currentTarget); - if (tShip) + const entt::entity t = *threat.currentTarget; + QVector2D dest = pos.value; + if (admin.isValid(t) && admin.hasAll(t)) { - dest = tShip->position; + dest = admin.get(t).value; } - else + if (3 > intent.priority) { - const Building* tBld = buildings.findBuilding( - *s.threatResponse->currentTarget); - dest = tBld ? buildingCenter(*tBld) : s.position; - } - if (3 > s.intent.priority) - { - s.intent = MovementIntent{3, dest}; + intent = MovementIntent{3, dest}; } } else { - if (3 > s.intent.priority) + if (3 > intent.priority) { - s.intent = MovementIntent{3, QVector2D(-10000.0f, s.position.y())}; + if (admin.hasAll(e)) + { + intent = MovementIntent{3, admin.get(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) // --------------------------------------------------------------------------- -void AiSystem::tickRepairBehavior(ShipSystem& ships, BuildingSystem& buildings) +void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings) { - const std::vector allBuildings = buildings.allBuildings(); - const std::vector allShips = ships.allShips(); - - ships.forEach([&](Ship& s) + // Snapshot all entities with health for repair targeting. + struct RepairableInfo { - if (!s.repairBehavior || !s.repairTool) { return; } + entt::entity entity; + QVector2D position; + bool isEnemy; + bool isShip; + float hp; + float maxHp; + }; + std::vector repairables; - const float repairRange = s.repairTool->range; + admin.forEach( + [&repairables](entt::entity e, const ShipIdentity& /*si*/, + const Position& pos, const Faction& f, const Health& h) + { + repairables.push_back({e, pos.value, f.isEnemy, true, h.hp, h.maxHp}); + }); - bool enemyNearby = false; - for (const Ship& candidate : allShips) + admin.forEach( + [&repairables](entt::entity e, const StationBody& /*sb*/, + const Position& pos, const Faction& f, const Health& h) { - if (candidate.isEnemy - && (candidate.position - s.position).length() <= s.sensorRange) - { - enemyNearby = true; - break; - } - } - if (enemyNearby) - { - if (2 > s.intent.priority) - { - s.intent = MovementIntent{2, QVector2D(-10000.0f, s.position.y())}; - } - return; - } + repairables.push_back({e, pos.value, f.isEnemy, false, h.hp, h.maxHp}); + }); - EntityId currentId = s.repairBehavior->currentTarget.value_or(kInvalidEntityId); - bool targetValid = false; - if (currentId != kInvalidEntityId) + // Snapshot enemy ships for threat detection. + struct EnemyInfo + { + QVector2D position; + }; + std::vector enemies; + admin.forEach( + [&enemies](entt::entity /*e*/, const ShipIdentity& /*si*/, + const Position& pos, const Faction& f) { - const Ship* tShip = ships.findShip(currentId); - if (tShip && !tShip->isEnemy && tShip->hp < tShip->maxHp) + if (f.isEnemy) { - targetValid = true; + enemies.push_back({pos.value}); } - else + }); + + admin.forEach( + [&](entt::entity e, RepairBehavior& rb, RepairTool& rt, Position& pos, + Faction& /*faction*/, SensorRange& sensor, MovementIntent& intent) + { + const float repairRange = rt.range; + + // Flee if enemy nearby. + bool enemyNearby = false; + for (const EnemyInfo& enemy : enemies) { - const Building* tBld = buildings.findBuilding(currentId); - if (tBld && tBld->type == BuildingType::PlayerDefenceStation - && tBld->hp < tBld->maxHp) + if ((enemy.position - pos.value).length() <= sensor.value) { - targetValid = true; + enemyNearby = true; + break; } } - } - - if (!targetValid) - { - s.repairBehavior->currentTarget = std::nullopt; - currentId = kInvalidEntityId; - float bestDist = s.sensorRange; - - for (const Ship& candidate : allShips) + if (enemyNearby) { - if (candidate.isEnemy || candidate.id == s.id - || candidate.hp >= candidate.maxHp) + if (2 > intent.priority) { - continue; + intent = MovementIntent{2, QVector2D(-10000.0f, pos.value.y())}; } - float dist = (candidate.position - s.position).length(); - if (dist < bestDist) + return; + } + + // Validate current target. + bool targetValid = false; + if (rb.currentTarget) + { + const entt::entity t = *rb.currentTarget; + if (admin.isValid(t) && admin.hasAll(t)) { - bestDist = dist; - s.repairBehavior->currentTarget = candidate.id; + const Health& th = admin.get(t); + if (th.hp > 0.0f && th.hp < th.maxHp) + { + targetValid = true; + } } } - for (const Building& b : allBuildings) + if (!targetValid) { - if (b.type != BuildingType::PlayerDefenceStation - || b.hp >= b.maxHp) + rb.currentTarget = std::nullopt; + float bestDist = sensor.value; + + for (const RepairableInfo& r : repairables) { - continue; - } - float dist = (buildingCenter(b) - s.position).length(); - if (dist < bestDist) - { - bestDist = dist; - s.repairBehavior->currentTarget = b.id; + 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; + } } } - currentId = s.repairBehavior->currentTarget.value_or(kInvalidEntityId); - } - - if (currentId == kInvalidEntityId) - { - if (2 > s.intent.priority) + if (!rb.currentTarget) { - s.intent = MovementIntent{2, QVector2D(s.position.x() + 1000.0f, - s.position.y())}; + if (2 > intent.priority) + { + intent = MovementIntent{2, QVector2D(pos.value.x() + 1000.0f, + pos.value.y())}; + } + return; } - 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) + const entt::entity target = *rb.currentTarget; + QVector2D targetPos = pos.value; + bool isShipTarget = false; + if (admin.isValid(target) && admin.hasAll(target)) { - ships.healShip(currentId, s.repairTool->ratePerTick); + targetPos = admin.get(target).value; + isShipTarget = admin.hasAll(target); } - else - { - buildings.healBuilding(currentId, s.repairTool->ratePerTick); - } - } - if (2 > s.intent.priority) - { - s.intent = MovementIntent{2, targetPos}; - } - }); + const float distToTarget = (targetPos - pos.value).length(); + if (distToTarget <= repairRange) + { + if (admin.isValid(target) && admin.hasAll(target)) + { + Health& targetHealth = admin.get(target); + targetHealth.hp = std::min(targetHealth.hp + rt.ratePerTick, + targetHealth.maxHp); + } + } + + if (2 > intent.priority) + { + intent = MovementIntent{2, targetPos}; + } + }); } // --------------------------------------------------------------------------- // tickScrapCollector (priority 1) // --------------------------------------------------------------------------- -void AiSystem::tickScrapCollector(ShipSystem& ships, ScrapSystem& scraps, +void AiSystem::tickScrapCollector(EntityAdmin& admin, ScrapSystem& scraps, BuildingSystem& buildings) { - const std::vector allShips = ships.allShips(); - - ships.forEach([&](Ship& s) + // Snapshot enemy ships for threat detection. + struct EnemyShipPos { - if (!s.scrapCollector || !s.cargo) { return; } - - const float collectRange = s.cargo->collectionRange; - - if (s.scrapCollector->deliveryBay == kInvalidEntityId) + QVector2D position; + }; + std::vector enemyShips; + admin.forEach( + [&enemyShips](entt::entity /*e*/, const ShipIdentity& /*si*/, + const Position& pos, const Faction& f) { - const Building* bay = buildings.findNearestBuilding(s.position, - BuildingType::SalvageBay); - if (bay) + if (f.isEnemy) { - s.scrapCollector->deliveryBay = bay->id; + enemyShips.push_back({pos.value}); } - } + }); - const EntityId bayId = s.scrapCollector->deliveryBay; + const std::vector allScrap = scraps.allScrapInfo(); - QVector2D bayPos = s.position; - if (bayId != kInvalidEntityId) + admin.forEach( + [&](entt::entity /*e*/, ScrapCollector& sc, SalvageCargo& cargo, + Position& pos, SensorRange& sensor, MovementIntent& intent) { - const Building* bay = buildings.findBuilding(bayId); - if (bay) - { - bayPos = buildingCenter(*bay); - } - } + const float collectRange = cargo.collectionRange; - const bool cargoFull = (s.cargo->current >= s.cargo->capacity); - - if (cargoFull) - { - if (1 > s.intent.priority) + // Assign nearest SalvageBay if needed. + if (sc.deliveryBay == kInvalidEntityId) { - s.intent = MovementIntent{1, bayPos}; - } - if (bayId != kInvalidEntityId - && (s.position - bayPos).length() <= 1.0f) - { - if (buildings.deliverScrapToSalvageBay(bayId)) + const Building* bay = buildings.findNearestBuilding(pos.value, + BuildingType::SalvageBay); + if (bay) { - --s.cargo->current; + sc.deliveryBay = bay->id; } } - return; - } - bool retreating = false; - if (s.cargo->current == 0) - { - for (const Ship& candidate : allShips) + const EntityId bayId = sc.deliveryBay; + + QVector2D bayPos = pos.value; + if (bayId != kInvalidEntityId) { - if (candidate.isEnemy - && (candidate.position - s.position).length() <= collectRange) + const Building* bay = buildings.findBuilding(bayId); + if (bay) { - if (1 > s.intent.priority) + 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 != kInvalidEntityId + && (pos.value - bayPos).length() <= 1.0f) + { + if (buildings.deliverScrapToSalvageBay(bayId)) { - s.intent = MovementIntent{1, QVector2D(-10000.0f, s.position.y())}; + --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; + sc.scrapTarget = std::nullopt; } - retreating = true; break; } } - } - if (retreating) { return; } - for (const Scrap& sc : scraps.allScraps()) - { - if ((sc.position - s.position).length() <= collectRange) + // Move toward scrap target or find a new one. + if (sc.scrapTarget) { - if (scraps.consume(sc.id)) + if (1 > intent.priority) { - ++s.cargo->current; - s.scrapCollector->scrapTarget = std::nullopt; - } - break; - } - } - - if (s.scrapCollector->scrapTarget) - { - if (1 > s.intent.priority) - { - s.intent = MovementIntent{1, *s.scrapCollector->scrapTarget}; - } - } - else - { - float bestDist = s.sensorRange; - std::optional bestPos; - for (const Scrap& sc : scraps.allScraps()) - { - float dist = (sc.position - s.position).length(); - if (dist < bestDist) - { - bestDist = dist; - bestPos = sc.position; - } - } - if (bestPos) - { - s.scrapCollector->scrapTarget = bestPos; - if (1 > s.intent.priority) - { - s.intent = MovementIntent{1, *bestPos}; + intent = MovementIntent{1, *sc.scrapTarget}; } } else { - if (1 > s.intent.priority) + float bestDist = sensor.value; + std::optional bestPos; + for (const ScrapInfo& si : allScrap) { - s.intent = MovementIntent{1, QVector2D(s.position.x() + 1000.0f, - s.position.y())}; + const float dist = (si.position - pos.value).length(); + if (dist < bestDist) + { + bestDist = dist; + bestPos = si.position; + } + } + if (bestPos) + { + sc.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())}; + } } } - } - }); + }); } diff --git a/src/lib/sim/AiSystem.h b/src/lib/sim/AiSystem.h index dabfdcb..4ad24a7 100644 --- a/src/lib/sim/AiSystem.h +++ b/src/lib/sim/AiSystem.h @@ -1,14 +1,14 @@ #pragma once class BuildingSystem; +class EntityAdmin; class ScrapSystem; -class ShipSystem; class AiSystem { public: - void tickHomeReturn(ShipSystem& ships); - void tickThreatResponse(ShipSystem& ships, const BuildingSystem& buildings); - void tickRepairBehavior(ShipSystem& ships, BuildingSystem& buildings); - void tickScrapCollector(ShipSystem& ships, ScrapSystem& scraps, BuildingSystem& buildings); + void tickHomeReturn(EntityAdmin& admin); + void tickThreatResponse(EntityAdmin& admin, const BuildingSystem& buildings); + void tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings); + void tickScrapCollector(EntityAdmin& admin, ScrapSystem& scraps, BuildingSystem& buildings); }; diff --git a/src/lib/sim/Building.h b/src/lib/sim/Building.h index 04ccf59..1dea84b 100644 --- a/src/lib/sim/Building.h +++ b/src/lib/sim/Building.h @@ -10,6 +10,8 @@ #include "BuildingType.h" #include "EntityId.h" + +#include "entt/entity/entity.hpp" #include "Item.h" #include "ItemType.h" #include "Port.h" @@ -62,7 +64,7 @@ struct StationWeapon float range; float fireRateHz; float cooldownTicks; - std::optional currentTarget; + std::optional currentTarget; }; // A fully constructed, operational building. diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 8c1927c..1dd30d4 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -1152,3 +1152,20 @@ void BuildingSystem::forEachBuilding(std::function fn) fn(b); } } + +void BuildingSystem::registerTileOccupancy(const std::vector& cells, + EntityId ownerPlaceholder) +{ + for (const QPoint& cell : cells) + { + m_tileOccupancy[{cell.x(), cell.y()}] = ownerPlaceholder; + } +} + +void BuildingSystem::unregisterTileOccupancy(const std::vector& cells) +{ + for (const QPoint& cell : cells) + { + m_tileOccupancy.erase({cell.x(), cell.y()}); + } +} diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index b61f61a..d47c62f 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -96,6 +96,10 @@ public: // Find nearest operational building of the given type; nullptr if none. const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const; + // Register / unregister tile occupancy for ECS station entities. + void registerTileOccupancy(const std::vector& cells, EntityId ownerPlaceholder); + void unregisterTileOccupancy(const std::vector& cells); + // Place one "scrap" item into a SalvageBay's output buffer. // Returns false if bay not found, wrong type, or output buffer is full. bool deliverScrapToSalvageBay(EntityId bayId); diff --git a/src/lib/sim/CombatSystem.cpp b/src/lib/sim/CombatSystem.cpp index 3159f89..2cd908e 100644 --- a/src/lib/sim/CombatSystem.cpp +++ b/src/lib/sim/CombatSystem.cpp @@ -1,11 +1,9 @@ #include "CombatSystem.h" -#include "BuildingSystem.h" -#include "BuildingType.h" -#include "Ship.h" -#include "ShipSystem.h" +#include "EcsComponents.h" +#include "EntityAdmin.h" -static constexpr Tick kWeaponImpactDelayTicks = 5; // 0.15 s × 30 Hz, rounded to nearest +static constexpr Tick kWeaponImpactDelayTicks = 5; CombatSystem::CombatSystem(const GameConfig& config) : m_config(config) @@ -13,199 +11,169 @@ CombatSystem::CombatSystem(const GameConfig& config) } void CombatSystem::tick(Tick currentTick, - ShipSystem& ships, - BuildingSystem& buildings, + EntityAdmin& admin, + BuildingSystem& /*buildings*/, std::vector& outFireEvents) { - // Ships: iterate and resolve weapon for each combat ship. - ships.forEach([&](Ship& ship) - { - 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) + // Ship weapons. + admin.forEach( + [&](entt::entity e, Weapon& weapon, ThreatResponse& threat, Position& pos) { - resolveStationWeapon(building, currentTick, ships, buildings, outFireEvents); - } - }); + resolveShipWeapon(e, weapon, threat, pos, currentTick, admin, outFireEvents); + }); + + // Station weapons. + admin.forEach( + [&](entt::entity e, StationWeapon& weapon, Position& pos, Faction& faction) + { + resolveStationWeapon(e, weapon, pos, faction, currentTick, admin, outFireEvents); + }); } -void CombatSystem::resolveShipWeapon(Ship& ship, Tick currentTick, - ShipSystem& ships, - BuildingSystem& buildings, +void CombatSystem::resolveShipWeapon(entt::entity shipEntity, Weapon& weapon, + const ThreatResponse& threat, + const Position& pos, Tick currentTick, + EntityAdmin& admin, std::vector& out) { - if (!ship.weapon || !ship.threatResponse || - !ship.threatResponse->currentTarget) + if (!threat.currentTarget) { return; } - Weapon& w = *ship.weapon; - - // Decrement cooldown toward zero. - if (w.cooldownTicks > 0.0f) + if (weapon.cooldownTicks > 0.0f) { - w.cooldownTicks -= 1.0f; + weapon.cooldownTicks -= 1.0f; } - - if (w.cooldownTicks > 0.0f) + if (weapon.cooldownTicks > 0.0f) { return; } - const EntityId targetId = *ship.threatResponse->currentTarget; - - const std::optional tPos = targetPosition(targetId, ships, buildings); - if (!tPos) - { - ship.threatResponse->currentTarget = std::nullopt; - return; - } - - const float dist = (ship.position - *tPos).length(); - if (dist > w.range) + const entt::entity targetEntity = *threat.currentTarget; + if (!admin.isValid(targetEntity) || !admin.hasAll(targetEntity)) { return; } - m_pendingDamage.push_back({targetId, w.damage, currentTick + kWeaponImpactDelayTicks}); + const QVector2D targetPos = admin.get(targetEntity).value; + const float dist = (pos.value - targetPos).length(); + if (dist > weapon.range) + { + return; + } + + m_pendingDamage.push_back({targetEntity, weapon.damage, + currentTick + kWeaponImpactDelayTicks}); FireEvent evt; - evt.shooter = ship.id; - evt.target = targetId; + evt.shooter = shipEntity; + evt.target = targetEntity; evt.emittedAt = currentTick; out.push_back(evt); - w.cooldownTicks = static_cast(kTickRateHz) / w.fireRateHz; + weapon.cooldownTicks = static_cast(kTickRateHz) / weapon.fireRateHz; } -void CombatSystem::resolveStationWeapon(Building& station, Tick currentTick, - ShipSystem& ships, - BuildingSystem& buildings, +void CombatSystem::resolveStationWeapon(entt::entity stationEntity, + StationWeapon& weapon, + const Position& stationPos, + const Faction& stationFaction, + Tick currentTick, + EntityAdmin& admin, std::vector& out) { - if (!station.weapon) - { - 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) + if (weapon.currentTarget) { - const std::optional tPos = - targetPosition(*w.currentTarget, ships, buildings); - if (!tPos || (stationCenter - *tPos).length() > w.range) + const entt::entity t = *weapon.currentTarget; + if (!admin.isValid(t) || !admin.hasAll(t)) { - w.currentTarget = std::nullopt; + weapon.currentTarget = std::nullopt; + } + else + { + const float dist = (stationPos.value - admin.get(t).value).length(); + if (dist > weapon.range) + { + weapon.currentTarget = std::nullopt; + } } } - // Acquire a new target if needed. - if (!w.currentTarget) + // Acquire a new target if needed (nearest opposing-faction ship). + if (!weapon.currentTarget) { - w.currentTarget = acquireStationTarget(station, stationIsEnemy, ships); + float bestDist = weapon.range; + admin.forEach( + [&](entt::entity candidate, const ShipIdentity& /*si*/, + const Position& candidatePos, const Faction& candidateFaction) + { + const bool isValidTarget = stationFaction.isEnemy + ? !candidateFaction.isEnemy + : candidateFaction.isEnemy; + if (!isValidTarget) + { + return; + } + const float dist = (candidatePos.value - stationPos.value).length(); + if (dist < bestDist) + { + bestDist = dist; + weapon.currentTarget = candidate; + } + }); } - if (!w.currentTarget) + if (!weapon.currentTarget) { return; } - // Decrement cooldown. - if (w.cooldownTicks > 0.0f) + if (weapon.cooldownTicks > 0.0f) { - w.cooldownTicks -= 1.0f; + weapon.cooldownTicks -= 1.0f; } - - if (w.cooldownTicks > 0.0f) + if (weapon.cooldownTicks > 0.0f) { return; } - const EntityId targetId = *w.currentTarget; - const std::optional tPos = targetPosition(targetId, ships, buildings); - if (!tPos) + const entt::entity targetEntity = *weapon.currentTarget; + if (!admin.isValid(targetEntity) || !admin.hasAll(targetEntity)) { - w.currentTarget = std::nullopt; + weapon.currentTarget = std::nullopt; return; } - if ((stationCenter - *tPos).length() > w.range) + const QVector2D targetPos = admin.get(targetEntity).value; + if ((stationPos.value - targetPos).length() > weapon.range) { return; } - m_pendingDamage.push_back({targetId, w.damage, currentTick + kWeaponImpactDelayTicks}); + m_pendingDamage.push_back({targetEntity, weapon.damage, + currentTick + kWeaponImpactDelayTicks}); FireEvent evt; - evt.shooter = station.id; - evt.target = targetId; + evt.shooter = stationEntity; + evt.target = targetEntity; evt.emittedAt = currentTick; out.push_back(evt); - w.cooldownTicks = static_cast(kTickRateHz) / w.fireRateHz; + weapon.cooldownTicks = static_cast(kTickRateHz) / weapon.fireRateHz; } -std::optional CombatSystem::acquireStationTarget( - const Building& station, bool stationIsEnemy, - const ShipSystem& ships) const +void CombatSystem::applyPendingDamage(Tick currentTick, EntityAdmin& admin) { - 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 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(); + std::vector::iterator it = m_pendingDamage.begin(); while (it != m_pendingDamage.end()) { if (it->appliesAt <= currentTick) { - if (ships.findShip(it->target)) + if (admin.isValid(it->target) && admin.hasAll(it->target)) { - ships.damageShip(it->target, it->amount); - } - else if (buildings.findBuilding(it->target)) - { - buildings.damageBuilding(it->target, it->amount); + admin.get(it->target).hp -= it->amount; } it = m_pendingDamage.erase(it); } @@ -215,22 +183,3 @@ void CombatSystem::applyPendingDamage(Tick currentTick, } } } - -std::optional 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; -} diff --git a/src/lib/sim/CombatSystem.h b/src/lib/sim/CombatSystem.h index 30ce208..65a1e81 100644 --- a/src/lib/sim/CombatSystem.h +++ b/src/lib/sim/CombatSystem.h @@ -6,67 +6,52 @@ #include #include "Building.h" -#include "EntityId.h" +#include "EcsComponents.h" #include "FireEvent.h" #include "GameConfig.h" #include "Ship.h" #include "Tick.h" -class BuildingSystem; -class ShipSystem; +#include "entt/entity/entity.hpp" + +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 { public: 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, - ShipSystem& ships, + EntityAdmin& admin, BuildingSystem& buildings, std::vector& outFireEvents); - // Apply any queued damage whose impact tick has arrived. Silently drops - // damage if the target no longer exists. - void applyPendingDamage(Tick currentTick, - ShipSystem& ships, - BuildingSystem& buildings); + void applyPendingDamage(Tick currentTick, EntityAdmin& admin); private: struct PendingDamage { - EntityId target; - float amount; - Tick appliesAt; + entt::entity target; + float amount; + Tick appliesAt; }; std::vector m_pendingDamage; - // Process one ship's weapon for this tick. - void resolveShipWeapon(Ship& ship, Tick currentTick, - ShipSystem& ships, - BuildingSystem& buildings, + + void resolveShipWeapon(entt::entity shipEntity, Weapon& weapon, + const ThreatResponse& threat, + const Position& pos, Tick currentTick, + EntityAdmin& admin, std::vector& out); - // Process one defence-station's weapon for this tick. - void resolveStationWeapon(Building& station, Tick currentTick, - ShipSystem& ships, - BuildingSystem& buildings, + void resolveStationWeapon(entt::entity stationEntity, + StationWeapon& weapon, + const Position& stationPos, + const Faction& stationFaction, + Tick currentTick, + EntityAdmin& admin, std::vector& out); - // Find the nearest valid target for a defence station within its range. - // Both enemy and player stations target ships of the opposing faction only. - std::optional 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 targetPosition(EntityId id, - const ShipSystem& ships, - const BuildingSystem& buildings) const; - const GameConfig& m_config; }; diff --git a/src/lib/sim/MovementSystem.cpp b/src/lib/sim/MovementSystem.cpp index ec85c92..debbc5e 100644 --- a/src/lib/sim/MovementSystem.cpp +++ b/src/lib/sim/MovementSystem.cpp @@ -5,8 +5,9 @@ #include -#include "Ship.h" -#include "ShipSystem.h" +#include "EcsComponents.h" +#include "EntityAdmin.h" +#include "MovementIntent.h" static float wrapAngle(float a) { @@ -17,86 +18,88 @@ static float wrapAngle(float a) return a; } -void MovementSystem::tick(ShipSystem& ships) +void MovementSystem::tick(EntityAdmin& admin) { - ships.forEach([&](Ship& s) - { - if (s.intent.priority == 0) + admin.forEach( + [](entt::entity /*e*/, Position& pos, Velocity& vel, Facing& facing, + ShipDynamics& dynamics, MovementIntent& intent) { - s.velocity = QVector2D(0.0f, 0.0f); - s.rotationSpeed = 0.0f; - return; - } + if (intent.priority == 0) + { + vel.value = QVector2D(0.0f, 0.0f); + facing.rotationSpeed = 0.0f; + return; + } - const QVector2D delta = s.intent.target - s.position; - const float dist = delta.length(); + const QVector2D delta = intent.target - pos.value; + const float dist = delta.length(); - if (dist < 0.001f) - { - s.velocity = QVector2D(0.0f, 0.0f); - return; - } + if (dist < 0.001f) + { + vel.value = QVector2D(0.0f, 0.0f); + return; + } - // ── Rotate toward target ────────────────────────────────────────── - const float desiredAngle = std::atan2(delta.y(), delta.x()); - const float angleDiff = wrapAngle(desiredAngle - s.facing); + // Rotate toward target. + const float desiredAngle = std::atan2(delta.y(), delta.x()); + const float angleDiff = wrapAngle(desiredAngle - facing.radians); - const float rotDelta = std::max(-s.angularAccelerationPerTick, - std::min(angleDiff, s.angularAccelerationPerTick)); - s.rotationSpeed += rotDelta; - s.rotationSpeed = std::max(-s.maxRotationSpeedPerTick, - std::min(s.rotationSpeed, s.maxRotationSpeedPerTick)); + const float rotDelta = std::max(-dynamics.angularAccelerationPerTick, + std::min(angleDiff, dynamics.angularAccelerationPerTick)); + facing.rotationSpeed += rotDelta; + facing.rotationSpeed = std::max(-dynamics.maxRotationSpeedPerTick, + std::min(facing.rotationSpeed, dynamics.maxRotationSpeedPerTick)); - const bool sameSign = (s.rotationSpeed >= 0.0f) == (angleDiff >= 0.0f); - if (sameSign && std::abs(s.rotationSpeed) > std::abs(angleDiff)) - { - s.rotationSpeed = angleDiff; - } + const bool sameSign = (facing.rotationSpeed >= 0.0f) == (angleDiff >= 0.0f); + if (sameSign && std::abs(facing.rotationSpeed) > std::abs(angleDiff)) + { + facing.rotationSpeed = angleDiff; + } - s.facing = wrapAngle(s.facing + s.rotationSpeed); + facing.radians = wrapAngle(facing.radians + facing.rotationSpeed); - // ── Desired velocity (with braking near target) ─────────────────── - const float manAccel = s.maneuveringAccelerationPerTick; - const float stoppingDist = (s.maxSpeedPerTick * s.maxSpeedPerTick) - / (2.0f * manAccel); - const float desiredSpeed = (dist <= stoppingDist) - ? std::sqrt(2.0f * manAccel * dist) - : s.maxSpeedPerTick; - const QVector2D desiredVel = delta.normalized() * desiredSpeed; - const QVector2D velError = desiredVel - s.velocity; + // Desired velocity (with braking near target). + const float manAccel = dynamics.maneuveringAccelerationPerTick; + const float stoppingDist = (dynamics.maxSpeedPerTick * dynamics.maxSpeedPerTick) + / (2.0f * manAccel); + const float desiredSpeed = (dist <= stoppingDist) + ? std::sqrt(2.0f * manAccel * dist) + : dynamics.maxSpeedPerTick; + const QVector2D desiredVel = delta.normalized() * desiredSpeed; + const QVector2D velError = desiredVel - vel.value; - // ── Main acceleration: forward only, along facing ───────────────── - const QVector2D facingVec(std::cos(s.facing), std::sin(s.facing)); - const float mainAligned = std::max(0.0f, - QVector2D::dotProduct(velError, facingVec)); - const float mainApplied = std::min(mainAligned, s.mainAccelerationPerTick); - const QVector2D mainDelta = facingVec * mainApplied; + // Main acceleration: forward only, along facing. + const QVector2D facingVec(std::cos(facing.radians), std::sin(facing.radians)); + const float mainAligned = std::max(0.0f, + QVector2D::dotProduct(velError, facingVec)); + const float mainApplied = std::min(mainAligned, dynamics.mainAccelerationPerTick); + const QVector2D mainDelta = facingVec * mainApplied; - // ── Maneuvering acceleration: any direction, handles the remainder ─ - const QVector2D remaining = velError - mainDelta; - const float remainLen = remaining.length(); - const QVector2D maneuverDelta = (remainLen > manAccel) - ? remaining.normalized() * manAccel - : remaining; + // Maneuvering acceleration: any direction, handles the remainder. + const QVector2D remaining = velError - mainDelta; + const float remainLen = remaining.length(); + const QVector2D maneuverDelta = (remainLen > manAccel) + ? remaining.normalized() * manAccel + : remaining; - s.velocity += mainDelta + maneuverDelta; + vel.value += mainDelta + maneuverDelta; - // ── Speed cap ───────────────────────────────────────────────────── - const float speed = s.velocity.length(); - if (speed > s.maxSpeedPerTick) - { - s.velocity = s.velocity.normalized() * s.maxSpeedPerTick; - } + // Speed cap. + const float speed = vel.value.length(); + if (speed > dynamics.maxSpeedPerTick) + { + vel.value = vel.value.normalized() * dynamics.maxSpeedPerTick; + } - // ── Snap to target or advance ───────────────────────────────────── - if (dist <= s.velocity.length()) - { - s.position = s.intent.target; - s.velocity = QVector2D(0.0f, 0.0f); - } - else - { - s.position += s.velocity; - } - }); + // Snap to target or advance. + if (dist <= vel.value.length()) + { + pos.value = intent.target; + vel.value = QVector2D(0.0f, 0.0f); + } + else + { + pos.value += vel.value; + } + }); } diff --git a/src/lib/sim/MovementSystem.h b/src/lib/sim/MovementSystem.h index 60969b4..89fdae6 100644 --- a/src/lib/sim/MovementSystem.h +++ b/src/lib/sim/MovementSystem.h @@ -1,9 +1,9 @@ #pragma once -class ShipSystem; +class EntityAdmin; class MovementSystem { public: - void tick(ShipSystem& ships); + void tick(EntityAdmin& admin); }; diff --git a/src/lib/sim/ScrapSystem.cpp b/src/lib/sim/ScrapSystem.cpp index 93ae90d..aaf0f5d 100644 --- a/src/lib/sim/ScrapSystem.cpp +++ b/src/lib/sim/ScrapSystem.cpp @@ -1,59 +1,53 @@ #include "ScrapSystem.h" -#include -#include +#include "EntityAdmin.h" -ScrapSystem::ScrapSystem(std::function allocateId) - : m_allocateId(std::move(allocateId)) +ScrapSystem::ScrapSystem(EntityAdmin& admin) + : m_admin(admin) { } -EntityId ScrapSystem::spawn(QVector2D position, int amount, Tick despawnAt) +entt::entity ScrapSystem::spawn(QVector2D position, int amount, Tick despawnAt) { - Scrap s; - s.id = m_allocateId(); - s.position = position; - s.amount = amount; - s.despawnAt = despawnAt; - m_scraps.push_back(s); - return s.id; + return m_admin.spawnScrap(position, amount, despawnAt); } void ScrapSystem::tickDespawn(Tick currentTick) { - m_scraps.erase( - std::remove_if(m_scraps.begin(), m_scraps.end(), - [currentTick](const Scrap& s) { return s.despawnAt <= currentTick; }), - m_scraps.end()); -} - -const Scrap* ScrapSystem::findScrap(EntityId id) const -{ - for (const Scrap& s : m_scraps) - { - if (s.id == id) + std::vector expired; + m_admin.forEach( + [&expired, currentTick](entt::entity e, DespawnAt& d) { - return &s; - } - } - return nullptr; -} + if (d.tick <= currentTick) + { + expired.push_back(e); + } + }); -std::optional ScrapSystem::consume(EntityId id) -{ - for (auto it = m_scraps.begin(); it != m_scraps.end(); ++it) + for (entt::entity e : expired) { - if (it->id == id) - { - Scrap result = *it; - m_scraps.erase(it); - return result; - } + m_admin.destroy(e); } - return std::nullopt; } -std::vector ScrapSystem::allScraps() const +std::optional ScrapSystem::consume(entt::entity entity) { - return m_scraps; + if (!m_admin.isValid(entity) || !m_admin.hasAll(entity)) + { + return std::nullopt; + } + int amount = m_admin.get(entity).amount; + m_admin.destroy(entity); + return amount; +} + +std::vector ScrapSystem::allScrapInfo() const +{ + std::vector result; + m_admin.forEach( + [&result, this](entt::entity e, const ScrapData& /*sd*/) + { + result.push_back(ScrapInfo{e, m_admin.get(e).value}); + }); + return result; } diff --git a/src/lib/sim/ScrapSystem.h b/src/lib/sim/ScrapSystem.h index 7c937bd..1c4b099 100644 --- a/src/lib/sim/ScrapSystem.h +++ b/src/lib/sim/ScrapSystem.h @@ -1,28 +1,37 @@ #pragma once -#include #include #include #include -#include "EntityId.h" -#include "Scrap.h" +#include "EcsComponents.h" #include "Tick.h" +#include "entt/entity/entity.hpp" + +class EntityAdmin; + +struct ScrapInfo +{ + entt::entity entity; + QVector2D position; +}; + class ScrapSystem { public: - explicit ScrapSystem(std::function allocateId); + explicit ScrapSystem(EntityAdmin& admin); - EntityId spawn(QVector2D position, int amount, Tick despawnAt); - void tickDespawn(Tick currentTick); - std::optional consume(EntityId id); // removes and returns scrap, or nullopt + entt::entity spawn(QVector2D position, int amount, Tick despawnAt); + void tickDespawn(Tick currentTick); - const Scrap* findScrap(EntityId id) const; - std::vector allScraps() const; + // Removes the scrap and returns its amount, or nullopt if not found. + std::optional consume(entt::entity entity); + + // Lightweight snapshot for callers that need to iterate all scrap. + std::vector allScrapInfo() const; private: - std::function m_allocateId; - std::vector m_scraps; + EntityAdmin& m_admin; }; diff --git a/src/lib/sim/Ship.h b/src/lib/sim/Ship.h index a1b990a..6899f8a 100644 --- a/src/lib/sim/Ship.h +++ b/src/lib/sim/Ship.h @@ -8,6 +8,8 @@ #include "EntityId.h" #include "MovementIntent.h" +#include "entt/entity/entity.hpp" + // --------------------------------------------------------------------------- // Hardware components — derived from config at spawn, stored on ship // --------------------------------------------------------------------------- @@ -18,21 +20,21 @@ struct Weapon float range; float fireRateHz; float cooldownTicks; - std::optional currentTarget; + std::optional currentTarget; }; struct SalvageCargo { int capacity; int current; - float collectionRange; // copy of ShipDef.salvage.collectionRange (tile units) + float collectionRange; }; struct RepairTool { float ratePerTick; float range; - std::optional currentTarget; + std::optional currentTarget; }; // --------------------------------------------------------------------------- @@ -41,7 +43,7 @@ struct RepairTool struct ThreatResponse { - std::optional currentTarget; + std::optional currentTarget; }; struct ScrapCollector @@ -52,7 +54,7 @@ struct ScrapCollector struct RepairBehavior { - std::optional currentTarget; + std::optional currentTarget; }; struct HomeReturn @@ -65,41 +67,3 @@ struct RallyBehavior { 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; - std::optional cargo; - std::optional repairTool; - std::optional threatResponse; - std::optional scrapCollector; - std::optional repairBehavior; - std::optional homeReturn; - std::optional 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; -}; diff --git a/src/lib/sim/ShipSystem.cpp b/src/lib/sim/ShipSystem.cpp index f87101c..cb760b9 100644 --- a/src/lib/sim/ShipSystem.cpp +++ b/src/lib/sim/ShipSystem.cpp @@ -1,17 +1,17 @@ #include "ShipSystem.h" -#include #include #include #include +#include "EcsComponents.h" +#include "EntityAdmin.h" #include "ModulesConfig.h" #include "Tick.h" -ShipSystem::ShipSystem(const GameConfig& config, - std::function allocateId) +ShipSystem::ShipSystem(const GameConfig& config, EntityAdmin& admin) : 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; } -EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D position, - bool isEnemy, - const std::optional& layout) +entt::entity ShipSystem::spawn(const std::string& schematicId, int level, QVector2D position, + bool isEnemy, + const std::optional& layout) { const ShipDef* def = findShipDef(schematicId); assert(def != nullptr); const double x = static_cast(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(def->health.hpFormula.evaluate(x)); - ship.hp = ship.maxHp; const float tickRate = static_cast(kTickRateHz); - ship.maxSpeedPerTick = static_cast(def->movement.speedFormula.evaluate(x)) - / tickRate; - ship.mainAccelerationPerTick = static_cast(def->movement.mainAccelerationFormula.evaluate(x)) - / tickRate; - ship.maneuveringAccelerationPerTick = static_cast(def->movement.maneuveringAccelerationFormula.evaluate(x)) - / tickRate; - ship.angularAccelerationPerTick = static_cast(def->movement.angularAccelerationFormula.evaluate(x)) - / tickRate; - ship.maxRotationSpeedPerTick = static_cast(def->movement.maxRotationSpeedFormula.evaluate(x)) - / tickRate; - ship.sensorRange = static_cast(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(def->health.hpFormula.evaluate(x)); + float maxHp = hp; + float maxSpeedPerTick = static_cast(def->movement.speedFormula.evaluate(x)) / tickRate; + float mainAccelPerTick = static_cast(def->movement.mainAccelerationFormula.evaluate(x)) / tickRate; + float maneuveringAccelPerTick = static_cast(def->movement.maneuveringAccelerationFormula.evaluate(x)) / tickRate; + float angularAccelPerTick = static_cast(def->movement.angularAccelerationFormula.evaluate(x)) / tickRate; + float maxRotationSpeedPerTick = static_cast(def->movement.maxRotationSpeedFormula.evaluate(x)) / tickRate; + float sensorRange = static_cast(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) { Weapon w; - w.damage = static_cast(def->combat->damageFormula.evaluate(x)); - w.range = static_cast(def->combat->attackRangeFormula.evaluate(x)); - w.fireRateHz = static_cast(def->combat->attackRateFormula.evaluate(x)); + w.damage = static_cast(def->combat->damageFormula.evaluate(x)); + w.range = static_cast(def->combat->attackRangeFormula.evaluate(x)); + w.fireRateHz = static_cast(def->combat->attackRateFormula.evaluate(x)); w.cooldownTicks = 0.0f; - ship.weapon = w; + w.currentTarget = std::nullopt; + m_admin.addComponent(entity, w); - ship.threatResponse = ThreatResponse{}; + m_admin.addComponent(entity, ThreatResponse{}); if (!isEnemy) { - ship.rallyBehavior = RallyBehavior{m_rallyPoint}; + m_admin.addComponent(entity, RallyBehavior{m_rallyPoint}); } } @@ -96,23 +89,23 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D cargo.capacity = def->salvage->cargoCapacity; cargo.current = 0; cargo.collectionRange = static_cast(def->salvage->collectionRange); - ship.cargo = cargo; + m_admin.addComponent(entity, cargo); ScrapCollector sc; sc.scrapTarget = std::nullopt; sc.deliveryBay = kInvalidEntityId; - ship.scrapCollector = sc; + m_admin.addComponent(entity, sc); } if (def->repair) { RepairTool rt; - rt.ratePerTick = static_cast(def->repair->repairRateFormula.evaluate(x)); - rt.range = static_cast(def->repair->repairRangeFormula.evaluate(x)); - ship.repairTool = rt; + rt.ratePerTick = static_cast(def->repair->repairRateFormula.evaluate(x)); + rt.range = static_cast(def->repair->repairRangeFormula.evaluate(x)); + rt.currentTarget = std::nullopt; + m_admin.addComponent(entity, rt); - RepairBehavior rb; - ship.repairBehavior = rb; + m_admin.addComponent(entity, RepairBehavior{}); } // 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"); - ship.hp = ship.maxHp; - applyMod(ship.maxSpeedPerTick, "speed"); - applyMod(ship.mainAccelerationPerTick, "main_acceleration"); - applyMod(ship.maneuveringAccelerationPerTick, "maneuvering_acceleration"); - applyMod(ship.angularAccelerationPerTick, "angular_acceleration"); - applyMod(ship.maxRotationSpeedPerTick, "max_rotation_speed"); - applyMod(ship.sensorRange, "sensor_range"); - if (ship.weapon.has_value()) + Health& health = m_admin.get(entity); + ShipDynamics& dynamics = m_admin.get(entity); + SensorRange& sensor = m_admin.get(entity); + + applyMod(health.maxHp, "hp"); + health.hp = health.maxHp; + applyMod(dynamics.maxSpeedPerTick, "speed"); + applyMod(dynamics.mainAccelerationPerTick, "main_acceleration"); + 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(entity)) { - applyMod(ship.weapon->damage, "damage"); - applyMod(ship.weapon->range, "attack_range"); - applyMod(ship.weapon->fireRateHz, "attack_rate"); + Weapon& weapon = m_admin.get(entity); + applyMod(weapon.damage, "damage"); + applyMod(weapon.range, "attack_range"); + applyMod(weapon.fireRateHz, "attack_rate"); } - if (ship.repairTool.has_value()) + if (m_admin.hasAll(entity)) { - applyMod(ship.repairTool->ratePerTick, "repair_rate"); - applyMod(ship.repairTool->range, "repair_range"); + RepairTool& repairTool = m_admin.get(entity); + applyMod(repairTool.ratePerTick, "repair_rate"); + applyMod(repairTool.range, "repair_range"); } } - m_ships.push_back(ship); - return ship.id; + return entity; } -void ShipSystem::despawn(EntityId id) +void ShipSystem::despawn(entt::entity entity) { - m_ships.erase( - std::remove_if(m_ships.begin(), m_ships.end(), - [id](const Ship& s) { return s.id == id; }), - m_ships.end()); + m_admin.destroy(entity); } -const Ship* ShipSystem::findShip(EntityId id) const -{ - for (const Ship& s : m_ships) - { - if (s.id == id) - { - return &s; - } - } - return nullptr; -} - -std::vector ShipSystem::allShips() const -{ - return m_ships; -} - -void ShipSystem::forEach(std::function 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() { - for (Ship& s : m_ships) + m_admin.forEach([](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) { m_rallyPoint = point; @@ -259,11 +196,17 @@ void ShipSystem::setRallyPoint(QVector2D point) void ShipSystem::triggerRallyDeparture() { - for (Ship& s : m_ships) - { - if (!s.isEnemy) + std::vector toRemove; + m_admin.forEach( + [&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(e); } } diff --git a/src/lib/sim/ShipSystem.h b/src/lib/sim/ShipSystem.h index 11f83e4..a819533 100644 --- a/src/lib/sim/ShipSystem.h +++ b/src/lib/sim/ShipSystem.h @@ -1,30 +1,27 @@ #pragma once -#include -#include +#include +#include #include -#include "EntityId.h" #include "GameConfig.h" #include "Ship.h" #include "ShipLayout.h" +#include "entt/entity/entity.hpp" + +class EntityAdmin; + class ShipSystem { public: - ShipSystem(const GameConfig& config, - std::function allocateId); + ShipSystem(const GameConfig& config, EntityAdmin& admin); - // isEnemy defaults to false; set true for enemy-faction ships (step 7 wave spawning). - EntityId spawn(const std::string& schematicId, int level, QVector2D position, - bool isEnemy = false, - const std::optional& layout = std::nullopt); - void despawn(EntityId id); - - const Ship* findShip(EntityId id) const; - std::vector allShips() const; - void forEach(std::function fn); + entt::entity spawn(const std::string& schematicId, int level, QVector2D position, + bool isEnemy = false, + const std::optional& layout = std::nullopt); + void despawn(entt::entity entity); // Reset all movement intents to priority 0 before behavior systems run. void clearMovementIntents(); @@ -35,20 +32,11 @@ public: // Release all gathered player combat ships to advance toward the enemy. 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: const ShipDef* findShipDef(const std::string& schematicId) const; const ModuleDef* findModuleDef(const std::string& id) const; - const GameConfig& m_config; - std::function m_allocateId; - std::vector m_ships; - QVector2D m_rallyPoint; + const GameConfig& m_config; + EntityAdmin& m_admin; + QVector2D m_rallyPoint; }; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index aa795a8..77ee09a 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -4,6 +4,7 @@ #include "AiSystem.h" #include "BuildingSystem.h" +#include "EcsComponents.h" #include "CombatSystem.h" #include "MovementSystem.h" #include "ScrapSystem.h" @@ -19,13 +20,14 @@ Simulation::Simulation(GameConfig config, unsigned int seed) , m_nextId(1) , m_buildingBlocksStock(m_config.world.startingBuildingBlocks) , m_gameOver(false) - , m_hqId(kInvalidEntityId) - , m_playerStation1Id(kInvalidEntityId) - , m_playerStation2Id(kInvalidEntityId) + , m_hqBuildingId(kInvalidEntityId) + , m_hqProxyEntity(entt::null) + , m_playerStation1Entity(entt::null) + , m_playerStation2Entity(entt::null) , m_beltSystem(m_config.world.beltSpeedTilesPerSecond) { - m_currentEnemyStationIds[0] = kInvalidEntityId; - m_currentEnemyStationIds[1] = kInvalidEntityId; + m_currentEnemyStationEntities[0] = entt::null; + m_currentEnemyStationEntities[1] = entt::null; m_buildingSystem = std::make_unique( m_config, @@ -43,10 +45,10 @@ Simulation::Simulation(GameConfig config, unsigned int seed) m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout); }, m_rng); - m_shipSystem = std::make_unique(m_config, [this]() { return allocateId(); }); + m_shipSystem = std::make_unique(m_config, m_admin); m_aiSystem = std::make_unique(); m_movementSystem = std::make_unique(); - m_scrapSystem = std::make_unique([this]() { return allocateId(); }); + m_scrapSystem = std::make_unique(m_admin); m_waveSystem = std::make_unique(m_config, m_rng); m_combatSystem = std::make_unique(m_config); @@ -82,15 +84,17 @@ void Simulation::reset(unsigned int seed) m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds); m_nextId = 1; m_buildingBlocksStock = m_config.world.startingBuildingBlocks; - m_gameOver = false; - m_hqId = kInvalidEntityId; - m_playerStation1Id = kInvalidEntityId; - m_playerStation2Id = kInvalidEntityId; - m_currentEnemyStationIds[0] = kInvalidEntityId; - m_currentEnemyStationIds[1] = kInvalidEntityId; + m_gameOver = false; + m_hqBuildingId = kInvalidEntityId; + m_hqProxyEntity = entt::null; + m_playerStation1Entity = entt::null; + m_playerStation2Entity = entt::null; + m_currentEnemyStationEntities[0] = entt::null; + m_currentEnemyStationEntities[1] = entt::null; m_fireEvents.clear(); m_schematicDropEvents.clear(); + m_admin.clear(); m_beltSystem = BeltSystem(m_config.world.beltSpeedTilesPerSecond); m_buildingSystem = std::make_unique( m_config, @@ -108,10 +112,10 @@ void Simulation::reset(unsigned int seed) m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout); }, m_rng); - m_shipSystem = std::make_unique(m_config, [this]() { return allocateId(); }); + m_shipSystem = std::make_unique(m_config, m_admin); m_aiSystem = std::make_unique(); m_movementSystem = std::make_unique(); - m_scrapSystem = std::make_unique([this]() { return allocateId(); }); + m_scrapSystem = std::make_unique(m_admin); m_waveSystem = std::make_unique(m_config, m_rng); m_combatSystem = std::make_unique(m_config); @@ -158,17 +162,17 @@ void Simulation::tick() } m_shipSystem->clearMovementIntents(); - m_aiSystem->tickHomeReturn(*m_shipSystem); // priority 4 - m_aiSystem->tickThreatResponse(*m_shipSystem, *m_buildingSystem); // priority 3 - m_aiSystem->tickRepairBehavior(*m_shipSystem, *m_buildingSystem); // priority 2 - m_aiSystem->tickScrapCollector(*m_shipSystem, *m_scrapSystem, *m_buildingSystem); // priority 1 + m_aiSystem->tickHomeReturn(m_admin); // priority 4 + m_aiSystem->tickThreatResponse(m_admin, *m_buildingSystem); // priority 3 + m_aiSystem->tickRepairBehavior(m_admin, *m_buildingSystem); // priority 2 + m_aiSystem->tickScrapCollector(m_admin, *m_scrapSystem, *m_buildingSystem); // priority 1 // Step 8: combat resolution - m_combatSystem->tick(m_currentTick, *m_shipSystem, + m_combatSystem->tick(m_currentTick, m_admin, *m_buildingSystem, m_fireEvents); // 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 if (!m_gameOver) @@ -177,7 +181,7 @@ void Simulation::tick() } // Step 10: advance ship positions - m_movementSystem->tick(*m_shipSystem); + m_movementSystem->tick(m_admin); // Step 11: scrap despawn m_scrapSystem->tickDespawn(m_currentTick); @@ -192,6 +196,7 @@ void Simulation::tick() void Simulation::placeInitialStructures() { // 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 = parseSurfaceMask(m_config.stations.hq.surfaceMask, Rotation::East); const int hqAnchorX = -hqParsed.footprint.width(); @@ -199,13 +204,18 @@ void Simulation::placeInitialStructures() (m_config.world.heightTiles - hqParsed.footprint.height()) / 2; const float hqHp = static_cast(m_config.stations.hq.hpFormula.evaluate(0.0)); - m_hqId = m_buildingSystem->placeImmediate( + m_hqBuildingId = m_buildingSystem->placeImmediate( BuildingType::Hq, m_config.stations.hq.surfaceMask, QPoint(hqAnchorX, hqAnchorY), Rotation::East, hqHp, hqHp); - // 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 = parseSurfaceMask(m_config.stations.playerStation.surfaceMask, Rotation::East); const int psAnchorX = @@ -222,21 +232,35 @@ void Simulation::placeInitialStructures() psWeapon.fireRateHz = static_cast( m_config.stations.playerStation.fireRateFormula.evaluate(psLevel)); psWeapon.cooldownTicks = 0.0f; + psWeapon.currentTarget = std::nullopt; const int ps1Y = m_config.world.heightTiles / 4; const int ps2Y = 3 * m_config.world.heightTiles / 4; - m_playerStation1Id = m_buildingSystem->placeImmediate( - BuildingType::PlayerDefenceStation, - m_config.stations.playerStation.surfaceMask, - QPoint(psAnchorX, ps1Y), Rotation::East, psHp, psHp); - m_buildingSystem->initStationWeapon(m_playerStation1Id, psWeapon); - - m_playerStation2Id = m_buildingSystem->placeImmediate( - BuildingType::PlayerDefenceStation, - m_config.stations.playerStation.surfaceMask, - QPoint(psAnchorX, ps2Y), Rotation::East, psHp, psHp); - m_buildingSystem->initStationWeapon(m_playerStation2Id, psWeapon); + { + const QPoint anchor(psAnchorX, ps1Y); + std::vector absCells; + for (const QPoint& rel : psParsed.bodyCells) + { + absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y())); + } + m_playerStation1Entity = m_admin.spawnStation( + anchor, psParsed.footprint, absCells, psHp, psHp, false); + m_admin.addComponent(m_playerStation1Entity, psWeapon); + m_buildingSystem->registerTileOccupancy(absCells, allocateId()); + } + { + const QPoint anchor(psAnchorX, ps2Y); + std::vector 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(m_playerStation2Entity, psWeapon); + m_buildingSystem->registerTileOccupancy(absCells, allocateId()); + } // Rally point: center of the player defence stations' X column, world vertical midpoint. const float rallyX = static_cast(psAnchorX) + psParsed.footprint.width() / 2.0f; @@ -252,7 +276,6 @@ void Simulation::placeEnemyStationSet(int generation) const ParsedSurfaceMask esParsed = 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 + m_config.world.regions.contestZoneWidth + generation * m_config.world.push.pushExpandColumns; @@ -270,24 +293,35 @@ void Simulation::placeEnemyStationSet(int generation) esWeapon.fireRateHz = static_cast( m_config.stations.enemyStation.fireRateFormula.evaluate(genD)); esWeapon.cooldownTicks = 0.0f; + esWeapon.currentTarget = std::nullopt; const int y1 = m_config.world.heightTiles / 4; const int y2 = 3 * m_config.world.heightTiles / 4; - const EntityId id1 = m_buildingSystem->placeImmediate( - BuildingType::EnemyDefenceStation, - m_config.stations.enemyStation.surfaceMask, - QPoint(anchorX, y1), Rotation::East, esHp, esHp); - m_buildingSystem->initStationWeapon(id1, esWeapon); - - const EntityId id2 = m_buildingSystem->placeImmediate( - BuildingType::EnemyDefenceStation, - m_config.stations.enemyStation.surfaceMask, - QPoint(anchorX, y2), Rotation::East, esHp, esHp); - m_buildingSystem->initStationWeapon(id2, esWeapon); - - m_currentEnemyStationIds[0] = id1; - m_currentEnemyStationIds[1] = id2; + { + const QPoint anchor(anchorX, y1); + std::vector absCells; + for (const QPoint& rel : esParsed.bodyCells) + { + absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y())); + } + m_currentEnemyStationEntities[0] = m_admin.spawnStation( + anchor, esParsed.footprint, absCells, esHp, esHp, true); + m_admin.addComponent(m_currentEnemyStationEntities[0], esWeapon); + m_buildingSystem->registerTileOccupancy(absCells, allocateId()); + } + { + const QPoint anchor(anchorX, y2); + std::vector 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(m_currentEnemyStationEntities[1], esWeapon); + m_buildingSystem->registerTileOccupancy(absCells, allocateId()); + } } // --------------------------------------------------------------------------- @@ -297,98 +331,92 @@ void Simulation::placeEnemyStationSet(int generation) void Simulation::tickDeathsAndLoot() { // --- Dead ships --- - std::vector deadShipIds; - m_shipSystem->forEach([&deadShipIds](Ship& s) - { - if (s.hp <= 0.0f) + std::vector deadShips; + m_admin.forEach( + [&deadShips](entt::entity e, const ShipIdentity& /*si*/, const Health& h) { - 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); - if (!s) - { - continue; - } - // Look up scrap drop amount from config. + const ShipIdentity& si = m_admin.get(deadEntity); + const Position& pos = m_admin.get(deadEntity); 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 + secondsToTicks(m_config.world.scrapDespawnSeconds); - m_scrapSystem->spawn(s->position, def.loot.scrapDrop, despawnAt); + m_scrapSystem->spawn(pos.value, def.loot.scrapDrop, despawnAt); break; } } - m_shipSystem->despawn(deadId); + m_shipSystem->despawn(deadEntity); } - // --- Dead buildings (HQ, player/enemy defence stations) --- - std::vector deadBuildingIds; - for (const Building& b : m_buildingSystem->allBuildings()) - { - if (b.hp <= 0.0f && - (b.type == BuildingType::Hq || - b.type == BuildingType::PlayerDefenceStation || - b.type == BuildingType::EnemyDefenceStation)) + // --- Dead stations --- + std::vector deadStations; + m_admin.forEach( + [&deadStations](entt::entity e, const StationBody& /*sb*/, const Health& h) { - 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); - if (!b) - { - continue; - } + const StationBody& sb = m_admin.get(deadEntity); + const Position& pos = m_admin.get(deadEntity); + const Faction& fac = m_admin.get(deadEntity); - 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( + m_config.stations.playerStation.level); + scrap = static_cast( + m_config.stations.playerStation.scrapDropFormula.evaluate(lv)); } else { - const QVector2D center( - b->anchor.x() + b->footprint.width() / 2.0f, - b->anchor.y() + b->footprint.height() / 2.0f); - const Tick despawnAt = m_currentTick - + secondsToTicks(m_config.world.scrapDespawnSeconds); - int scrap = 0; - if (b->type == BuildingType::PlayerDefenceStation) - { - const double lv = static_cast( - m_config.stations.playerStation.level); - scrap = static_cast( - m_config.stations.playerStation.scrapDropFormula.evaluate(lv)); - } - else if (b->type == BuildingType::EnemyDefenceStation) - { - const double genD = static_cast(m_waveSystem->generation()); - scrap = static_cast( - m_config.stations.enemyStation.scrapDropFormula.evaluate(genD)); - } - if (scrap > 0) - { - m_scrapSystem->spawn(center, scrap, despawnAt); - } + const double genD = static_cast(m_waveSystem->generation()); + scrap = static_cast( + m_config.stations.enemyStation.scrapDropFormula.evaluate(genD)); + } + if (scrap > 0) + { + m_scrapSystem->spawn(pos.value, scrap, despawnAt); + } + m_buildingSystem->unregisterTileOccupancy(sb.bodyCells); + m_admin.destroy(deadEntity); + } + + // --- HQ death check --- + if (m_admin.isValid(m_hqProxyEntity)) + { + const Health& hqHealth = m_admin.get(m_hqProxyEntity); + if (hqHealth.hp <= 0.0f) + { + m_gameOver = true; } - m_buildingSystem->removeBuilding(deadId); } // --- Push check: if both current enemy stations are gone, trigger push --- - const bool es0Gone = - (m_buildingSystem->findBuilding(m_currentEnemyStationIds[0]) == nullptr); - const bool es1Gone = - (m_buildingSystem->findBuilding(m_currentEnemyStationIds[1]) == nullptr); + const bool es0Gone = !m_admin.isValid(m_currentEnemyStationEntities[0]) + || m_admin.get(m_currentEnemyStationEntities[0]).hp <= 0.0f; + const bool es1Gone = !m_admin.isValid(m_currentEnemyStationEntities[1]) + || m_admin.get(m_currentEnemyStationEntities[1]).hp <= 0.0f; if (es0Gone && es1Gone && - m_currentEnemyStationIds[0] != kInvalidEntityId) + m_currentEnemyStationEntities[0] != entt::null) { m_waveSystem->applyPush(); placeEnemyStationSet(m_waveSystem->generation()); @@ -548,6 +576,16 @@ const ScrapSystem& Simulation::scraps() const return *m_scrapSystem; } +EntityAdmin& Simulation::admin() +{ + return m_admin; +} + +const EntityAdmin& Simulation::admin() const +{ + return m_admin; +} + EntityId Simulation::allocateId() { return m_nextId++; diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index f7745d2..ab68a89 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -9,6 +9,9 @@ #include #include "BeltSystem.h" +#include "EntityAdmin.h" + +#include "entt/entity/entity.hpp" #include "SchematicDropEvent.h" #include "BuildingType.h" #include "EntityId.h" @@ -73,6 +76,8 @@ public: const ShipSystem& ships() const; ScrapSystem& scraps(); const ScrapSystem& scraps() const; + EntityAdmin& admin(); + const EntityAdmin& admin() const; private: EntityId allocateId(); // Strictly increasing; never returns kInvalidEntityId. @@ -100,10 +105,11 @@ private: bool m_gameOver = false; // Pre-placed structure IDs. - EntityId m_hqId; - EntityId m_playerStation1Id; - EntityId m_playerStation2Id; - EntityId m_currentEnemyStationIds[2]; + EntityId m_hqBuildingId; // Building id (for belt integration) + entt::entity m_hqProxyEntity; // ECS entity (HP, targeting) + entt::entity m_playerStation1Entity; + entt::entity m_playerStation2Entity; + entt::entity m_currentEnemyStationEntities[2]; // Schematic unlock state (REQ-DEF-SCHEMATIC-DROP). struct SchematicState @@ -113,6 +119,7 @@ private: }; std::map m_schematicLevels; + EntityAdmin m_admin; BeltSystem m_beltSystem; std::unique_ptr m_buildingSystem; std::unique_ptr m_shipSystem; diff --git a/src/test/BehaviorSystemTest.cpp b/src/test/BehaviorSystemTest.cpp index 8153ccd..db2f6cc 100644 --- a/src/test/BehaviorSystemTest.cpp +++ b/src/test/BehaviorSystemTest.cpp @@ -10,9 +10,10 @@ #include "BuildingSystem.h" #include "BuildingType.h" #include "ConfigLoader.h" +#include "EcsComponents.h" +#include "EntityAdmin.h" #include "MovementSystem.h" #include "Rotation.h" -#include "Scrap.h" #include "ScrapSystem.h" #include "Ship.h" #include "ShipSystem.h" @@ -34,6 +35,7 @@ struct Fixture EntityId nextId; int stock; std::mt19937 rng; + EntityAdmin admin; BuildingSystem buildings; ShipSystem ships; AiSystem ai; @@ -52,8 +54,8 @@ struct Fixture [this](int n) { stock += n; }, [](const std::string&, QVector2D, const std::optional&) {}, rng) - , ships(cfg, [this]() { return nextId++; }) - , scraps([this]() { return nextId++; }) + , ships(cfg, admin) + , scraps(admin) , tick(0) { } @@ -62,15 +64,31 @@ struct Fixture void runBehaviorTick() { ships.clearMovementIntents(); - ai.tickHomeReturn(ships); - ai.tickThreatResponse(ships, buildings); - ai.tickRepairBehavior(ships, buildings); - ai.tickScrapCollector(ships, scraps, buildings); - movement.tick(ships); + ai.tickHomeReturn(admin); + ai.tickThreatResponse(admin, buildings); + ai.tickRepairBehavior(admin, buildings); + ai.tickScrapCollector(admin, scraps, buildings); + movement.tick(admin); ++tick; } }; +// Helpers to read ECS data for a ship entity. +static const MovementIntent& intent(EntityAdmin& a, entt::entity e) +{ + return a.get(e); +} + +static const Health& health(EntityAdmin& a, entt::entity e) +{ + return a.get(e); +} + +static const Position& pos(EntityAdmin& a, entt::entity e) +{ + return a.get(e); +} + // --------------------------------------------------------------------------- // clearMovementIntents // --------------------------------------------------------------------------- @@ -79,65 +97,45 @@ TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to priority 0", "[behavior]") { Fixture f; - const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - - // Manually write a non-zero intent. - f.ships.forEach([](Ship& s) { - s.intent = MovementIntent{3, QVector2D(10.0f, 0.0f)}; - }); + const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + f.admin.get(e) = MovementIntent{3, QVector2D(10.0f, 0.0f)}; f.ships.clearMovementIntents(); - const Ship* s = f.ships.findShip(id); - REQUIRE(s != nullptr); - REQUIRE(s->intent.priority == 0); + REQUIRE(intent(f.admin, e).priority == 0); } // --------------------------------------------------------------------------- // 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", "[behavior]") { 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 QVector2D target(100.0f, 0.0f); + const float speed = f.admin.get(e).maxSpeedPerTick; + f.admin.get(e) = MovementIntent{1, QVector2D(100.0f, 0.0f)}; + f.movement.tick(f.admin); - f.ships.forEach([&target](Ship& s) { - s.intent = MovementIntent{1, target}; - }); - 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)); + REQUIRE(pos(f.admin, e).value.x() == Approx(speed)); + REQUIRE(pos(f.admin, e).value.y() == Approx(0.0f)); } -// With very high maneuvering thrust the stopping distance is ~0, so desiredSpeed -// still exceeds maxSpeedPerTick and the snap-to-target branch fires. TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoot", "[behavior]") { 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.ships.findShip(id)->maxSpeedPerTick; + const float speed = f.admin.get(e).maxSpeedPerTick; const QVector2D target(speed * 0.5f, 0.0f); + f.admin.get(e) = MovementIntent{1, target}; + f.movement.tick(f.admin); - f.ships.forEach([&target](Ship& s) { - s.intent = MovementIntent{1, target}; - }); - 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())); + REQUIRE(pos(f.admin, e).value.x() == Approx(target.x())); + REQUIRE(pos(f.admin, e).value.y() == Approx(target.y())); } // --------------------------------------------------------------------------- @@ -148,63 +146,49 @@ TEST_CASE("BehaviorSystem: tickHomeReturn does nothing when HP is above threshol "[behavior]") { Fixture f; - const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - - f.ships.forEach([](Ship& s) { - s.homeReturn = HomeReturn{0.3f, QVector2D(-10.0f, 0.0f)}; - s.hp = s.maxHp; // full HP — above threshold - }); + const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + f.admin.addComponent(e, HomeReturn{0.3f, QVector2D(-10.0f, 0.0f)}); + f.admin.get(e).hp = f.admin.get(e).maxHp; // full HP f.ships.clearMovementIntents(); - f.ai.tickHomeReturn(f.ships); + f.ai.tickHomeReturn(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", "[behavior]") { 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); - - f.ships.forEach([&homePos](Ship& s) { - s.homeReturn = HomeReturn{0.5f, homePos}; - s.hp = s.maxHp * 0.2f; // below 50% threshold - }); + f.admin.addComponent(e, HomeReturn{0.5f, homePos}); + f.admin.get(e).hp = f.admin.get(e).maxHp * 0.2f; // below threshold f.ships.clearMovementIntents(); - f.ai.tickHomeReturn(f.ships); + f.ai.tickHomeReturn(f.admin); - const Ship* s = f.ships.findShip(id); - REQUIRE(s->intent.priority == 4); - REQUIRE(s->intent.target.x() == Approx(homePos.x())); + REQUIRE(intent(f.admin, e).priority == 4); + REQUIRE(intent(f.admin, e).target.x() == Approx(homePos.x())); } TEST_CASE("BehaviorSystem: tickHomeReturn priority-4 beats tickThreatResponse priority-3", "[behavior]") { Fixture f; - // Player ship with both homeReturn (low HP) and an enemy in range. - const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f), /*isEnemy=*/true); const QVector2D homePos(-50.0f, 0.0f); - f.ships.forEach([&homePos, playerId](Ship& s) { - if (s.id == playerId) - { - s.homeReturn = HomeReturn{0.5f, homePos}; - s.hp = s.maxHp * 0.1f; - } - }); + f.admin.addComponent(player, HomeReturn{0.5f, homePos}); + f.admin.get(player).hp = f.admin.get(player).maxHp * 0.1f; f.ships.clearMovementIntents(); - f.ai.tickHomeReturn(f.ships); - f.ai.tickThreatResponse(f.ships, f.buildings); + f.ai.tickHomeReturn(f.admin); + f.ai.tickThreatResponse(f.admin, f.buildings); - const Ship* s = f.ships.findShip(playerId); - REQUIRE(s->intent.priority == 4); - REQUIRE(s->intent.target.x() == Approx(homePos.x())); + REQUIRE(intent(f.admin, player).priority == 4); + REQUIRE(intent(f.admin, player).target.x() == Approx(homePos.x())); } // --------------------------------------------------------------------------- @@ -215,48 +199,44 @@ TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in ran "[behavior]") { Fixture f; - const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - // Spawn enemy within attack range (150 tile units). - const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f), - /*isEnemy=*/true); + const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f), + /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ai.tickThreatResponse(f.ships, f.buildings); + f.ai.tickThreatResponse(f.admin, f.buildings); - const Ship* player = f.ships.findShip(playerId); - REQUIRE(player->threatResponse.has_value()); - REQUIRE(player->threatResponse->currentTarget.has_value()); - REQUIRE(*player->threatResponse->currentTarget == enemyId); + REQUIRE(f.admin.hasAll(player)); + const ThreatResponse& tr = f.admin.get(player); + REQUIRE(tr.currentTarget.has_value()); + REQUIRE(*tr.currentTarget == enemy); } TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships", "[behavior]") { Fixture f; - const EntityId id1 = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player (isEnemy=false) + 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 f.ships.clearMovementIntents(); - f.ai.tickThreatResponse(f.ships, f.buildings); + f.ai.tickThreatResponse(f.admin, f.buildings); - const Ship* s = f.ships.findShip(id1); - REQUIRE(s->threatResponse.has_value()); - REQUIRE_FALSE(s->threatResponse->currentTarget.has_value()); + REQUIRE(f.admin.hasAll(e1)); + REQUIRE_FALSE(f.admin.get(e1).currentTarget.has_value()); } TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range", "[behavior]") { Fixture f; - const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - // Place enemy far beyond engagement range (150 tile units). + const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); f.ships.spawn("interceptor", 1, QVector2D(500.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ai.tickThreatResponse(f.ships, f.buildings); + f.ai.tickThreatResponse(f.admin, f.buildings); - const Ship* s = f.ships.findShip(playerId); - REQUIRE_FALSE(s->threatResponse->currentTarget.has_value()); + REQUIRE_FALSE(f.admin.get(player).currentTarget.has_value()); } // --------------------------------------------------------------------------- @@ -267,32 +247,31 @@ TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range", "[behavior]") { Fixture f; - const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f), - /*isEnemy=*/true); + const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f), + /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ai.tickThreatResponse(f.ships, f.buildings); + f.ai.tickThreatResponse(f.admin, f.buildings); - const Ship* enemy = f.ships.findShip(enemyId); - REQUIRE(enemy->threatResponse.has_value()); - REQUIRE(enemy->threatResponse->currentTarget.has_value()); - REQUIRE(*enemy->threatResponse->currentTarget == playerId); + REQUIRE(f.admin.hasAll(enemy)); + const ThreatResponse& tr = f.admin.get(enemy); + REQUIRE(tr.currentTarget.has_value()); + REQUIRE(*tr.currentTarget == player); } TEST_CASE("BehaviorSystem: enemy ship with no target writes leftward movement intent", "[behavior]") { Fixture f; - const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f), - /*isEnemy=*/true); + const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f), + /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ai.tickThreatResponse(f.ships, f.buildings); + f.ai.tickThreatResponse(f.admin, f.buildings); - const Ship* enemy = f.ships.findShip(enemyId); - REQUIRE(enemy->intent.priority == 3); - REQUIRE(enemy->intent.target.x() < 0.0f); // moving leftward (toward asteroid) + REQUIRE(intent(f.admin, enemy).priority == 3); + REQUIRE(intent(f.admin, enemy).target.x() < 0.0f); } // --------------------------------------------------------------------------- @@ -303,72 +282,51 @@ TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly shi "[behavior]") { Fixture f; - const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); - const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); + const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); - // Damage the friendly ship. - f.ships.forEach([friendlyId](Ship& s) { - if (s.id == friendlyId) - { - s.hp = s.maxHp * 0.5f; - } - }); + f.admin.get(friendly).hp = f.admin.get(friendly).maxHp * 0.5f; 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(repair->intent.priority == 2); - REQUIRE(repair->intent.target.x() == Approx(5.0f)); + REQUIRE(intent(f.admin, repairShip).priority == 2); + REQUIRE(intent(f.admin, repairShip).target.x() == Approx(5.0f)); } TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range", "[behavior]") { Fixture f; - // Repair range = 80 tile units; place ships close together. - const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); - const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f)); + const entt::entity repairShip = 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 float initialHp = f.ships.findShip(friendlyId)->maxHp * 0.5f; - f.ships.forEach([friendlyId, initialHp](Ship& s) { - if (s.id == friendlyId) - { - s.hp = initialHp; - } - }); + const float initialHp = f.admin.get(friendly).maxHp * 0.5f; + f.admin.get(friendly).hp = initialHp; 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. - const Ship* friendly = f.ships.findShip(friendlyId); - REQUIRE(friendly->hp > initialHp); + REQUIRE(health(f.admin, friendly).hp > initialHp); } TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]") { Fixture f; - const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); - const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f)); + 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)); - // Nearly full HP — one repair tick must not exceed maxHp. - f.ships.forEach([friendlyId](Ship& s) { - if (s.id == friendlyId) - { - s.hp = s.maxHp - 0.001f; - } - }); + f.admin.get(friendly).hp = f.admin.get(friendly).maxHp - 0.001f; for (int i = 0; i < 5; ++i) { f.ships.clearMovementIntents(); - f.ai.tickRepairBehavior(f.ships, f.buildings); + f.ai.tickRepairBehavior(f.admin, f.buildings); } - const Ship* friendly = f.ships.findShip(friendlyId); - REQUIRE(friendly->hp <= friendly->maxHp); - REQUIRE(friendly->hp == Approx(friendly->maxHp)); + const Health& h = health(f.admin, friendly); + REQUIRE(h.hp <= h.maxHp); + REQUIRE(h.hp == Approx(h.maxHp)); } // --------------------------------------------------------------------------- @@ -378,51 +336,41 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]") TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[behavior]") { Fixture f; - const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); + 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 Tick farFuture = 100000; - f.scraps.spawn(scrapPos, 1, farFuture); + f.scraps.spawn(scrapPos, 1, 100000); f.ships.clearMovementIntents(); - f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); + f.ai.tickScrapCollector(f.admin, f.scraps, f.buildings); - const Ship* s = f.ships.findShip(shipId); - REQUIRE(s->intent.priority == 1); - REQUIRE(s->intent.target.x() == Approx(scrapPos.x())); + REQUIRE(intent(f.admin, ship).priority == 1); + REQUIRE(intent(f.admin, ship).target.x() == Approx(scrapPos.x())); } TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]") { Fixture f; - // Place scrap exactly at ship position so it is within collectionRange immediately. - const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); - const Tick farFuture = 100000; - const EntityId scrapId = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, farFuture); + const entt::entity ship = 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); f.ships.clearMovementIntents(); - f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); + f.ai.tickScrapCollector(f.admin, f.scraps, f.buildings); - const Ship* s = f.ships.findShip(shipId); - REQUIRE(s->cargo->current == 1); - REQUIRE(f.scraps.findScrap(scrapId) == nullptr); // consumed + REQUIRE(f.admin.get(ship).current == 1); + REQUIRE_FALSE(f.admin.isValid(scrapEntity)); } TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[behavior]") { Fixture f; - // Place a SalvageBay building so the ship has somewhere to deliver. - // The SalvageBay occupies asteroid tiles (x < 0 convention); use negative coords. - // We bypass construction time by ticking until it is operational. const EntityId bayId = f.buildings.place(BuildingType::SalvageBay, QPoint(-4, 0), Rotation::East, 0); - Tick tick = 0; - // SalvageBay construction_time_seconds = 15 → 450 ticks; run 500 to be safe. + Tick t = 0; for (int i = 0; i < 500; ++i) { - f.buildings.tickConstruction(tick++); + f.buildings.tickConstruction(t++); if (f.buildings.findBuilding(bayId) != nullptr) { break; @@ -430,22 +378,16 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b } REQUIRE(f.buildings.findBuilding(bayId) != nullptr); - // Spawn salvage ship and fill its cargo. - const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f)); - f.ships.forEach([](Ship& s) { - if (s.cargo) - { - s.cargo->current = s.cargo->capacity; // full cargo - } - }); + const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f)); + SalvageCargo& cargo = f.admin.get(ship); + cargo.current = cargo.capacity; // full cargo f.ships.clearMovementIntents(); - f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); + f.ai.tickScrapCollector(f.admin, f.scraps, f.buildings); - // Intent should point toward the bay (x < 0 area), not rightward. - const Ship* s = f.ships.findShip(shipId); - REQUIRE(s->intent.priority == 1); - REQUIRE(s->intent.target.x() < s->position.x()); + const MovementIntent& i = intent(f.admin, ship); + REQUIRE(i.priority == 1); + REQUIRE(i.target.x() < pos(f.admin, ship).value.x()); } // --------------------------------------------------------------------------- @@ -455,9 +397,8 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn", "[sensor]") { Fixture f; - // interceptor sensor_range_formula = "200" (test config); verify at level 1. - const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - REQUIRE(f.ships.findShip(id)->sensorRange == Approx(200.0f)); + const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + REQUIRE(f.admin.get(e).value == Approx(200.0f)); } // --------------------------------------------------------------------------- @@ -467,45 +408,39 @@ TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn", TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]") { Fixture f; - // interceptor sensor_range = 200 (test config); enemy at 190 tiles. - const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f), - /*isEnemy=*/true); + const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f), + /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ai.tickThreatResponse(f.ships, f.buildings); + f.ai.tickThreatResponse(f.admin, f.buildings); - const Ship* player = f.ships.findShip(playerId); - REQUIRE(player->threatResponse->currentTarget == enemyId); + REQUIRE(f.admin.get(player).currentTarget == enemy); } TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]") { Fixture f; - // interceptor sensor_range = 200 (test config); enemy at 210 tiles. - 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)); f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ai.tickThreatResponse(f.ships, f.buildings); + f.ai.tickThreatResponse(f.admin, f.buildings); - const Ship* player = f.ships.findShip(playerId); - REQUIRE_FALSE(player->threatResponse->currentTarget.has_value()); + REQUIRE_FALSE(f.admin.get(player).currentTarget.has_value()); } TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]") { Fixture f; - // interceptor sensor_range = 200 (test config); player at 210 tiles from enemy. f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), - /*isEnemy=*/true); + const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), + /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ai.tickThreatResponse(f.ships, f.buildings); + f.ai.tickThreatResponse(f.admin, f.buildings); - const Ship* enemy = f.ships.findShip(enemyId); - REQUIRE_FALSE(enemy->threatResponse->currentTarget.has_value()); + REQUIRE_FALSE(f.admin.get(enemy).currentTarget.has_value()); } // --------------------------------------------------------------------------- @@ -515,47 +450,39 @@ TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[ TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]") { Fixture f; - // repair_ship sensor_range = 250; enemy at 200 tiles. - 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)); f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true); 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(repair->intent.priority == 2); - REQUIRE(repair->intent.target.x() < 0.0f); // retreating leftward + REQUIRE(intent(f.admin, repairShip).priority == 2); + REQUIRE(intent(f.admin, repairShip).target.x() < 0.0f); } TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]") { Fixture f; - // repair_ship sensor_range = 250; enemy at 300 tiles. - 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)); f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true); 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. - const Ship* repair = f.ships.findShip(repairId); - REQUIRE(repair->intent.target.x() > repair->position.x()); + REQUIRE(intent(f.admin, repairShip).target.x() > pos(f.admin, repairShip).value.x()); } TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor range", "[sensor]") { Fixture f; - // repair_ship sensor_range = 250; damaged friendly at 300 tiles. - const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); - const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f)); - f.ships.forEach([friendlyId](Ship& s) { - if (s.id == friendlyId) { s.hp = s.maxHp * 0.5f; } - }); + const entt::entity repairShip = 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)); + f.admin.get(friendly).hp = f.admin.get(friendly).maxHp * 0.5f; 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(repairShip).currentTarget.has_value()); } // --------------------------------------------------------------------------- @@ -565,14 +492,12 @@ TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]") { Fixture f; - // salvage_ship sensor_range = 250; scrap at 300 tiles. - 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)); f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000); f.ships.clearMovementIntents(); - f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); + f.ai.tickScrapCollector(f.admin, f.scraps, f.buildings); - const Ship* s = f.ships.findShip(shipId); - REQUIRE(s->scrapCollector->scrapTarget == std::nullopt); - REQUIRE(s->intent.target.x() > s->position.x()); // patrolling rightward + REQUIRE_FALSE(f.admin.get(ship).scrapTarget.has_value()); + REQUIRE(intent(f.admin, ship).target.x() > pos(f.admin, ship).value.x()); } diff --git a/src/test/CombatSystemTest.cpp b/src/test/CombatSystemTest.cpp index 2ff7bd2..8974027 100644 --- a/src/test/CombatSystemTest.cpp +++ b/src/test/CombatSystemTest.cpp @@ -8,6 +8,8 @@ #include "BuildingType.h" #include "CombatSystem.h" #include "ConfigLoader.h" +#include "EcsComponents.h" +#include "EntityAdmin.h" #include "FireEvent.h" #include "ScrapSystem.h" #include "Ship.h" @@ -20,7 +22,6 @@ static GameConfig loadConfig() return ConfigLoader::loadFromDirectory(CONFIG_DIR); } -// Find the first ShipDef with a combat component. static const ShipDef* findCombatShip(const GameConfig& cfg) { for (const ShipDef& def : cfg.ships.ships) @@ -33,159 +34,104 @@ static const ShipDef* findCombatShip(const GameConfig& cfg) 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; + EntityId nextBldId; + BeltSystem belts; + ShipSystem ships; + BuildingSystem buildings; + CombatSystem combat; + + explicit CombatFixture() + : cfg(loadConfig()) + , rng(42) + , nextBldId(1) + , belts(cfg.world.beltSpeedTilesPerSecond) + , ships(cfg, admin) + , buildings(cfg, belts, + [this]() { return nextBldId++; }, + [](int){}, + [](const std::string&, QVector2D, const std::optional&) {}, + rng) + , combat(cfg) + { + } + + void wireEnemyTarget(entt::entity enemy, entt::entity playerTarget) + { + if (admin.hasAll(enemy)) + { + admin.get(enemy).currentTarget = playerTarget; + admin.get(enemy).cooldownTicks = 0.0f; + } + if (admin.hasAll(enemy)) + { + admin.get(enemy).currentTarget = playerTarget; + } + } +}; + // --------------------------------------------------------------------------- // Ship weapon firing // --------------------------------------------------------------------------- TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[combat]") { - const GameConfig cfg = loadConfig(); - std::mt19937 rng(42); - - const ShipDef* combatDef = findCombatShip(cfg); + CombatFixture f; + const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); - BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); - EntityId nextShipId = 1; - EntityId nextBldId = 100; - ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; }); - BuildingSystem buildings(cfg, belts, - [&nextBldId]() { return nextBldId++; }, - [](int){}, - [](const std::string&, QVector2D, const std::optional&) {}, - rng); + const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); + const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); + f.wireEnemyTarget(enemy, player); - // Spawn an enemy combat ship close to the player side. - const EntityId enemyId = ships.spawn(combatDef->id, 1, - QVector2D(5.0f, 5.0f), /*isEnemy=*/true); + const float hpBefore = f.admin.get(player).hp; - // 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 events; - combat.tick(0, ships, buildings, events); - combat.applyPendingDamage(5, ships, buildings); + f.combat.tick(0, f.admin, f.buildings, events); + f.combat.applyPendingDamage(5, f.admin); - float hpAfter = 0.0f; - for (const Ship& s : ships.allShips()) - { - if (s.id == playerId) { hpAfter = s.hp; } - } - - REQUIRE(hpAfter < hpBefore); + REQUIRE(f.admin.get(player).hp < hpBefore); REQUIRE(events.size() >= 1); } TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]") { - const GameConfig cfg = loadConfig(); - std::mt19937 rng(42); - - const ShipDef* combatDef = findCombatShip(cfg); + CombatFixture f; + const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); - BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); - EntityId nextShipId = 1; - EntityId nextBldId = 100; - ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; }); - BuildingSystem buildings(cfg, belts, - [&nextBldId]() { return nextBldId++; }, - [](int){}, - [](const std::string&, QVector2D, const std::optional&) {}, - rng); + const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); + const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); - 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); + f.wireEnemyTarget(enemy, player); + f.admin.get(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 events; - - // Ticks 0 and 1: cooldown still > 0 after decrement → no fire. - combat.tick(0, ships, buildings, events); - combat.tick(1, ships, buildings, events); - + f.combat.tick(0, f.admin, f.buildings, events); + f.combat.tick(1, f.admin, f.buildings, events); REQUIRE(events.empty()); - // Tick 2: cooldown reaches 0 → fires. - combat.tick(2, ships, buildings, events); + f.combat.tick(2, f.admin, f.buildings, events); REQUIRE(events.size() == 1); } TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]") { - const GameConfig cfg = loadConfig(); - std::mt19937 rng(42); - - const ShipDef* combatDef = findCombatShip(cfg); + CombatFixture f; + const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); - BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); - EntityId nextShipId = 1; - EntityId nextBldId = 100; - ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; }); - BuildingSystem buildings(cfg, belts, - [&nextBldId]() { return nextBldId++; }, - [](int){}, - [](const std::string&, QVector2D, const std::optional&) {}, - rng); + const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(0.0f, 0.0f), true); + const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false); + f.wireEnemyTarget(enemy, player); - 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 events; - combat.tick(0, ships, buildings, events); - + f.combat.tick(0, f.admin, f.buildings, events); REQUIRE(events.empty()); } @@ -197,49 +143,37 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat] { Simulation sim(loadConfig(), 42); - // Find the player defence station. - EntityId stationId = kInvalidEntityId; - 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. + // Find the player station entity via ECS. + entt::entity stationEntity = entt::null; QVector2D stationCenter; - for (const Building& b : sim.buildings().allBuildings()) - { - if (b.id == stationId) + sim.admin().forEach( + [&](entt::entity e, const StationBody& sb, const Faction& f) { - stationCenter = QVector2D( - b.anchor.x() + b.footprint.width() / 2.0f, - b.anchor.y() + b.footprint.height() / 2.0f); - break; - } - } + if (!f.isEnemy && stationEntity == entt::null) + { + stationEntity = e; + 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()); REQUIRE(combatDef != nullptr); - const EntityId enemyId = sim.ships().spawn( + const entt::entity enemyShip = sim.ships().spawn( combatDef->id, 1, QVector2D(stationCenter.x() + 1.0f, stationCenter.y()), /*isEnemy=*/true); - // Tick to let station auto-acquire and fire. sim.tick(); - // Check that a fire event was emitted with stationId as shooter. const std::vector events = sim.drainFireEvents(); 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); } @@ -248,26 +182,24 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat] { Simulation sim(loadConfig(), 42); - // Find the enemy defence station. - EntityId stationId = kInvalidEntityId; + entt::entity stationEntity = entt::null; QVector2D stationCenter; - for (const Building& b : sim.buildings().allBuildings()) - { - if (b.type == BuildingType::EnemyDefenceStation) + sim.admin().forEach( + [&](entt::entity e, const StationBody& sb, const Faction& f) { - stationId = b.id; - stationCenter = QVector2D( - b.anchor.x() + b.footprint.width() / 2.0f, - b.anchor.y() + b.footprint.height() / 2.0f); - break; - } - } - REQUIRE(stationId != kInvalidEntityId); + if (f.isEnemy && stationEntity == entt::null) + { + stationEntity = e; + stationCenter = QVector2D( + sb.anchor.x() + sb.footprint.width() / 2.0f, + sb.anchor.y() + sb.footprint.height() / 2.0f); + } + }); + REQUIRE(sim.admin().isValid(stationEntity)); const ShipDef* combatDef = findCombatShip(sim.config()); REQUIRE(combatDef != nullptr); - // Spawn a player ship right next to the enemy station. sim.ships().spawn( combatDef->id, 1, 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 events = sim.drainFireEvents(); 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); } @@ -288,25 +220,25 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat] { Simulation sim(loadConfig(), 42); - EntityId stationId = kInvalidEntityId; + entt::entity stationEntity = entt::null; QVector2D stationCenter; - for (const Building& b : sim.buildings().allBuildings()) - { - if (b.type == BuildingType::EnemyDefenceStation) + sim.admin().forEach( + [&](entt::entity e, const StationBody& sb, const Faction& f) { - stationId = b.id; - stationCenter = QVector2D( - b.anchor.x() + b.footprint.width() / 2.0f, - b.anchor.y() + b.footprint.height() / 2.0f); - break; - } - } - REQUIRE(stationId != kInvalidEntityId); + if (f.isEnemy && stationEntity == entt::null) + { + stationEntity = e; + stationCenter = QVector2D( + sb.anchor.x() + sb.footprint.width() / 2.0f, + sb.anchor.y() + sb.footprint.height() / 2.0f); + } + }); + REQUIRE(sim.admin().isValid(stationEntity)); const ShipDef* combatDef = findCombatShip(sim.config()); REQUIRE(combatDef != nullptr); - const EntityId playerId = sim.ships().spawn( + const entt::entity playerShip = sim.ships().spawn( combatDef->id, 1, QVector2D(stationCenter.x() - 1.0f, stationCenter.y()), /*isEnemy=*/false); @@ -315,9 +247,9 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat] const std::vector events = sim.drainFireEvents(); 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; } @@ -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]") { - const GameConfig cfg = loadConfig(); - std::mt19937 rng(42); - - const ShipDef* combatDef = findCombatShip(cfg); + CombatFixture f; + const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); - BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); - EntityId nextShipId = 1; - EntityId nextBldId = 100; - ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; }); - BuildingSystem buildings(cfg, belts, - [&nextBldId]() { return nextBldId++; }, - [](int){}, - [](const std::string&, QVector2D, const std::optional&) {}, - rng); + const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); + const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); + f.wireEnemyTarget(enemy, player); - 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); + const float hpBefore = f.admin.get(player).hp; - 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 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) { - combat.applyPendingDamage(t, ships, buildings); - float hp = 0.0f; - for (const Ship& s : ships.allShips()) - { - if (s.id == playerId) { hp = s.hp; } - } - REQUIRE(hp == Approx(hpBefore)); + f.combat.applyPendingDamage(t, f.admin); + REQUIRE(f.admin.get(player).hp == Approx(hpBefore)); } } TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]") { - const GameConfig cfg = loadConfig(); - std::mt19937 rng(42); - - const ShipDef* combatDef = findCombatShip(cfg); + CombatFixture f; + const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); - BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); - EntityId nextShipId = 1; - EntityId nextBldId = 100; - ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; }); - BuildingSystem buildings(cfg, belts, - [&nextBldId]() { return nextBldId++; }, - [](int){}, - [](const std::string&, QVector2D, const std::optional&) {}, - rng); + const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); + const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); + f.wireEnemyTarget(enemy, player); - 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); + const float hpBefore = f.admin.get(player).hp; - 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 events; - combat.tick(0, ships, buildings, events); - combat.applyPendingDamage(5, ships, buildings); + f.combat.tick(0, f.admin, f.buildings, events); + f.combat.applyPendingDamage(5, f.admin); - float hpAfter = 0.0f; - for (const Ship& s : ships.allShips()) - { - if (s.id == playerId) { hpAfter = s.hp; } - } - - REQUIRE(hpAfter < hpBefore); + REQUIRE(f.admin.get(player).hp < hpBefore); } TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[combat]") { - const GameConfig cfg = loadConfig(); - std::mt19937 rng(42); - - const ShipDef* combatDef = findCombatShip(cfg); + CombatFixture f; + const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); - BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); - EntityId nextShipId = 1; - EntityId nextBldId = 100; - ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; }); - BuildingSystem buildings(cfg, belts, - [&nextBldId]() { return nextBldId++; }, - [](int){}, - [](const std::string&, QVector2D, const std::optional&) {}, - rng); + const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); + const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); + f.wireEnemyTarget(enemy, player); - 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 events; - combat.tick(0, ships, buildings, events); + f.combat.tick(0, f.admin, f.buildings, events); - // Target is removed before impact. - ships.despawn(playerId); + f.ships.despawn(player); - // Should not crash; damage is silently dropped. - combat.applyPendingDamage(5, ships, buildings); + // Should not crash. + 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]") { - const GameConfig cfg = loadConfig(); - std::mt19937 rng(42); - - const ShipDef* combatDef = findCombatShip(cfg); + CombatFixture f; + const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); - BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); - EntityId nextShipId = 1; - EntityId nextBldId = 100; - ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; }); - BuildingSystem buildings(cfg, belts, - [&nextBldId]() { return nextBldId++; }, - [](int){}, - [](const std::string&, QVector2D, const std::optional&) {}, - rng); + const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); + const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); + f.wireEnemyTarget(enemy, player); - 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); + const float hpBefore = f.admin.get(player).hp; - 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 events; - combat.tick(0, ships, buildings, events); + f.combat.tick(0, f.admin, f.buildings, events); - // Shooter is removed before impact. - ships.despawn(enemyId); + f.ships.despawn(enemy); - // Damage must still land on the target. - combat.applyPendingDamage(5, ships, buildings); + f.combat.applyPendingDamage(5, f.admin); - float hpAfter = 0.0f; - for (const Ship& s : ships.allShips()) - { - if (s.id == playerId) { hpAfter = s.hp; } - } - - REQUIRE(hpAfter < hpBefore); + REQUIRE(f.admin.get(player).hp < hpBefore); } // --------------------------------------------------------------------------- @@ -557,22 +356,20 @@ TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]") const ShipDef* combatDef = findCombatShip(sim.config()); 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)); - // Set hp to lethal. - sim.ships().damageShip(shipId, 9999.0f); + sim.admin().get(ship).hp = -1.0f; sim.tick(); - REQUIRE(sim.ships().findShip(shipId) == nullptr); + REQUIRE_FALSE(sim.admin().isValid(ship)); } TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]") { Simulation sim(loadConfig(), 42); - // Find a ship def that drops scrap. const ShipDef* droppingDef = nullptr; 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); - const EntityId shipId = sim.ships().spawn(droppingDef->id, 1, + const entt::entity ship = sim.ships().spawn(droppingDef->id, 1, QVector2D(10.0f, 10.0f)); - sim.ships().damageShip(shipId, 9999.0f); + sim.admin().get(ship).hp = -1.0f; sim.tick(); - // At least one scrap entity should now exist. - REQUIRE(!sim.scraps().allScraps().empty()); + REQUIRE(!sim.scraps().allScrapInfo().empty()); } TEST_CASE("CombatSystem: HQ death sets game over", "[combat]") { Simulation sim(loadConfig(), 42); - sim.buildings().forEachBuilding([](Building& b) - { - if (b.type == BuildingType::Hq) + // Damage the HQ proxy entity (has HqProxy + Health). + sim.admin().forEach( + [](entt::entity /*e*/, const HqProxy& /*hq*/, Health& h) { - b.hp = -1.0f; - } - }); + h.hp = -1.0f; + }); sim.tick(); diff --git a/src/test/ScrapTest.cpp b/src/test/ScrapTest.cpp index d7c5a3f..8f4b3a3 100644 --- a/src/test/ScrapTest.cpp +++ b/src/test/ScrapTest.cpp @@ -2,25 +2,23 @@ #include -#include "EntityId.h" -#include "Scrap.h" +#include "EntityAdmin.h" #include "ScrapSystem.h" // --------------------------------------------------------------------------- // 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; - ScrapSystem ss([&nextId]() { return nextId++; }); + EntityAdmin admin; + ScrapSystem ss(admin); - const EntityId id = ss.spawn(QVector2D(3.0f, 4.0f), 5, 100); - const Scrap* s = ss.findScrap(id); + const entt::entity e = ss.spawn(QVector2D(3.0f, 4.0f), 5, 100); - REQUIRE(s != nullptr); - REQUIRE(s->amount == 5); - REQUIRE(s->despawnAt == 100); + REQUIRE(admin.isValid(e)); + REQUIRE(admin.get(e).amount == 5); + REQUIRE(admin.get(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]") { - EntityId nextId = 1; - ScrapSystem ss([&nextId]() { return nextId++; }); + EntityAdmin admin; + 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); - REQUIRE(ss.findScrap(id) != nullptr); + REQUIRE(admin.isValid(e)); } TEST_CASE("ScrapSystem: scrap removed at despawnAt tick", "[scrap]") { - EntityId nextId = 1; - ScrapSystem ss([&nextId]() { return nextId++; }); + EntityAdmin admin; + 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); - 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]") { - EntityId nextId = 1; - ScrapSystem ss([&nextId]() { return nextId++; }); + EntityAdmin admin; + ScrapSystem ss(admin); - const EntityId earlyId = ss.spawn(QVector2D(0.0f, 0.0f), 1, 30); - const EntityId lateId = ss.spawn(QVector2D(1.0f, 0.0f), 2, 60); + const entt::entity earlyE = ss.spawn(QVector2D(0.0f, 0.0f), 1, 30); + const entt::entity lateE = ss.spawn(QVector2D(1.0f, 0.0f), 2, 60); ss.tickDespawn(30); - REQUIRE(ss.findScrap(earlyId) == nullptr); - REQUIRE(ss.findScrap(lateId) != nullptr); + REQUIRE_FALSE(admin.isValid(earlyE)); + 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; - ScrapSystem ss([&nextId]() { return nextId++; }); + EntityAdmin admin; + ScrapSystem ss(admin); - const EntityId id1 = ss.spawn(QVector2D(0.0f, 0.0f), 1, 100); - const EntityId id2 = ss.spawn(QVector2D(1.0f, 0.0f), 2, 200); + const entt::entity e = ss.spawn(QVector2D(0.0f, 0.0f), 7, 100); - REQUIRE(id2 > id1); + const std::optional 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 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 info = ss.allScrapInfo(); + REQUIRE(info.size() == 2); } diff --git a/src/test/ShipModuleTest.cpp b/src/test/ShipModuleTest.cpp index b266098..8e36174 100644 --- a/src/test/ShipModuleTest.cpp +++ b/src/test/ShipModuleTest.cpp @@ -4,6 +4,8 @@ #include "BuildingSystem.h" #include "BuildingType.h" #include "ConfigLoader.h" +#include "EcsComponents.h" +#include "EntityAdmin.h" #include "GameConfig.h" #include "ItemType.h" #include "ModulesConfig.h" @@ -96,13 +98,12 @@ TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]") const double x = static_cast(def->schematic.playerProductionLevel); const float expectedHp = static_cast(def->health.hpFormula.evaluate(x)); - const EntityId id = sim.ships().spawn("interceptor", + const entt::entity e = sim.ships().spawn("interceptor", def->schematic.playerProductionLevel, QVector2D(5.0f, 5.0f), false, std::nullopt); - const Ship* ship = sim.ships().findShip(id); - REQUIRE(ship != nullptr); - CHECK(ship->maxHp == Approx(expectedHp)); + REQUIRE(sim.admin().isValid(e)); + CHECK(sim.admin().get(e).maxHp == Approx(expectedHp)); } TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]") @@ -121,16 +122,15 @@ TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]") pm.rotation = Rotation::East; layout.placedModules.push_back(pm); - const EntityId id = sim.ships().spawn("interceptor", + const entt::entity e = sim.ships().spawn("interceptor", def->schematic.playerProductionLevel, QVector2D(5.0f, 5.0f), false, layout); - const Ship* ship = sim.ships().findShip(id); - REQUIRE(ship != nullptr); + REQUIRE(sim.admin().isValid(e)); // armor_plate has multiplied_hp_formula = "1.5" // final = base * (1 + (1.5 - 1)) + 0 = base * 1.5 - CHECK(ship->maxHp == Approx(baseHp * 1.5f)); - CHECK(ship->hp == ship->maxHp); + CHECK(sim.admin().get(e).maxHp == Approx(baseHp * 1.5f)); + CHECK(sim.admin().get(e).hp == sim.admin().get(e).maxHp); } TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]") @@ -149,15 +149,14 @@ TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]") pm.rotation = Rotation::East; layout.placedModules.push_back(pm); - const EntityId id = sim.ships().spawn("interceptor", + const entt::entity e = sim.ships().spawn("interceptor", def->schematic.playerProductionLevel, QVector2D(5.0f, 5.0f), false, layout); - const Ship* ship = sim.ships().findShip(id); - REQUIRE(ship != nullptr); + REQUIRE(sim.admin().isValid(e)); // sensor_booster has added_sensor_range_formula = "10" // final = base * 1.0 + 10 = base + 10 - CHECK(ship->sensorRange == Approx(baseRange + 10.0f)); + CHECK(sim.admin().get(e).value == Approx(baseRange + 10.0f)); } TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]") @@ -179,16 +178,15 @@ TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]") layout.placedModules.push_back(pm); } - const EntityId id = sim.ships().spawn("interceptor", + const entt::entity e = sim.ships().spawn("interceptor", def->schematic.playerProductionLevel, QVector2D(5.0f, 5.0f), false, layout); - const Ship* ship = sim.ships().findShip(id); - REQUIRE(ship != nullptr); + REQUIRE(sim.admin().isValid(e)); // Two armor_plates: each 1.5 multiplier // total_mult = 1 + (1.5 - 1) + (1.5 - 1) = 2.0 // final = base * 2.0 - CHECK(ship->maxHp == Approx(baseHp * 2.0f)); + CHECK(sim.admin().get(e).maxHp == Approx(baseHp * 2.0f)); } // --------------------------------------------------------------------------- diff --git a/src/test/ShipTest.cpp b/src/test/ShipTest.cpp index d4668a4..2b7dfd4 100644 --- a/src/test/ShipTest.cpp +++ b/src/test/ShipTest.cpp @@ -1,13 +1,13 @@ #include "catch.hpp" #include -#include #include -#include #include #include "ConfigLoader.h" +#include "EcsComponents.h" +#include "EntityAdmin.h" #include "EntityId.h" #include "Ship.h" #include "ShipSystem.h" @@ -25,70 +25,65 @@ static GameConfig loadConfig() TEST_CASE("ShipSystem: interceptor spawn has weapon and threatResponse, no cargo or repair", "[ship]") { + EntityAdmin admin; const GameConfig cfg = loadConfig(); - EntityId nextId = 1; - ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + ShipSystem ss(cfg, admin); - const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - const Ship* ship = ss.findShip(id); + const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - REQUIRE(ship != nullptr); - REQUIRE(ship->weapon.has_value()); - REQUIRE(ship->threatResponse.has_value()); - REQUIRE_FALSE(ship->cargo.has_value()); - REQUIRE_FALSE(ship->repairTool.has_value()); - REQUIRE_FALSE(ship->repairBehavior.has_value()); - REQUIRE_FALSE(ship->scrapCollector.has_value()); + REQUIRE(admin.isValid(e)); + REQUIRE(admin.hasAll(e)); + REQUIRE(admin.hasAll(e)); + REQUIRE_FALSE(admin.hasAll(e)); + REQUIRE_FALSE(admin.hasAll(e)); + REQUIRE_FALSE(admin.hasAll(e)); + REQUIRE_FALSE(admin.hasAll(e)); } TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]") { + EntityAdmin admin; const GameConfig cfg = loadConfig(); - EntityId nextId = 1; - ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + ShipSystem ss(cfg, admin); - const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - const Ship* ship = ss.findShip(id); + const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - REQUIRE(ship != nullptr); // hp_formula = "40 + 5*x" at x=1 → 45 - REQUIRE(ship->maxHp == Approx(45.0f)); - REQUIRE(ship->hp == Approx(45.0f)); + REQUIRE(admin.get(e).maxHp == Approx(45.0f)); + REQUIRE(admin.get(e).hp == Approx(45.0f)); // damage_formula = "10 + 2*x" at x=1 → 12 - REQUIRE(ship->weapon->damage == Approx(12.0f)); + REQUIRE(admin.get(e).damage == Approx(12.0f)); // attack_range_formula = "150" - REQUIRE(ship->weapon->range == Approx(150.0f)); + REQUIRE(admin.get(e).range == Approx(150.0f)); // sensor_range_formula = "200" - REQUIRE(ship->sensorRange == Approx(200.0f)); + REQUIRE(admin.get(e).value == Approx(200.0f)); // cooldownTicks starts at 0 - REQUIRE(ship->weapon->cooldownTicks == Approx(0.0f)); + REQUIRE(admin.get(e).cooldownTicks == Approx(0.0f)); } TEST_CASE("ShipSystem: interceptor level 5 hp matches formula", "[ship]") { + EntityAdmin admin; const GameConfig cfg = loadConfig(); - EntityId nextId = 1; - ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + ShipSystem ss(cfg, admin); - const EntityId id = ss.spawn("interceptor", 5, QVector2D(0.0f, 0.0f)); - const Ship* ship = ss.findShip(id); + const entt::entity e = ss.spawn("interceptor", 5, QVector2D(0.0f, 0.0f)); // hp_formula = "40 + 5*x" at x=5 → 65 - REQUIRE(ship->maxHp == Approx(65.0f)); + REQUIRE(admin.get(e).maxHp == Approx(65.0f)); } TEST_CASE("ShipSystem: interceptor level 0 maxSpeedPerTick matches formula / kTickRateHz", "[ship]") { + EntityAdmin admin; const GameConfig cfg = loadConfig(); - EntityId nextId = 1; - ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + ShipSystem ss(cfg, admin); - const EntityId id = ss.spawn("interceptor", 0, QVector2D(0.0f, 0.0f)); - const Ship* ship = ss.findShip(id); + const entt::entity e = ss.spawn("interceptor", 0, QVector2D(0.0f, 0.0f)); // speed_formula = "200 + 5*x" at x=0 → 200; maxSpeedPerTick = 200/30 const float expected = 200.0f / static_cast(kTickRateHz); - REQUIRE(ship->maxSpeedPerTick == Approx(expected)); + REQUIRE(admin.get(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", "[ship]") { + EntityAdmin admin; const GameConfig cfg = loadConfig(); - EntityId nextId = 1; - ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + ShipSystem ss(cfg, admin); - const EntityId id = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); - const Ship* ship = ss.findShip(id); + const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); - REQUIRE(ship != nullptr); - REQUIRE(ship->cargo.has_value()); - REQUIRE(ship->scrapCollector.has_value()); - REQUIRE_FALSE(ship->weapon.has_value()); - REQUIRE_FALSE(ship->repairTool.has_value()); + REQUIRE(admin.hasAll(e)); + REQUIRE(admin.hasAll(e)); + REQUIRE_FALSE(admin.hasAll(e)); + REQUIRE_FALSE(admin.hasAll(e)); } TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]") { + EntityAdmin admin; const GameConfig cfg = loadConfig(); - EntityId nextId = 1; - ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + ShipSystem ss(cfg, admin); - const EntityId id = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); - const Ship* ship = ss.findShip(id); + const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); // cargo_capacity = 10 - REQUIRE(ship->cargo->capacity == 10); - REQUIRE(ship->cargo->current == 0); - REQUIRE(ship->scrapCollector->deliveryBay == kInvalidEntityId); - REQUIRE_FALSE(ship->scrapCollector->scrapTarget.has_value()); + REQUIRE(admin.get(e).capacity == 10); + REQUIRE(admin.get(e).current == 0); + REQUIRE(admin.get(e).deliveryBay == kInvalidEntityId); + REQUIRE_FALSE(admin.get(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", "[ship]") { + EntityAdmin admin; const GameConfig cfg = loadConfig(); - EntityId nextId = 1; - ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + ShipSystem ss(cfg, admin); - const EntityId id = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); - const Ship* ship = ss.findShip(id); + const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); - REQUIRE(ship != nullptr); - REQUIRE(ship->repairTool.has_value()); - REQUIRE(ship->repairBehavior.has_value()); - REQUIRE_FALSE(ship->weapon.has_value()); - REQUIRE_FALSE(ship->cargo.has_value()); + REQUIRE(admin.hasAll(e)); + REQUIRE(admin.hasAll(e)); + REQUIRE_FALSE(admin.hasAll(e)); + REQUIRE_FALSE(admin.hasAll(e)); } TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas", "[ship]") { + EntityAdmin admin; const GameConfig cfg = loadConfig(); - EntityId nextId = 1; - ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + ShipSystem ss(cfg, admin); - const EntityId id = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); - const Ship* ship = ss.findShip(id); + const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); // repair_rate_formula = "5 + x" at x=1 → 6 - REQUIRE(ship->repairTool->ratePerTick == Approx(6.0f)); + REQUIRE(admin.get(e).ratePerTick == Approx(6.0f)); // repair_range_formula = "80" - REQUIRE(ship->repairTool->range == Approx(80.0f)); + REQUIRE(admin.get(e).range == Approx(80.0f)); } // --------------------------------------------------------------------------- // 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(); - EntityId nextId = 1; - ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + ShipSystem ss(cfg, admin); - const EntityId id1 = 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 e1 = ss.spawn("interceptor", 1, QVector2D(0.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]") { + EntityAdmin admin; const GameConfig cfg = loadConfig(); - EntityId nextId = 1; - ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + ShipSystem ss(cfg, admin); - const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - REQUIRE(ss.findShip(id) != nullptr); + const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + REQUIRE(admin.isValid(e)); - ss.despawn(id); - REQUIRE(ss.findShip(id) == nullptr); + ss.despawn(e); + REQUIRE_FALSE(admin.isValid(e)); } - diff --git a/src/test/ShipyardTest.cpp b/src/test/ShipyardTest.cpp index e7d5db7..6e82425 100644 --- a/src/test/ShipyardTest.cpp +++ b/src/test/ShipyardTest.cpp @@ -4,6 +4,8 @@ #include "BuildingSystem.h" #include "BuildingType.h" #include "ConfigLoader.h" +#include "EcsComponents.h" +#include "EntityAdmin.h" #include "GameConfig.h" #include "ItemType.h" #include "Rotation.h" @@ -51,6 +53,14 @@ static EntityId placeShipyard(Simulation& sim, const BuildingDef& yardDef) 100.0f, 100.0f); } +static int countShips(Simulation& sim) +{ + int n = 0; + sim.admin().forEach( + [&n](entt::entity /*e*/, const ShipIdentity& /*si*/) { ++n; }); + return n; +} + static void fillMaterials(Simulation& sim, EntityId yardId, const ShipDef& def) { sim.buildings().forEachBuilding([&](Building& b) @@ -80,7 +90,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes", const BuildingDef* yardDef = findShipyardDef(sim.config()); REQUIRE(yardDef != nullptr); - const int shipsBefore = static_cast(sim.ships().allShips().size()); + const int shipsBefore = countShips(sim); const EntityId yardId = placeShipyard(sim, *yardDef); REQUIRE(yardId != kInvalidEntityId); @@ -90,7 +100,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes", // First tick: materials consumed, production cycle starts — no ship yet. sim.tick(); - REQUIRE(static_cast(sim.ships().allShips().size()) == shipsBefore); + REQUIRE(countShips(sim) == shipsBefore); // Tick until the cycle completes. const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds); @@ -98,21 +108,21 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes", { sim.tick(); } - REQUIRE(static_cast(sim.ships().allShips().size()) == shipsBefore); + REQUIRE(countShips(sim) == shipsBefore); // Final tick: cycle completes, ship spawns. sim.tick(); - REQUIRE(static_cast(sim.ships().allShips().size()) == shipsBefore + 1); + REQUIRE(countShips(sim) == shipsBefore + 1); bool foundPlayerShip = false; - for (const Ship& ship : sim.ships().allShips()) - { - if (!ship.isEnemy && ship.schematicId == def->id) + sim.admin().forEach( + [&](entt::entity /*e*/, const ShipIdentity& si, const Faction& f) { - foundPlayerShip = true; - break; - } - } + if (!f.isEnemy && si.schematicId == def->id) + { + foundPlayerShip = true; + } + }); REQUIRE(foundPlayerShip); } @@ -123,13 +133,13 @@ TEST_CASE("Shipyard: does not spawn without a schematic set", "[shipyard]") const BuildingDef* yardDef = findShipyardDef(sim.config()); REQUIRE(yardDef != nullptr); - const int shipsBefore = static_cast(sim.ships().allShips().size()); + const int shipsBefore = countShips(sim); placeShipyard(sim, *yardDef); sim.tick(); - REQUIRE(static_cast(sim.ships().allShips().size()) == shipsBefore); + REQUIRE(countShips(sim) == shipsBefore); } TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]") @@ -141,7 +151,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]") const BuildingDef* yardDef = findShipyardDef(sim.config()); REQUIRE(yardDef != nullptr); - const int shipsBefore = static_cast(sim.ships().allShips().size()); + const int shipsBefore = countShips(sim); const EntityId yardId = placeShipyard(sim, *yardDef); sim.buildings().setRecipe(yardId, def->id); @@ -153,7 +163,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]") sim.tick(); } - REQUIRE(static_cast(sim.ships().allShips().size()) == shipsBefore); + REQUIRE(countShips(sim) == shipsBefore); } TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipyard]") @@ -176,7 +186,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya { sim.tick(); } - const int after1 = static_cast(sim.ships().allShips().size()); + const int after1 = countShips(sim); // Second cycle: capture count immediately after the next spawn tick. fillMaterials(sim, yardId, *def); @@ -184,7 +194,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya { sim.tick(); } - const int after2 = static_cast(sim.ships().allShips().size()); + const int after2 = countShips(sim); // 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. diff --git a/src/test/WaveSystemTest.cpp b/src/test/WaveSystemTest.cpp index 55186fd..024780c 100644 --- a/src/test/WaveSystemTest.cpp +++ b/src/test/WaveSystemTest.cpp @@ -6,6 +6,8 @@ #include "BuildingSystem.h" #include "BuildingType.h" #include "ConfigLoader.h" +#include "EcsComponents.h" +#include "EntityAdmin.h" #include "Rotation.h" #include "Ship.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); - int hqCount = 0; - int playerCount = 0; - int enemyCount = 0; + // HQ is still a Building (for belt integration). + int hqCount = 0; for (const Building& b : sim.buildings().allBuildings()) { - if (b.type == BuildingType::Hq) { ++hqCount; } - else if (b.type == BuildingType::PlayerDefenceStation) { ++playerCount; } - else if (b.type == BuildingType::EnemyDefenceStation) { ++enemyCount; } + if (b.type == BuildingType::Hq) { ++hqCount; } } + // Stations are ECS entities. + int playerCount = 0; + int enemyCount = 0; + sim.admin().forEach( + [&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f) + { + if (f.isEnemy) { ++enemyCount; } + else { ++playerCount; } + }); + REQUIRE(hqCount == 1); REQUIRE(playerCount == 2); REQUIRE(enemyCount == 2); @@ -159,16 +168,18 @@ TEST_CASE("WaveSystem: player stations have weapon set", "[wave]") const Simulation sim(loadConfig(), 42); int armedPlayerStations = 0; - for (const Building& b : sim.buildings().allBuildings()) - { - if (b.type == BuildingType::PlayerDefenceStation && b.weapon) + sim.admin().forEach( + [&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, + const StationWeapon& w) { - ++armedPlayerStations; - REQUIRE(b.weapon->damage > 0.0f); - REQUIRE(b.weapon->range > 0.0f); - REQUIRE(b.weapon->fireRateHz > 0.0f); - } - } + if (!f.isEnemy) + { + ++armedPlayerStations; + REQUIRE(w.damage > 0.0f); + REQUIRE(w.range > 0.0f); + REQUIRE(w.fireRateHz > 0.0f); + } + }); REQUIRE(armedPlayerStations == 2); } @@ -177,16 +188,18 @@ TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]") const Simulation sim(loadConfig(), 42); int armedEnemyStations = 0; - for (const Building& b : sim.buildings().allBuildings()) - { - if (b.type == BuildingType::EnemyDefenceStation && b.weapon) + sim.admin().forEach( + [&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, + const StationWeapon& w) { - ++armedEnemyStations; - REQUIRE(b.weapon->damage > 0.0f); - REQUIRE(b.weapon->range > 0.0f); - REQUIRE(b.weapon->fireRateHz > 0.0f); - } - } + if (f.isEnemy) + { + ++armedEnemyStations; + REQUIRE(w.damage > 0.0f); + REQUIRE(w.range > 0.0f); + REQUIRE(w.fireRateHz > 0.0f); + } + }); REQUIRE(armedEnemyStations == 2); } @@ -207,14 +220,11 @@ TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave] } bool foundEnemyShip = false; - for (const Ship& s : sim.ships().allShips()) - { - if (s.isEnemy) + sim.admin().forEach( + [&](entt::entity /*e*/, const ShipIdentity& /*si*/, const Faction& f) { - foundEnemyShip = true; - break; - } - } + if (f.isEnemy) { foundEnemyShip = true; } + }); REQUIRE(foundEnemyShip); } @@ -229,13 +239,14 @@ TEST_CASE("WaveSystem: only eligible ships (cost > 0) appear in waves", "[wave]" sim.tick(); } - for (const Ship& s : sim.ships().allShips()) - { - if (!s.isEnemy) { continue; } - // salvage_ship and repair_ship have cost_formula = "0" and must not spawn. - REQUIRE(s.schematicId != "salvage_ship"); - REQUIRE(s.schematicId != "repair_ship"); - } + sim.admin().forEach( + [&](entt::entity /*e*/, const ShipIdentity& si, const Faction& f) + { + if (!f.isEnemy) { return; } + // salvage_ship and repair_ship have cost_formula = "0" and must not spawn. + REQUIRE(si.schematicId != "salvage_ship"); + REQUIRE(si.schematicId != "repair_ship"); + }); } // --------------------------------------------------------------------------- @@ -247,22 +258,21 @@ TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]" Simulation sim(loadConfig(), 42); // Damage both enemy stations to 0. - sim.buildings().forEachBuilding([](Building& b) - { - if (b.type == BuildingType::EnemyDefenceStation) + sim.admin().forEach( + [](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h) { - b.hp = -1.0f; - } - }); + if (f.isEnemy) { h.hp = -1.0f; } + }); sim.tick(); // After push: should have 2 new enemy stations. int enemyCount = 0; - for (const Building& b : sim.buildings().allBuildings()) - { - if (b.type == BuildingType::EnemyDefenceStation) { ++enemyCount; } - } + sim.admin().forEach( + [&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f) + { + if (f.isEnemy) { ++enemyCount; } + }); REQUIRE(enemyCount == 2); } @@ -270,13 +280,11 @@ TEST_CASE("WaveSystem: push emits exactly one SchematicDropEvent", "[wave]") { Simulation sim(loadConfig(), 42); - sim.buildings().forEachBuilding([](Building& b) - { - if (b.type == BuildingType::EnemyDefenceStation) + sim.admin().forEach( + [](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h) { - b.hp = -1.0f; - } - }); + if (f.isEnemy) { h.hp = -1.0f; } + }); sim.tick(); @@ -288,13 +296,11 @@ TEST_CASE("WaveSystem: push schematic drop awards a known ship id", "[wave]") { Simulation sim(loadConfig(), 42); - sim.buildings().forEachBuilding([](Building& b) - { - if (b.type == BuildingType::EnemyDefenceStation) + sim.admin().forEach( + [](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h) { - b.hp = -1.0f; - } - }); + if (f.isEnemy) { h.hp = -1.0f; } + }); sim.tick(); const std::vector events = sim.drainSchematicDropEvents(); @@ -319,28 +325,31 @@ TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]") // Record the X position of the initial enemy stations. int initialX = std::numeric_limits::min(); - for (const Building& b : sim.buildings().allBuildings()) - { - if (b.type == BuildingType::EnemyDefenceStation) + sim.admin().forEach( + [&](entt::entity /*e*/, const StationBody& sb, const Faction& f) { - 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) - { - if (b.type == BuildingType::EnemyDefenceStation) { b.hp = -1.0f; } - }); + sim.admin().forEach( + [](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h) + { + if (f.isEnemy) { h.hp = -1.0f; } + }); sim.tick(); int newX = std::numeric_limits::min(); - for (const Building& b : sim.buildings().allBuildings()) - { - if (b.type == BuildingType::EnemyDefenceStation) + sim.admin().forEach( + [&](entt::entity /*e*/, const StationBody& sb, const Faction& f) { - if (b.anchor.x() > newX) { newX = b.anchor.x(); } - } - } + if (f.isEnemy && sb.anchor.x() > newX) + { + newX = sb.anchor.x(); + } + }); REQUIRE(newX > initialX); } diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 88ed7e7..244161d 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -20,7 +20,7 @@ #include "Building.h" #include "BuildingSystem.h" #include "BeltSystem.h" -#include "Scrap.h" +#include "EcsComponents.h" #include "ScrapSystem.h" #include "Ship.h" #include "ShipSystem.h" @@ -55,11 +55,11 @@ Rotation rotateCounterClockwise(Rotation r) return Rotation::East; } -ShipRole shipRole(const Ship& ship) +ShipRole shipRoleFromComponents(bool isEnemy, bool hasCargo, bool hasRepairTool) { - if (ship.isEnemy) { return ShipRole::Enemy; } - if (ship.cargo.has_value()) { return ShipRole::Salvage; } - if (ship.repairTool.has_value()) { return ShipRole::Repair; } + if (isEnemy) { return ShipRole::Enemy; } + if (hasCargo) { return ShipRole::Salvage; } + if (hasRepairTool){ return ShipRole::Repair; } return ShipRole::PlayerCombat; } @@ -160,11 +160,11 @@ void GameWorldView::onFrame() for (const FireEvent& fe : fires) { float maxRadius = 0.125f; - const Building* tBld = m_sim->buildings().findBuilding(fe.target); - if (tBld) + if (m_sim->admin().isValid(fe.target) + && m_sim->admin().hasAll(fe.target)) { - const int shorter = std::min(tBld->footprint.width(), - tBld->footprint.height()); + const StationBody& sb = m_sim->admin().get(fe.target); + const int shorter = std::min(sb.footprint.width(), sb.footprint.height()); maxRadius = shorter / 2.0f; } @@ -443,21 +443,13 @@ EntityId GameWorldView::siteAtTile(QPoint tile) const } -std::optional GameWorldView::entityPosition(EntityId id) const +std::optional GameWorldView::entityPosition(entt::entity entity) const { - const Ship* ship = m_sim->ships().findShip(id); - if (ship) + if (!m_sim->admin().isValid(entity) || !m_sim->admin().hasAll(entity)) { - return ship->position; + return std::nullopt; } - const Building* bldg = m_sim->buildings().findBuilding(id); - if (bldg) - { - return QVector2D( - bldg->anchor.x() + bldg->footprint.width() * 0.5f, - bldg->anchor.y() + bldg->footprint.height() * 0.5f); - } - return std::nullopt; + return m_sim->admin().get(entity).value; } void GameWorldView::stepSpeed(int delta) @@ -790,7 +782,7 @@ void GameWorldView::drawBeltItems(QPainter& painter) void GameWorldView::drawScrap(QPainter& painter) { 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); painter.setBrush(QColor(128, 110, 90)); @@ -802,52 +794,58 @@ void GameWorldView::drawScrap(QPainter& painter) void GameWorldView::drawShips(QPainter& painter) { - for (const Ship& ship : m_sim->ships().allShips()) - { - const ShipRole role = shipRole(ship); - const std::map::const_iterator it = - m_visuals->ships.find(role); - if (it == m_visuals->ships.end()) { continue; } + m_sim->admin().forEach( + [&](entt::entity e, const ShipIdentity& /*si*/, const Position& pos, + const Facing& facing, const Faction& fac) + { + const bool hasCargo = m_sim->admin().hasAll(e); + const bool hasRepair = m_sim->admin().hasAll(e); + const ShipRole role = shipRoleFromComponents(fac.isEnemy, hasCargo, hasRepair); + const std::map::const_iterator it = + m_visuals->ships.find(role); + if (it == m_visuals->ships.end()) { return; } - const QPointF center = worldToWidget(ship.position); - const QVector2D dir(std::cos(ship.facing), std::sin(ship.facing)); - const QVector2D perp(-dir.y(), dir.x()); + const QPointF center = worldToWidget(pos.value); + const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians)); + const QVector2D perp(-dir.y(), dir.x()); - const float fwd = tilePx() * 0.45f; - const float side = tilePx() * 0.25f; + const float fwd = tilePx() * 0.45f; + const float side = tilePx() * 0.25f; - QPolygonF tri; - tri << QPointF(center.x() + static_cast(dir.x() * fwd), - center.y() + static_cast(dir.y() * fwd)) - << QPointF(center.x() + static_cast(perp.x() * side - dir.x() * side), - center.y() + static_cast(perp.y() * side - dir.y() * side)) - << QPointF(center.x() + static_cast(-perp.x() * side - dir.x() * side), - center.y() + static_cast(-perp.y() * side - dir.y() * side)); + QPolygonF tri; + tri << QPointF(center.x() + static_cast(dir.x() * fwd), + center.y() + static_cast(dir.y() * fwd)) + << QPointF(center.x() + static_cast(perp.x() * side - dir.x() * side), + center.y() + static_cast(perp.y() * side - dir.y() * side)) + << QPointF(center.x() + static_cast(-perp.x() * side - dir.x() * side), + center.y() + static_cast(-perp.y() * side - dir.y() * side)); - painter.setPen(QPen(it->second.outline, 1)); - painter.setBrush(it->second.fill); - painter.drawPolygon(tri); - } + painter.setPen(QPen(it->second.outline, 1)); + painter.setBrush(it->second.fill); + painter.drawPolygon(tri); + }); } void GameWorldView::drawDebugSensorRanges(QPainter& painter) { painter.setBrush(Qt::NoBrush); - for (const Ship& ship : m_sim->ships().allShips()) - { - const ShipRole role = shipRole(ship); - const std::map::const_iterator it = - m_visuals->ships.find(role); - if (it == m_visuals->ships.end()) { continue; } + m_sim->admin().forEach( + [&](entt::entity e, const ShipIdentity& /*si*/, const Position& pos, + const Facing& /*facing*/, const Faction& fac, const SensorRange& sensor) + { + const bool hasCargo = m_sim->admin().hasAll(e); + const bool hasRepair = m_sim->admin().hasAll(e); + const ShipRole role = shipRoleFromComponents(fac.isEnemy, hasCargo, hasRepair); + const std::map::const_iterator it = + m_visuals->ships.find(role); + if (it == m_visuals->ships.end()) { return; } - const float range = ship.sensorRange; - - const QPointF center = worldToWidget(ship.position); - const qreal radiusPx = static_cast(range) - * static_cast(tilePx()); - painter.setPen(QPen(it->second.outline, 1)); - painter.drawEllipse(center, radiusPx, radiusPx); - } + const QPointF center = worldToWidget(pos.value); + const qreal radiusPx = static_cast(sensor.value) + * static_cast(tilePx()); + painter.setPen(QPen(it->second.outline, 1)); + painter.drawEllipse(center, radiusPx, radiusPx); + }); } void GameWorldView::drawBeams(QPainter& painter) diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index b8cc731..ddb3201 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -17,6 +17,8 @@ #include "BuildingType.h" #include "EntityId.h" #include "FireEvent.h" + +#include "entt/entity/entity.hpp" #include "GameConfig.h" #include "Rotation.h" #include "Tick.h" @@ -109,7 +111,7 @@ private: void placeBlueprintAtTile(QPoint center); - std::optional entityPosition(EntityId id) const; + std::optional entityPosition(entt::entity entity) const; void stepSpeed(int delta); void placeAtTile(QPoint tile);