switch to ECS architecture

This commit is contained in:
2026-05-22 20:31:39 +02:00
parent c18c4e4804
commit ca07cbaf0e
34 changed files with 1943 additions and 2074 deletions

View File

@@ -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

View 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; };

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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;
};

View File

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

View File

@@ -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);
};

View File

@@ -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.

View File

@@ -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()});
}
}

View File

@@ -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);

View File

@@ -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)
{
resolveShipWeapon(ship, currentTick, ships, buildings, outFireEvents);
});
// Defence stations: acquire targets and fire.
buildings.forEachBuilding([&](Building& building)
{
if (building.type == BuildingType::PlayerDefenceStation ||
building.type == BuildingType::EnemyDefenceStation)
// Ship weapons.
admin.forEach<Weapon, ThreatResponse, Position>(
[&](entt::entity e, Weapon& weapon, ThreatResponse& threat, Position& pos)
{
resolveStationWeapon(building, currentTick, ships, buildings, outFireEvents);
}
});
resolveShipWeapon(e, weapon, threat, pos, currentTick, admin, outFireEvents);
});
// Station weapons.
admin.forEach<StationWeapon, Position, Faction>(
[&](entt::entity e, StationWeapon& weapon, Position& pos, Faction& faction)
{
resolveStationWeapon(e, weapon, pos, faction, currentTick, admin, outFireEvents);
});
}
void CombatSystem::resolveShipWeapon(Ship& ship, Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
void CombatSystem::resolveShipWeapon(entt::entity shipEntity, Weapon& weapon,
const ThreatResponse& threat,
const Position& pos, Tick currentTick,
EntityAdmin& admin,
std::vector<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);
float bestDist = weapon.range;
admin.forEach<ShipIdentity, Position, Faction>(
[&](entt::entity candidate, const ShipIdentity& /*si*/,
const Position& candidatePos, const Faction& candidateFaction)
{
const bool isValidTarget = stationFaction.isEnemy
? !candidateFaction.isEnemy
: candidateFaction.isEnemy;
if (!isValidTarget)
{
return;
}
const float dist = (candidatePos.value - stationPos.value).length();
if (dist < bestDist)
{
bestDist = dist;
weapon.currentTarget = candidate;
}
});
}
if (!w.currentTarget)
if (!weapon.currentTarget)
{
return;
}
// Decrement cooldown.
if (w.cooldownTicks > 0.0f)
if (weapon.cooldownTicks > 0.0f)
{
w.cooldownTicks -= 1.0f;
weapon.cooldownTicks -= 1.0f;
}
if (w.cooldownTicks > 0.0f)
if (weapon.cooldownTicks > 0.0f)
{
return;
}
const EntityId targetId = *w.currentTarget;
const std::optional<QVector2D> tPos = targetPosition(targetId, ships, buildings);
if (!tPos)
const entt::entity targetEntity = *weapon.currentTarget;
if (!admin.isValid(targetEntity) || !admin.hasAll<Position>(targetEntity))
{
w.currentTarget = std::nullopt;
weapon.currentTarget = std::nullopt;
return;
}
if ((stationCenter - *tPos).length() > w.range)
const QVector2D targetPos = admin.get<Position>(targetEntity).value;
if ((stationPos.value - targetPos).length() > weapon.range)
{
return;
}
m_pendingDamage.push_back({targetId, w.damage, currentTick + kWeaponImpactDelayTicks});
m_pendingDamage.push_back({targetEntity, weapon.damage,
currentTick + kWeaponImpactDelayTicks});
FireEvent evt;
evt.shooter = station.id;
evt.target = targetId;
evt.shooter = stationEntity;
evt.target = targetEntity;
evt.emittedAt = currentTick;
out.push_back(evt);
w.cooldownTicks = static_cast<float>(kTickRateHz) / w.fireRateHz;
weapon.cooldownTicks = static_cast<float>(kTickRateHz) / weapon.fireRateHz;
}
std::optional<EntityId> CombatSystem::acquireStationTarget(
const Building& station, bool stationIsEnemy,
const ShipSystem& ships) const
void CombatSystem::applyPendingDamage(Tick currentTick, EntityAdmin& admin)
{
const QVector2D stationCenter(
station.anchor.x() + station.footprint.width() / 2.0f,
station.anchor.y() + station.footprint.height() / 2.0f);
const float range = station.weapon->range;
std::optional<EntityId> best;
float bestDist = range;
// Scan ships for valid targets.
for (const Ship& candidate : ships.allShips())
{
const bool isValidTarget = stationIsEnemy ? !candidate.isEnemy
: candidate.isEnemy;
if (!isValidTarget)
{
continue;
}
const float dist = (candidate.position - stationCenter).length();
if (dist < bestDist)
{
bestDist = dist;
best = candidate.id;
}
}
return best;
}
void CombatSystem::applyPendingDamage(Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings)
{
auto it = m_pendingDamage.begin();
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;
}

