change repair_tool application and add beams for salvager and repair_tool

This commit is contained in:
2026-06-18 22:14:09 +02:00
parent 7924e037aa
commit 9573b9789a
37 changed files with 498 additions and 199 deletions

View File

@@ -277,15 +277,16 @@ ArenaStatus ArenaSimulation::status() const
void ArenaSimulation::tick()
{
// Ship behavior systems (tick step 7): evaluate, select winner, execute.
// Module + combat systems emit their tool beams into a shared buffer.
m_shipSystem->clearMovementIntents();
m_aiSystem->tick(m_admin, *m_buildingSystem, *m_scrapSystem);
m_salvagerSystem->tick(*m_scrapSystem, *m_buildingSystem);
m_repairSystem->tick();
std::vector<BeamFiredEvent> beamFiredEvents;
m_salvagerSystem->tick(m_currentTick, *m_scrapSystem, *m_buildingSystem, beamFiredEvents);
m_repairSystem->tick(m_currentTick, beamFiredEvents);
// Combat resolution (tick step 8).
std::vector<WeaponFiredEvent> weaponFiredEvents;
m_combatSystem->tick(m_currentTick, m_admin, *m_buildingSystem, weaponFiredEvents);
m_weaponFiredEvents.insert(m_weaponFiredEvents.end(), weaponFiredEvents.begin(), weaponFiredEvents.end());
m_combatSystem->tick(m_currentTick, m_admin, *m_buildingSystem, beamFiredEvents);
m_beamFiredEvents.insert(m_beamFiredEvents.end(), beamFiredEvents.begin(), beamFiredEvents.end());
m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
// Deaths (tick step 9, simplified).
@@ -417,10 +418,10 @@ void ArenaSimulation::tickOnce()
}
}
std::vector<WeaponFiredEvent> ArenaSimulation::drainWeaponFiredEvents()
std::vector<BeamFiredEvent> ArenaSimulation::drainBeamFiredEvents()
{
std::vector<WeaponFiredEvent> result;
result.swap(m_weaponFiredEvents);
std::vector<BeamFiredEvent> result;
result.swap(m_beamFiredEvents);
return result;
}

View File

@@ -13,7 +13,7 @@
#include "BuildingId.h"
#include "entt/entity/entity.hpp"
#include "WeaponFiredEvent.h"
#include "BeamFiredEvent.h"
#include "GameConfig.h"
#include "Tick.h"
@@ -61,7 +61,7 @@ public:
void requestStop();
void tickOnce();
std::vector<WeaponFiredEvent> drainWeaponFiredEvents();
std::vector<BeamFiredEvent> drainBeamFiredEvents();
ArenaStatus status() const;
bool isFinished() const;
@@ -112,7 +112,7 @@ private:
// Static accumulated threat per team, computed once from the configured roster.
double m_teamThreat[2] = {0.0, 0.0};
std::vector<WeaponFiredEvent> m_weaponFiredEvents;
std::vector<BeamFiredEvent> m_beamFiredEvents;
mutable std::mutex m_statusMutex;
ArenaStatus m_status;

View File

