switch to ECS architecture
This commit is contained in:
@@ -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<ShipLayoutConfig>&) {},
|
||||
m_rng);
|
||||
|
||||
m_shipSystem = std::make_unique<ShipSystem>(
|
||||
m_gameConfig, [this]() { return allocateId(); });
|
||||
|
||||
m_shipSystem = std::make_unique<ShipSystem>(m_gameConfig, m_admin);
|
||||
m_aiSystem = std::make_unique<AiSystem>();
|
||||
m_movementSystem = std::make_unique<MovementSystem>();
|
||||
m_combatSystem = std::make_unique<CombatSystem>(m_gameConfig);
|
||||
m_scrapSystem = std::make_unique<ScrapSystem>([this]() { return allocateId(); });
|
||||
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
||||
|
||||
placeStructures();
|
||||
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<float>(
|
||||
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<QPoint> 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<float>(
|
||||
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<QPoint> 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<double>(entry.level);
|
||||
|
||||
const std::vector<std::string>& mask = isEnemy
|
||||
? m_gameConfig.stations.enemyStation.surfaceMask
|
||||
: m_gameConfig.stations.playerStation.surfaceMask;
|
||||
|
||||
if (entry.stationType == "player_station")
|
||||
{
|
||||
hp = static_cast<float>(
|
||||
m_gameConfig.stations.playerStation.hpFormula.evaluate(lv));
|
||||
weapon.damage = static_cast<float>(
|
||||
m_gameConfig.stations.playerStation.damageFormula.evaluate(lv));
|
||||
weapon.range = static_cast<float>(
|
||||
m_gameConfig.stations.playerStation.rangeFormula.evaluate(lv));
|
||||
weapon.fireRateHz = static_cast<float>(
|
||||
m_gameConfig.stations.playerStation.fireRateFormula.evaluate(lv));
|
||||
}
|
||||
else
|
||||
{
|
||||
hp = static_cast<float>(
|
||||
m_gameConfig.stations.enemyStation.hpFormula.evaluate(lv));
|
||||
weapon.damage = static_cast<float>(
|
||||
m_gameConfig.stations.enemyStation.damageFormula.evaluate(lv));
|
||||
weapon.range = static_cast<float>(
|
||||
m_gameConfig.stations.enemyStation.rangeFormula.evaluate(lv));
|
||||
weapon.fireRateHz = static_cast<float>(
|
||||
m_gameConfig.stations.enemyStation.fireRateFormula.evaluate(lv));
|
||||
}
|
||||
|
||||
const ParsedSurfaceMask parsed = parseSurfaceMask(mask, Rotation::East);
|
||||
const QPoint& anchor = entry.position;
|
||||
std::vector<QPoint> absCells;
|
||||
for (const QPoint& rel : parsed.bodyCells)
|
||||
{
|
||||
absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
|
||||
}
|
||||
const entt::entity stationEntity = m_admin.spawnStation(
|
||||
anchor, parsed.footprint, absCells, hp, hp, isEnemy);
|
||||
m_admin.addComponent<StationWeapon>(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<double>(entry.level);
|
||||
|
||||
if (entry.stationType == "player_station")
|
||||
{
|
||||
hp = static_cast<float>(
|
||||
m_gameConfig.stations.playerStation.hpFormula.evaluate(lv));
|
||||
weapon.damage = static_cast<float>(
|
||||
m_gameConfig.stations.playerStation.damageFormula.evaluate(lv));
|
||||
weapon.range = static_cast<float>(
|
||||
m_gameConfig.stations.playerStation.rangeFormula.evaluate(lv));
|
||||
weapon.fireRateHz = static_cast<float>(
|
||||
m_gameConfig.stations.playerStation.fireRateFormula.evaluate(lv));
|
||||
placeArenaStation(entry, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
hp = static_cast<float>(
|
||||
m_gameConfig.stations.enemyStation.hpFormula.evaluate(lv));
|
||||
weapon.damage = static_cast<float>(
|
||||
m_gameConfig.stations.enemyStation.damageFormula.evaluate(lv));
|
||||
weapon.range = static_cast<float>(
|
||||
m_gameConfig.stations.enemyStation.rangeFormula.evaluate(lv));
|
||||
weapon.fireRateHz = static_cast<float>(
|
||||
m_gameConfig.stations.enemyStation.fireRateFormula.evaluate(lv));
|
||||
}
|
||||
|
||||
const EntityId stationId = m_buildingSystem->placeImmediate(
|
||||
BuildingType::PlayerDefenceStation,
|
||||
m_gameConfig.stations.playerStation.surfaceMask,
|
||||
entry.position,
|
||||
Rotation::East, hp, hp);
|
||||
m_buildingSystem->initStationWeapon(stationId, weapon);
|
||||
}
|
||||
|
||||
// Team 2 defence stations (EnemyDefenceStation — targeted by team 1).
|
||||
for (const ArenaStationEntry& entry : m_arenaConfig.teams[1].stations)
|
||||
{
|
||||
float hp = 0.0f;
|
||||
StationWeapon weapon;
|
||||
weapon.cooldownTicks = 0.0f;
|
||||
const double lv = static_cast<double>(entry.level);
|
||||
|
||||
if (entry.stationType == "player_station")
|
||||
{
|
||||
hp = static_cast<float>(
|
||||
m_gameConfig.stations.playerStation.hpFormula.evaluate(lv));
|
||||
weapon.damage = static_cast<float>(
|
||||
m_gameConfig.stations.playerStation.damageFormula.evaluate(lv));
|
||||
weapon.range = static_cast<float>(
|
||||
m_gameConfig.stations.playerStation.rangeFormula.evaluate(lv));
|
||||
weapon.fireRateHz = static_cast<float>(
|
||||
m_gameConfig.stations.playerStation.fireRateFormula.evaluate(lv));
|
||||
}
|
||||
else
|
||||
{
|
||||
hp = static_cast<float>(
|
||||
m_gameConfig.stations.enemyStation.hpFormula.evaluate(lv));
|
||||
weapon.damage = static_cast<float>(
|
||||
m_gameConfig.stations.enemyStation.damageFormula.evaluate(lv));
|
||||
weapon.range = static_cast<float>(
|
||||
m_gameConfig.stations.enemyStation.rangeFormula.evaluate(lv));
|
||||
weapon.fireRateHz = static_cast<float>(
|
||||
m_gameConfig.stations.enemyStation.fireRateFormula.evaluate(lv));
|
||||
}
|
||||
|
||||
const EntityId stationId = m_buildingSystem->placeImmediate(
|
||||
BuildingType::EnemyDefenceStation,
|
||||
m_gameConfig.stations.enemyStation.surfaceMask,
|
||||
entry.position,
|
||||
Rotation::East, hp, hp);
|
||||
m_buildingSystem->initStationWeapon(stationId, weapon);
|
||||
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<FireEvent> fireEvents;
|
||||
m_combatSystem->tick(m_currentTick, *m_shipSystem, *m_buildingSystem, fireEvents);
|
||||
m_combatSystem->tick(m_currentTick, m_admin, *m_buildingSystem, fireEvents);
|
||||
m_fireEvents.insert(m_fireEvents.end(), fireEvents.begin(), fireEvents.end());
|
||||
m_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<EntityId> deadShipIds;
|
||||
m_shipSystem->forEach([&deadShipIds](Ship& s)
|
||||
std::vector<entt::entity> deadShips;
|
||||
m_admin.forEach<ShipIdentity, Health>(
|
||||
[&deadShips](entt::entity e, const ShipIdentity& /*si*/, const Health& h)
|
||||
{
|
||||
if (s.hp <= 0.0f)
|
||||
if (h.hp <= 0.0f)
|
||||
{
|
||||
deadShipIds.push_back(s.id);
|
||||
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<ShipIdentity>(deadEntity);
|
||||
const Position& pos = m_admin.get<Position>(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<EntityId> deadBuildingIds;
|
||||
for (const Building& b : m_buildingSystem->allBuildings())
|
||||
// Dead stations.
|
||||
std::vector<entt::entity> deadStations;
|
||||
m_admin.forEach<StationBody, Health>(
|
||||
[&deadStations](entt::entity e, const StationBody& /*sb*/, const Health& h)
|
||||
{
|
||||
if (b.hp <= 0.0f
|
||||
&& (b.type == BuildingType::Hq
|
||||
|| b.type == BuildingType::PlayerDefenceStation
|
||||
|| b.type == BuildingType::EnemyDefenceStation))
|
||||
if (h.hp <= 0.0f)
|
||||
{
|
||||
deadBuildingIds.push_back(b.id);
|
||||
deadStations.push_back(e);
|
||||
}
|
||||
});
|
||||
|
||||
for (entt::entity deadEntity : deadStations)
|
||||
{
|
||||
const StationBody& sb = m_admin.get<StationBody>(deadEntity);
|
||||
m_buildingSystem->unregisterTileOccupancy(sb.bodyCells);
|
||||
m_admin.destroy(deadEntity);
|
||||
}
|
||||
|
||||
for (EntityId deadId : deadBuildingIds)
|
||||
{
|
||||
m_buildingSystem->removeBuilding(deadId);
|
||||
}
|
||||
|
||||
// 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<Health>(m_team1HqEntity).hp <= 0.0f;
|
||||
const bool team2HqGone = !m_admin.isValid(m_team2HqEntity)
|
||||
|| m_admin.get<Health>(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)
|
||||
m_admin.forEach<ShipIdentity, Faction>(
|
||||
[&team1HasUnits, &team2HasUnits](entt::entity /*e*/,
|
||||
const ShipIdentity& /*si*/,
|
||||
const Faction& f)
|
||||
{
|
||||
if (s.isEnemy)
|
||||
{
|
||||
team2HasUnits = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
team1HasUnits = true;
|
||||
}
|
||||
if (f.isEnemy) { team2HasUnits = true; }
|
||||
else { team1HasUnits = true; }
|
||||
});
|
||||
|
||||
for (const Building& b : m_buildingSystem->allBuildings())
|
||||
m_admin.forEach<StationBody, Faction>(
|
||||
[&team1HasUnits, &team2HasUnits](entt::entity /*e*/,
|
||||
const StationBody& /*sb*/,
|
||||
const Faction& f)
|
||||
{
|
||||
if (b.type == BuildingType::PlayerDefenceStation)
|
||||
{
|
||||
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<Health>(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<ShipIdentity, Faction, Health>(
|
||||
[&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())
|
||||
const bool isEnemyTeam = (ti == 1);
|
||||
m_admin.forEach<StationBody, Faction, Health>(
|
||||
[&surviving, &stationEntry, isEnemyTeam](entt::entity /*e*/,
|
||||
const StationBody& sb, const Faction& f, const Health& h)
|
||||
{
|
||||
if (b.type == expectedType && b.anchor == stationEntry.position)
|
||||
if (f.isEnemy == isEnemyTeam
|
||||
&& sb.anchor == stationEntry.position
|
||||
&& h.hp > 0.0f)
|
||||
{
|
||||
surviving = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
entry.surviving = surviving;
|
||||
teamStatus.entries.push_back(entry);
|
||||
}
|
||||
|
||||
@@ -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<BuildingSystem> m_buildingSystem;
|
||||
std::unique_ptr<ShipSystem> m_shipSystem;
|
||||
@@ -89,8 +96,8 @@ private:
|
||||
std::unique_ptr<CombatSystem> m_combatSystem;
|
||||
std::unique_ptr<ScrapSystem> m_scrapSystem;
|
||||
|
||||
EntityId m_team1HqId;
|
||||
EntityId m_team2HqId;
|
||||
entt::entity m_team1HqEntity;
|
||||
entt::entity m_team2HqEntity;
|
||||
|
||||
bool m_finished;
|
||||
int m_winnerTeam;
|
||||
|
||||
@@ -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<StationBody>(fe.target))
|
||||
{
|
||||
const int shorter = std::min(tBld->footprint.width(),
|
||||
tBld->footprint.height());
|
||||
const StationBody& sb = m_sim->admin().get<StationBody>(fe.target);
|
||||
const int shorter = std::min(sb.footprint.width(),
|
||||
sb.footprint.height());
|
||||
maxRadius = shorter / 2.0f;
|
||||
}
|
||||
|
||||
@@ -188,24 +188,14 @@ QRectF ArenaView::tileRect(QPoint tile) const
|
||||
static_cast<qreal>(tilePx()), static_cast<qreal>(tilePx()));
|
||||
}
|
||||
|
||||
std::optional<QVector2D> ArenaView::entityPosition(EntityId id) const
|
||||
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
|
||||
{
|
||||
for (const Ship& ship : m_sim->ships().allShips())
|
||||
if (!m_sim->admin().isValid(entity) || !m_sim->admin().hasAll<Position>(entity))
|
||||
{
|
||||
if (ship.id == id)
|
||||
{
|
||||
return ship.position;
|
||||
}
|
||||
}
|
||||
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<Position>(entity).value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
@@ -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,17 +266,20 @@ void ArenaView::drawScrap(QPainter& painter)
|
||||
|
||||
void ArenaView::drawShips(QPainter& painter)
|
||||
{
|
||||
for (const Ship& ship : m_sim->ships().allShips())
|
||||
m_sim->admin().forEach<ShipIdentity, Position, Velocity, Faction>(
|
||||
[&](entt::entity e, const ShipIdentity& /*si*/, const Position& pos,
|
||||
const Velocity& vel, const Faction& fac)
|
||||
{
|
||||
const ShipRole role = shipRole(ship);
|
||||
const bool hasCargo = m_sim->admin().hasAll<SalvageCargo>(e);
|
||||
const bool hasRepair = m_sim->admin().hasAll<RepairTool>(e);
|
||||
const ShipRole role = shipRoleFromComponents(fac.isEnemy, hasCargo, hasRepair);
|
||||
const std::map<ShipRole, ShipVisuals>::const_iterator it =
|
||||
m_visuals->ships.find(role);
|
||||
if (it == m_visuals->ships.end()) { continue; }
|
||||
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()
|
||||
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());
|
||||
|
||||
@@ -304,7 +297,7 @@ void ArenaView::drawShips(QPainter& painter)
|
||||
painter.setPen(QPen(it->second.outline, 1));
|
||||
painter.setBrush(it->second.fill);
|
||||
painter.drawPolygon(tri);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void ArenaView::drawBeams(QPainter& painter)
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
#include <QTimer>
|
||||
#include <QVector2D>
|
||||
|
||||
#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<QVector2D> entityPosition(EntityId id) const;
|
||||
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
||||
|
||||
struct ActiveBeam
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
107
src/lib/core/EcsComponents.h
Normal file
107
src/lib/core/EcsComponents.h
Normal file
@@ -0,0 +1,107 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
#include <QSize>
|
||||
#include <QVector2D>
|
||||
|
||||
#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<QPoint> 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; };
|
||||
@@ -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<Position>(entity, Position{position});
|
||||
add<Health>(entity, Health{hp, maxHp});
|
||||
add<Faction>(entity, Faction{isEnemy});
|
||||
add<Velocity>(entity, Velocity{QVector2D(0.0f, 0.0f)});
|
||||
add<Facing>(entity, Facing{0.0f, 0.0f});
|
||||
add<ShipDynamics>(entity, ShipDynamics{
|
||||
maxSpeedPerTick, mainAccelPerTick, maneuveringAccelPerTick,
|
||||
angularAccelPerTick, maxRotationSpeedPerTick});
|
||||
add<SensorRange>(entity, SensorRange{sensorRange});
|
||||
add<ShipIdentity>(entity, ShipIdentity{level, schematicId});
|
||||
add<MovementIntent>(entity, MovementIntent{0, QVector2D(0.0f, 0.0f)});
|
||||
return entity;
|
||||
}
|
||||
|
||||
entt::entity EntityAdmin::spawnStation(QPoint anchor, QSize footprint,
|
||||
const std::vector<QPoint>& bodyCells,
|
||||
float hp, float maxHp, bool isEnemy)
|
||||
{
|
||||
entt::entity entity = createEntity();
|
||||
QVector2D center(anchor.x() + footprint.width() / 2.0f,
|
||||
anchor.y() + footprint.height() / 2.0f);
|
||||
add<Position>(entity, Position{center});
|
||||
add<Health>(entity, Health{hp, maxHp});
|
||||
add<Faction>(entity, Faction{isEnemy});
|
||||
add<StationBody>(entity, StationBody{anchor, footprint, bodyCells});
|
||||
return entity;
|
||||
}
|
||||
|
||||
entt::entity EntityAdmin::spawnScrap(QVector2D position, int amount, Tick despawnAt)
|
||||
{
|
||||
entt::entity entity = createEntity();
|
||||
add<Position>(entity, Position{position});
|
||||
add<ScrapData>(entity, ScrapData{amount});
|
||||
add<DespawnAt>(entity, DespawnAt{despawnAt});
|
||||
return entity;
|
||||
}
|
||||
|
||||
entt::entity EntityAdmin::spawnHqProxy(QVector2D position, float hp, float maxHp)
|
||||
{
|
||||
entt::entity entity = createEntity();
|
||||
add<Position>(entity, Position{position});
|
||||
add<Health>(entity, Health{hp, maxHp});
|
||||
add<Faction>(entity, Faction{false});
|
||||
add<HqProxy>(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
#ifndef ENTITY_ADMIN_H
|
||||
#define ENTITY_ADMIN_H
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
#include <QSize>
|
||||
#include <QVector2D>
|
||||
|
||||
#include "Tick.h"
|
||||
|
||||
#include "entt/entity/registry.hpp"
|
||||
|
||||
class EntityAdmin
|
||||
@@ -10,36 +19,84 @@ public:
|
||||
EntityAdmin(const EntityAdmin&) = delete;
|
||||
EntityAdmin& operator=(const EntityAdmin&) = delete;
|
||||
|
||||
// -- Queries / iteration ------------------------------------------------
|
||||
|
||||
template <typename... Ts, typename Func>
|
||||
void forEach(Func&& f);
|
||||
|
||||
template <typename... Ts, typename Func>
|
||||
void forEach(Func&& f) const;
|
||||
|
||||
template <typename... Ts>
|
||||
bool hasAll(entt::entity entity);
|
||||
|
||||
template <typename T>
|
||||
T& get(entt::entity entity);
|
||||
|
||||
bool isValid(entt::entity entity);
|
||||
template <typename T>
|
||||
const T& get(entt::entity entity) const;
|
||||
|
||||
bool isValid(entt::entity entity) const;
|
||||
void destroy(entt::entity entity);
|
||||
void 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 <typename T, typename... Args>
|
||||
void addComponent(entt::entity entity, Args&&... args);
|
||||
|
||||
template <typename T>
|
||||
void removeComponent(entt::entity entity);
|
||||
|
||||
// -- Factory methods ----------------------------------------------------
|
||||
|
||||
entt::entity spawnShip(QVector2D position, float hp, float maxHp,
|
||||
float maxSpeedPerTick, float mainAccelPerTick,
|
||||
float maneuveringAccelPerTick, float angularAccelPerTick,
|
||||
float maxRotationSpeedPerTick, float sensorRange,
|
||||
int level, const std::string& schematicId, bool isEnemy);
|
||||
|
||||
entt::entity spawnStation(QPoint anchor, QSize footprint,
|
||||
const std::vector<QPoint>& bodyCells,
|
||||
float hp, float maxHp, bool isEnemy);
|
||||
|
||||
entt::entity spawnScrap(QVector2D position, int amount, Tick despawnAt);
|
||||
|
||||
entt::entity spawnHqProxy(QVector2D position, float hp, float maxHp);
|
||||
|
||||
private:
|
||||
entt::entity createEntity();
|
||||
|
||||
template <typename T, typename... Args>
|
||||
T& add(entt::entity entity, Args&&... args);
|
||||
void add(entt::entity entity, Args&&... args);
|
||||
|
||||
entt::registry m_registry;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
template <typename... Ts, typename Func>
|
||||
void EntityAdmin::forEach(Func&& f)
|
||||
{
|
||||
m_registry.view<Ts...>().each(std::forward<Func>(f));
|
||||
// Avoid view.each() — MSVC 2017 ICEs on the extended_storage_iterator it instantiates.
|
||||
for (entt::entity entity : m_registry.view<Ts...>())
|
||||
{
|
||||
f(entity, m_registry.get<Ts>(entity)...);
|
||||
}
|
||||
}
|
||||
|
||||
template <typename... Ts, typename Func>
|
||||
void EntityAdmin::forEach(Func&& f) const
|
||||
{
|
||||
entt::registry& reg = const_cast<entt::registry&>(m_registry);
|
||||
for (entt::entity entity : reg.view<Ts...>())
|
||||
{
|
||||
f(entity, reg.get<Ts>(entity)...);
|
||||
}
|
||||
}
|
||||
|
||||
template <typename... Ts>
|
||||
@@ -54,10 +111,28 @@ T& EntityAdmin::get(entt::entity entity)
|
||||
return m_registry.get<T>(entity);
|
||||
}
|
||||
|
||||
template <typename T, typename... Args>
|
||||
T& EntityAdmin::add(entt::entity entity, Args&&... args)
|
||||
template <typename T>
|
||||
const T& EntityAdmin::get(entt::entity entity) const
|
||||
{
|
||||
return m_registry.emplace<T>(entity, std::forward<Args>(args)...);
|
||||
return m_registry.get<T>(entity);
|
||||
}
|
||||
|
||||
template <typename T, typename... Args>
|
||||
void EntityAdmin::addComponent(entt::entity entity, Args&&... args)
|
||||
{
|
||||
m_registry.emplace<T>(entity, std::forward<Args>(args)...);
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
void EntityAdmin::removeComponent(entt::entity entity)
|
||||
{
|
||||
m_registry.remove<T>(entity);
|
||||
}
|
||||
|
||||
template <typename T, typename... Args>
|
||||
void EntityAdmin::add(entt::entity entity, Args&&... args)
|
||||
{
|
||||
m_registry.emplace<T>(entity, std::forward<Args>(args)...);
|
||||
}
|
||||
|
||||
#endif // ENTITY_ADMIN_H
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -8,45 +8,28 @@
|
||||
#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)
|
||||
admin.forEach<HomeReturn, Health, MovementIntent>(
|
||||
[](entt::entity /*e*/, const HomeReturn& hr, const Health& h, MovementIntent& intent)
|
||||
{
|
||||
if (!s.homeReturn) { return; }
|
||||
if (s.hp / s.maxHp < s.homeReturn->retreatHpFraction)
|
||||
if (h.hp / h.maxHp < hr.retreatHpFraction)
|
||||
{
|
||||
if (4 > s.intent.priority)
|
||||
if (4 > intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{4, s.homeReturn->homePos};
|
||||
intent = MovementIntent{4, hr.homePos};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -56,141 +39,115 @@ void AiSystem::tickHomeReturn(ShipSystem& ships)
|
||||
// tickThreatResponse (priority 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AiSystem::tickThreatResponse(ShipSystem& ships, const BuildingSystem& buildings)
|
||||
void AiSystem::tickThreatResponse(EntityAdmin& admin, const BuildingSystem& buildings)
|
||||
{
|
||||
const std::vector<Building> allBuildings = buildings.allBuildings();
|
||||
const std::vector<Ship> allShips = ships.allShips();
|
||||
// Snapshot all combatant entities for target acquisition.
|
||||
struct CombatantInfo
|
||||
{
|
||||
entt::entity entity;
|
||||
QVector2D position;
|
||||
bool isEnemy;
|
||||
bool isStation;
|
||||
};
|
||||
std::vector<CombatantInfo> combatants;
|
||||
|
||||
ships.forEach([&](Ship& s)
|
||||
admin.forEach<Position, Faction, ShipIdentity>(
|
||||
[&combatants](entt::entity e, const Position& pos, const Faction& f, const ShipIdentity& /*si*/)
|
||||
{
|
||||
if (!s.threatResponse) { return; }
|
||||
combatants.push_back({e, pos.value, f.isEnemy, false});
|
||||
});
|
||||
|
||||
const float range = s.sensorRange;
|
||||
admin.forEach<Position, Faction, StationBody>(
|
||||
[&combatants](entt::entity e, const Position& pos, const Faction& f, const StationBody& /*sb*/)
|
||||
{
|
||||
combatants.push_back({e, pos.value, f.isEnemy, true});
|
||||
});
|
||||
|
||||
if (!s.isEnemy)
|
||||
admin.forEach<Position, Faction, HqProxy>(
|
||||
[&combatants](entt::entity e, const Position& pos, const Faction& f, const HqProxy& /*hq*/)
|
||||
{
|
||||
if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId),
|
||||
range, s, ships, buildings))
|
||||
{
|
||||
s.threatResponse->currentTarget = std::nullopt;
|
||||
float bestDist = range;
|
||||
for (const Ship& candidate : allShips)
|
||||
{
|
||||
if (!candidate.isEnemy) { continue; }
|
||||
float dist = (candidate.position - s.position).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
s.threatResponse->currentTarget = candidate.id;
|
||||
}
|
||||
}
|
||||
combatants.push_back({e, pos.value, f.isEnemy, true});
|
||||
});
|
||||
|
||||
for (const Building& b : allBuildings)
|
||||
admin.forEach<ThreatResponse, Position, Faction, SensorRange, MovementIntent>(
|
||||
[&](entt::entity e, ThreatResponse& threat, Position& pos, Faction& faction,
|
||||
SensorRange& sensor, MovementIntent& intent)
|
||||
{
|
||||
if (b.type != BuildingType::EnemyDefenceStation) { continue; }
|
||||
float dist = (buildingCenter(b) - s.position).length();
|
||||
if (dist < bestDist)
|
||||
const float range = sensor.value;
|
||||
|
||||
// Validate current target.
|
||||
bool targetValid = false;
|
||||
if (threat.currentTarget)
|
||||
{
|
||||
bestDist = dist;
|
||||
s.threatResponse->currentTarget = b.id;
|
||||
const entt::entity t = *threat.currentTarget;
|
||||
if (admin.isValid(t) && admin.hasAll<Position>(t))
|
||||
{
|
||||
const float dist = (admin.get<Position>(t).value - pos.value).length();
|
||||
if (dist <= range)
|
||||
{
|
||||
targetValid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (s.threatResponse->currentTarget)
|
||||
if (!targetValid)
|
||||
{
|
||||
QVector2D dest;
|
||||
const Ship* tShip = ships.findShip(*s.threatResponse->currentTarget);
|
||||
if (tShip)
|
||||
{
|
||||
dest = tShip->position;
|
||||
}
|
||||
else
|
||||
{
|
||||
const Building* tBld = buildings.findBuilding(
|
||||
*s.threatResponse->currentTarget);
|
||||
dest = tBld ? buildingCenter(*tBld) : s.position;
|
||||
}
|
||||
if (3 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{3, dest};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (3 > s.intent.priority)
|
||||
{
|
||||
if (s.rallyBehavior)
|
||||
{
|
||||
s.intent = MovementIntent{3, s.rallyBehavior->rallyPoint};
|
||||
}
|
||||
else
|
||||
{
|
||||
s.intent = MovementIntent{3, QVector2D(s.position.x() + 1000.0f,
|
||||
s.position.y())};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId),
|
||||
range, s, ships, buildings))
|
||||
{
|
||||
s.threatResponse->currentTarget = std::nullopt;
|
||||
threat.currentTarget = std::nullopt;
|
||||
float bestDist = range;
|
||||
|
||||
for (const Ship& candidate : allShips)
|
||||
for (const CombatantInfo& c : combatants)
|
||||
{
|
||||
if (candidate.isEnemy) { continue; }
|
||||
float dist = (candidate.position - s.position).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
s.threatResponse->currentTarget = candidate.id;
|
||||
}
|
||||
}
|
||||
if (c.entity == e) { continue; }
|
||||
|
||||
for (const Building& b : allBuildings)
|
||||
bool isValidTarget = false;
|
||||
if (!faction.isEnemy)
|
||||
{
|
||||
if (b.type != BuildingType::PlayerDefenceStation
|
||||
&& b.type != BuildingType::Hq)
|
||||
{
|
||||
continue;
|
||||
isValidTarget = c.isEnemy;
|
||||
}
|
||||
float dist = (buildingCenter(b) - s.position).length();
|
||||
else
|
||||
{
|
||||
isValidTarget = !c.isEnemy;
|
||||
}
|
||||
if (!isValidTarget) { continue; }
|
||||
|
||||
const float dist = (c.position - pos.value).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<Position>(t))
|
||||
{
|
||||
dest = tShip->position;
|
||||
dest = admin.get<Position>(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<RallyBehavior>(e))
|
||||
{
|
||||
intent = MovementIntent{3, admin.get<RallyBehavior>(e).rallyPoint};
|
||||
}
|
||||
else if (!faction.isEnemy)
|
||||
{
|
||||
intent = MovementIntent{3, QVector2D(pos.value.x() + 1000.0f,
|
||||
pos.value.y())};
|
||||
}
|
||||
else
|
||||
{
|
||||
intent = MovementIntent{3, QVector2D(-10000.0f, pos.value.y())};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,22 +158,61 @@ void AiSystem::tickThreatResponse(ShipSystem& ships, const BuildingSystem& build
|
||||
// tickRepairBehavior (priority 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AiSystem::tickRepairBehavior(ShipSystem& ships, BuildingSystem& buildings)
|
||||
void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings)
|
||||
{
|
||||
const std::vector<Building> allBuildings = buildings.allBuildings();
|
||||
const std::vector<Ship> 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<RepairableInfo> repairables;
|
||||
|
||||
const float repairRange = s.repairTool->range;
|
||||
admin.forEach<ShipIdentity, Position, Faction, Health>(
|
||||
[&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});
|
||||
});
|
||||
|
||||
admin.forEach<StationBody, Position, Faction, Health>(
|
||||
[&repairables](entt::entity e, const StationBody& /*sb*/,
|
||||
const Position& pos, const Faction& f, const Health& h)
|
||||
{
|
||||
repairables.push_back({e, pos.value, f.isEnemy, false, h.hp, h.maxHp});
|
||||
});
|
||||
|
||||
// Snapshot enemy ships for threat detection.
|
||||
struct EnemyInfo
|
||||
{
|
||||
QVector2D position;
|
||||
};
|
||||
std::vector<EnemyInfo> enemies;
|
||||
admin.forEach<ShipIdentity, Position, Faction>(
|
||||
[&enemies](entt::entity /*e*/, const ShipIdentity& /*si*/,
|
||||
const Position& pos, const Faction& f)
|
||||
{
|
||||
if (f.isEnemy)
|
||||
{
|
||||
enemies.push_back({pos.value});
|
||||
}
|
||||
});
|
||||
|
||||
admin.forEach<RepairBehavior, RepairTool, Position, Faction, SensorRange, MovementIntent>(
|
||||
[&](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 Ship& candidate : allShips)
|
||||
for (const EnemyInfo& enemy : enemies)
|
||||
{
|
||||
if (candidate.isEnemy
|
||||
&& (candidate.position - s.position).length() <= s.sensorRange)
|
||||
if ((enemy.position - pos.value).length() <= sensor.value)
|
||||
{
|
||||
enemyNearby = true;
|
||||
break;
|
||||
@@ -224,27 +220,22 @@ void AiSystem::tickRepairBehavior(ShipSystem& ships, BuildingSystem& buildings)
|
||||
}
|
||||
if (enemyNearby)
|
||||
{
|
||||
if (2 > s.intent.priority)
|
||||
if (2 > intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{2, QVector2D(-10000.0f, s.position.y())};
|
||||
intent = MovementIntent{2, QVector2D(-10000.0f, pos.value.y())};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
EntityId currentId = s.repairBehavior->currentTarget.value_or(kInvalidEntityId);
|
||||
// Validate current target.
|
||||
bool targetValid = false;
|
||||
if (currentId != kInvalidEntityId)
|
||||
if (rb.currentTarget)
|
||||
{
|
||||
const Ship* tShip = ships.findShip(currentId);
|
||||
if (tShip && !tShip->isEnemy && tShip->hp < tShip->maxHp)
|
||||
const entt::entity t = *rb.currentTarget;
|
||||
if (admin.isValid(t) && admin.hasAll<Health>(t))
|
||||
{
|
||||
targetValid = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
const Building* tBld = buildings.findBuilding(currentId);
|
||||
if (tBld && tBld->type == BuildingType::PlayerDefenceStation
|
||||
&& tBld->hp < tBld->maxHp)
|
||||
const Health& th = admin.get<Health>(t);
|
||||
if (th.hp > 0.0f && th.hp < th.maxHp)
|
||||
{
|
||||
targetValid = true;
|
||||
}
|
||||
@@ -253,83 +244,56 @@ void AiSystem::tickRepairBehavior(ShipSystem& ships, BuildingSystem& buildings)
|
||||
|
||||
if (!targetValid)
|
||||
{
|
||||
s.repairBehavior->currentTarget = std::nullopt;
|
||||
currentId = kInvalidEntityId;
|
||||
float bestDist = s.sensorRange;
|
||||
rb.currentTarget = std::nullopt;
|
||||
float bestDist = sensor.value;
|
||||
|
||||
for (const Ship& candidate : allShips)
|
||||
for (const RepairableInfo& r : repairables)
|
||||
{
|
||||
if (candidate.isEnemy || candidate.id == s.id
|
||||
|| candidate.hp >= candidate.maxHp)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float dist = (candidate.position - s.position).length();
|
||||
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;
|
||||
s.repairBehavior->currentTarget = candidate.id;
|
||||
rb.currentTarget = r.entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const Building& b : allBuildings)
|
||||
if (!rb.currentTarget)
|
||||
{
|
||||
if (b.type != BuildingType::PlayerDefenceStation
|
||||
|| b.hp >= b.maxHp)
|
||||
if (2 > intent.priority)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float dist = (buildingCenter(b) - s.position).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
s.repairBehavior->currentTarget = b.id;
|
||||
}
|
||||
}
|
||||
|
||||
currentId = s.repairBehavior->currentTarget.value_or(kInvalidEntityId);
|
||||
}
|
||||
|
||||
if (currentId == kInvalidEntityId)
|
||||
{
|
||||
if (2 > s.intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{2, QVector2D(s.position.x() + 1000.0f,
|
||||
s.position.y())};
|
||||
intent = MovementIntent{2, QVector2D(pos.value.x() + 1000.0f,
|
||||
pos.value.y())};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
QVector2D targetPos;
|
||||
const entt::entity target = *rb.currentTarget;
|
||||
QVector2D targetPos = pos.value;
|
||||
bool isShipTarget = false;
|
||||
const Ship* tShip = ships.findShip(currentId);
|
||||
if (tShip)
|
||||
if (admin.isValid(target) && admin.hasAll<Position>(target))
|
||||
{
|
||||
targetPos = tShip->position;
|
||||
isShipTarget = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
const Building* tBld = buildings.findBuilding(currentId);
|
||||
targetPos = tBld ? buildingCenter(*tBld) : s.position;
|
||||
targetPos = admin.get<Position>(target).value;
|
||||
isShipTarget = admin.hasAll<ShipIdentity>(target);
|
||||
}
|
||||
|
||||
float distToTarget = (targetPos - s.position).length();
|
||||
const float distToTarget = (targetPos - pos.value).length();
|
||||
if (distToTarget <= repairRange)
|
||||
{
|
||||
if (isShipTarget)
|
||||
if (admin.isValid(target) && admin.hasAll<Health>(target))
|
||||
{
|
||||
ships.healShip(currentId, s.repairTool->ratePerTick);
|
||||
}
|
||||
else
|
||||
{
|
||||
buildings.healBuilding(currentId, s.repairTool->ratePerTick);
|
||||
Health& targetHealth = admin.get<Health>(target);
|
||||
targetHealth.hp = std::min(targetHealth.hp + rt.ratePerTick,
|
||||
targetHealth.maxHp);
|
||||
}
|
||||
}
|
||||
|
||||
if (2 > s.intent.priority)
|
||||
if (2 > intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{2, targetPos};
|
||||
intent = MovementIntent{2, targetPos};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -338,69 +302,87 @@ void AiSystem::tickRepairBehavior(ShipSystem& ships, BuildingSystem& buildings)
|
||||
// tickScrapCollector (priority 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AiSystem::tickScrapCollector(ShipSystem& ships, ScrapSystem& scraps,
|
||||
void AiSystem::tickScrapCollector(EntityAdmin& admin, ScrapSystem& scraps,
|
||||
BuildingSystem& buildings)
|
||||
{
|
||||
const std::vector<Ship> 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<EnemyShipPos> enemyShips;
|
||||
admin.forEach<ShipIdentity, Position, Faction>(
|
||||
[&enemyShips](entt::entity /*e*/, const ShipIdentity& /*si*/,
|
||||
const Position& pos, const Faction& f)
|
||||
{
|
||||
const Building* bay = buildings.findNearestBuilding(s.position,
|
||||
if (f.isEnemy)
|
||||
{
|
||||
enemyShips.push_back({pos.value});
|
||||
}
|
||||
});
|
||||
|
||||
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
|
||||
|
||||
admin.forEach<ScrapCollector, SalvageCargo, Position, SensorRange, MovementIntent>(
|
||||
[&](entt::entity /*e*/, ScrapCollector& sc, SalvageCargo& cargo,
|
||||
Position& pos, SensorRange& sensor, MovementIntent& intent)
|
||||
{
|
||||
const float collectRange = cargo.collectionRange;
|
||||
|
||||
// Assign nearest SalvageBay if needed.
|
||||
if (sc.deliveryBay == kInvalidEntityId)
|
||||
{
|
||||
const Building* bay = buildings.findNearestBuilding(pos.value,
|
||||
BuildingType::SalvageBay);
|
||||
if (bay)
|
||||
{
|
||||
s.scrapCollector->deliveryBay = bay->id;
|
||||
sc.deliveryBay = bay->id;
|
||||
}
|
||||
}
|
||||
|
||||
const EntityId bayId = s.scrapCollector->deliveryBay;
|
||||
const EntityId bayId = sc.deliveryBay;
|
||||
|
||||
QVector2D bayPos = s.position;
|
||||
QVector2D bayPos = pos.value;
|
||||
if (bayId != kInvalidEntityId)
|
||||
{
|
||||
const Building* bay = buildings.findBuilding(bayId);
|
||||
if (bay)
|
||||
{
|
||||
bayPos = buildingCenter(*bay);
|
||||
bayPos = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
||||
bay->anchor.y() + bay->footprint.height() / 2.0f);
|
||||
}
|
||||
}
|
||||
|
||||
const bool cargoFull = (s.cargo->current >= s.cargo->capacity);
|
||||
const bool cargoFull = (cargo.current >= cargo.capacity);
|
||||
|
||||
if (cargoFull)
|
||||
{
|
||||
if (1 > s.intent.priority)
|
||||
if (1 > intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{1, bayPos};
|
||||
intent = MovementIntent{1, bayPos};
|
||||
}
|
||||
if (bayId != kInvalidEntityId
|
||||
&& (s.position - bayPos).length() <= 1.0f)
|
||||
&& (pos.value - bayPos).length() <= 1.0f)
|
||||
{
|
||||
if (buildings.deliverScrapToSalvageBay(bayId))
|
||||
{
|
||||
--s.cargo->current;
|
||||
--cargo.current;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Retreat if enemy near and cargo empty.
|
||||
bool retreating = false;
|
||||
if (s.cargo->current == 0)
|
||||
if (cargo.current == 0)
|
||||
{
|
||||
for (const Ship& candidate : allShips)
|
||||
for (const EnemyShipPos& enemy : enemyShips)
|
||||
{
|
||||
if (candidate.isEnemy
|
||||
&& (candidate.position - s.position).length() <= collectRange)
|
||||
if ((enemy.position - pos.value).length() <= collectRange)
|
||||
{
|
||||
if (1 > s.intent.priority)
|
||||
if (1 > intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{1, QVector2D(-10000.0f, s.position.y())};
|
||||
intent = MovementIntent{1, QVector2D(-10000.0f, pos.value.y())};
|
||||
}
|
||||
retreating = true;
|
||||
break;
|
||||
@@ -409,53 +391,55 @@ void AiSystem::tickScrapCollector(ShipSystem& ships, ScrapSystem& scraps,
|
||||
}
|
||||
if (retreating) { return; }
|
||||
|
||||
for (const Scrap& sc : scraps.allScraps())
|
||||
// Collect nearby scrap.
|
||||
for (const ScrapInfo& si : allScrap)
|
||||
{
|
||||
if ((sc.position - s.position).length() <= collectRange)
|
||||
if ((si.position - pos.value).length() <= collectRange)
|
||||
{
|
||||
if (scraps.consume(sc.id))
|
||||
if (scraps.consume(si.entity))
|
||||
{
|
||||
++s.cargo->current;
|
||||
s.scrapCollector->scrapTarget = std::nullopt;
|
||||
++cargo.current;
|
||||
sc.scrapTarget = std::nullopt;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (s.scrapCollector->scrapTarget)
|
||||
// Move toward scrap target or find a new one.
|
||||
if (sc.scrapTarget)
|
||||
{
|
||||
if (1 > s.intent.priority)
|
||||
if (1 > intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{1, *s.scrapCollector->scrapTarget};
|
||||
intent = MovementIntent{1, *sc.scrapTarget};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
float bestDist = s.sensorRange;
|
||||
float bestDist = sensor.value;
|
||||
std::optional<QVector2D> bestPos;
|
||||
for (const Scrap& sc : scraps.allScraps())
|
||||
for (const ScrapInfo& si : allScrap)
|
||||
{
|
||||
float dist = (sc.position - s.position).length();
|
||||
const float dist = (si.position - pos.value).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
bestPos = sc.position;
|
||||
bestPos = si.position;
|
||||
}
|
||||
}
|
||||
if (bestPos)
|
||||
{
|
||||
s.scrapCollector->scrapTarget = bestPos;
|
||||
if (1 > s.intent.priority)
|
||||
sc.scrapTarget = bestPos;
|
||||
if (1 > intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{1, *bestPos};
|
||||
intent = MovementIntent{1, *bestPos};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (1 > s.intent.priority)
|
||||
if (1 > intent.priority)
|
||||
{
|
||||
s.intent = MovementIntent{1, QVector2D(s.position.x() + 1000.0f,
|
||||
s.position.y())};
|
||||
intent = MovementIntent{1, QVector2D(pos.value.x() + 1000.0f,
|
||||
pos.value.y())};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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<EntityId> currentTarget;
|
||||
std::optional<entt::entity> currentTarget;
|
||||
};
|
||||
|
||||
// A fully constructed, operational building.
|
||||
|
||||
@@ -1152,3 +1152,20 @@ void BuildingSystem::forEachBuilding(std::function<void(Building&)> fn)
|
||||
fn(b);
|
||||
}
|
||||
}
|
||||
|
||||
void BuildingSystem::registerTileOccupancy(const std::vector<QPoint>& cells,
|
||||
EntityId ownerPlaceholder)
|
||||
{
|
||||
for (const QPoint& cell : cells)
|
||||
{
|
||||
m_tileOccupancy[{cell.x(), cell.y()}] = ownerPlaceholder;
|
||||
}
|
||||
}
|
||||
|
||||
void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells)
|
||||
{
|
||||
for (const QPoint& cell : cells)
|
||||
{
|
||||
m_tileOccupancy.erase({cell.x(), cell.y()});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<QPoint>& cells, EntityId ownerPlaceholder);
|
||||
void unregisterTileOccupancy(const std::vector<QPoint>& 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);
|
||||
|
||||
@@ -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<FireEvent>& outFireEvents)
|
||||
{
|
||||
// Ships: iterate and resolve weapon for each combat ship.
|
||||
ships.forEach([&](Ship& ship)
|
||||
// Ship weapons.
|
||||
admin.forEach<Weapon, ThreatResponse, Position>(
|
||||
[&](entt::entity e, Weapon& weapon, ThreatResponse& threat, Position& pos)
|
||||
{
|
||||
resolveShipWeapon(ship, currentTick, ships, buildings, outFireEvents);
|
||||
resolveShipWeapon(e, weapon, threat, pos, currentTick, admin, outFireEvents);
|
||||
});
|
||||
|
||||
// Defence stations: acquire targets and fire.
|
||||
buildings.forEachBuilding([&](Building& building)
|
||||
// Station weapons.
|
||||
admin.forEach<StationWeapon, Position, Faction>(
|
||||
[&](entt::entity e, StationWeapon& weapon, Position& pos, Faction& faction)
|
||||
{
|
||||
if (building.type == BuildingType::PlayerDefenceStation ||
|
||||
building.type == BuildingType::EnemyDefenceStation)
|
||||
{
|
||||
resolveStationWeapon(building, currentTick, ships, buildings, outFireEvents);
|
||||
}
|
||||
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<FireEvent>& 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<QVector2D> tPos = targetPosition(targetId, ships, buildings);
|
||||
if (!tPos)
|
||||
{
|
||||
ship.threatResponse->currentTarget = std::nullopt;
|
||||
return;
|
||||
}
|
||||
|
||||
const float dist = (ship.position - *tPos).length();
|
||||
if (dist > w.range)
|
||||
const entt::entity targetEntity = *threat.currentTarget;
|
||||
if (!admin.isValid(targetEntity) || !admin.hasAll<Position>(targetEntity))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_pendingDamage.push_back({targetId, w.damage, currentTick + kWeaponImpactDelayTicks});
|
||||
const QVector2D targetPos = admin.get<Position>(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<float>(kTickRateHz) / w.fireRateHz;
|
||||
weapon.cooldownTicks = static_cast<float>(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<FireEvent>& 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<QVector2D> 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<Position>(t))
|
||||
{
|
||||
w.currentTarget = std::nullopt;
|
||||
weapon.currentTarget = std::nullopt;
|
||||
}
|
||||
else
|
||||
{
|
||||
const float dist = (stationPos.value - admin.get<Position>(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);
|
||||
}
|
||||
|
||||
if (!w.currentTarget)
|
||||
float bestDist = weapon.range;
|
||||
admin.forEach<ShipIdentity, Position, Faction>(
|
||||
[&](entt::entity candidate, const ShipIdentity& /*si*/,
|
||||
const Position& candidatePos, const Faction& candidateFaction)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrement cooldown.
|
||||
if (w.cooldownTicks > 0.0f)
|
||||
{
|
||||
w.cooldownTicks -= 1.0f;
|
||||
}
|
||||
|
||||
if (w.cooldownTicks > 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const EntityId targetId = *w.currentTarget;
|
||||
const std::optional<QVector2D> tPos = targetPosition(targetId, ships, buildings);
|
||||
if (!tPos)
|
||||
{
|
||||
w.currentTarget = std::nullopt;
|
||||
return;
|
||||
}
|
||||
|
||||
if ((stationCenter - *tPos).length() > w.range)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_pendingDamage.push_back({targetId, w.damage, currentTick + kWeaponImpactDelayTicks});
|
||||
|
||||
FireEvent evt;
|
||||
evt.shooter = station.id;
|
||||
evt.target = targetId;
|
||||
evt.emittedAt = currentTick;
|
||||
out.push_back(evt);
|
||||
|
||||
w.cooldownTicks = static_cast<float>(kTickRateHz) / w.fireRateHz;
|
||||
}
|
||||
|
||||
std::optional<EntityId> CombatSystem::acquireStationTarget(
|
||||
const Building& station, bool stationIsEnemy,
|
||||
const ShipSystem& ships) const
|
||||
{
|
||||
const QVector2D stationCenter(
|
||||
station.anchor.x() + station.footprint.width() / 2.0f,
|
||||
station.anchor.y() + station.footprint.height() / 2.0f);
|
||||
const float range = station.weapon->range;
|
||||
|
||||
std::optional<EntityId> best;
|
||||
float bestDist = range;
|
||||
|
||||
// Scan ships for valid targets.
|
||||
for (const Ship& candidate : ships.allShips())
|
||||
{
|
||||
const bool isValidTarget = stationIsEnemy ? !candidate.isEnemy
|
||||
: candidate.isEnemy;
|
||||
const bool isValidTarget = stationFaction.isEnemy
|
||||
? !candidateFaction.isEnemy
|
||||
: candidateFaction.isEnemy;
|
||||
if (!isValidTarget)
|
||||
{
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
const float dist = (candidate.position - stationCenter).length();
|
||||
const float dist = (candidatePos.value - stationPos.value).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
best = candidate.id;
|
||||
weapon.currentTarget = candidate;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
void CombatSystem::applyPendingDamage(Tick currentTick,
|
||||
ShipSystem& ships,
|
||||
BuildingSystem& buildings)
|
||||
if (!weapon.currentTarget)
|
||||
{
|
||||
auto it = m_pendingDamage.begin();
|
||||
return;
|
||||
}
|
||||
|
||||
if (weapon.cooldownTicks > 0.0f)
|
||||
{
|
||||
weapon.cooldownTicks -= 1.0f;
|
||||
}
|
||||
if (weapon.cooldownTicks > 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const entt::entity targetEntity = *weapon.currentTarget;
|
||||
if (!admin.isValid(targetEntity) || !admin.hasAll<Position>(targetEntity))
|
||||
{
|
||||
weapon.currentTarget = std::nullopt;
|
||||
return;
|
||||
}
|
||||
|
||||
const QVector2D targetPos = admin.get<Position>(targetEntity).value;
|
||||
if ((stationPos.value - targetPos).length() > weapon.range)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_pendingDamage.push_back({targetEntity, weapon.damage,
|
||||
currentTick + kWeaponImpactDelayTicks});
|
||||
|
||||
FireEvent evt;
|
||||
evt.shooter = stationEntity;
|
||||
evt.target = targetEntity;
|
||||
evt.emittedAt = currentTick;
|
||||
out.push_back(evt);
|
||||
|
||||
weapon.cooldownTicks = static_cast<float>(kTickRateHz) / weapon.fireRateHz;
|
||||
}
|
||||
|
||||
void CombatSystem::applyPendingDamage(Tick currentTick, EntityAdmin& admin)
|
||||
{
|
||||
std::vector<PendingDamage>::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<Health>(it->target))
|
||||
{
|
||||
ships.damageShip(it->target, it->amount);
|
||||
}
|
||||
else if (buildings.findBuilding(it->target))
|
||||
{
|
||||
buildings.damageBuilding(it->target, it->amount);
|
||||
admin.get<Health>(it->target).hp -= it->amount;
|
||||
}
|
||||
it = m_pendingDamage.erase(it);
|
||||
}
|
||||
@@ -215,22 +183,3 @@ void CombatSystem::applyPendingDamage(Tick currentTick,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<QVector2D> CombatSystem::targetPosition(
|
||||
EntityId id,
|
||||
const ShipSystem& ships,
|
||||
const BuildingSystem& buildings) const
|
||||
{
|
||||
const Ship* ship = ships.findShip(id);
|
||||
if (ship)
|
||||
{
|
||||
return ship->position;
|
||||
}
|
||||
const Building* bld = buildings.findBuilding(id);
|
||||
if (bld)
|
||||
{
|
||||
return QVector2D(bld->anchor.x() + bld->footprint.width() / 2.0f,
|
||||
bld->anchor.y() + bld->footprint.height() / 2.0f);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -6,67 +6,52 @@
|
||||
#include <QVector2D>
|
||||
|
||||
#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<FireEvent>& 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;
|
||||
entt::entity target;
|
||||
float amount;
|
||||
Tick appliesAt;
|
||||
};
|
||||
|
||||
std::vector<PendingDamage> 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<FireEvent>& 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<FireEvent>& 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<EntityId> acquireStationTarget(
|
||||
const Building& station, bool stationIsEnemy,
|
||||
const ShipSystem& ships) const;
|
||||
|
||||
// Return the world position of the entity, or nullopt if it no longer exists.
|
||||
std::optional<QVector2D> targetPosition(EntityId id,
|
||||
const ShipSystem& ships,
|
||||
const BuildingSystem& buildings) const;
|
||||
|
||||
const GameConfig& m_config;
|
||||
};
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#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)
|
||||
admin.forEach<Position, Velocity, Facing, ShipDynamics, MovementIntent>(
|
||||
[](entt::entity /*e*/, Position& pos, Velocity& vel, Facing& facing,
|
||||
ShipDynamics& dynamics, MovementIntent& intent)
|
||||
{
|
||||
if (s.intent.priority == 0)
|
||||
if (intent.priority == 0)
|
||||
{
|
||||
s.velocity = QVector2D(0.0f, 0.0f);
|
||||
s.rotationSpeed = 0.0f;
|
||||
vel.value = QVector2D(0.0f, 0.0f);
|
||||
facing.rotationSpeed = 0.0f;
|
||||
return;
|
||||
}
|
||||
|
||||
const QVector2D delta = s.intent.target - s.position;
|
||||
const QVector2D delta = intent.target - pos.value;
|
||||
const float dist = delta.length();
|
||||
|
||||
if (dist < 0.001f)
|
||||
{
|
||||
s.velocity = QVector2D(0.0f, 0.0f);
|
||||
vel.value = QVector2D(0.0f, 0.0f);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Rotate toward target ──────────────────────────────────────────
|
||||
// Rotate toward target.
|
||||
const float desiredAngle = std::atan2(delta.y(), delta.x());
|
||||
const float angleDiff = wrapAngle(desiredAngle - s.facing);
|
||||
const float angleDiff = wrapAngle(desiredAngle - facing.radians);
|
||||
|
||||
const float rotDelta = std::max(-s.angularAccelerationPerTick,
|
||||
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))
|
||||
const bool sameSign = (facing.rotationSpeed >= 0.0f) == (angleDiff >= 0.0f);
|
||||
if (sameSign && std::abs(facing.rotationSpeed) > std::abs(angleDiff))
|
||||
{
|
||||
s.rotationSpeed = angleDiff;
|
||||
facing.rotationSpeed = angleDiff;
|
||||
}
|
||||
|
||||
s.facing = wrapAngle(s.facing + s.rotationSpeed);
|
||||
facing.radians = wrapAngle(facing.radians + facing.rotationSpeed);
|
||||
|
||||
// ── Desired velocity (with braking near target) ───────────────────
|
||||
const float manAccel = s.maneuveringAccelerationPerTick;
|
||||
const float stoppingDist = (s.maxSpeedPerTick * s.maxSpeedPerTick)
|
||||
// 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)
|
||||
: s.maxSpeedPerTick;
|
||||
: dynamics.maxSpeedPerTick;
|
||||
const QVector2D desiredVel = delta.normalized() * desiredSpeed;
|
||||
const QVector2D velError = desiredVel - s.velocity;
|
||||
const QVector2D velError = desiredVel - vel.value;
|
||||
|
||||
// ── Main acceleration: forward only, along facing ─────────────────
|
||||
const QVector2D facingVec(std::cos(s.facing), std::sin(s.facing));
|
||||
// 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, s.mainAccelerationPerTick);
|
||||
const float mainApplied = std::min(mainAligned, dynamics.mainAccelerationPerTick);
|
||||
const QVector2D mainDelta = facingVec * mainApplied;
|
||||
|
||||
// ── Maneuvering acceleration: any direction, handles the remainder ─
|
||||
// Maneuvering acceleration: any direction, handles the remainder.
|
||||
const QVector2D remaining = velError - mainDelta;
|
||||
const 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)
|
||||
// Speed cap.
|
||||
const float speed = vel.value.length();
|
||||
if (speed > dynamics.maxSpeedPerTick)
|
||||
{
|
||||
s.velocity = s.velocity.normalized() * s.maxSpeedPerTick;
|
||||
vel.value = vel.value.normalized() * dynamics.maxSpeedPerTick;
|
||||
}
|
||||
|
||||
// ── Snap to target or advance ─────────────────────────────────────
|
||||
if (dist <= s.velocity.length())
|
||||
// Snap to target or advance.
|
||||
if (dist <= vel.value.length())
|
||||
{
|
||||
s.position = s.intent.target;
|
||||
s.velocity = QVector2D(0.0f, 0.0f);
|
||||
pos.value = intent.target;
|
||||
vel.value = QVector2D(0.0f, 0.0f);
|
||||
}
|
||||
else
|
||||
{
|
||||
s.position += s.velocity;
|
||||
pos.value += vel.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
class ShipSystem;
|
||||
class EntityAdmin;
|
||||
|
||||
class MovementSystem
|
||||
{
|
||||
public:
|
||||
void tick(ShipSystem& ships);
|
||||
void tick(EntityAdmin& admin);
|
||||
};
|
||||
|
||||
@@ -1,59 +1,53 @@
|
||||
#include "ScrapSystem.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <optional>
|
||||
#include "EntityAdmin.h"
|
||||
|
||||
ScrapSystem::ScrapSystem(std::function<EntityId()> 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());
|
||||
std::vector<entt::entity> expired;
|
||||
m_admin.forEach<DespawnAt>(
|
||||
[&expired, currentTick](entt::entity e, DespawnAt& d)
|
||||
{
|
||||
if (d.tick <= currentTick)
|
||||
{
|
||||
expired.push_back(e);
|
||||
}
|
||||
});
|
||||
|
||||
for (entt::entity e : expired)
|
||||
{
|
||||
m_admin.destroy(e);
|
||||
}
|
||||
}
|
||||
|
||||
const Scrap* ScrapSystem::findScrap(EntityId id) const
|
||||
std::optional<int> ScrapSystem::consume(entt::entity entity)
|
||||
{
|
||||
for (const Scrap& s : m_scraps)
|
||||
if (!m_admin.isValid(entity) || !m_admin.hasAll<ScrapData>(entity))
|
||||
{
|
||||
if (s.id == id)
|
||||
{
|
||||
return &s;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::optional<Scrap> ScrapSystem::consume(EntityId id)
|
||||
{
|
||||
for (auto it = m_scraps.begin(); it != m_scraps.end(); ++it)
|
||||
{
|
||||
if (it->id == id)
|
||||
{
|
||||
Scrap result = *it;
|
||||
m_scraps.erase(it);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<Scrap> ScrapSystem::allScraps() const
|
||||
{
|
||||
return m_scraps;
|
||||
int amount = m_admin.get<ScrapData>(entity).amount;
|
||||
m_admin.destroy(entity);
|
||||
return amount;
|
||||
}
|
||||
|
||||
std::vector<ScrapInfo> ScrapSystem::allScrapInfo() const
|
||||
{
|
||||
std::vector<ScrapInfo> result;
|
||||
m_admin.forEach<ScrapData>(
|
||||
[&result, this](entt::entity e, const ScrapData& /*sd*/)
|
||||
{
|
||||
result.push_back(ScrapInfo{e, m_admin.get<Position>(e).value});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#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<EntityId()> allocateId);
|
||||
explicit ScrapSystem(EntityAdmin& admin);
|
||||
|
||||
EntityId spawn(QVector2D position, int amount, Tick despawnAt);
|
||||
entt::entity spawn(QVector2D position, int amount, Tick despawnAt);
|
||||
void tickDespawn(Tick currentTick);
|
||||
std::optional<Scrap> consume(EntityId id); // removes and returns scrap, or nullopt
|
||||
|
||||
const Scrap* findScrap(EntityId id) const;
|
||||
std::vector<Scrap> allScraps() const;
|
||||
// Removes the scrap and returns its amount, or nullopt if not found.
|
||||
std::optional<int> consume(entt::entity entity);
|
||||
|
||||
// Lightweight snapshot for callers that need to iterate all scrap.
|
||||
std::vector<ScrapInfo> allScrapInfo() const;
|
||||
|
||||
private:
|
||||
std::function<EntityId()> m_allocateId;
|
||||
std::vector<Scrap> m_scraps;
|
||||
EntityAdmin& m_admin;
|
||||
};
|
||||
|
||||
@@ -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<EntityId> currentTarget;
|
||||
std::optional<entt::entity> 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<EntityId> currentTarget;
|
||||
std::optional<entt::entity> currentTarget;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -41,7 +43,7 @@ struct RepairTool
|
||||
|
||||
struct ThreatResponse
|
||||
{
|
||||
std::optional<EntityId> currentTarget;
|
||||
std::optional<entt::entity> currentTarget;
|
||||
};
|
||||
|
||||
struct ScrapCollector
|
||||
@@ -52,7 +54,7 @@ struct ScrapCollector
|
||||
|
||||
struct RepairBehavior
|
||||
{
|
||||
std::optional<EntityId> currentTarget;
|
||||
std::optional<entt::entity> 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> weapon;
|
||||
std::optional<SalvageCargo> cargo;
|
||||
std::optional<RepairTool> repairTool;
|
||||
std::optional<ThreatResponse> threatResponse;
|
||||
std::optional<ScrapCollector> scrapCollector;
|
||||
std::optional<RepairBehavior> repairBehavior;
|
||||
std::optional<HomeReturn> homeReturn;
|
||||
std::optional<RallyBehavior> rallyBehavior;
|
||||
|
||||
// Cleared at the start of the behavior step each tick; the highest-priority
|
||||
// write from behavior systems wins (architecture.md §Movement Arbitration).
|
||||
MovementIntent intent;
|
||||
};
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
#include "ShipSystem.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <map>
|
||||
#include <utility>
|
||||
|
||||
#include "EcsComponents.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "ModulesConfig.h"
|
||||
#include "Tick.h"
|
||||
|
||||
ShipSystem::ShipSystem(const GameConfig& config,
|
||||
std::function<EntityId()> allocateId)
|
||||
ShipSystem::ShipSystem(const GameConfig& config, EntityAdmin& admin)
|
||||
: m_config(config)
|
||||
, m_allocateId(std::move(allocateId))
|
||||
, m_admin(admin)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ const ModuleDef* ShipSystem::findModuleDef(const std::string& id) const
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D position,
|
||||
entt::entity ShipSystem::spawn(const std::string& schematicId, int level, QVector2D position,
|
||||
bool isEnemy,
|
||||
const std::optional<ShipLayoutConfig>& layout)
|
||||
{
|
||||
@@ -47,32 +47,24 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
|
||||
assert(def != nullptr);
|
||||
|
||||
const double x = static_cast<double>(level);
|
||||
|
||||
Ship ship;
|
||||
ship.id = m_allocateId();
|
||||
ship.position = position;
|
||||
ship.velocity = QVector2D(0.0f, 0.0f);
|
||||
ship.facing = 0.0f;
|
||||
ship.rotationSpeed = 0.0f;
|
||||
ship.maxHp = static_cast<float>(def->health.hpFormula.evaluate(x));
|
||||
ship.hp = ship.maxHp;
|
||||
const float tickRate = static_cast<float>(kTickRateHz);
|
||||
ship.maxSpeedPerTick = static_cast<float>(def->movement.speedFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.mainAccelerationPerTick = static_cast<float>(def->movement.mainAccelerationFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.maneuveringAccelerationPerTick = static_cast<float>(def->movement.maneuveringAccelerationFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.angularAccelerationPerTick = static_cast<float>(def->movement.angularAccelerationFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.maxRotationSpeedPerTick = static_cast<float>(def->movement.maxRotationSpeedFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
ship.sensorRange = static_cast<float>(def->sensor.sensorRangeFormula.evaluate(x));
|
||||
ship.level = level;
|
||||
ship.schematicId = schematicId;
|
||||
ship.isEnemy = isEnemy;
|
||||
ship.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)};
|
||||
|
||||
float hp = static_cast<float>(def->health.hpFormula.evaluate(x));
|
||||
float maxHp = hp;
|
||||
float maxSpeedPerTick = static_cast<float>(def->movement.speedFormula.evaluate(x)) / tickRate;
|
||||
float mainAccelPerTick = static_cast<float>(def->movement.mainAccelerationFormula.evaluate(x)) / tickRate;
|
||||
float maneuveringAccelPerTick = static_cast<float>(def->movement.maneuveringAccelerationFormula.evaluate(x)) / tickRate;
|
||||
float angularAccelPerTick = static_cast<float>(def->movement.angularAccelerationFormula.evaluate(x)) / tickRate;
|
||||
float maxRotationSpeedPerTick = static_cast<float>(def->movement.maxRotationSpeedFormula.evaluate(x)) / tickRate;
|
||||
float sensorRange = static_cast<float>(def->sensor.sensorRangeFormula.evaluate(x));
|
||||
|
||||
entt::entity entity = m_admin.spawnShip(
|
||||
position, hp, maxHp,
|
||||
maxSpeedPerTick, mainAccelPerTick, maneuveringAccelPerTick,
|
||||
angularAccelPerTick, maxRotationSpeedPerTick, sensorRange,
|
||||
level, schematicId, isEnemy);
|
||||
|
||||
// Optional components based on ship role.
|
||||
if (def->combat)
|
||||
{
|
||||
Weapon w;
|
||||
@@ -80,13 +72,14 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
|
||||
w.range = static_cast<float>(def->combat->attackRangeFormula.evaluate(x));
|
||||
w.fireRateHz = static_cast<float>(def->combat->attackRateFormula.evaluate(x));
|
||||
w.cooldownTicks = 0.0f;
|
||||
ship.weapon = w;
|
||||
w.currentTarget = std::nullopt;
|
||||
m_admin.addComponent<Weapon>(entity, w);
|
||||
|
||||
ship.threatResponse = ThreatResponse{};
|
||||
m_admin.addComponent<ThreatResponse>(entity, ThreatResponse{});
|
||||
|
||||
if (!isEnemy)
|
||||
{
|
||||
ship.rallyBehavior = RallyBehavior{m_rallyPoint};
|
||||
m_admin.addComponent<RallyBehavior>(entity, RallyBehavior{m_rallyPoint});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,12 +89,12 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
|
||||
cargo.capacity = def->salvage->cargoCapacity;
|
||||
cargo.current = 0;
|
||||
cargo.collectionRange = static_cast<float>(def->salvage->collectionRange);
|
||||
ship.cargo = cargo;
|
||||
m_admin.addComponent<SalvageCargo>(entity, cargo);
|
||||
|
||||
ScrapCollector sc;
|
||||
sc.scrapTarget = std::nullopt;
|
||||
sc.deliveryBay = kInvalidEntityId;
|
||||
ship.scrapCollector = sc;
|
||||
m_admin.addComponent<ScrapCollector>(entity, sc);
|
||||
}
|
||||
|
||||
if (def->repair)
|
||||
@@ -109,10 +102,10 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
|
||||
RepairTool rt;
|
||||
rt.ratePerTick = static_cast<float>(def->repair->repairRateFormula.evaluate(x));
|
||||
rt.range = static_cast<float>(def->repair->repairRangeFormula.evaluate(x));
|
||||
ship.repairTool = rt;
|
||||
rt.currentTarget = std::nullopt;
|
||||
m_admin.addComponent<RepairTool>(entity, rt);
|
||||
|
||||
RepairBehavior rb;
|
||||
ship.repairBehavior = rb;
|
||||
m_admin.addComponent<RepairBehavior>(entity, RepairBehavior{});
|
||||
}
|
||||
|
||||
// Apply module stat modifiers (REQ-MOD-STAT-CALC).
|
||||
@@ -152,105 +145,49 @@ 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<Health>(entity);
|
||||
ShipDynamics& dynamics = m_admin.get<ShipDynamics>(entity);
|
||||
SensorRange& sensor = m_admin.get<SensorRange>(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<Weapon>(entity))
|
||||
{
|
||||
applyMod(ship.weapon->damage, "damage");
|
||||
applyMod(ship.weapon->range, "attack_range");
|
||||
applyMod(ship.weapon->fireRateHz, "attack_rate");
|
||||
Weapon& weapon = m_admin.get<Weapon>(entity);
|
||||
applyMod(weapon.damage, "damage");
|
||||
applyMod(weapon.range, "attack_range");
|
||||
applyMod(weapon.fireRateHz, "attack_rate");
|
||||
}
|
||||
if (ship.repairTool.has_value())
|
||||
if (m_admin.hasAll<RepairTool>(entity))
|
||||
{
|
||||
applyMod(ship.repairTool->ratePerTick, "repair_rate");
|
||||
applyMod(ship.repairTool->range, "repair_range");
|
||||
RepairTool& repairTool = m_admin.get<RepairTool>(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<Ship> ShipSystem::allShips() const
|
||||
{
|
||||
return m_ships;
|
||||
}
|
||||
|
||||
void ShipSystem::forEach(std::function<void(Ship&)> fn)
|
||||
{
|
||||
for (Ship& s : m_ships)
|
||||
{
|
||||
fn(s);
|
||||
}
|
||||
}
|
||||
|
||||
bool ShipSystem::healShip(EntityId id, float amount)
|
||||
{
|
||||
for (Ship& s : m_ships)
|
||||
{
|
||||
if (s.id == id)
|
||||
{
|
||||
s.hp = std::min(s.hp + amount, s.maxHp);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ShipSystem::damageShip(EntityId id, float amount)
|
||||
{
|
||||
for (Ship& s : m_ships)
|
||||
{
|
||||
if (s.id == id)
|
||||
{
|
||||
s.hp -= amount;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clearMovementIntents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ShipSystem::clearMovementIntents()
|
||||
{
|
||||
for (Ship& s : m_ships)
|
||||
m_admin.forEach<MovementIntent>([](entt::entity /*e*/, MovementIntent& i)
|
||||
{
|
||||
s.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)};
|
||||
i = MovementIntent{0, QVector2D(0.0f, 0.0f)};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rally point management (REQ-SHP-RALLY)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ShipSystem::setRallyPoint(QVector2D point)
|
||||
{
|
||||
@@ -259,11 +196,17 @@ void ShipSystem::setRallyPoint(QVector2D point)
|
||||
|
||||
void ShipSystem::triggerRallyDeparture()
|
||||
{
|
||||
for (Ship& s : m_ships)
|
||||
std::vector<entt::entity> toRemove;
|
||||
m_admin.forEach<RallyBehavior, Faction>(
|
||||
[&toRemove](entt::entity e, const RallyBehavior& /*rb*/, const Faction& f)
|
||||
{
|
||||
if (!s.isEnemy)
|
||||
if (!f.isEnemy)
|
||||
{
|
||||
s.rallyBehavior = std::nullopt;
|
||||
}
|
||||
toRemove.push_back(e);
|
||||
}
|
||||
});
|
||||
for (entt::entity e : toRemove)
|
||||
{
|
||||
m_admin.removeComponent<RallyBehavior>(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#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<EntityId()> 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,
|
||||
entt::entity spawn(const std::string& schematicId, int level, QVector2D position,
|
||||
bool isEnemy = false,
|
||||
const std::optional<ShipLayoutConfig>& layout = std::nullopt);
|
||||
void despawn(EntityId id);
|
||||
|
||||
const Ship* findShip(EntityId id) const;
|
||||
std::vector<Ship> allShips() const;
|
||||
void forEach(std::function<void(Ship&)> fn);
|
||||
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<EntityId()> m_allocateId;
|
||||
std::vector<Ship> m_ships;
|
||||
EntityAdmin& m_admin;
|
||||
QVector2D m_rallyPoint;
|
||||
};
|
||||
|
||||
@@ -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<BuildingSystem>(
|
||||
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<ShipSystem>(m_config, [this]() { return allocateId(); });
|
||||
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
|
||||
m_aiSystem = std::make_unique<AiSystem>();
|
||||
m_movementSystem = std::make_unique<MovementSystem>();
|
||||
m_scrapSystem = std::make_unique<ScrapSystem>([this]() { return allocateId(); });
|
||||
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
||||
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
|
||||
m_combatSystem = std::make_unique<CombatSystem>(m_config);
|
||||
|
||||
@@ -83,14 +85,16 @@ void Simulation::reset(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_currentEnemyStationIds[0] = kInvalidEntityId;
|
||||
m_currentEnemyStationIds[1] = kInvalidEntityId;
|
||||
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<BuildingSystem>(
|
||||
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<ShipSystem>(m_config, [this]() { return allocateId(); });
|
||||
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
|
||||
m_aiSystem = std::make_unique<AiSystem>();
|
||||
m_movementSystem = std::make_unique<MovementSystem>();
|
||||
m_scrapSystem = std::make_unique<ScrapSystem>([this]() { return allocateId(); });
|
||||
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
||||
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
|
||||
m_combatSystem = std::make_unique<CombatSystem>(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<float>(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<float>(
|
||||
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<QPoint> 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<StationWeapon>(m_playerStation1Entity, psWeapon);
|
||||
m_buildingSystem->registerTileOccupancy(absCells, allocateId());
|
||||
}
|
||||
{
|
||||
const QPoint anchor(psAnchorX, ps2Y);
|
||||
std::vector<QPoint> absCells;
|
||||
for (const QPoint& rel : psParsed.bodyCells)
|
||||
{
|
||||
absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
|
||||
}
|
||||
m_playerStation2Entity = m_admin.spawnStation(
|
||||
anchor, psParsed.footprint, absCells, psHp, psHp, false);
|
||||
m_admin.addComponent<StationWeapon>(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<float>(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<float>(
|
||||
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<QPoint> 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<StationWeapon>(m_currentEnemyStationEntities[0], esWeapon);
|
||||
m_buildingSystem->registerTileOccupancy(absCells, allocateId());
|
||||
}
|
||||
{
|
||||
const QPoint anchor(anchorX, y2);
|
||||
std::vector<QPoint> absCells;
|
||||
for (const QPoint& rel : esParsed.bodyCells)
|
||||
{
|
||||
absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
|
||||
}
|
||||
m_currentEnemyStationEntities[1] = m_admin.spawnStation(
|
||||
anchor, esParsed.footprint, absCells, esHp, esHp, true);
|
||||
m_admin.addComponent<StationWeapon>(m_currentEnemyStationEntities[1], esWeapon);
|
||||
m_buildingSystem->registerTileOccupancy(absCells, allocateId());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -297,77 +331,61 @@ void Simulation::placeEnemyStationSet(int generation)
|
||||
void Simulation::tickDeathsAndLoot()
|
||||
{
|
||||
// --- Dead ships ---
|
||||
std::vector<EntityId> deadShipIds;
|
||||
m_shipSystem->forEach([&deadShipIds](Ship& s)
|
||||
std::vector<entt::entity> deadShips;
|
||||
m_admin.forEach<ShipIdentity, Health>(
|
||||
[&deadShips](entt::entity e, const ShipIdentity& /*si*/, const Health& h)
|
||||
{
|
||||
if (s.hp <= 0.0f)
|
||||
if (h.hp <= 0.0f)
|
||||
{
|
||||
deadShipIds.push_back(s.id);
|
||||
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<ShipIdentity>(deadEntity);
|
||||
const Position& pos = m_admin.get<Position>(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<EntityId> deadBuildingIds;
|
||||
for (const Building& b : m_buildingSystem->allBuildings())
|
||||
// --- Dead stations ---
|
||||
std::vector<entt::entity> deadStations;
|
||||
m_admin.forEach<StationBody, Health>(
|
||||
[&deadStations](entt::entity e, const StationBody& /*sb*/, const Health& h)
|
||||
{
|
||||
if (b.hp <= 0.0f &&
|
||||
(b.type == BuildingType::Hq ||
|
||||
b.type == BuildingType::PlayerDefenceStation ||
|
||||
b.type == BuildingType::EnemyDefenceStation))
|
||||
if (h.hp <= 0.0f)
|
||||
{
|
||||
deadBuildingIds.push_back(b.id);
|
||||
}
|
||||
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<StationBody>(deadEntity);
|
||||
const Position& pos = m_admin.get<Position>(deadEntity);
|
||||
const Faction& fac = m_admin.get<Faction>(deadEntity);
|
||||
|
||||
if (b->type == BuildingType::Hq)
|
||||
{
|
||||
m_gameOver = true;
|
||||
}
|
||||
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)
|
||||
if (!fac.isEnemy)
|
||||
{
|
||||
const double lv = static_cast<double>(
|
||||
m_config.stations.playerStation.level);
|
||||
scrap = static_cast<int>(
|
||||
m_config.stations.playerStation.scrapDropFormula.evaluate(lv));
|
||||
}
|
||||
else if (b->type == BuildingType::EnemyDefenceStation)
|
||||
else
|
||||
{
|
||||
const double genD = static_cast<double>(m_waveSystem->generation());
|
||||
scrap = static_cast<int>(
|
||||
@@ -375,20 +393,30 @@ void Simulation::tickDeathsAndLoot()
|
||||
}
|
||||
if (scrap > 0)
|
||||
{
|
||||
m_scrapSystem->spawn(center, scrap, despawnAt);
|
||||
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<Health>(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<Health>(m_currentEnemyStationEntities[0]).hp <= 0.0f;
|
||||
const bool es1Gone = !m_admin.isValid(m_currentEnemyStationEntities[1])
|
||||
|| m_admin.get<Health>(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++;
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
#include <QPoint>
|
||||
|
||||
#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<std::string, SchematicState> m_schematicLevels;
|
||||
|
||||
EntityAdmin m_admin;
|
||||
BeltSystem m_beltSystem;
|
||||
std::unique_ptr<BuildingSystem> m_buildingSystem;
|
||||
std::unique_ptr<ShipSystem> m_shipSystem;
|
||||
|
||||
@@ -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<ShipLayoutConfig>&) {},
|
||||
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<MovementIntent>(e);
|
||||
}
|
||||
|
||||
static const Health& health(EntityAdmin& a, entt::entity e)
|
||||
{
|
||||
return a.get<Health>(e);
|
||||
}
|
||||
|
||||
static const Position& pos(EntityAdmin& a, entt::entity e)
|
||||
{
|
||||
return a.get<Position>(e);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clearMovementIntents
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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<MovementIntent>(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<ShipDynamics>(e).maxSpeedPerTick;
|
||||
f.admin.get<MovementIntent>(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<ShipDynamics>(e).maxSpeedPerTick;
|
||||
const QVector2D target(speed * 0.5f, 0.0f);
|
||||
f.admin.get<MovementIntent>(e) = MovementIntent{1, target};
|
||||
f.movement.tick(f.admin);
|
||||
|
||||
f.ships.forEach([&target](Ship& s) {
|
||||
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<HomeReturn>(e, HomeReturn{0.3f, QVector2D(-10.0f, 0.0f)});
|
||||
f.admin.get<Health>(e).hp = f.admin.get<Health>(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<HomeReturn>(e, HomeReturn{0.5f, homePos});
|
||||
f.admin.get<Health>(e).hp = f.admin.get<Health>(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<HomeReturn>(player, HomeReturn{0.5f, homePos});
|
||||
f.admin.get<Health>(player).hp = f.admin.get<Health>(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),
|
||||
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<ThreatResponse>(player));
|
||||
const ThreatResponse& tr = f.admin.get<ThreatResponse>(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<ThreatResponse>(e1));
|
||||
REQUIRE_FALSE(f.admin.get<ThreatResponse>(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<ThreatResponse>(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),
|
||||
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<ThreatResponse>(enemy));
|
||||
const ThreatResponse& tr = f.admin.get<ThreatResponse>(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),
|
||||
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<Health>(friendly).hp = f.admin.get<Health>(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<Health>(friendly).maxHp * 0.5f;
|
||||
f.admin.get<Health>(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<Health>(friendly).hp = f.admin.get<Health>(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<SalvageCargo>(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<SalvageCargo>(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<SensorRange>(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),
|
||||
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<ThreatResponse>(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<ThreatResponse>(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),
|
||||
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<ThreatResponse>(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<Health>(friendly).hp = f.admin.get<Health>(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<RepairBehavior>(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<ScrapCollector>(ship).scrapTarget.has_value());
|
||||
REQUIRE(intent(f.admin, ship).target.x() > pos(f.admin, ship).value.x());
|
||||
}
|
||||
|
||||
@@ -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<ShipLayoutConfig>&) {},
|
||||
rng)
|
||||
, combat(cfg)
|
||||
{
|
||||
}
|
||||
|
||||
void wireEnemyTarget(entt::entity enemy, entt::entity playerTarget)
|
||||
{
|
||||
if (admin.hasAll<Weapon>(enemy))
|
||||
{
|
||||
admin.get<Weapon>(enemy).currentTarget = playerTarget;
|
||||
admin.get<Weapon>(enemy).cooldownTicks = 0.0f;
|
||||
}
|
||||
if (admin.hasAll<ThreatResponse>(enemy))
|
||||
{
|
||||
admin.get<ThreatResponse>(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<ShipLayoutConfig>&) {},
|
||||
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<Health>(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<FireEvent> 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<Health>(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<ShipLayoutConfig>&) {},
|
||||
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<Weapon>(enemy).cooldownTicks = 3.0f; // override to 3
|
||||
|
||||
// Set cooldown to 3 so it won't fire on tick 0 or 1 or 2.
|
||||
ships.forEach([&](Ship& s)
|
||||
{
|
||||
if (s.id == enemyId && s.weapon)
|
||||
{
|
||||
s.weapon->currentTarget = playerId;
|
||||
s.weapon->cooldownTicks = 3.0f;
|
||||
if (s.threatResponse)
|
||||
{
|
||||
s.threatResponse->currentTarget = playerId;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
CombatSystem combat(cfg);
|
||||
std::vector<FireEvent> events;
|
||||
|
||||
// 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<ShipLayoutConfig>&) {},
|
||||
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<FireEvent> 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())
|
||||
sim.admin().forEach<StationBody, Faction>(
|
||||
[&](entt::entity e, const StationBody& sb, const Faction& f)
|
||||
{
|
||||
if (b.id == stationId)
|
||||
if (!f.isEnemy && stationEntity == entt::null)
|
||||
{
|
||||
stationEntity = e;
|
||||
stationCenter = QVector2D(
|
||||
b.anchor.x() + b.footprint.width() / 2.0f,
|
||||
b.anchor.y() + b.footprint.height() / 2.0f);
|
||||
break;
|
||||
}
|
||||
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<FireEvent> 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())
|
||||
sim.admin().forEach<StationBody, Faction>(
|
||||
[&](entt::entity e, const StationBody& sb, const Faction& f)
|
||||
{
|
||||
if (b.type == BuildingType::EnemyDefenceStation)
|
||||
if (f.isEnemy && stationEntity == entt::null)
|
||||
{
|
||||
stationId = b.id;
|
||||
stationEntity = e;
|
||||
stationCenter = QVector2D(
|
||||
b.anchor.x() + b.footprint.width() / 2.0f,
|
||||
b.anchor.y() + b.footprint.height() / 2.0f);
|
||||
break;
|
||||
sb.anchor.x() + sb.footprint.width() / 2.0f,
|
||||
sb.anchor.y() + sb.footprint.height() / 2.0f);
|
||||
}
|
||||
}
|
||||
REQUIRE(stationId != kInvalidEntityId);
|
||||
});
|
||||
REQUIRE(sim.admin().isValid(stationEntity));
|
||||
|
||||
const ShipDef* combatDef = findCombatShip(sim.config());
|
||||
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<FireEvent> 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())
|
||||
sim.admin().forEach<StationBody, Faction>(
|
||||
[&](entt::entity e, const StationBody& sb, const Faction& f)
|
||||
{
|
||||
if (b.type == BuildingType::EnemyDefenceStation)
|
||||
if (f.isEnemy && stationEntity == entt::null)
|
||||
{
|
||||
stationId = b.id;
|
||||
stationEntity = e;
|
||||
stationCenter = QVector2D(
|
||||
b.anchor.x() + b.footprint.width() / 2.0f,
|
||||
b.anchor.y() + b.footprint.height() / 2.0f);
|
||||
break;
|
||||
sb.anchor.x() + sb.footprint.width() / 2.0f,
|
||||
sb.anchor.y() + sb.footprint.height() / 2.0f);
|
||||
}
|
||||
}
|
||||
REQUIRE(stationId != kInvalidEntityId);
|
||||
});
|
||||
REQUIRE(sim.admin().isValid(stationEntity));
|
||||
|
||||
const ShipDef* combatDef = findCombatShip(sim.config());
|
||||
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<FireEvent> 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<ShipLayoutConfig>&) {},
|
||||
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<Health>(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<FireEvent> events;
|
||||
combat.tick(0, ships, buildings, events);
|
||||
f.combat.tick(0, f.admin, f.buildings, events);
|
||||
|
||||
// Ticks 1-4: damage must not have arrived yet.
|
||||
for (Tick t = 1; t < 5; ++t)
|
||||
{
|
||||
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<Health>(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<ShipLayoutConfig>&) {},
|
||||
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<Health>(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<FireEvent> 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<Health>(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<ShipLayoutConfig>&) {},
|
||||
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<FireEvent> 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<ShipLayoutConfig>&) {},
|
||||
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<Health>(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<FireEvent> 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<Health>(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<Health>(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,26 +381,24 @@ 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<Health>(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)
|
||||
// Damage the HQ proxy entity (has HqProxy + Health).
|
||||
sim.admin().forEach<HqProxy, Health>(
|
||||
[](entt::entity /*e*/, const HqProxy& /*hq*/, Health& h)
|
||||
{
|
||||
if (b.type == BuildingType::Hq)
|
||||
{
|
||||
b.hp = -1.0f;
|
||||
}
|
||||
h.hp = -1.0f;
|
||||
});
|
||||
|
||||
sim.tick();
|
||||
|
||||
@@ -2,25 +2,23 @@
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#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<ScrapData>(e).amount == 5);
|
||||
REQUIRE(admin.get<DespawnAt>(e).tick == 100);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -29,24 +27,24 @@ TEST_CASE("ScrapSystem: spawn returns a findable scrap with correct fields", "[s
|
||||
|
||||
TEST_CASE("ScrapSystem: scrap still present one tick before despawnAt", "[scrap]")
|
||||
{
|
||||
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<int> amount = ss.consume(e);
|
||||
REQUIRE(amount.has_value());
|
||||
REQUIRE(*amount == 7);
|
||||
REQUIRE_FALSE(admin.isValid(e));
|
||||
}
|
||||
|
||||
TEST_CASE("ScrapSystem: consume returns nullopt for invalid entity", "[scrap]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
ScrapSystem ss(admin);
|
||||
|
||||
const std::optional<int> amount = ss.consume(entt::null);
|
||||
REQUIRE_FALSE(amount.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// allScrapInfo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("ScrapSystem: allScrapInfo returns all spawned scrap", "[scrap]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
ScrapSystem ss(admin);
|
||||
|
||||
ss.spawn(QVector2D(1.0f, 2.0f), 3, 100);
|
||||
ss.spawn(QVector2D(4.0f, 5.0f), 6, 200);
|
||||
|
||||
const std::vector<ScrapInfo> info = ss.allScrapInfo();
|
||||
REQUIRE(info.size() == 2);
|
||||
}
|
||||
|
||||
@@ -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<double>(def->schematic.playerProductionLevel);
|
||||
const float expectedHp = static_cast<float>(def->health.hpFormula.evaluate(x));
|
||||
|
||||
const EntityId id = sim.ships().spawn("interceptor",
|
||||
const entt::entity e = sim.ships().spawn("interceptor",
|
||||
def->schematic.playerProductionLevel,
|
||||
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<Health>(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<Health>(e).maxHp == Approx(baseHp * 1.5f));
|
||||
CHECK(sim.admin().get<Health>(e).hp == sim.admin().get<Health>(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<SensorRange>(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<Health>(e).maxHp == Approx(baseHp * 2.0f));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#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<Weapon>(e));
|
||||
REQUIRE(admin.hasAll<ThreatResponse>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<SalvageCargo>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<RepairTool>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<RepairBehavior>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<ScrapCollector>(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<Health>(e).maxHp == Approx(45.0f));
|
||||
REQUIRE(admin.get<Health>(e).hp == Approx(45.0f));
|
||||
// damage_formula = "10 + 2*x" at x=1 → 12
|
||||
REQUIRE(ship->weapon->damage == Approx(12.0f));
|
||||
REQUIRE(admin.get<Weapon>(e).damage == Approx(12.0f));
|
||||
// attack_range_formula = "150"
|
||||
REQUIRE(ship->weapon->range == Approx(150.0f));
|
||||
REQUIRE(admin.get<Weapon>(e).range == Approx(150.0f));
|
||||
// sensor_range_formula = "200"
|
||||
REQUIRE(ship->sensorRange == Approx(200.0f));
|
||||
REQUIRE(admin.get<SensorRange>(e).value == Approx(200.0f));
|
||||
// cooldownTicks starts at 0
|
||||
REQUIRE(ship->weapon->cooldownTicks == Approx(0.0f));
|
||||
REQUIRE(admin.get<Weapon>(e).cooldownTicks == Approx(0.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("ShipSystem: interceptor level 5 hp matches formula", "[ship]")
|
||||
{
|
||||
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<Health>(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<float>(kTickRateHz);
|
||||
REQUIRE(ship->maxSpeedPerTick == Approx(expected));
|
||||
REQUIRE(admin.get<ShipDynamics>(e).maxSpeedPerTick == Approx(expected));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -98,34 +93,31 @@ TEST_CASE("ShipSystem: interceptor level 0 maxSpeedPerTick matches formula / kTi
|
||||
TEST_CASE("ShipSystem: salvage_ship spawn has cargo and scrapCollector, no weapon",
|
||||
"[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<SalvageCargo>(e));
|
||||
REQUIRE(admin.hasAll<ScrapCollector>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<Weapon>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<RepairTool>(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<SalvageCargo>(e).capacity == 10);
|
||||
REQUIRE(admin.get<SalvageCargo>(e).current == 0);
|
||||
REQUIRE(admin.get<ScrapCollector>(e).deliveryBay == kInvalidEntityId);
|
||||
REQUIRE_FALSE(admin.get<ScrapCollector>(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<RepairTool>(e));
|
||||
REQUIRE(admin.hasAll<RepairBehavior>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<Weapon>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<SalvageCargo>(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<RepairTool>(e).ratePerTick == Approx(6.0f));
|
||||
// repair_range_formula = "80"
|
||||
REQUIRE(ship->repairTool->range == Approx(80.0f));
|
||||
REQUIRE(admin.get<RepairTool>(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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ShipIdentity>(
|
||||
[&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<int>(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<int>(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<int>(sim.ships().allShips().size()) == shipsBefore);
|
||||
REQUIRE(countShips(sim) == shipsBefore);
|
||||
|
||||
// Final tick: cycle completes, ship spawns.
|
||||
sim.tick();
|
||||
REQUIRE(static_cast<int>(sim.ships().allShips().size()) == shipsBefore + 1);
|
||||
REQUIRE(countShips(sim) == shipsBefore + 1);
|
||||
|
||||
bool foundPlayerShip = false;
|
||||
for (const Ship& ship : sim.ships().allShips())
|
||||
sim.admin().forEach<ShipIdentity, Faction>(
|
||||
[&](entt::entity /*e*/, const ShipIdentity& si, const Faction& f)
|
||||
{
|
||||
if (!ship.isEnemy && ship.schematicId == def->id)
|
||||
if (!f.isEnemy && si.schematicId == def->id)
|
||||
{
|
||||
foundPlayerShip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
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<int>(sim.ships().allShips().size());
|
||||
const int shipsBefore = countShips(sim);
|
||||
|
||||
placeShipyard(sim, *yardDef);
|
||||
|
||||
sim.tick();
|
||||
|
||||
REQUIRE(static_cast<int>(sim.ships().allShips().size()) == shipsBefore);
|
||||
REQUIRE(countShips(sim) == shipsBefore);
|
||||
}
|
||||
|
||||
TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
|
||||
@@ -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<int>(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<int>(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<int>(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<int>(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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
// HQ is still a Building (for belt integration).
|
||||
int hqCount = 0;
|
||||
int playerCount = 0;
|
||||
int enemyCount = 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; }
|
||||
}
|
||||
|
||||
// Stations are ECS entities.
|
||||
int playerCount = 0;
|
||||
int enemyCount = 0;
|
||||
sim.admin().forEach<StationBody, Faction>(
|
||||
[&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f)
|
||||
{
|
||||
if (f.isEnemy) { ++enemyCount; }
|
||||
else { ++playerCount; }
|
||||
});
|
||||
|
||||
REQUIRE(hqCount == 1);
|
||||
REQUIRE(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())
|
||||
sim.admin().forEach<StationBody, Faction, StationWeapon>(
|
||||
[&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f,
|
||||
const StationWeapon& w)
|
||||
{
|
||||
if (b.type == BuildingType::PlayerDefenceStation && b.weapon)
|
||||
if (!f.isEnemy)
|
||||
{
|
||||
++armedPlayerStations;
|
||||
REQUIRE(b.weapon->damage > 0.0f);
|
||||
REQUIRE(b.weapon->range > 0.0f);
|
||||
REQUIRE(b.weapon->fireRateHz > 0.0f);
|
||||
}
|
||||
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())
|
||||
sim.admin().forEach<StationBody, Faction, StationWeapon>(
|
||||
[&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f,
|
||||
const StationWeapon& w)
|
||||
{
|
||||
if (b.type == BuildingType::EnemyDefenceStation && b.weapon)
|
||||
if (f.isEnemy)
|
||||
{
|
||||
++armedEnemyStations;
|
||||
REQUIRE(b.weapon->damage > 0.0f);
|
||||
REQUIRE(b.weapon->range > 0.0f);
|
||||
REQUIRE(b.weapon->fireRateHz > 0.0f);
|
||||
}
|
||||
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())
|
||||
sim.admin().forEach<ShipIdentity, Faction>(
|
||||
[&](entt::entity /*e*/, const ShipIdentity& /*si*/, const Faction& f)
|
||||
{
|
||||
if (s.isEnemy)
|
||||
{
|
||||
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())
|
||||
sim.admin().forEach<ShipIdentity, Faction>(
|
||||
[&](entt::entity /*e*/, const ShipIdentity& si, const Faction& f)
|
||||
{
|
||||
if (!s.isEnemy) { continue; }
|
||||
if (!f.isEnemy) { return; }
|
||||
// salvage_ship and repair_ship have cost_formula = "0" and must not spawn.
|
||||
REQUIRE(s.schematicId != "salvage_ship");
|
||||
REQUIRE(s.schematicId != "repair_ship");
|
||||
}
|
||||
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)
|
||||
sim.admin().forEach<StationBody, Faction, Health>(
|
||||
[](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
|
||||
{
|
||||
if (b.type == BuildingType::EnemyDefenceStation)
|
||||
{
|
||||
b.hp = -1.0f;
|
||||
}
|
||||
if (f.isEnemy) { h.hp = -1.0f; }
|
||||
});
|
||||
|
||||
sim.tick();
|
||||
|
||||
// After push: should have 2 new enemy stations.
|
||||
int enemyCount = 0;
|
||||
for (const Building& b : sim.buildings().allBuildings())
|
||||
sim.admin().forEach<StationBody, Faction>(
|
||||
[&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f)
|
||||
{
|
||||
if (b.type == BuildingType::EnemyDefenceStation) { ++enemyCount; }
|
||||
}
|
||||
if (f.isEnemy) { ++enemyCount; }
|
||||
});
|
||||
REQUIRE(enemyCount == 2);
|
||||
}
|
||||
|
||||
@@ -270,12 +280,10 @@ TEST_CASE("WaveSystem: push emits exactly one SchematicDropEvent", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
|
||||
sim.buildings().forEachBuilding([](Building& b)
|
||||
sim.admin().forEach<StationBody, Faction, Health>(
|
||||
[](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
|
||||
{
|
||||
if (b.type == BuildingType::EnemyDefenceStation)
|
||||
{
|
||||
b.hp = -1.0f;
|
||||
}
|
||||
if (f.isEnemy) { h.hp = -1.0f; }
|
||||
});
|
||||
|
||||
sim.tick();
|
||||
@@ -288,12 +296,10 @@ TEST_CASE("WaveSystem: push schematic drop awards a known ship id", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
|
||||
sim.buildings().forEachBuilding([](Building& b)
|
||||
sim.admin().forEach<StationBody, Faction, Health>(
|
||||
[](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
|
||||
{
|
||||
if (b.type == BuildingType::EnemyDefenceStation)
|
||||
{
|
||||
b.hp = -1.0f;
|
||||
}
|
||||
if (f.isEnemy) { h.hp = -1.0f; }
|
||||
});
|
||||
|
||||
sim.tick();
|
||||
@@ -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<int>::min();
|
||||
for (const Building& b : sim.buildings().allBuildings())
|
||||
sim.admin().forEach<StationBody, Faction>(
|
||||
[&](entt::entity /*e*/, const StationBody& sb, const Faction& f)
|
||||
{
|
||||
if (b.type == BuildingType::EnemyDefenceStation)
|
||||
if (f.isEnemy && sb.anchor.x() > initialX)
|
||||
{
|
||||
if (b.anchor.x() > initialX) { initialX = b.anchor.x(); }
|
||||
}
|
||||
initialX = sb.anchor.x();
|
||||
}
|
||||
});
|
||||
|
||||
sim.buildings().forEachBuilding([](Building& b)
|
||||
sim.admin().forEach<StationBody, Faction, Health>(
|
||||
[](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
|
||||
{
|
||||
if (b.type == BuildingType::EnemyDefenceStation) { b.hp = -1.0f; }
|
||||
if (f.isEnemy) { h.hp = -1.0f; }
|
||||
});
|
||||
sim.tick();
|
||||
|
||||
int newX = std::numeric_limits<int>::min();
|
||||
for (const Building& b : sim.buildings().allBuildings())
|
||||
sim.admin().forEach<StationBody, Faction>(
|
||||
[&](entt::entity /*e*/, const StationBody& sb, const Faction& f)
|
||||
{
|
||||
if (b.type == BuildingType::EnemyDefenceStation)
|
||||
if (f.isEnemy && sb.anchor.x() > newX)
|
||||
{
|
||||
if (b.anchor.x() > newX) { newX = b.anchor.x(); }
|
||||
}
|
||||
newX = sb.anchor.x();
|
||||
}
|
||||
});
|
||||
|
||||
REQUIRE(newX > initialX);
|
||||
}
|
||||
|
||||
@@ -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<StationBody>(fe.target))
|
||||
{
|
||||
const int shorter = std::min(tBld->footprint.width(),
|
||||
tBld->footprint.height());
|
||||
const StationBody& sb = m_sim->admin().get<StationBody>(fe.target);
|
||||
const int shorter = std::min(sb.footprint.width(), sb.footprint.height());
|
||||
maxRadius = shorter / 2.0f;
|
||||
}
|
||||
|
||||
@@ -443,22 +443,14 @@ EntityId GameWorldView::siteAtTile(QPoint tile) const
|
||||
}
|
||||
|
||||
|
||||
std::optional<QVector2D> GameWorldView::entityPosition(EntityId id) const
|
||||
std::optional<QVector2D> GameWorldView::entityPosition(entt::entity entity) const
|
||||
{
|
||||
const Ship* ship = m_sim->ships().findShip(id);
|
||||
if (ship)
|
||||
if (!m_sim->admin().isValid(entity) || !m_sim->admin().hasAll<Position>(entity))
|
||||
{
|
||||
return ship->position;
|
||||
}
|
||||
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<Position>(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,15 +794,19 @@ void GameWorldView::drawScrap(QPainter& painter)
|
||||
|
||||
void GameWorldView::drawShips(QPainter& painter)
|
||||
{
|
||||
for (const Ship& ship : m_sim->ships().allShips())
|
||||
m_sim->admin().forEach<ShipIdentity, Position, Facing, Faction>(
|
||||
[&](entt::entity e, const ShipIdentity& /*si*/, const Position& pos,
|
||||
const Facing& facing, const Faction& fac)
|
||||
{
|
||||
const ShipRole role = shipRole(ship);
|
||||
const bool hasCargo = m_sim->admin().hasAll<SalvageCargo>(e);
|
||||
const bool hasRepair = m_sim->admin().hasAll<RepairTool>(e);
|
||||
const ShipRole role = shipRoleFromComponents(fac.isEnemy, hasCargo, hasRepair);
|
||||
const std::map<ShipRole, ShipVisuals>::const_iterator it =
|
||||
m_visuals->ships.find(role);
|
||||
if (it == m_visuals->ships.end()) { continue; }
|
||||
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 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;
|
||||
@@ -827,27 +823,29 @@ void GameWorldView::drawShips(QPainter& painter)
|
||||
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())
|
||||
m_sim->admin().forEach<ShipIdentity, Position, Facing, Faction, SensorRange>(
|
||||
[&](entt::entity e, const ShipIdentity& /*si*/, const Position& pos,
|
||||
const Facing& /*facing*/, const Faction& fac, const SensorRange& sensor)
|
||||
{
|
||||
const ShipRole role = shipRole(ship);
|
||||
const bool hasCargo = m_sim->admin().hasAll<SalvageCargo>(e);
|
||||
const bool hasRepair = m_sim->admin().hasAll<RepairTool>(e);
|
||||
const ShipRole role = shipRoleFromComponents(fac.isEnemy, hasCargo, hasRepair);
|
||||
const std::map<ShipRole, ShipVisuals>::const_iterator it =
|
||||
m_visuals->ships.find(role);
|
||||
if (it == m_visuals->ships.end()) { continue; }
|
||||
if (it == m_visuals->ships.end()) { return; }
|
||||
|
||||
const float range = ship.sensorRange;
|
||||
|
||||
const QPointF center = worldToWidget(ship.position);
|
||||
const qreal radiusPx = static_cast<qreal>(range)
|
||||
const QPointF center = worldToWidget(pos.value);
|
||||
const qreal radiusPx = static_cast<qreal>(sensor.value)
|
||||
* static_cast<qreal>(tilePx());
|
||||
painter.setPen(QPen(it->second.outline, 1));
|
||||
painter.drawEllipse(center, radiusPx, radiusPx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void GameWorldView::drawBeams(QPainter& painter)
|
||||
|
||||
@@ -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<QVector2D> entityPosition(EntityId id) const;
|
||||
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
||||
void stepSpeed(int delta);
|
||||
void placeAtTile(QPoint tile);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user