Compare commits

...

3 Commits

Author SHA1 Message Date
15d8fa4f2c add boss wave counter and countdown to the title bar 2026-06-03 22:44:56 +02:00
b5185b0906 boss waves 2026-06-03 22:14:31 +02:00
457fc47c75 draw HP bars below ships 2026-06-03 20:47:48 +02:00
16 changed files with 297 additions and 172 deletions

View File

@@ -19,7 +19,7 @@ cost_building_blocks = 200
[push]
push_expand_columns = 10
scaling_factor = 1.2
boss_advance_seconds = 60
[waves]
threat_rate_formula = "0.01*x"
@@ -27,3 +27,7 @@ ship_level_formula = "1"
gap_min_seconds = 15
gap_max_seconds = 45
spawn_duration_seconds = 10
boss_countdown_seconds = 300
boss_threat_duration_seconds = 60
boss_quiet_before_seconds = 60
boss_quiet_after_seconds = 60

View File

@@ -19,11 +19,15 @@ cost_building_blocks = 200
[push]
push_expand_columns = 20
scaling_factor = 1.2
boss_advance_seconds = 60
[waves]
threat_rate_formula = "1*x - 30"
ship_level_formula = "1 + x / 120"
threat_rate_formula = "x"
ship_level_formula = "1 + x / 10"
gap_min_seconds = 15
gap_max_seconds = 45
spawn_duration_seconds = 10
boss_countdown_seconds = 300
boss_threat_duration_seconds = 60
boss_quiet_before_seconds = 60
boss_quiet_after_seconds = 60

View File