@@ -28,6 +28,7 @@
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
#include "StationBodyComponent.h"
#include "ScrapDataComponent.h"
namespace
{
@@ -111,11 +112,11 @@ void ArenaView::onFrame()
// Emit fire events via EventManager
{
const std::vector<WeaponFiredEvent> fires = m_sim->drainWeaponFiredEvents();
for (const WeaponFiredEvent& fe : fires)
const std::vector<BeamFiredEvent> fires = m_sim->drainBeamFiredEvents();
for (const BeamFiredEvent& fe : fires)
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<WeaponFiredEvent>(fe));
std::make_shared<BeamFiredEvent>(fe));
}
}
@@ -140,7 +141,7 @@ void ArenaView::onFrame()
update();
}
void ArenaView::handleEvent(std::shared_ptr<const WeaponFiredEvent> event)
void ArenaView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
{
float maxRadius = 0.125f;
if (m_sim->admin().isValid(event->target)
@@ -151,6 +152,11 @@ void ArenaView::handleEvent(std::shared_ptr<const WeaponFiredEvent> event)
sb.footprint.height());
maxRadius = shorter / 2.0f;
}
else if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<ScrapDataComponent>(event->target))
{
maxRadius = 0.1f;
}
std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f);
std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius);
@@ -526,12 +532,20 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
void ArenaView::drawBeams(QPainter& painter)
{
painter.setPen(QPen(m_visuals->beams.color, m_visuals->beams.widthPx));
for (const ActiveBeam& beam : m_activeBeams)
{
const std::optional<QVector2D> shooterPos = entityPosition(beam.event.shooter);
const std::optional<QVector2D> targetPos = entityPosition(beam.event.target);
if (!shooterPos.has_value() || !targetPos.has_value()) { continue; }
QColor color = m_visuals->beams.weaponColor;
switch (beam.event.kind)
{
case BeamKind::Weapon: color = m_visuals->beams.weaponColor; break;
case BeamKind::Repair: color = m_visuals->beams.repairColor; break;
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
}
painter.setPen(QPen(color, m_visuals->beams.widthPx));
painter.drawLine(worldToWidget(*shooterPos),
worldToWidget(*targetPos + beam.targetOffset));
}

View File

@@ -10,7 +10,7 @@
#include <QVector2D>
#include "EventHandler.h"
#include "WeaponFiredEvent.h"
#include "BeamFiredEvent.h"
#include "entt/entity/entity.hpp"
#include "EntitySelectedEvent.h"
@@ -22,7 +22,7 @@ class ArenaSimulation;
class QPainter;
class ArenaView : public QOpenGLWidget,
public EventHandler<WeaponFiredEvent>
public EventHandler<BeamFiredEvent>
{
Q_OBJECT
@@ -45,7 +45,7 @@ private slots:
void onFrame();
private:
void handleEvent(std::shared_ptr<const WeaponFiredEvent> event) override;
void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter);
@@ -66,7 +66,7 @@ private:
struct ActiveBeam
{
WeaponFiredEvent event;
BeamFiredEvent event;
qint64 emittedWallMs;
QVector2D targetOffset;
};

View File

@@ -678,9 +678,11 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
if (rMt.contains("repair_rate_hz_formula") || rMt.contains("repair_range_m_formula"))
{
ModuleRepairCapability cap;
cap.repairRateFormula = requireFormula(rMt["repair_rate_hz_formula"],
cap.repairRateFormula = requireFormula(rMt["repair_rate_hz_formula"],
file, rPath + ".repair_rate_hz_formula");
cap.repairRangeFormula = requireFormula(rMt["repair_range_m_formula"],
cap.repairAmountHpFormula = requireFormula(rMt["repair_amount_hp_formula"],
file, rPath + ".repair_amount_hp_formula");
cap.repairRangeFormula = requireFormula(rMt["repair_range_m_formula"],
file, rPath + ".repair_range_m_formula");
def.repairCapability = std::move(cap);
}

View File

@@ -33,7 +33,8 @@ struct ModuleSalvageCapability
struct ModuleRepairCapability
{
Formula repairRateFormula;
Formula repairRateFormula; // repair cycles per second
Formula repairAmountHpFormula; // HP restored per cycle
Formula repairRangeFormula;
};

View File

@@ -8,6 +8,12 @@ constexpr int kTickRateHz = 30;
constexpr double kTickDurationMs = 1000.0 / kTickRateHz;
constexpr double kTickDurationSeconds = 1.0 / kTickRateHz;
// Delay between a tool activating (emitting its beam) and its effect being
// applied — half the 0.3 s beam duration. Shared by weapons, repair tools, and
// salvage modules so all three apply their effect mid-beam (REQ-SHP-FIRING,
// REQ-SHP-FIRING-BEAM).
constexpr Tick kBeamImpactDelayTicks = 5;
// Converts a wall-clock duration (in seconds, as it appears in config TOML) to
// an integer tick count. Rounds to nearest to avoid systematic drift from
// repeated conversions.

View File

@@ -6,7 +6,9 @@
struct RepairToolComponent
{
float ratePerTick;
float repairAmountHp; // HP restored per repair cycle
int repairIntervalTicks; // cycle period = kTickRateHz / repair-rate (cycles/s); 0 = never
int cooldownTicksRemaining; // ticks until this tool may start its next cycle
float range_tiles;
std::optional<entt::entity> currentTarget;
};

View File

@@ -10,8 +10,6 @@
#include "tracing.h"
#include "WeaponComponent.h"
static constexpr Tick kWeaponImpactDelayTicks = 5;
CombatSystem::CombatSystem(const GameConfig& config)
: m_config(config)
{
@@ -20,7 +18,7 @@ CombatSystem::CombatSystem(const GameConfig& config)
void CombatSystem::tick(Tick currentTick,
EntityAdmin& admin,
BuildingSystem& /*buildings*/,
std::vector<WeaponFiredEvent>& outWeaponFiredEvents)
std::vector<BeamFiredEvent>& outBeamFiredEvents)
{
TRACE();
// All weapons (ships and stations) are child entities linked via ModuleOwnerComponent.
@@ -31,7 +29,7 @@ void CombatSystem::tick(Tick currentTick,
{
const PositionComponent& pos = admin.get<PositionComponent>(owner.owner);
const FactionComponent& faction = admin.get<FactionComponent>(owner.owner);
resolveWeapon(owner.owner, weapon, pos, faction, currentTick, admin, outWeaponFiredEvents);
resolveWeapon(owner.owner, weapon, pos, faction, currentTick, admin, outBeamFiredEvents);
});
}
@@ -42,7 +40,7 @@ void CombatSystem::resolveWeapon(
const FactionComponent& ownFaction,
Tick currentTick,
EntityAdmin& admin,
std::vector<WeaponFiredEvent>& out)
std::vector<BeamFiredEvent>& out)
{
if (weapon.cooldownTicks > 0.0f)
{
@@ -109,9 +107,10 @@ void CombatSystem::resolveWeapon(
const entt::entity targetEntity = *weapon.currentTarget;
m_pendingDamage.push_back({targetEntity, weapon.damage,
currentTick + kWeaponImpactDelayTicks});
currentTick + kBeamImpactDelayTicks});
WeaponFiredEvent evt;
BeamFiredEvent evt;
evt.kind = BeamKind::Weapon;
evt.shooter = shipEntity;
evt.target = targetEntity;
evt.emittedAt = currentTick;

View File

@@ -7,7 +7,7 @@
#include "Building.h"
#include "FactionComponent.h"
#include "WeaponFiredEvent.h"
#include "BeamFiredEvent.h"
#include "GameConfig.h"
#include "PositionComponent.h"
#include "Tick.h"
@@ -26,7 +26,7 @@ public:
void tick(Tick currentTick,
EntityAdmin& admin,
BuildingSystem& buildings,
std::vector<WeaponFiredEvent>& outWeaponFiredEvents);
std::vector<BeamFiredEvent>& outBeamFiredEvents);
void applyPendingDamage(Tick currentTick, EntityAdmin& admin);
@@ -47,7 +47,7 @@ private:
const FactionComponent& ownFaction,
Tick currentTick,
EntityAdmin& admin,
std::vector<WeaponFiredEvent>& out);
std::vector<BeamFiredEvent>& out);
const GameConfig& m_config;
};

View File

