Compare commits

..

5 Commits

8 changed files with 365 additions and 27 deletions

View File

@@ -114,8 +114,8 @@ Output port indicators are not building tiles themselves. A building may have mo
- REQ-SHP-MOVEMENT: Ships move in straight lines toward their current destination at the speed defined by their speed formula. Ship position refers to the ship's center for all range, sensor, and attack checks.
- REQ-SHP-NO-COLLISION: Ships do not collide with each other or with defence stations; they may visually overlap.
- REQ-SHP-SENSOR: A ship perceives only entities within its sensor range. Behavior is driven by what is in sensor range; entities outside sensor range are ignored.
- REQ-SHP-FIRING: All weapons — on ships and on defence stations — are hitscan lasers. When a weapon is off cooldown and its target is within attack range, it fires: damage is applied instantly to the target with no projectile entity, no travel time, and no intervening collision. The weapon's cooldown then begins, derived from its fire rate formula.
- REQ-SHP-FIRING-BEAM: Each fire event produces a visual laser beam drawn from the shooter's position to the target's position for 0.3 seconds. The beam is a pure rendering effect and has no simulation state (does not block movement, does not re-apply damage over its lifetime). Beams follow the shooter and target positions if either moves during the 0.3-second window.
- REQ-SHP-FIRING: All weapons — on ships and on defence stations — fire when off cooldown and the target is within attack range. Firing emits a fire event and starts a 0.15-second damage delay (half the beam duration). When that delay expires, damage is applied to the target — unless the target has already been destroyed, in which case the damage is silently dropped. If the shooter is destroyed before the delay expires, damage is still applied when the delay expires. There is no projectile entity and no intervening collision. The weapon's cooldown begins at the moment of firing, not at damage application.
- REQ-SHP-FIRING-BEAM: Each fire event produces a visual laser beam drawn from the shooter's position to the target for 0.3 seconds. The beam endpoint is not the target's center but a point randomly offset from it: the offset direction is uniformly random and the offset magnitude is uniformly random up to half the target's visual size (for ships: half their rendered radius; for buildings/stations: half the shorter side of their tile footprint, in world units). The offset is chosen once per fire event and held fixed for the beam's lifetime. The beam is a pure rendering effect and has no simulation state (does not block movement, does not re-apply damage over its lifetime). Beams follow the shooter and target positions if either moves during the 0.3-second window. The beam is rendered for its full 0.3-second duration even if the shooter or target is destroyed before it expires.
- REQ-SHP-COMBAT: **Combat ships** (player) — engage enemy ships within sensor range. The player can configure the following per shipyard (applied to all ships produced by that shipyard):
- Stance: aggressive (advance toward enemies) / defensive (hold position near asteroid).
- Target priority: closest / highest HP / structures first.

View File