View File

@@ -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;
float amount;
Tick appliesAt;
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;
};

View File

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

View File

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

View File

@@ -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());
}
const Scrap* ScrapSystem::findScrap(EntityId id) const
{
for (const Scrap& s : m_scraps)
{
if (s.id == id)
std::vector<entt::entity> expired;
m_admin.forEach<DespawnAt>(
[&expired, currentTick](entt::entity e, DespawnAt& d)
{
return &s;
}
}
return nullptr;
}
if (d.tick <= currentTick)
{
expired.push_back(e);
}
});
std::optional<Scrap> ScrapSystem::consume(EntityId id)
{
for (auto it = m_scraps.begin(); it != m_scraps.end(); ++it)
for (entt::entity e : expired)
{
if (it->id == id)
{
Scrap result = *it;
m_scraps.erase(it);
return result;
}
m_admin.destroy(e);
}
return std::nullopt;
}
std::vector<Scrap> ScrapSystem::allScraps() const
std::optional<int> ScrapSystem::consume(entt::entity entity)
{
return m_scraps;
if (!m_admin.isValid(entity) || !m_admin.hasAll<ScrapData>(entity))
{
return std::nullopt;
}
int amount = m_admin.get<ScrapData>(entity).amount;
m_admin.destroy(entity);
return amount;
}
std::vector<ScrapInfo> ScrapSystem::allScrapInfo() const
{
std::vector<ScrapInfo> result;
m_admin.forEach<ScrapData>(
[&result, this](entt::entity e, const ScrapData& /*sd*/)
{
result.push_back(ScrapInfo{e, m_admin.get<Position>(e).value});
});
return result;
}

View File

@@ -1,28 +1,37 @@
#pragma once
#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);
void tickDespawn(Tick currentTick);
std::optional<Scrap> consume(EntityId id); // removes and returns scrap, or nullopt
entt::entity spawn(QVector2D position, int amount, Tick despawnAt);
void tickDespawn(Tick currentTick);
const Scrap* findScrap(EntityId id) const;
std::vector<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;
};

View File

@@ -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;
};

View File