@@ -19,52 +19,91 @@ RepairSystem::RepairSystem(EntityAdmin& admin)
{
}
void RepairSystem::tick()
void RepairSystem::tick(Tick currentTick, std::vector<BeamFiredEvent>& outBeamFiredEvents)
{
TRACE();
// Apply heals whose mid-beam delay has elapsed (cycles started on prior ticks).
applyPendingHeals(currentTick);
const std::vector<RepairableInfo> repairables = buildRepairables(m_admin);
m_admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
[&](entt::entity /*re*/, RepairToolComponent& tool, const ModuleOwnerComponent& owner)
{
if (tool.cooldownTicksRemaining > 0) { --tool.cooldownTicksRemaining; }
if (tool.cooldownTicksRemaining > 0) { return; }
if (tool.repairIntervalTicks <= 0) { return; }
if (!m_admin.hasAll<PositionComponent>(owner.owner)) { return; }
const QVector2D ownerPos = m_admin.get<PositionComponent>(owner.owner).value;
// Honour the executor-set target if it is still valid and in range.
// Choose a target: honour the executor-set target if it is still valid
// and in range, else fall back to the nearest damaged friendly in range.
std::optional<entt::entity> target;
if (tool.currentTarget)
{
const entt::entity t = *tool.currentTarget;
if (m_admin.isValid(t) && m_admin.hasAll<HealthComponent, PositionComponent>(t))
{
HealthComponent& th = m_admin.get<HealthComponent>(t);
const HealthComponent& th = m_admin.get<HealthComponent>(t);
const float dist =
(m_admin.get<PositionComponent>(t).value - ownerPos).length();
if (th.hp > 0.0f && th.hp < th.maxHp && dist <= tool.range_tiles)
{
th.hp = std::min(th.hp + tool.ratePerTick, th.maxHp);
return;
target = t;
}
}
}
// Fallback: heal the nearest damaged friendly within tool range.
tool.currentTarget = std::nullopt;
float bestDist = tool.range_tiles;
for (const RepairableInfo& r : repairables)
if (!target)
{
if (r.isEnemy) { continue; }
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
const float dist = (r.position - ownerPos).length();
if (dist < bestDist)
tool.currentTarget = std::nullopt;
float bestDist = tool.range_tiles;
for (const RepairableInfo& r : repairables)
{
bestDist = dist;
tool.currentTarget = r.entity;
if (r.isEnemy) { continue; }
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
const float dist = (r.position - ownerPos).length();
if (dist < bestDist)
{
bestDist = dist;
tool.currentTarget = r.entity;
}
}
target = tool.currentTarget;
}
if (!tool.currentTarget) { return; }
if (!target) { return; }
HealthComponent& targetHealth = m_admin.get<HealthComponent>(*tool.currentTarget);
targetHealth.hp = std::min(targetHealth.hp + tool.ratePerTick, targetHealth.maxHp);
// Start a repair cycle: emit the beam now, apply the heal mid-beam, and
// begin the cooldown at cycle start (not at effect application).
outBeamFiredEvents.push_back(
BeamFiredEvent{BeamKind::Repair, owner.owner, *target, currentTick});
m_pendingHeals.push_back({*target, tool.repairAmountHp,
currentTick + kBeamImpactDelayTicks});
tool.cooldownTicksRemaining = tool.repairIntervalTicks;
});
}
void RepairSystem::applyPendingHeals(Tick currentTick)
{
std::vector<PendingHeal>::iterator it = m_pendingHeals.begin();
while (it != m_pendingHeals.end())
{
if (it->appliesAt <= currentTick)
{
if (m_admin.isValid(it->target) && m_admin.hasAll<HealthComponent>(it->target))
{
HealthComponent& h = m_admin.get<HealthComponent>(it->target);
if (h.hp > 0.0f && h.hp < h.maxHp)
{
h.hp = std::min(h.hp + it->amountHp, h.maxHp);
}
}
it = m_pendingHeals.erase(it);
}
else
{
++it;
}
}
}

View File

@@ -1,17 +1,36 @@
#pragma once
#include <vector>
#include "BeamFiredEvent.h"
#include "Tick.h"
#include "entt/entity/entity.hpp"
class EntityAdmin;
// World-mutation system for repair modules: validates each tool's target (set by
// RepairExecutor), falls back to the nearest damaged friendly in range, and
// applies healing. Runs every tick, independent of behavior selection.
// World-mutation system for repair modules: each tool runs a cycle on its own
// cooldown. When a cycle starts it picks a target (the RepairExecutor-set target,
// else the nearest damaged friendly in range), emits a repair beam, and schedules
// the heal for mid-beam (kBeamImpactDelayTicks later) — mirroring weapon firing.
// Runs every tick, independent of behavior selection.
class RepairSystem
{
public:
explicit RepairSystem(EntityAdmin& admin);
void tick();
void tick(Tick currentTick, std::vector<BeamFiredEvent>& outBeamFiredEvents);
private:
EntityAdmin& m_admin;
struct PendingHeal
{
entt::entity target;
float amountHp;
Tick appliesAt;
};
void applyPendingHeals(Tick currentTick);
EntityAdmin& m_admin;
std::vector<PendingHeal> m_pendingHeals;
};

View File

@@ -8,9 +8,12 @@
#include "BuildingSystem.h"
#include "DeliverScrapBehavior.h"
#include "EntityAdmin.h"
#include <map>
#include "ModuleOwnerComponent.h"
#include "PositionComponent.h"
#include "SalvageCargoComponent.h"
#include "ScrapDataComponent.h"
#include "ScrapSystem.h"
#include "tracing.h"
@@ -19,9 +22,13 @@ SalvagerSystem::SalvagerSystem(EntityAdmin& admin)
{
}
void SalvagerSystem::tick(ScrapSystem& scraps, BuildingSystem& buildings)
void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem& buildings,
std::vector<BeamFiredEvent>& outBeamFiredEvents)
{
TRACE();
// Apply collections whose mid-beam delay has elapsed (cycles started earlier).
applyPendingCollections(currentTick, scraps);
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
// Tick down per-module collection cooldowns.
@@ -31,23 +38,39 @@ void SalvagerSystem::tick(ScrapSystem& scraps, BuildingSystem& buildings)
if (c.cooldownTicksRemaining > 0) { --c.cooldownTicksRemaining; }
});
// Collection: each ready, in-range module collects one scrap.
// Scrap units already claimed by not-yet-applied collection cycles, so two
// modules don't both target the last unit of the same pile (the claim would be
// dropped at apply time). A pile is available while its amount exceeds its claims.
std::map<entt::entity, int> claimedUnits;
for (const PendingCollection& pc : m_pendingCollections)
{
++claimedUnits[pc.scrap];
}
// Cycle start: each ready, in-range module with free cargo begins a collection
// cycle — emit the beam now, collect one scrap mid-beam, start the cooldown now.
m_admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity /*ce*/, SalvageCargoComponent& c, const ModuleOwnerComponent& o)
[&](entt::entity moduleEntity, SalvageCargoComponent& c, const ModuleOwnerComponent& o)
{
if (c.current >= c.capacity || c.cooldownTicksRemaining > 0) { return; }
if (c.collectionIntervalTicks <= 0) { return; }
if (!m_admin.hasAll<PositionComponent>(o.owner)) { return; }
const QVector2D ownerPos = m_admin.get<PositionComponent>(o.owner).value;
for (const ScrapInfo& si : allScrap)
{
if ((si.position - ownerPos).length() > c.collectionRange_tiles) { continue; }
if (scraps.consume(si.entity))
if (claimedUnits[si.entity] >= m_admin.get<ScrapDataComponent>(si.entity).amount)
{
++c.current;
c.cooldownTicksRemaining = c.collectionIntervalTicks;
break;
continue; // every remaining unit of this pile is already spoken for
}
outBeamFiredEvents.push_back(
BeamFiredEvent{BeamKind::Salvage, o.owner, si.entity, currentTick});
m_pendingCollections.push_back({moduleEntity, si.entity,
currentTick + kBeamImpactDelayTicks});
++claimedUnits[si.entity];
c.cooldownTicksRemaining = c.collectionIntervalTicks;
break;
}
});
@@ -77,3 +100,27 @@ void SalvagerSystem::tick(ScrapSystem& scraps, BuildingSystem& buildings)
});
});
}
void SalvagerSystem::applyPendingCollections(Tick currentTick, ScrapSystem& scraps)
{
std::vector<PendingCollection>::iterator it = m_pendingCollections.begin();
while (it != m_pendingCollections.end())
{
if (it->appliesAt <= currentTick)
{
if (m_admin.isValid(it->module) && m_admin.hasAll<SalvageCargoComponent>(it->module))
{
SalvageCargoComponent& c = m_admin.get<SalvageCargoComponent>(it->module);
if (c.current < c.capacity && scraps.collectOne(it->scrap))
{
++c.current;
}
}
it = m_pendingCollections.erase(it);
}
else
{
++it;
}
}
}