@@ -5,6 +5,8 @@
#include "Ship.h"
#include "ShipSystem.h"
static constexpr Tick kWeaponImpactDelayTicks = 5; // 0.15 s × 30 Hz, rounded to nearest
CombatSystem::CombatSystem(const GameConfig& config)
: m_config(config)
{
@@ -71,15 +73,7 @@ void CombatSystem::resolveShipWeapon(Ship& ship, Tick currentTick,
return;
}
// Apply damage to the correct pool.
if (ships.findShip(targetId))
{
ships.damageShip(targetId, w.damage);
}
else
{
buildings.damageBuilding(targetId, w.damage);
}
m_pendingDamage.push_back({targetId, w.damage, currentTick + kWeaponImpactDelayTicks});
FireEvent evt;
evt.shooter = ship.id;
@@ -153,14 +147,7 @@ void CombatSystem::resolveStationWeapon(Building& station, Tick currentTick,
return;
}
if (ships.findShip(targetId))
{
ships.damageShip(targetId, w.damage);
}
else
{
buildings.damageBuilding(targetId, w.damage);
}
m_pendingDamage.push_back({targetId, w.damage, currentTick + kWeaponImpactDelayTicks});
FireEvent evt;
evt.shooter = station.id;
@@ -203,6 +190,32 @@ std::optional<EntityId> CombatSystem::acquireStationTarget(
return best;
}
void CombatSystem::applyPendingDamage(Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings)
{
auto it = m_pendingDamage.begin();
while (it != m_pendingDamage.end())
{
if (it->appliesAt <= currentTick)
{
if (ships.findShip(it->target))
{
ships.damageShip(it->target, it->amount);
}
else if (buildings.findBuilding(it->target))
{
buildings.damageBuilding(it->target, it->amount);
}
it = m_pendingDamage.erase(it);
}
else
{
++it;
}
}
}
std::optional<QVector2D> CombatSystem::targetPosition(
EntityId id,
const ShipSystem& ships,

View File

@@ -23,15 +23,28 @@ public:
explicit CombatSystem(const GameConfig& config);
// Advance weapon cooldowns, acquire targets for stations, fire when ready,
// apply damage, and append FireEvents. Damage is applied immediately via
// ShipSystem::damageShip and BuildingSystem::damageBuilding; step 9
// removes entities whose HP dropped to zero or below.
// queue deferred damage, and append FireEvents. Call applyPendingDamage()
// after tick() (step 8b) and before death processing (step 9).
void tick(Tick currentTick,
ShipSystem& ships,
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);
private:
struct PendingDamage
{
EntityId 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,

View File

@@ -245,7 +245,7 @@ void ShipSystem::tickThreatResponse(const BuildingSystem& buildings)
if (!s.isEnemy)
{
// Player combat ship: target nearest enemy ship.
// Player combat ship: target nearest enemy ship or enemy building.
if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId),
range, s, buildings))
{
@@ -264,14 +264,39 @@ void ShipSystem::tickThreatResponse(const BuildingSystem& buildings)
s.threatResponse->currentTarget = candidate.id;
}
}
for (const Building& b : allBuildings)
{
if (b.type != BuildingType::EnemyDefenceStation)
{
continue;
}
float dist = (buildingCenter(b) - s.position).length();
if (dist < bestDist)
{
bestDist = dist;
s.threatResponse->currentTarget = b.id;
}
}
}
if (s.threatResponse->currentTarget)
{
const Ship* target = findShip(*s.threatResponse->currentTarget);
if (target && 3 > s.intent.priority)
QVector2D dest;
const Ship* tShip = findShip(*s.threatResponse->currentTarget);
if (tShip)
{
s.intent = MovementIntent{3, target->position};
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

View File

@@ -159,6 +159,9 @@ void Simulation::tick()
m_combatSystem->tick(m_currentTick, *m_shipSystem,
*m_buildingSystem, m_fireEvents);
// Step 8b: deferred damage whose impact tick has arrived
m_combatSystem->applyPendingDamage(m_currentTick, *m_shipSystem, *m_buildingSystem);
// Step 9: deaths & loot
if (!m_gameOver)
{

View File

@@ -87,6 +87,7 @@ TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[comb
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
combat.applyPendingDamage(5, ships, buildings);
float hpAfter = 0.0f;
for (const Ship& s : ships.allShips())
@@ -283,6 +284,268 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
REQUIRE(stationFired);
}
TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]")
{
Simulation sim(loadConfig(), 42);
EntityId stationId = kInvalidEntityId;
QVector2D stationCenter;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
{
stationId = b.id;
stationCenter = QVector2D(
b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
break;
}
}
REQUIRE(stationId != kInvalidEntityId);
const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr);
const EntityId playerId = sim.ships().spawn(
combatDef->id, 1,
QVector2D(stationCenter.x() - 1.0f, stationCenter.y()),
/*isEnemy=*/false);
sim.tick();
const std::vector<FireEvent> events = sim.drainFireEvents();
bool playerFiredAtStation = false;
for (const FireEvent& e : events)
{
if (e.shooter == playerId && e.target == stationId)
{
playerFiredAtStation = true;
}
}
REQUIRE(playerFiredAtStation);
}
// ---------------------------------------------------------------------------
// Deferred damage timing
// ---------------------------------------------------------------------------
TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
// Ticks 1-4: damage must not have arrived yet.
for (Tick t = 1; t < 5; ++t)
{
combat.applyPendingDamage(t, ships, buildings);
float hp = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hp = s.hp; }
}
REQUIRE(hp == Approx(hpBefore));
}
}
TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
combat.applyPendingDamage(5, ships, buildings);
float hpAfter = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpAfter = s.hp; }
}
REQUIRE(hpAfter < hpBefore);
}
TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
// Target is removed before impact.
ships.despawn(playerId);
// Should not crash; damage is silently dropped.
combat.applyPendingDamage(5, ships, buildings);
REQUIRE(ships.findShip(playerId) == nullptr);
}
TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
// Shooter is removed before impact.
ships.despawn(enemyId);
// Damage must still land on the target.
combat.applyPendingDamage(5, ships, buildings);
float hpAfter = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpAfter = s.hp; }
}
REQUIRE(hpAfter < hpBefore);
}
// ---------------------------------------------------------------------------
// Deaths & loot (tick step 9)
// ---------------------------------------------------------------------------

View File

@@ -118,6 +118,7 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
, m_demolishMode(false)
, m_demolishHoverId(kInvalidEntityId)
, m_debugDraw(false)
, m_rng(std::random_device{}())
, m_boxSelecting(false)
, m_scrollLeft(false)
, m_scrollRight(false)
@@ -158,9 +159,25 @@ void GameWorldView::onFrame()
const std::vector<FireEvent> fires = m_sim->drainFireEvents();
for (const FireEvent& fe : fires)
{
float maxRadius = 0.125f;
const Building* tBld = m_sim->buildings().findBuilding(fe.target);
if (tBld)
{
const int shorter = std::min(tBld->footprint.width(),
tBld->footprint.height());
maxRadius = shorter / 2.0f;
}
std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f);
std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius);
const float angle = angleDist(m_rng);
const float radius = radiusDist(m_rng);
ActiveBeam beam;
beam.event = fe;
beam.emittedWallMs = m_wallMs;
beam.targetOffset = QVector2D(radius * std::cos(angle),
radius * std::sin(angle));
m_activeBeams.push_back(beam);
}
}
@@ -821,7 +838,8 @@ void GameWorldView::drawBeams(QPainter& painter)
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; }
painter.drawLine(worldToWidget(*shooterPos), worldToWidget(*targetPos));
painter.drawLine(worldToWidget(*shooterPos),
worldToWidget(*targetPos + beam.targetOffset));
}
}

View File

@@ -1,6 +1,7 @@
#pragma once
#include <optional>
#include <random>
#include <set>
#include <vector>
@@ -116,6 +117,7 @@ private:
{
FireEvent event;
qint64 emittedWallMs;
QVector2D targetOffset;
};
struct ToastEntry
@@ -136,6 +138,7 @@ private:
TickDriver m_tickDriver;
QElapsedTimer m_frameTimer;
qint64 m_wallMs;
std::mt19937 m_rng;
double m_gameSpeedMultiplier;
double m_prevNonZeroSpeed;
float m_scrollXTiles;