@@ -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,54 +39,47 @@ const ModuleDef* ShipSystem::findModuleDef(const std::string& id) const
return nullptr;
}
EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D position,
bool isEnemy,
const std::optional<ShipLayoutConfig>& layout)
entt::entity ShipSystem::spawn(const std::string& schematicId, int level, QVector2D position,
bool isEnemy,
const std::optional<ShipLayoutConfig>& layout)
{
const ShipDef* def = findShipDef(schematicId);
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;
w.damage = static_cast<float>(def->combat->damageFormula.evaluate(x));
w.range = static_cast<float>(def->combat->attackRangeFormula.evaluate(x));
w.fireRateHz = static_cast<float>(def->combat->attackRateFormula.evaluate(x));
w.damage = static_cast<float>(def->combat->damageFormula.evaluate(x));
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,23 +89,23 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
cargo.capacity = def->salvage->cargoCapacity;
cargo.current = 0;
cargo.collectionRange = static_cast<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)
{
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.ratePerTick = static_cast<float>(def->repair->repairRateFormula.evaluate(x));
rt.range = static_cast<float>(def->repair->repairRangeFormula.evaluate(x));
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,106 +145,50 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
}
};
applyMod(ship.maxHp, "hp");
ship.hp = ship.maxHp;
applyMod(ship.maxSpeedPerTick, "speed");
applyMod(ship.mainAccelerationPerTick, "main_acceleration");
applyMod(ship.maneuveringAccelerationPerTick, "maneuvering_acceleration");
applyMod(ship.angularAccelerationPerTick, "angular_acceleration");
applyMod(ship.maxRotationSpeedPerTick, "max_rotation_speed");
applyMod(ship.sensorRange, "sensor_range");
if (ship.weapon.has_value())
Health& health = m_admin.get<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)
{
m_rallyPoint = point;
@@ -259,11 +196,17 @@ void ShipSystem::setRallyPoint(QVector2D point)
void ShipSystem::triggerRallyDeparture()
{
for (Ship& s : m_ships)
{
if (!s.isEnemy)
std::vector<entt::entity> toRemove;
m_admin.forEach<RallyBehavior, Faction>(
[&toRemove](entt::entity e, const RallyBehavior& /*rb*/, const Faction& f)
{
s.rallyBehavior = std::nullopt;
}
if (!f.isEnemy)
{
toRemove.push_back(e);
}
});
for (entt::entity e : toRemove)
{
m_admin.removeComponent<RallyBehavior>(e);
}
}

View File

@@ -1,30 +1,27 @@
#pragma once
#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,
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);
entt::entity spawn(const std::string& schematicId, int level, QVector2D position,
bool isEnemy = false,
const std::optional<ShipLayoutConfig>& layout = std::nullopt);
void despawn(entt::entity entity);
// Reset all movement intents to priority 0 before behavior systems run.
void clearMovementIntents();
@@ -35,20 +32,11 @@ public:
// Release all gathered player combat ships to advance toward the enemy.
void triggerRallyDeparture();
// Reduce ship HP by amount. Does not remove the ship; step 9 handles death.
// Returns false if ship not found.
bool damageShip(EntityId id, float amount);
// Heal the ship with the given id by amount, clamped to maxHp.
// Returns false if the ship is not found.
bool healShip(EntityId id, float amount);
private:
const ShipDef* findShipDef(const std::string& schematicId) const;
const ModuleDef* findModuleDef(const std::string& id) const;
const GameConfig& m_config;
std::function<EntityId()> m_allocateId;
std::vector<Ship> m_ships;
QVector2D m_rallyPoint;
const GameConfig& m_config;
EntityAdmin& m_admin;
QVector2D m_rallyPoint;
};

View File