View File

@@ -1,19 +1,39 @@
#pragma once
#include <vector>
#include "BeamFiredEvent.h"
#include "Tick.h"
#include "entt/entity/entity.hpp"
class BuildingSystem;
class EntityAdmin;
class ScrapSystem;
// World-mutation system for salvage modules: collects scrap into cargo and
// delivers full cargo at a SalvageBay. Runs every tick, independent of which
// behavior the AiSystem selected.
// World-mutation system for salvage modules: each module runs a collection cycle
// on its own cooldown. When a cycle starts it emits a salvage beam toward an
// in-range scrap pile and schedules the collection of one scrap for mid-beam
// (kBeamImpactDelayTicks later) — mirroring weapon firing. Also delivers full
// cargo at a SalvageBay. Runs every tick, independent of behavior selection.
class SalvagerSystem
{
public:
explicit SalvagerSystem(EntityAdmin& admin);
void tick(ScrapSystem& scraps, BuildingSystem& buildings);
void tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem& buildings,
std::vector<BeamFiredEvent>& outBeamFiredEvents);
private:
EntityAdmin& m_admin;
struct PendingCollection
{
entt::entity module;
entt::entity scrap;
Tick appliesAt;
};
void applyPendingCollections(Tick currentTick, ScrapSystem& scraps);
EntityAdmin& m_admin;
std::vector<PendingCollection> m_pendingCollections;
};

View File