@@ -4,7 +4,7 @@
Config files use the TOML format. The following config files drive game parameters:
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, enemy ship level formula, belt speed, starting building blocks, departure interval.
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, enemy ship level formula, belt speed, starting building blocks, departure interval.
- **buildings.toml** — building block cost and construction time per building type.
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities.
- **ships.toml** — per schematic: a human-readable display name (used in toasts and UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as formulas of ship level, required build materials, threat cost formula, player production level, whether the schematic is available from game start, a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES).
@@ -249,21 +249,25 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-DEF-ENEMY-PLACEMENT: 2 enemy defence stations are placed at the right boundary of the scrollable area at game start, and again each time a new set is spawned after a push. Stats scale with the station level (REQ-PSH-STATION-STATS).
- REQ-DEF-ENEMY-FIRE: Enemy defence stations automatically fire at player ships within range.
- REQ-DEF-NO-CROSSFIRE: Enemy and player defence stations are never in each other's firing range.
- REQ-DEF-PUSH: When both enemy defence stations in a set are destroyed, the push scaling multiplier is applied (REQ-PSH-ACCUMULATION), the scrollable area is extended (REQ-GW-PUSH-EXPAND), a new set of enemy defence stations is placed at the new boundary, and exactly one schematic drop is awarded for the destroyed set (REQ-DEF-SCHEMATIC-DROP).
- REQ-DEF-PUSH: When both enemy defence stations in a set are destroyed, the boss countdown is advanced (REQ-WAV-BOSS-ADVANCE), the scrollable area is extended (REQ-GW-PUSH-EXPAND), a new set of enemy defence stations is placed at the new boundary, and exactly one schematic drop is awarded for the destroyed set (REQ-DEF-SCHEMATIC-DROP).
- REQ-DEF-SCHEMATIC-DROP: Each destroyed set of enemy defence stations awards exactly one schematic drop (not one per station). The drop is automatic — no physical item to collect. A schematic is chosen uniformly at random from all schematics defined in `ships.toml`. If the player does not yet have that schematic, it is unlocked. If the player already has it, the schematic's `[ship.schematic].player_production_level` is incremented by 1 — so subsequent ships of that type are produced at a higher level. The player is notified via a toast (REQ-UI-SCHEMATIC-TOAST).
## Threat Level & Enemy Waves
- REQ-WAV-THREAT-RATE: A global **threat level** accumulates continuously over time. The rate of increase per second is determined by `world.toml [waves].threat_rate_formula` where x is elapsed game time in seconds, clamped to a minimum of 0 (negative formula values are treated as 0). Example: `1*x - 30` yields 0 threat/s for x ≤ 30s and increases linearly after that.
- REQ-WAV-GAP: At game start and immediately after each wave is triggered, a random inter-wave gap is drawn uniformly from [`world.toml [waves].gap_min_seconds`, `gap_max_seconds`].
- REQ-WAV-TRIGGER: When the gap expires, a wave is triggered. Ships are selected one at a time: from all schematics whose `threat.cost_formula` evaluates to > 0 at the current enemy ship level, uniformly randomly pick one whose cost fits the remaining threat budget. Repeat until no eligible schematic fits. Any remaining threat carries over to the next wave. A longer gap results in a larger wave. Because enemy ship level increases with time (REQ-WAV-SHIP-LEVEL), threat cost per ship rises naturally over the course of the game.
- REQ-WAV-SHIP-LEVEL: Each wave's enemy ships are assigned a level determined by `world.toml [waves].ship_level_formula` where x is elapsed game time in seconds. This is the sole mechanism by which individual enemy ships become stronger over time. Wave *size* grows separately via threat accumulation (REQ-WAV-THREAT-RATE) and push scaling (REQ-PSH-ACCUMULATION). Per-ship stats and threat cost are computed from the ship level via the formulas in `ships.toml` (see REQ-SHP-STATS).
- REQ-WAV-BOSS-COUNTER: A global **boss wave counter** `x` starts at 1 at game start and increments by 1 immediately after each boss wave fires. It represents the current boss wave cycle number and is used as the variable in the threat rate and ship level formulas.
- REQ-WAV-THREAT-RATE: A global **threat level** accumulates continuously over real game time. The rate of increase per second is determined by `world.toml [waves].threat_rate_formula` where `x` is the boss wave counter (REQ-WAV-BOSS-COUNTER), clamped to a minimum of 0 (negative formula values are treated as 0). The rate is constant within each boss wave cycle and steps up each time `x` increments. Threat accumulation continues uninterrupted during quiet windows (REQ-WAV-QUIET). Example: `1*x - 30` yields 0 threat/s when x ≤ 30 and increases linearly beyond that.
- REQ-WAV-GAP: At game start and immediately after each normal wave is triggered, a random inter-wave gap is drawn uniformly from [`world.toml [waves].gap_min_seconds`, `gap_max_seconds`]. The gap timer does not advance while inside a quiet window (REQ-WAV-QUIET); if a gap would expire inside a quiet window, its expiry is deferred until the quiet window ends.
- REQ-WAV-TRIGGER: When the gap timer expires outside a quiet window, a normal wave is triggered. Ships are selected one at a time: from all schematics whose `threat.cost_formula` evaluates to > 0 at the current enemy ship level, uniformly randomly pick one whose cost fits the remaining threat budget. Repeat until no eligible schematic fits. Any remaining threat carries over to the next normal wave. A longer gap results in a larger wave. Because enemy ship level increases with the boss wave counter (REQ-WAV-SHIP-LEVEL), threat cost per ship rises as the game progresses.
- REQ-WAV-SHIP-LEVEL: Each wave's (normal and boss) enemy ships are assigned a level determined by `world.toml [waves].ship_level_formula` where `x` is the boss wave counter (REQ-WAV-BOSS-COUNTER). Per-ship stats and threat cost are computed from the ship level via the formulas in `ships.toml` (see REQ-SHP-STATS).
- REQ-WAV-BOSS-COUNTDOWN: A **boss countdown** timer starts at `world.toml [waves].boss_countdown_seconds` (default 300) at game start and counts down continuously in real game-time seconds. It is not paused during quiet windows. When it reaches 0, a boss wave is triggered (REQ-WAV-BOSS-TRIGGER). Immediately after the boss wave fires, `x` increments (REQ-WAV-BOSS-COUNTER) and a fresh countdown starts at the same configured value.
- REQ-WAV-BOSS-ADVANCE: When the player destroys a set of enemy defence stations, the boss countdown is reduced by `world.toml [push].boss_advance_seconds` (default 60), clamped to a minimum of 0. Threat that would have accumulated during the skipped time is not added. If the countdown reaches 0 by this reduction, the boss wave is triggered immediately.
- REQ-WAV-QUIET: A **quiet window** suppresses normal wave spawning around each boss wave. The pre-boss quiet window begins when the boss countdown falls to or below `world.toml [waves].boss_quiet_before_seconds` and ends when the countdown reaches 0. The post-boss quiet window begins immediately when the boss wave fires and lasts `world.toml [waves].boss_quiet_after_seconds` seconds. Threat continues to accumulate during both windows. The normal wave gap timer does not advance during either window (REQ-WAV-GAP). The new boss countdown runs during the post-boss quiet window.
- REQ-WAV-BOSS-TRIGGER: When the boss countdown reaches 0, a boss wave is triggered. Its threat budget is the sum of: (a) `world.toml [waves].boss_threat_duration_seconds` (default 60) multiplied by the current threat rate, and (b) all unspent threat carried over from normal waves. Ships are selected using the same random process as normal waves (REQ-WAV-TRIGGER). Any threat remaining unspent after ship selection carries over to the first normal wave of the new cycle.
- REQ-WAV-DEFAULT-MODULES: Enemy ships spawned by waves use the `default_modules` list defined per schematic in `ships.toml`. The `default_modules` array uses the same format as layout blueprints (see Layout Blueprint TOML Format). If `default_modules` is absent or empty, the ship spawns with no modules. Invalid module instances (unknown type, position outside the grid, position on a non-buildable cell, or overlapping another module) are silently skipped.
- REQ-WAV-SPAWN-DURATION: Ships in a wave are spawned one at a time over `world.toml [waves].spawn_duration_seconds`.
## Push Scaling
## Push Effects
- REQ-PSH-ACCUMULATION: Each time the player destroys a set of enemy defence stations, `world.toml [push].scaling_factor` is multiplied permanently into the threat level accumulation rate. Scaling factors stack multiplicatively with each other and with the time-based threat formula, causing all subsequent waves to be larger.
- REQ-PSH-STATION-STATS: Enemy defence station stats are each defined as formulas in `stations.toml [enemy_station]`: `hp_formula`, `damage_formula`, `range_formula`, `fire_rate_formula`, `scrap_drop_formula`, where x is the station level — an integer starting at 0 for the initial set and incrementing by 1 each time a new set is placed.
## Asteroid Expansion
@@ -291,7 +295,8 @@ The screen is divided into three vertical sections:
+-----------------+-----------------+--------------+
```
- REQ-UI-HEADER: The header bar spans the full width above the game world and always shows the elapsed survival time and the current global building blocks stock on the left, and game speed controls on the right.
- REQ-UI-HEADER: The header bar spans the full width above the game world and always shows the elapsed survival time and the current global building blocks stock on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) to the left of the speed buttons, and game speed controls on the right.
- REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs.
- REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 4× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game.
- REQ-UI-WORLD-HEIGHT: The game world view occupies 70% of the remaining screen height below the header bar.
- REQ-UI-PANEL-HEIGHT: The UI panel occupies the remaining 30% of the screen height, split horizontally into a selected building panel (left), a build button grid (center), and a blueprint panel (right).
@@ -301,6 +306,7 @@ The screen is divided into three vertical sections:
- REQ-UI-SCROLL: The player scrolls the view horizontally across the scrollable area by pressing A (scroll left) and D (scroll right).
- REQ-UI-CONSTRUCTION-PROGRESS: Construction sites display the building's glyph centered on the footprint (same as an operational building). Below the glyph — or centered on the footprint if the building has no glyph — a construction progress percentage is shown (integer, e.g. `42%`), increasing from 0% to 100% as construction completes.
- REQ-UI-PORT-GLYPH: Every output port of every building is indicated by a directional glyph drawn on the port's tile. The glyph is a `>` rotated to face the port's exit direction (`>` for East, `^` for North, `<` for West, `v` for South). It is drawn at the midpoint between the tile center and the tile edge that the port exits through (i.e. halfway from center toward the exit edge). The indicator is rendered for all building states: operational buildings, construction sites, and the builder-mode ghost. Buildings with multiple output ports (e.g. splitters) show one indicator per port.
- REQ-UI-HP-BARS: All entities with HP — the HQ, player and enemy defence stations, and player and enemy ships — render an HP bar below them. The bar is always visible regardless of current HP. The bar's filled portion represents the fraction of current HP to maximum HP.
- REQ-UI-NO-ZOOM: The view has a fixed zoom level; the player cannot zoom in or out.
- REQ-UI-SCHEMATIC-TOAST: When a schematic is unlocked or leveled up (REQ-DEF-SCHEMATIC-DROP), a transient notification toast appears in the top-right corner of the game world view for 4 seconds and then fades out. `<Ship Name>` in the text below is the schematic's `ships.toml [ship.schematic].display_name`. Toast text:
- **New unlock**: `Schematic unlocked: <Ship Name>`

View File

@@ -310,10 +310,10 @@ void ArenaView::drawStations(QPainter& painter)
void ArenaView::drawShips(QPainter& painter)
{
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent>(
FactionComponent, HealthComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const FacingComponent& facing,
const FactionComponent& /*fac*/)
const FactionComponent& fac, const HealthComponent& h)
{
const std::map<std::string, ShipVisuals>::const_iterator it =
m_visuals->ships.find(si.schematicId);
@@ -337,6 +337,18 @@ void ArenaView::drawShips(QPainter& painter)
painter.setPen(QPen(it->second.outline, 1));
painter.setBrush(it->second.fill);
painter.drawPolygon(tri);
if (h.maxHp > 0.0f)
{
const float fraction = std::max(0.0f, h.hp / h.maxHp);
const qreal barW = static_cast<qreal>(fwd) * 2.0;
const qreal barH = static_cast<qreal>(tilePx()) * 0.12;
const qreal barX = center.x() - static_cast<qreal>(fwd);
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60));
painter.fillRect(QRectF(barX, barY, barW * static_cast<qreal>(fraction), barH),
fac.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
}
});
}

View File

@@ -277,13 +277,17 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
cfg.expansion.costBuildingBlocks = static_cast<int>(requireInt(tbl["expansion"]["cost_building_blocks"], file, "expansion.cost_building_blocks"));
cfg.push.pushExpandColumns = static_cast<int>(requireInt(tbl["push"]["push_expand_columns"], file, "push.push_expand_columns"));
cfg.push.scalingFactor = requireDouble(tbl["push"]["scaling_factor"], file, "push.scaling_factor");
cfg.push.bossAdvanceSeconds = requireDouble(tbl["push"]["boss_advance_seconds"], file, "push.boss_advance_seconds");
cfg.waves.threatRateFormula = requireFormula(tbl["waves"]["threat_rate_formula"], file, "waves.threat_rate_formula");
cfg.waves.shipLevelFormula = requireFormula(tbl["waves"]["ship_level_formula"], file, "waves.ship_level_formula");
cfg.waves.gapMinSeconds = requireDouble(tbl["waves"]["gap_min_seconds"], file, "waves.gap_min_seconds");
cfg.waves.gapMaxSeconds = requireDouble(tbl["waves"]["gap_max_seconds"], file, "waves.gap_max_seconds");
cfg.waves.spawnDurationSeconds = requireDouble(tbl["waves"]["spawn_duration_seconds"], file, "waves.spawn_duration_seconds");
cfg.waves.threatRateFormula = requireFormula(tbl["waves"]["threat_rate_formula"], file, "waves.threat_rate_formula");
cfg.waves.shipLevelFormula = requireFormula(tbl["waves"]["ship_level_formula"], file, "waves.ship_level_formula");
cfg.waves.gapMinSeconds = requireDouble(tbl["waves"]["gap_min_seconds"], file, "waves.gap_min_seconds");
cfg.waves.gapMaxSeconds = requireDouble(tbl["waves"]["gap_max_seconds"], file, "waves.gap_max_seconds");
cfg.waves.spawnDurationSeconds = requireDouble(tbl["waves"]["spawn_duration_seconds"], file, "waves.spawn_duration_seconds");
cfg.waves.bossCountdownSeconds = requireDouble(tbl["waves"]["boss_countdown_seconds"], file, "waves.boss_countdown_seconds");
cfg.waves.bossThreatDurationSeconds = requireDouble(tbl["waves"]["boss_threat_duration_seconds"], file, "waves.boss_threat_duration_seconds");
cfg.waves.bossQuietBeforeSeconds = requireDouble(tbl["waves"]["boss_quiet_before_seconds"], file, "waves.boss_quiet_before_seconds");
cfg.waves.bossQuietAfterSeconds = requireDouble(tbl["waves"]["boss_quiet_after_seconds"], file, "waves.boss_quiet_after_seconds");
if (cfg.waves.gapMinSeconds > cfg.waves.gapMaxSeconds)
{

View File

@@ -18,21 +18,25 @@ struct WorldExpansion
int costBuildingBlocks;
};
// Push scaling (REQ-PSH-*).
// Push effects (REQ-PSH-*, REQ-WAV-BOSS-ADVANCE).
struct WorldPush
{
int pushExpandColumns;
double scalingFactor;
int pushExpandColumns;
double bossAdvanceSeconds; // boss countdown advanced by this much per push
};
// Wave scheduling (REQ-WAV-*).
struct WorldWaves
{
Formula threatRateFormula; // threat/s as a function of elapsed game-time seconds
Formula shipLevelFormula; // enemy ship level as a function of elapsed game-time seconds
Formula threatRateFormula; // threat/s as a function of boss wave counter x
Formula shipLevelFormula; // enemy ship level as a function of boss wave counter x
double gapMinSeconds;
double gapMaxSeconds;
double spawnDurationSeconds;
double bossCountdownSeconds; // duration of each boss cycle (REQ-WAV-BOSS-COUNTDOWN)
double bossThreatDurationSeconds; // boss budget = rate * this (REQ-WAV-BOSS-TRIGGER)
double bossQuietBeforeSeconds; // suppress normal waves this long before boss (REQ-WAV-QUIET)
double bossQuietAfterSeconds; // suppress normal waves this long after boss (REQ-WAV-QUIET)
};
struct WorldConfig

View File

@@ -151,7 +151,7 @@ void Simulation::tick()
m_config.world.heightTiles);
// Step 2: threat accumulation
m_waveSystem->tickThreatAccumulation(m_currentTick);
m_waveSystem->tickThreatAccumulation();
// Construction + production pipeline
m_buildingSystem->tickConstruction(m_currentTick);
@@ -460,7 +460,7 @@ void Simulation::tickDeathsAndLoot()
if (es0Gone && es1Gone &&
m_currentEnemyStationEntities[0] != entt::null)
{
m_waveSystem->applyPush();
m_waveSystem->onEnemyStationsDestroyed();
placeEnemyStationSet(m_waveSystem->generation());
awardSchematicDrop();
}
@@ -532,6 +532,16 @@ double Simulation::threatLevel() const
return m_waveSystem->threatLevel();
}
int Simulation::bossWaveCounter() const
{
return m_waveSystem->bossWaveCounter();
}
Tick Simulation::bossCountdownTicks() const
{
return m_waveSystem->bossCountdownTicks();
}
int Simulation::schematicLevel(const std::string& shipId) const
{
const std::map<std::string, SchematicState>::const_iterator it =

View File

@@ -57,6 +57,8 @@ public:
int buildingBlocksStock() const;
bool isGameOver() const;
double threatLevel() const;
int bossWaveCounter() const;
Tick bossCountdownTicks() const;
// Schematic state queries.
int schematicLevel(const std::string& shipId) const;

View File

@@ -8,61 +8,85 @@ WaveSystem::WaveSystem(const GameConfig& config, std::mt19937& rng)
: m_config(config)
, m_rng(rng)
{
// Draw the initial inter-wave gap (REQ-WAV-GAP, REQ-WAV-GRACE-PERIOD).
m_nextEventTick = drawGapTicks();
m_bossCountdownTicks = secondsToTicks(config.world.waves.bossCountdownSeconds);
m_normalGapRemainingTicks = drawGapTicks();
}
void WaveSystem::tickWaveScheduler(Tick currentTick, ShipSystem& ships,
int worldHeightTiles)
{
if (!m_waveActive)
// 1. Advance boss countdown.
--m_bossCountdownTicks;
// 2. Trigger boss wave when countdown expires.
if (m_bossCountdownTicks <= 0)
{
if (currentTick < m_nextEventTick)
{
return;
}
// Gap expired: compose the next wave.
m_pendingSpawns = composeWave(currentTick, worldHeightTiles);
m_waveActive = true;
triggerBossWave(currentTick, worldHeightTiles);
}
// Spawn any ships whose scheduled tick has arrived.
std::vector<SpawnEntry> remaining;
remaining.reserve(m_pendingSpawns.size());
for (const SpawnEntry& entry : m_pendingSpawns)
// 3. Advance post-boss quiet window.
if (m_postBossQuietRemainingTicks > 0)
{
if (currentTick >= entry.spawnAt)
--m_postBossQuietRemainingTicks;
}
// 4. Normal wave gap: only advances outside quiet windows and between waves.
if (!isInQuietWindow() && !m_normalWaveActive)
{
if (m_normalGapRemainingTicks > 0)
{
ships.spawn(entry.schematicId, entry.level, entry.position,
/*isEnemy=*/true, entry.layout);
--m_normalGapRemainingTicks;
}
else
if (m_normalGapRemainingTicks == 0)
{
remaining.push_back(entry);
triggerNormalWave(currentTick, worldHeightTiles);
}
}
m_pendingSpawns = std::move(remaining);
if (m_pendingSpawns.empty())
// 5. Spawn any ships (from either queue) whose scheduled tick has arrived.
auto spawnDue = [&](std::vector<SpawnEntry>& queue)
{
m_waveActive = false;
m_nextEventTick = currentTick + drawGapTicks();
std::vector<SpawnEntry> remaining;
remaining.reserve(queue.size());
for (const SpawnEntry& entry : queue)
{
if (currentTick >= entry.spawnAt)
{
ships.spawn(entry.schematicId, entry.level, entry.position,
/*isEnemy=*/true, entry.layout);
}
else
{
remaining.push_back(entry);
}
}
queue = std::move(remaining);
};
spawnDue(m_normalPendingSpawns);
spawnDue(m_bossPendingSpawns);
// 6. When all normal wave ships have spawned, draw the next gap.
if (m_normalWaveActive && m_normalPendingSpawns.empty())
{
m_normalWaveActive = false;
m_normalGapRemainingTicks = drawGapTicks();
}
}
void WaveSystem::tickThreatAccumulation(Tick currentTick)
void WaveSystem::tickThreatAccumulation()
{
const double elapsedSeconds = static_cast<double>(currentTick) * kTickDurationSeconds;
const double rate = m_config.world.waves.threatRateFormula.evaluate(elapsedSeconds);
const double x = static_cast<double>(m_bossWaveCounter);
const double rate = m_config.world.waves.threatRateFormula.evaluate(x);
if (rate > 0.0)
{
m_threatLevel += rate * m_pushScalingMultiplier * kTickDurationSeconds;
m_threatLevel += rate * kTickDurationSeconds;
}
}
void WaveSystem::applyPush()
void WaveSystem::onEnemyStationsDestroyed()
{
m_pushScalingMultiplier *= m_config.world.push.scalingFactor;
const Tick advance = secondsToTicks(m_config.world.push.bossAdvanceSeconds);
m_bossCountdownTicks = std::max(Tick{0}, m_bossCountdownTicks - advance);
++m_generation;
}
@@ -76,22 +100,66 @@ int WaveSystem::generation() const
return m_generation;
}
int WaveSystem::bossWaveCounter() const
{
return m_bossWaveCounter;
}
Tick WaveSystem::bossCountdownTicks() const
{
return m_bossCountdownTicks;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick,
int worldHeightTiles)
bool WaveSystem::isInQuietWindow() const
{
if (m_postBossQuietRemainingTicks > 0)
{
return true;
}
const Tick quietBeforeTicks = secondsToTicks(m_config.world.waves.bossQuietBeforeSeconds);
return m_bossCountdownTicks <= quietBeforeTicks;
}
void WaveSystem::triggerNormalWave(Tick currentTick, int worldHeightTiles)
{
double budget = m_threatLevel;
m_threatLevel = 0.0;
m_normalPendingSpawns = selectWaveShips(budget, currentTick, worldHeightTiles);
m_threatLevel += budget; // carry leftover forward
m_normalWaveActive = true;
}
void WaveSystem::triggerBossWave(Tick currentTick, int worldHeightTiles)
{
const double x = static_cast<double>(m_bossWaveCounter);
const double rate = std::max(0.0, m_config.world.waves.threatRateFormula.evaluate(x));
double budget = rate * m_config.world.waves.bossThreatDurationSeconds + m_threatLevel;
m_threatLevel = 0.0;
m_bossPendingSpawns = selectWaveShips(budget, currentTick, worldHeightTiles);
m_threatLevel += budget; // carry leftover to first normal wave of new cycle
m_bossCountdownTicks = secondsToTicks(m_config.world.waves.bossCountdownSeconds);
m_postBossQuietRemainingTicks = secondsToTicks(m_config.world.waves.bossQuietAfterSeconds);
++m_bossWaveCounter;
}
std::vector<WaveSystem::SpawnEntry> WaveSystem::selectWaveShips(double& budget,
Tick currentTick,
int worldHeightTiles)
{
const double elapsedSeconds = static_cast<double>(currentTick) * kTickDurationSeconds;
const int shipLevel = std::max(1, static_cast<int>(
m_config.world.waves.shipLevelFormula.evaluate(elapsedSeconds)));
m_config.world.waves.shipLevelFormula.evaluate(
static_cast<double>(m_bossWaveCounter))));
// Build eligible ship list with their costs at the current level.
struct EligibleShip
{
std::string schematicId;
double cost;
std::string schematicId;
double cost;
std::vector<PlacedModule> defaultModules;
};
std::vector<EligibleShip> eligible;
@@ -113,17 +181,13 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick,
return {};
}
// Take the current threat level as the wave budget and reset it.
// Unspent budget is re-added after composition (carry-over).
double budget = m_threatLevel;
m_threatLevel = 0.0;
// Enemy spawn buffer X range for the current generation.
const float leftX = static_cast<float>(
m_config.world.regions.playerBufferWidth
+ m_config.world.regions.contestZoneWidth
+ m_generation * m_config.world.push.pushExpandColumns);
const float rightX = leftX + static_cast<float>(m_config.world.regions.enemyBufferWidth) - 1.0f;
const float rightX = leftX
+ static_cast<float>(m_config.world.regions.enemyBufferWidth) - 1.0f;
std::uniform_real_distribution<float> xDist(leftX, rightX);
std::uniform_int_distribution<int> yDist(0, worldHeightTiles - 1);
@@ -147,24 +211,21 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick,
}
std::uniform_int_distribution<int> pick(0, static_cast<int>(fitting.size()) - 1);
const std::size_t chosenIdx = fitting[static_cast<std::size_t>(pick(m_rng))];
const EligibleShip& chosen = eligible[chosenIdx];
const std::size_t chosenIdx = fitting[static_cast<std::size_t>(pick(m_rng))];
const EligibleShip& chosen = eligible[chosenIdx];
budget -= chosen.cost;
SpawnEntry entry;
entry.schematicId = chosen.schematicId;
entry.level = shipLevel;
entry.spawnAt = 0; // set below after all picks are done
entry.position = QVector2D(xDist(m_rng),
static_cast<float>(yDist(m_rng)) + 0.5f);
entry.layout.placedModules = chosen.defaultModules;
entry.schematicId = chosen.schematicId;
entry.level = shipLevel;
entry.spawnAt = 0; // set below after all picks are done
entry.position = QVector2D(xDist(m_rng),
static_cast<float>(yDist(m_rng)) + 0.5f);
entry.layout.placedModules = chosen.defaultModules;
picked.push_back(entry);
}
// Carry leftover budget forward to the next wave.
m_threatLevel += budget;
// Spread spawn times evenly across spawnDurationSeconds.
const int count = static_cast<int>(picked.size());
if (count == 1)

View File

@@ -19,18 +19,20 @@ class WaveSystem
public:
WaveSystem(const GameConfig& config, std::mt19937& rng);
// Tick step 1: start a new wave when the current gap has expired; spawn
// any ships in the pending list whose scheduled tick has arrived.
// Tick step 1: advance the boss countdown and normal wave gap; trigger
// normal or boss waves when their timers expire; spawn any ships in the
// pending queues whose scheduled tick has arrived.
void tickWaveScheduler(Tick currentTick, ShipSystem& ships,
int worldHeightTiles);
// Tick step 2: accumulate threat from the rate formula, scaled by the
// current push multiplier (REQ-WAV-THREAT-RATE, REQ-PSH-ACCUMULATION).
void tickThreatAccumulation(Tick currentTick);
// Tick step 2: accumulate threat from the rate formula evaluated at the
// current boss wave counter (REQ-WAV-THREAT-RATE).
void tickThreatAccumulation();
// Called by Simulation (tick step 9) when the current enemy-station set
// is fully destroyed: multiplies the push scaling and increments generation.
void applyPush();
// is fully destroyed: advances the boss countdown and increments generation
// (REQ-WAV-BOSS-ADVANCE, REQ-PSH-STATION-STATS).
void onEnemyStationsDestroyed();
double threatLevel() const;
@@ -38,20 +40,32 @@ public:
// incremented by 1 after each push — REQ-PSH-STATION-STATS).
int generation() const;
// Boss wave counter (REQ-WAV-BOSS-COUNTER): current cycle number, starts at 1.
int bossWaveCounter() const;
// Ticks remaining until the next boss wave fires (REQ-WAV-BOSS-COUNTDOWN).
Tick bossCountdownTicks() const;
private:
struct SpawnEntry
{
std::string schematicId;
int level;
Tick spawnAt;
QVector2D position;
ShipLayoutConfig layout;
std::string schematicId;
int level;
Tick spawnAt;
QVector2D position;
ShipLayoutConfig layout;
};
// Compose the next wave from the current threat budget, returning timed
// spawn entries spread across spawnDurationSeconds. Leaves any unspent
// budget in m_threatLevel (carry-over, REQ-WAV-TRIGGER).
std::vector<SpawnEntry> composeWave(Tick currentTick, int worldHeightTiles);
// Select ships from the given threat budget until no eligible ship fits.
// Reduces budget in-place to the remaining (carry-over) amount.
std::vector<SpawnEntry> selectWaveShips(double& budget, Tick currentTick,
int worldHeightTiles);
void triggerNormalWave(Tick currentTick, int worldHeightTiles);
void triggerBossWave(Tick currentTick, int worldHeightTiles);
// Returns true while normal-wave spawning should be suppressed (REQ-WAV-QUIET).
bool isInQuietWindow() const;
// Draw a random gap duration in ticks from [gapMin, gapMax].
Tick drawGapTicks();
@@ -59,11 +73,19 @@ private:
const GameConfig& m_config;
std::mt19937& m_rng;
double m_threatLevel = 0.0;
double m_pushScalingMultiplier = 1.0;
int m_generation = 0;
double m_threatLevel = 0.0;
int m_generation = 0;
bool m_waveActive = false;
Tick m_nextEventTick = 0; // absolute tick when the current gap expires
std::vector<SpawnEntry> m_pendingSpawns;
// Boss wave cycle (REQ-WAV-BOSS-COUNTER, REQ-WAV-BOSS-COUNTDOWN).
int m_bossWaveCounter = 1;
Tick m_bossCountdownTicks; // counts down each tick; reset after boss fires
Tick m_postBossQuietRemainingTicks = 0;
// Normal wave gap — frozen during quiet windows (REQ-WAV-GAP).
bool m_normalWaveActive = false;
Tick m_normalGapRemainingTicks; // replaces old m_nextEventTick
// Spawn queues — kept separate so normal-wave completion is trackable.
std::vector<SpawnEntry> m_normalPendingSpawns;
std::vector<SpawnEntry> m_bossPendingSpawns;
};

View File

@@ -75,12 +75,12 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
REQUIRE(cfg.world.regions.playerBufferWidth == 10);
REQUIRE(cfg.world.regions.enemyBufferWidth == 15);
REQUIRE(cfg.world.expansion.columnsPerExpansion == 10);
REQUIRE(cfg.world.push.scalingFactor == Approx(1.2));
REQUIRE(cfg.world.push.bossAdvanceSeconds == Approx(60.0));
// Spot-check that a config-derived formula computes as expected.
// threat_rate_formula = "1*x - 30": zero at x=30, 30 at x=60.
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(30.0) == Approx(0.0));
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(60.0) == Approx(30.0));
// threat_rate_formula = "x": evaluates to the input value.
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(1.0) == Approx(1.0));
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(5.0) == Approx(5.0));
// buildings.toml
REQUIRE(cfg.buildings.buildings.size() >= 8);
@@ -222,11 +222,11 @@ cost_building_blocks = 200
[push]
push_expand_columns = 20
scaling_factor = 1.2
boss_advance_seconds = 60
[waves]
threat_rate_formula = "1 +"
ship_level_formula = "1 + x / 120"
ship_level_formula = "1 + x / 10"
gap_min_seconds = 15
gap_max_seconds = 45
spawn_duration_seconds = 10

View File

@@ -29,73 +29,33 @@ static GameConfig loadConfig()
// Threat accumulation
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: threat stays 0 for first 30 game-seconds", "[wave]")
TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// threat_rate_formula = "1*x - 30", which is <= 0 for x <= 30.
const int ticks30s = static_cast<int>(secondsToTicks(30.0));
for (int i = 0; i < ticks30s; ++i)
// threat_rate_formula = "x", boss wave counter starts at 1 → rate = 1 threat/s.
// After 1 second: threat ≈ 1.0.
const int ticks1s = static_cast<int>(secondsToTicks(1.0));
for (int i = 0; i < ticks1s; ++i)
{
ws.tickThreatAccumulation(static_cast<Tick>(i));
ws.tickThreatAccumulation();
}
REQUIRE(ws.threatLevel() == Approx(0.0));
REQUIRE(ws.threatLevel() == Approx(1.0));
}
TEST_CASE("WaveSystem: threat accumulates after 30 game-seconds", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// Run 31 seconds worth of ticks.
const int ticks31s = static_cast<int>(secondsToTicks(31.0));
for (int i = 0; i < ticks31s; ++i)
{
ws.tickThreatAccumulation(static_cast<Tick>(i));
}
REQUIRE(ws.threatLevel() > 0.0);
}
TEST_CASE("WaveSystem: applyPush increases threat accumulation rate", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// Accumulate for 1 tick past the 30s mark to get a baseline rate.
const Tick baseTick = secondsToTicks(31.0);
ws.tickThreatAccumulation(baseTick);
const double levelBefore = ws.threatLevel();
// Apply push: multiplier should increase.
ws.applyPush();
WaveSystem ws2(cfg, rng);
ws2.tickThreatAccumulation(baseTick);
// After the push the same tick adds more threat.
ws.tickThreatAccumulation(baseTick + 1);
ws2.tickThreatAccumulation(baseTick + 1);
// ws has the push multiplier applied; ws2 does not.
REQUIRE(ws.threatLevel() > ws2.threatLevel());
}
TEST_CASE("WaveSystem: generation starts at 0 and increments on push", "[wave]")
TEST_CASE("WaveSystem: generation starts at 0 and increments on station destruction", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
REQUIRE(ws.generation() == 0);
ws.applyPush();
ws.onEnemyStationsDestroyed();
REQUIRE(ws.generation() == 1);
ws.applyPush();
ws.onEnemyStationsDestroyed();
REQUIRE(ws.generation() == 2);
}
@@ -217,18 +177,23 @@ TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]
// The maximum gap is gapMaxSeconds = 45s → 1350 ticks.
// Run 1500 ticks to guarantee at least one wave has triggered.
// Check each tick: enemy ships may be killed quickly by player stations,
// so we must detect them while they are alive, not only after the loop.
const int limit = static_cast<int>(secondsToTicks(50.0));
bool foundEnemyShip = false;
for (int i = 0; i < limit; ++i)
{
sim.tick();
}
bool foundEnemyShip = false;
sim.admin().forEach<ShipIdentityComponent, FactionComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/, const FactionComponent& f)
if (!foundEnemyShip)
{
if (f.isEnemy) { foundEnemyShip = true; }
});
sim.admin().forEach<ShipIdentityComponent, FactionComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const FactionComponent& f)
{
if (f.isEnemy) { foundEnemyShip = true; }
});
}
}
REQUIRE(foundEnemyShip);
}

View File

@@ -240,7 +240,9 @@ void GameWorldView::onFrame()
// Emit state update for header bar / build grid
emit stateUpdated(m_sim->currentTick(),
m_sim->buildingBlocksStock(),
m_gameSpeedMultiplier);
m_gameSpeedMultiplier,
m_sim->bossWaveCounter(),
m_sim->bossCountdownTicks());
// Game over check
if (m_sim->isGameOver() && !m_gameOverShown)
@@ -837,10 +839,10 @@ void GameWorldView::drawStations(QPainter& painter)
void GameWorldView::drawShips(QPainter& painter)
{
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent>(
FactionComponent, HealthComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const FacingComponent& facing,
const FactionComponent& /*fac*/)
const FactionComponent& fac, const HealthComponent& h)
{
const std::map<std::string, ShipVisuals>::const_iterator it =
m_visuals->ships.find(si.schematicId);
@@ -864,6 +866,18 @@ void GameWorldView::drawShips(QPainter& painter)
painter.setPen(QPen(it->second.outline, 1));
painter.setBrush(it->second.fill);
painter.drawPolygon(tri);
if (h.maxHp > 0.0f)
{
const float fraction = std::max(0.0f, h.hp / h.maxHp);
const qreal barW = static_cast<qreal>(fwd) * 2.0;
const qreal barH = static_cast<qreal>(tilePx()) * 0.12;
const qreal barX = center.x() - static_cast<qreal>(fwd);
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60));
painter.fillRect(QRectF(barX, barY, barW * static_cast<qreal>(fraction), barH),
fac.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
}
});
}
@@ -1378,7 +1392,9 @@ void GameWorldView::setGameSpeed(double multiplier)
m_gameSpeedMultiplier = multiplier;
emit stateUpdated(m_sim->currentTick(),
m_sim->buildingBlocksStock(),
m_gameSpeedMultiplier);
m_gameSpeedMultiplier,
m_sim->bossWaveCounter(),
m_sim->bossCountdownTicks());
}
void GameWorldView::resetForNewGame()

View File

@@ -47,7 +47,7 @@ public:
signals:
void selectionChanged(const std::vector<BuildingId>& ids);
void stateUpdated(Tick tick, int blocks, double speed);
void stateUpdated(Tick tick, int blocks, double speed, int bossCounter, Tick bossCountdownTicks);
void gameOver();
void builderModeExited();
void blueprintModeExited();

View File

@@ -22,9 +22,11 @@ HeaderBar::HeaderBar(QWidget* parent)
m_timeLabel = new QLabel("00:00", this);
m_blocksLabel = new QLabel("Blocks: 0", this);
m_bossLabel = new QLabel("Boss Wave #1 Next boss: 5:00", this);
layout->addWidget(m_timeLabel);
layout->addWidget(m_blocksLabel);
layout->addStretch();
layout->addWidget(m_bossLabel);
const char* labels[] = { "0x", "0.5x", "1x", "2x", "4x" };
QSignalMapper* mapper = new QSignalMapper(this);
@@ -43,7 +45,8 @@ HeaderBar::HeaderBar(QWidget* parent)
setFixedHeight(sizeHint().height());
}
void HeaderBar::onStateUpdated(Tick tick, int buildingBlocks, double gameSpeed)
void HeaderBar::onStateUpdated(Tick tick, int buildingBlocks, double gameSpeed,
int bossCounter, Tick bossCountdownTicks)
{
const int totalSeconds = static_cast<int>(ticksToSeconds(tick));
const int minutes = totalSeconds / 60;
@@ -56,6 +59,16 @@ void HeaderBar::onStateUpdated(Tick tick, int buildingBlocks, double gameSpeed)
m_blocksLabel->setText(QString("Blocks: %1").arg(buildingBlocks));
const int bossSeconds = static_cast<int>(ticksToSeconds(
bossCountdownTicks > 0 ? bossCountdownTicks : 0));
const int bossMin = bossSeconds / 60;
const int bossSec = bossSeconds % 60;
m_bossLabel->setText(
QString("Boss Wave #%1 Next boss: %2:%3")
.arg(bossCounter)
.arg(bossMin)
.arg(bossSec, 2, 10, QChar('0')));
for (int i = 0; i < kSpeedCount; ++i)
{
const bool active = (std::abs(kSpeeds[i] - gameSpeed) < 0.001);

View File

@@ -17,7 +17,8 @@ public:
explicit HeaderBar(QWidget* parent = nullptr);
public slots:
void onStateUpdated(Tick tick, int buildingBlocks, double gameSpeed);
void onStateUpdated(Tick tick, int buildingBlocks, double gameSpeed,
int bossCounter, Tick bossCountdownTicks);
signals:
void speedChanged(double multiplier);
@@ -28,6 +29,7 @@ private slots:
private:
QLabel* m_timeLabel;
QLabel* m_blocksLabel;
QLabel* m_bossLabel;
std::vector<QPushButton*> m_speedButtons;
static const double kSpeeds[];