@@ -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);
@@ -82,15 +84,17 @@ void Simulation::reset(unsigned int seed)
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
m_nextId = 1;
m_buildingBlocksStock = m_config.world.startingBuildingBlocks;
m_gameOver = false;
m_hqId = kInvalidEntityId;
m_playerStation1Id = kInvalidEntityId;
m_playerStation2Id = kInvalidEntityId;
m_currentEnemyStationIds[0] = kInvalidEntityId;
m_currentEnemyStationIds[1] = kInvalidEntityId;
m_gameOver = false;
m_hqBuildingId = kInvalidEntityId;
m_hqProxyEntity = entt::null;
m_playerStation1Entity = entt::null;
m_playerStation2Entity = entt::null;
m_currentEnemyStationEntities[0] = entt::null;
m_currentEnemyStationEntities[1] = entt::null;
m_fireEvents.clear();
m_schematicDropEvents.clear();
m_admin.clear();
m_beltSystem = BeltSystem(m_config.world.beltSpeedTilesPerSecond);
m_buildingSystem = std::make_unique<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,98 +331,92 @@ void Simulation::placeEnemyStationSet(int generation)
void Simulation::tickDeathsAndLoot()
{
// --- Dead ships ---
std::vector<EntityId> deadShipIds;
m_shipSystem->forEach([&deadShipIds](Ship& s)
{
if (s.hp <= 0.0f)
std::vector<entt::entity> deadShips;
m_admin.forEach<ShipIdentity, Health>(
[&deadShips](entt::entity e, const ShipIdentity& /*si*/, const Health& h)
{
deadShipIds.push_back(s.id);
}
});
if (h.hp <= 0.0f)
{
deadShips.push_back(e);
}
});
for (EntityId deadId : deadShipIds)
for (entt::entity deadEntity : deadShips)
{
const Ship* s = m_shipSystem->findShip(deadId);
if (!s)
{
continue;
}
// Look up scrap drop amount from config.
const ShipIdentity& si = m_admin.get<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())
{
if (b.hp <= 0.0f &&
(b.type == BuildingType::Hq ||
b.type == BuildingType::PlayerDefenceStation ||
b.type == BuildingType::EnemyDefenceStation))
// --- Dead stations ---
std::vector<entt::entity> deadStations;
m_admin.forEach<StationBody, Health>(
[&deadStations](entt::entity e, const StationBody& /*sb*/, const Health& h)
{
deadBuildingIds.push_back(b.id);
}
}
if (h.hp <= 0.0f)
{
deadStations.push_back(e);
}
});
for (EntityId deadId : deadBuildingIds)
for (entt::entity deadEntity : deadStations)
{
const Building* b = m_buildingSystem->findBuilding(deadId);
if (!b)
{
continue;
}
const StationBody& sb = m_admin.get<StationBody>(deadEntity);
const Position& pos = m_admin.get<Position>(deadEntity);
const Faction& fac = m_admin.get<Faction>(deadEntity);
if (b->type == BuildingType::Hq)
const Tick despawnAt = m_currentTick
+ secondsToTicks(m_config.world.scrapDespawnSeconds);
int scrap = 0;
if (!fac.isEnemy)
{
m_gameOver = true;
const double lv = static_cast<double>(
m_config.stations.playerStation.level);
scrap = static_cast<int>(
m_config.stations.playerStation.scrapDropFormula.evaluate(lv));
}
else
{
const QVector2D center(
b->anchor.x() + b->footprint.width() / 2.0f,
b->anchor.y() + b->footprint.height() / 2.0f);
const Tick despawnAt = m_currentTick
+ secondsToTicks(m_config.world.scrapDespawnSeconds);
int scrap = 0;
if (b->type == BuildingType::PlayerDefenceStation)
{
const double lv = static_cast<double>(
m_config.stations.playerStation.level);
scrap = static_cast<int>(
m_config.stations.playerStation.scrapDropFormula.evaluate(lv));
}
else if (b->type == BuildingType::EnemyDefenceStation)
{
const double genD = static_cast<double>(m_waveSystem->generation());
scrap = static_cast<int>(
m_config.stations.enemyStation.scrapDropFormula.evaluate(genD));
}
if (scrap > 0)
{
m_scrapSystem->spawn(center, scrap, despawnAt);
}
const double genD = static_cast<double>(m_waveSystem->generation());
scrap = static_cast<int>(
m_config.stations.enemyStation.scrapDropFormula.evaluate(genD));
}
if (scrap > 0)
{
m_scrapSystem->spawn(pos.value, scrap, despawnAt);
}
m_buildingSystem->unregisterTileOccupancy(sb.bodyCells);
m_admin.destroy(deadEntity);
}
// --- HQ death check ---
if (m_admin.isValid(m_hqProxyEntity))
{
const Health& hqHealth = m_admin.get<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++;

View File

@@ -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;