@@ -46,6 +46,25 @@ std::optional<int> ScrapSystem::consume(entt::entity entity)
return amount;
}
bool ScrapSystem::collectOne(entt::entity entity)
{
if (!m_admin.isValid(entity) || !m_admin.hasAll<ScrapDataComponent>(entity))
{
return false;
}
ScrapDataComponent& data = m_admin.get<ScrapDataComponent>(entity);
if (data.amount <= 0)
{
return false;
}
--data.amount;
if (data.amount <= 0)
{
m_admin.destroy(entity);
}
return true;
}
std::vector<ScrapInfo> ScrapSystem::allScrapInfo() const
{
std::vector<ScrapInfo> result;

View File

@@ -28,6 +28,11 @@ public:
// Removes the scrap and returns its amount, or nullopt if not found.
std::optional<int> consume(entt::entity entity);
// Collects a single scrap unit from the pile: decrements its amount by one,
// destroying the entity once depleted. Returns true if a scrap was collected,
// false if the entity is invalid or already empty (REQ-SHP-SALVAGE).
bool collectOne(entt::entity entity);
// Lightweight snapshot for callers that need to iterate all scrap.
std::vector<ScrapInfo> allScrapInfo() const;

View File

@@ -157,9 +157,14 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
if (modDef->repairCapability)
{
RepairToolComponent rt;
rt.ratePerTick = static_cast<float>(
modDef->repairCapability->repairRateFormula.evaluate(mx))
/ static_cast<float>(kTickRateHz);
const double repairRateHz =
modDef->repairCapability->repairRateFormula.evaluate(mx);
rt.repairIntervalTicks = (repairRateHz > 0.0)
? static_cast<int>(kTickRateHz / repairRateHz + 0.5)
: 0;
rt.repairAmountHp = static_cast<float>(
modDef->repairCapability->repairAmountHpFormula.evaluate(mx));
rt.cooldownTicksRemaining = 0;
rt.range_tiles = static_cast<float>(
modDef->repairCapability->repairRangeFormula.evaluate(mx)) / tileSize;
rt.currentTarget = std::nullopt;
@@ -321,8 +326,15 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
for (entt::entity child : repairChildren)
{
RepairToolComponent& rt = m_admin.get<RepairToolComponent>(child);
applyMod(rt.ratePerTick, "repair_rate", repairMods);
applyMod(rt.range_tiles, "repair_range", repairMods);
// Apply rate modifier: compute cycles/s from interval, apply, convert back.
float fRate = (rt.repairIntervalTicks > 0)
? static_cast<float>(kTickRateHz) / static_cast<float>(rt.repairIntervalTicks)
: 0.0f;
applyMod(fRate, "repair_rate", repairMods);
applyMod(rt.range_tiles, "repair_range", repairMods);
rt.repairIntervalTicks = (fRate > 0.0f)
? static_cast<int>(static_cast<float>(kTickRateHz) / fRate + 0.5f)
: 0;
}
// --- Pass 3: attach behavior components based on capability presence -----

View File

@@ -0,0 +1,31 @@
#pragma once
#include "Event.h"
#include "Tick.h"
#include "entt/entity/entity.hpp"
// The kind of tool that produced a beam. Used by the renderer to choose the
// beam color (REQ-SHP-FIRING-BEAM).
enum class BeamKind
{
Weapon,
Repair,
Salvage,
};
// Transient record emitted whenever a weapon fires, a repair tool starts a heal
// cycle, or a salvage module starts a collection cycle (REQ-SHP-FIRING,
// REQ-SHP-FIRING-BEAM). Buffered in a sim-owned vector during the tick, then
// drained and re-emitted via EventManager by the UI frame handler.
struct BeamFiredEvent : public Event
{
BeamFiredEvent() = default;
BeamFiredEvent(BeamKind kind, entt::entity shooter, entt::entity target, Tick emittedAt)
: kind(kind), shooter(shooter), target(target), emittedAt(emittedAt) {}
BeamKind kind = BeamKind::Weapon;
entt::entity shooter = entt::null;
entt::entity target = entt::null;
Tick emittedAt = 0;
};

View File

@@ -23,7 +23,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindowClosedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ArenaStartRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/WeaponFiredEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
PARENT_SCOPE
)

View File

@@ -1,17 +0,0 @@
#pragma once
#include "Event.h"
#include "Tick.h"
#include "entt/entity/entity.hpp"
struct WeaponFiredEvent : public Event
{
WeaponFiredEvent() = default;
WeaponFiredEvent(entt::entity shooter, entt::entity target, Tick emittedAt)
: shooter(shooter), target(target), emittedAt(emittedAt) {}
entt::entity shooter = entt::null;
entt::entity target = entt::null;
Tick emittedAt = 0;
};

View File

@@ -60,7 +60,7 @@ ShipStats calculateShipStats(const GameConfig& config,
// --- Pass 1: base capability stats per module instance -------------------
struct WeaponInstance { float damage; float range_tiles; float rate_hz; };
struct SalvageInstance { float range_tiles; float rate; };
struct RepairInstance { float rate_hps; float range_tiles; };
struct RepairInstance { float rate_hz; float amount_hp; float range_tiles; };
std::vector<WeaponInstance> weaponInstances;
std::vector<SalvageInstance> salvageInstances;
@@ -93,7 +93,8 @@ ShipStats calculateShipStats(const GameConfig& config,
if (def->repairCapability)
{
RepairInstance ri;
ri.rate_hps = static_cast<float>(def->repairCapability->repairRateFormula.evaluate(mx));
ri.rate_hz = static_cast<float>(def->repairCapability->repairRateFormula.evaluate(mx));
ri.amount_hp = static_cast<float>(def->repairCapability->repairAmountHpFormula.evaluate(mx));
ri.range_tiles = static_cast<float>(def->repairCapability->repairRangeFormula.evaluate(mx) / tileSize);
repairInstances.push_back(ri);
}
@@ -238,9 +239,9 @@ ShipStats calculateShipStats(const GameConfig& config,
float maxRange = 0.0f;
for (RepairInstance& ri : repairInstances)
{
applyMod(ri.rate_hps, "repair_rate", repairMods);
applyMod(ri.rate_hz, "repair_rate", repairMods);
applyMod(ri.range_tiles, "repair_range", repairMods);
combinedRate += ri.rate_hps;
combinedRate += ri.rate_hz * ri.amount_hp;
if (ri.range_tiles > maxRange) { maxRange = ri.range_tiles; }
}
result.repair = ShipStats::RepairStats{combinedRate, maxRange};
@@ -303,7 +304,10 @@ ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEn
{
if (owner.owner != shipEntity) { return; }
hasRepair = true;
repairRate += r.ratePerTick * kTickRateHz;
const float cyclesPerSec = (r.repairIntervalTicks > 0)
? static_cast<float>(kTickRateHz) / static_cast<float>(r.repairIntervalTicks)
: 0.0f;
repairRate += cyclesPerSec * r.repairAmountHp;
if (r.range_tiles > repairMaxRange) { repairMaxRange = r.range_tiles; }
});

View File

@@ -140,7 +140,7 @@ void Simulation::reset(unsigned int seed)
m_playerStation2Entity = entt::null;
m_currentEnemyStationEntities[0] = entt::null;
m_currentEnemyStationEntities[1] = entt::null;
m_weaponFiredEvents.clear();
m_beamFiredEvents.clear();
m_pendingSchematicChoices.clear();
m_admin.clear();
@@ -248,12 +248,13 @@ void Simulation::tick()
// movement intent + preferred module targets only — no world mutation).
m_aiSystem->tick(m_admin, *m_buildingSystem, *m_scrapSystem);
// Module systems perform the world mutation (collection/delivery, healing).
m_salvagerSystem->tick(*m_scrapSystem, *m_buildingSystem);
m_repairSystem->tick();
// Each emits its tool beams and applies its own delayed (mid-beam) effects.
m_salvagerSystem->tick(m_currentTick, *m_scrapSystem, *m_buildingSystem, m_beamFiredEvents);
m_repairSystem->tick(m_currentTick, m_beamFiredEvents);
// Step 8: combat resolution
m_combatSystem->tick(m_currentTick, m_admin,
*m_buildingSystem, m_weaponFiredEvents);
*m_buildingSystem, m_beamFiredEvents);
// Step 8b: deferred damage whose impact tick has arrived
m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
@@ -828,10 +829,10 @@ bool Simulation::isItemUnlocked(const std::string& itemId) const
// Drains
// ---------------------------------------------------------------------------
std::vector<WeaponFiredEvent> Simulation::drainWeaponFiredEvents()
std::vector<BeamFiredEvent> Simulation::drainBeamFiredEvents()
{
std::vector<WeaponFiredEvent> result;
result.swap(m_weaponFiredEvents);
std::vector<BeamFiredEvent> result;
result.swap(m_beamFiredEvents);
return result;
}

View File

@@ -16,7 +16,7 @@
#include "BuildingType.h"
#include "BuildingId.h"
#include "EventHandler.h"
#include "WeaponFiredEvent.h"
#include "BeamFiredEvent.h"
#include "GameConfig.h"
#include "Rotation.h"
#include "Tick.h"
@@ -52,7 +52,7 @@ public:
// Returns all fire events accumulated since the last drain, clearing the
// internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM).
std::vector<WeaponFiredEvent> drainWeaponFiredEvents();
std::vector<BeamFiredEvent> drainBeamFiredEvents();
// Returns the pending schematic choices (empty if no drop is pending).
const std::vector<SchematicChoiceOption>& getPendingSchematicChoices() const;
@@ -192,6 +192,6 @@ private:
std::unique_ptr<WaveSystem> m_waveSystem;
std::unique_ptr<CombatSystem> m_combatSystem;
std::vector<WeaponFiredEvent> m_weaponFiredEvents;
std::vector<BeamFiredEvent> m_beamFiredEvents;
std::vector<SchematicChoiceOption> m_pendingSchematicChoices;
};

View File

@@ -70,6 +70,7 @@ struct Fixture
DynamicBodySystem dynamicBody;
ScrapSystem scraps;
Tick tick;
std::vector<BeamFiredEvent> beamEvents;
explicit Fixture()
: cfg(loadConfig())
@@ -102,8 +103,9 @@ struct Fixture
// World mutation: collection/delivery and healing.
void runModules()
{
salvager.tick(scraps, buildings);
repair.tick();
beamEvents.clear();
salvager.tick(tick, scraps, buildings, beamEvents);
repair.tick(tick, beamEvents);
}
// Run one full behavior+movement tick (steps 7 and 10).
@@ -115,6 +117,35 @@ struct Fixture
dynamicBody.tick(admin);
++tick;
}
// One repair-system tick at the current sim time (advances the tick counter).
// Starts cycles and applies any due (mid-beam-delayed) heals.
void repairTick()
{
beamEvents.clear();
repair.tick(tick, beamEvents);
++tick;
}
// Drive the repair system long enough for a started cycle's delayed heal to land.
void runRepairHeal()
{
for (int i = 0; i <= kBeamImpactDelayTicks; ++i) { repairTick(); }
}
// One salvage-system tick at the current sim time (advances the tick counter).
void salvageTick()
{
beamEvents.clear();
salvager.tick(tick, scraps, buildings, beamEvents);
++tick;
}
// Drive the salvage system long enough for a started cycle's delayed collection.
void runSalvageCollect()
{
for (int i = 0; i <= kBeamImpactDelayTicks; ++i) { salvageTick(); }
}
};
static ShipLayoutConfig makeSingleModuleLayout(const std::string& moduleId)
@@ -602,7 +633,7 @@ TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
f.admin.get<HealthComponent>(friendly).hp = initialHp;
f.decide();
f.runModules();
f.runRepairHeal();
REQUIRE(health(f.admin, friendly).hp > initialHp);
}
@@ -616,11 +647,8 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp - 0.001f;
for (int i = 0; i < 5; ++i)
{
f.decide();
f.runModules();
}
f.decide();
f.runRepairHeal();
const HealthComponent& h = health(f.admin, friendly);
REQUIRE(h.hp <= h.maxHp);
@@ -644,7 +672,7 @@ TEST_CASE("RepairSystem: tool heals the in-range damaged target chosen by the ex
f.admin.get<HealthComponent>(friendly).hp = initHp;
f.decide();
f.runModules();
f.runRepairHeal();
const entt::entity rc = firstRepairChild(f.admin, repairShip);
REQUIRE(f.admin.isValid(rc));
@@ -674,7 +702,7 @@ TEST_CASE("RepairSystem: tool falls back to in-range target when its target is o
const entt::entity rc = firstRepairChild(f.admin, repairShip);
f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange;
f.repair.tick();
f.runRepairHeal();
REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
@@ -699,7 +727,7 @@ TEST_CASE("RepairSystem: tool falls back when its target is fully healed",
const entt::entity rc = firstRepairChild(f.admin, repairShip);
f.admin.get<RepairToolComponent>(rc).currentTarget = healed;
f.repair.tick();
f.runRepairHeal();
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
@@ -722,7 +750,7 @@ TEST_CASE("RepairSystem: tool falls back when its target is destroyed",
f.admin.get<RepairToolComponent>(rc).currentTarget = gone;
f.ships.despawn(gone);
f.repair.tick();
f.runRepairHeal();
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
@@ -744,7 +772,7 @@ TEST_CASE("RepairSystem: tool target is cleared when no repairable target is in
const entt::entity rc = firstRepairChild(f.admin, repairShip);
f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange;
f.repair.tick();
f.runRepairHeal();
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
REQUIRE(health(f.admin, outOfRange).hp == Approx(initHp));
@@ -763,11 +791,12 @@ TEST_CASE("RepairSystem: two repair modules both heal the chosen target additive
f.admin.get<HealthComponent>(targetA).hp = initHp;
f.decide();
f.runModules();
f.runRepairHeal();
// Both modules should have healed targetA — total increase is 2 * ratePerTick.
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz);
REQUIRE(health(f.admin, targetA).hp == Approx(initHp + 2.0f * ratePerTick));
// Both modules run one cycle and heal targetA — total increase is 2 * repairAmountHp.
// repair_amount_hp_formula = "5 + x" at x=1 → 6 HP per cycle.
const float repairAmountHp = 5.0f + 1.0f;
REQUIRE(health(f.admin, targetA).hp == Approx(initHp + 2.0f * repairAmountHp));
const std::vector<entt::entity> children = allRepairChildren(f.admin, repairShip);
REQUIRE(children.size() == 2);
@@ -797,10 +826,10 @@ TEST_CASE("RepairSystem: two modules both fall back and heal the same target",
f.admin.get<RepairToolComponent>(child).currentTarget = healed;
}
f.repair.tick();
f.runRepairHeal();
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz);
REQUIRE(health(f.admin, targetB).hp == Approx(initHp + 2.0f * ratePerTick));
const float repairAmountHp = 5.0f + 1.0f;
REQUIRE(health(f.admin, targetB).hp == Approx(initHp + 2.0f * repairAmountHp));
const std::vector<entt::entity> children = allRepairChildren(f.admin, repairShip);
REQUIRE(children.size() == 2);
@@ -819,14 +848,16 @@ TEST_CASE("RepairSystem: does not crash when a tool's owner is not a repair ship
const entt::entity ownerShip = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const entt::entity moduleEntity = f.admin.createModuleEntity();
RepairToolComponent rt;
rt.ratePerTick = 1.0f;
rt.range_tiles = 10.0f;
rt.currentTarget = std::nullopt;
rt.repairAmountHp = 1.0f;
rt.repairIntervalTicks = kTickRateHz;
rt.cooldownTicksRemaining = 0;
rt.range_tiles = 10.0f;
rt.currentTarget = std::nullopt;
f.admin.addComponent<RepairToolComponent>(moduleEntity, rt);
f.admin.addComponent<ModuleOwnerComponent>(moduleEntity, ModuleOwnerComponent{ownerShip});
// Must not crash; no damaged friendly in range, so no target is set.
f.repair.tick();
f.runRepairHeal();
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(moduleEntity).currentTarget.has_value());
}
@@ -866,7 +897,7 @@ TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]"
false, salvageLayout);
const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings);
f.runSalvageCollect();
const entt::entity sc = firstSalvageChild(f.admin, ship);
REQUIRE(f.admin.isValid(sc));
@@ -935,7 +966,7 @@ TEST_CASE("SalvagerSystem: module does not collect scrap beyond its collection r
false, salvageLayout);
f.scraps.spawn(QVector2D(55.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings);
f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
}
@@ -950,7 +981,7 @@ TEST_CASE("SalvagerSystem: module collects scrap within its collection range",
false, salvageLayout);
f.scraps.spawn(QVector2D(45.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings);
f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 1);
}
@@ -967,11 +998,13 @@ TEST_CASE("SalvagerSystem: collection sets cooldown on module", "[behavior]")
false, salvageLayout);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings);
// Starting a collection cycle sets the cooldown immediately; the scrap is not
// collected until mid-beam (REQ-SHP-SALVAGE), so cargo is still empty now.
f.salvageTick();
const SalvageCargoComponent& cargo =
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship));
REQUIRE(cargo.current == 1);
REQUIRE(cargo.current == 0);
REQUIRE(cargo.cooldownTicksRemaining == cargo.collectionIntervalTicks);
}
@@ -985,7 +1018,7 @@ TEST_CASE("SalvagerSystem: module on cooldown does not collect scrap", "[behavio
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10;
f.salvager.tick(f.scraps, f.buildings);
f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
}
@@ -999,15 +1032,16 @@ TEST_CASE("SalvagerSystem: module collects again after cooldown expires", "[beha
const entt::entity sc = firstSalvageChild(f.admin, ship);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings);
f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 1);
// Shorten cooldown to 1 tick and place a second scrap.
f.admin.get<SalvageCargoComponent>(sc).cooldownTicksRemaining = 1;
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
// Next tick: cooldown decrements to 0, module collects the second scrap.
f.salvager.tick(f.scraps, f.buildings);
// Once the cooldown expires the module starts another cycle and collects the
// second scrap after the mid-beam delay.
f.runSalvageCollect();
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 2);
}
@@ -1026,7 +1060,7 @@ TEST_CASE("SalvagerSystem: two salvage modules collect independently in same tic
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings);
f.runSalvageCollect();
REQUIRE(totalSalvageCurrent(f.admin, ship) == 2);
}
@@ -1055,7 +1089,7 @@ TEST_CASE("SalvagerSystem: second salvage module does not collect when first is
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
f.salvager.tick(f.scraps, f.buildings);
f.runSalvageCollect();
// Only one module was ready, so only one scrap is collected.
REQUIRE(totalSalvageCurrent(f.admin, ship) == 1);

View File

@@ -10,7 +10,7 @@
#include "ConfigLoader.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "WeaponFiredEvent.h"
#include "BeamFiredEvent.h"
#include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "ModuleOwnerComponent.h"
@@ -112,7 +112,7 @@ TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[comb
const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<WeaponFiredEvent> events;
std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events);
f.combat.applyPendingDamage(5, f.admin);
@@ -136,16 +136,16 @@ TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]"
f.admin.get<WeaponComponent>(wc).cooldownTicks = 3.0f; // override to 3
}
auto enemyFiredIn = [&enemy](const std::vector<WeaponFiredEvent>& evts)
auto enemyFiredIn = [&enemy](const std::vector<BeamFiredEvent>& evts)
{
for (const WeaponFiredEvent& evt : evts)
for (const BeamFiredEvent& evt : evts)
{
if (evt.shooter == enemy) { return true; }
}
return false;
};
std::vector<WeaponFiredEvent> events;
std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events);
REQUIRE_FALSE(enemyFiredIn(events));
@@ -166,7 +166,7 @@ TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false);
f.wireEnemyTarget(enemy, player);
std::vector<WeaponFiredEvent> events;
std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events);
REQUIRE(events.empty());
}
@@ -205,9 +205,9 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]
sim.tick();
const std::vector<WeaponFiredEvent> events = sim.drainWeaponFiredEvents();
const std::vector<BeamFiredEvent> events = sim.drainBeamFiredEvents();
bool stationFired = false;
for (const WeaponFiredEvent& evt : events)
for (const BeamFiredEvent& evt : events)
{
if (evt.shooter == stationEntity) { stationFired = true; }
}
@@ -243,9 +243,9 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
sim.tick();
const std::vector<WeaponFiredEvent> events = sim.drainWeaponFiredEvents();
const std::vector<BeamFiredEvent> events = sim.drainBeamFiredEvents();
bool stationFired = false;
for (const WeaponFiredEvent& evt : events)
for (const BeamFiredEvent& evt : events)
{
if (evt.shooter == stationEntity) { stationFired = true; }
}
@@ -281,9 +281,9 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]
sim.tick();
const std::vector<WeaponFiredEvent> events = sim.drainWeaponFiredEvents();
const std::vector<BeamFiredEvent> events = sim.drainBeamFiredEvents();
bool playerFiredAtStation = false;
for (const WeaponFiredEvent& evt : events)
for (const BeamFiredEvent& evt : events)
{
if (evt.shooter == playerShip && evt.target == stationEntity)
{
@@ -309,7 +309,7 @@ TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]")
const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<WeaponFiredEvent> events;
std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events);
for (Tick t = 1; t < 5; ++t)
@@ -331,7 +331,7 @@ TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]")
const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<WeaponFiredEvent> events;
std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events);
f.combat.applyPendingDamage(5, f.admin);
@@ -348,7 +348,7 @@ TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[comb
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player);
std::vector<WeaponFiredEvent> events;
std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events);
f.ships.despawn(player);
@@ -371,7 +371,7 @@ TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat
const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<WeaponFiredEvent> events;
std::vector<BeamFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events);
f.ships.despawn(enemy);

View File

@@ -93,6 +93,38 @@ TEST_CASE("ScrapSystem: consume returns nullopt for invalid entity", "[scrap]")
REQUIRE_FALSE(amount.has_value());
}
// ---------------------------------------------------------------------------
// collectOne
// ---------------------------------------------------------------------------
TEST_CASE("ScrapSystem: collectOne depletes one scrap and keeps the pile until empty", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
const entt::entity e = ss.spawn(QVector2D(0.0f, 0.0f), 3, 100);
REQUIRE(ss.collectOne(e));
REQUIRE(admin.isValid(e));
REQUIRE(admin.get<ScrapDataComponent>(e).amount == 2);
REQUIRE(ss.collectOne(e));
REQUIRE(admin.isValid(e));
REQUIRE(admin.get<ScrapDataComponent>(e).amount == 1);
// Final unit collected: the pile is removed once depleted.
REQUIRE(ss.collectOne(e));
REQUIRE_FALSE(admin.isValid(e));
}
TEST_CASE("ScrapSystem: collectOne returns false for an invalid entity", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
REQUIRE_FALSE(ss.collectOne(entt::null));
}
// ---------------------------------------------------------------------------
// allScrapInfo
// ---------------------------------------------------------------------------

View File

@@ -256,11 +256,13 @@ TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas",
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool");
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
// repair_tool: repair_rate_hz_formula = "5 + x" at x=1 → 6 / kTickRateHz
const float expectedRate = 6.0f / static_cast<float>(kTickRateHz);
const entt::entity rc = firstRepairChild(admin, e);
REQUIRE(admin.isValid(rc));
REQUIRE(admin.get<RepairToolComponent>(rc).ratePerTick == Approx(expectedRate));
// repair_rate_hz_formula = "1" cycle/s → interval = kTickRateHz ticks
REQUIRE(admin.get<RepairToolComponent>(rc).repairIntervalTicks == kTickRateHz);
// repair_amount_hp_formula = "5 + x" at x=1 → 6 HP per cycle
REQUIRE(admin.get<RepairToolComponent>(rc).repairAmountHp == Approx(6.0f));
REQUIRE(admin.get<RepairToolComponent>(rc).cooldownTicksRemaining == 0);
// repair_range_m_formula = "800" m → 800/10 = 80 tiles
REQUIRE(admin.get<RepairToolComponent>(rc).range_tiles == Approx(80.0f));
REQUIRE(admin.get<RepairBehavior>(e).maxRepairRange_tiles == Approx(80.0f));

View File

@@ -43,22 +43,22 @@ TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]")
REQUIRE(sim.currentTick() == 10);
}
TEST_CASE("Simulation::drainWeaponFiredEvents returns empty initially", "[simulation]")
TEST_CASE("Simulation::drainBeamFiredEvents returns empty initially", "[simulation]")
{
Simulation sim(loadConfig());
REQUIRE(sim.drainWeaponFiredEvents().empty());
REQUIRE(sim.drainBeamFiredEvents().empty());
}
TEST_CASE("Simulation::drainWeaponFiredEvents clears queue on drain", "[simulation]")
TEST_CASE("Simulation::drainBeamFiredEvents clears queue on drain", "[simulation]")
{
Simulation sim(loadConfig());
// First drain: empty.
sim.drainWeaponFiredEvents();
sim.drainBeamFiredEvents();
// Second drain must also be empty (not a double-return).
REQUIRE(sim.drainWeaponFiredEvents().empty());
REQUIRE(sim.drainBeamFiredEvents().empty());
}
TEST_CASE("Simulation::hasSchematicChoicesPending returns false initially", "[simulation]")

View File

@@ -41,6 +41,7 @@
#include "ShipSystem.h"
#include "Simulation.h"
#include "StationBodyComponent.h"
#include "ScrapDataComponent.h"
#include "SurfaceMask.h"
#include "Tick.h"
#include "EscapeMenuRequestedEvent.h"
@@ -157,11 +158,11 @@ void GameWorldView::onFrame()
// Emit fire events via EventManager
{
const std::vector<WeaponFiredEvent> fires = m_sim->drainWeaponFiredEvents();
for (const WeaponFiredEvent& fe : fires)
const std::vector<BeamFiredEvent> fires = m_sim->drainBeamFiredEvents();
for (const BeamFiredEvent& fe : fires)
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<WeaponFiredEvent>(fe));
std::make_shared<BeamFiredEvent>(fe));
}
}
@@ -1031,12 +1032,20 @@ void GameWorldView::drawDebugOverlay(QPainter& painter)
void GameWorldView::drawBeams(QPainter& painter)
{
painter.setPen(QPen(m_visuals->beams.color, m_visuals->beams.widthPx));
for (const ActiveBeam& beam : m_activeBeams)
{
const std::optional<QVector2D> shooterPos = entityPosition(beam.event.shooter);
const std::optional<QVector2D> targetPos = entityPosition(beam.event.target);
if (!shooterPos.has_value() || !targetPos.has_value()) { continue; }
QColor color = m_visuals->beams.weaponColor;
switch (beam.event.kind)
{
case BeamKind::Weapon: color = m_visuals->beams.weaponColor; break;
case BeamKind::Repair: color = m_visuals->beams.repairColor; break;
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
}
painter.setPen(QPen(color, m_visuals->beams.widthPx));
painter.drawLine(worldToWidget(*shooterPos),
worldToWidget(*targetPos + beam.targetOffset));
}
@@ -1567,8 +1576,11 @@ void GameWorldView::resetForNewGame()
// Event handlers
// ---------------------------------------------------------------------------
void GameWorldView::handleEvent(std::shared_ptr<const WeaponFiredEvent> event)
void GameWorldView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
{
// Endpoint offset is a fraction of the target's visual size (REQ-SHP-FIRING-BEAM):
// half a ship's rendered radius, half a station's shorter footprint side, or
// half a scrap pile's rendered radius (scrap is drawn at tilePx()*0.2).
float maxRadius = 0.125f;
if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<StationBodyComponent>(event->target))
@@ -1577,6 +1589,11 @@ void GameWorldView::handleEvent(std::shared_ptr<const WeaponFiredEvent> event)
const int shorter = std::min(sb.footprint.width(), sb.footprint.height());
maxRadius = shorter / 2.0f;
}
else if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<ScrapDataComponent>(event->target))
{
maxRadius = 0.1f;
}
std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f);
std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius);

View File

@@ -25,7 +25,7 @@
#include "ExitBlueprintModeRequestedEvent.h"
#include "ExitBuilderModeRequestedEvent.h"
#include "DebugDrawToggledEvent.h"
#include "WeaponFiredEvent.h"
#include "BeamFiredEvent.h"
#include "SchematicChoiceOption.h"
#include "SpeedChangeRequestedEvent.h"
@@ -50,7 +50,7 @@ struct QPointCompare
};
class GameWorldView : public QOpenGLWidget,
public CombinedEventHandler<WeaponFiredEvent,
public CombinedEventHandler<BeamFiredEvent,
BuildingTypeSelectedEvent,
ExitBuilderModeRequestedEvent,
DemolishModeToggleRequestedEvent,
@@ -84,7 +84,7 @@ private slots:
void onFrame();
private:
void handleEvent(std::shared_ptr<const WeaponFiredEvent> event) override;
void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
void handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event) override;
void handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const DemolishModeToggleRequestedEvent> event) override;
@@ -140,7 +140,7 @@ private:
struct ActiveBeam
{
WeaponFiredEvent event;
BeamFiredEvent event;
qint64 emittedWallMs;
QVector2D targetOffset;
};

View File

@@ -34,7 +34,9 @@ struct ShipVisuals
struct BeamVisuals
{
QColor color;
QColor weaponColor;
QColor repairColor;
QColor salvageColor;
int widthPx;
};

View File

@@ -209,8 +209,10 @@ VisualsConfig VisualsLoader::load(const std::string& path)
// Beams
{
toml::table& beams = requireSubtable(tbl, "beams", "root");
cfg.beams.color = parseColor(requireString(beams, "color", "beams"), "beams.color");
cfg.beams.widthPx = requireInt(beams, "width_px", "beams");
cfg.beams.weaponColor = parseColor(requireString(beams, "weapon_color", "beams"), "beams.weapon_color");
cfg.beams.repairColor = parseColor(requireString(beams, "repair_color", "beams"), "beams.repair_color");
cfg.beams.salvageColor = parseColor(requireString(beams, "salvage_color", "beams"), "beams.salvage_color");
cfg.beams.widthPx = requireInt(beams, "width_px", "beams");
}
// Overlays