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]
push_expand_columns = 10 push_expand_columns = 10
scaling_factor = 1.2 boss_advance_seconds = 60
[waves] [waves]
threat_rate_formula = "0.01*x" threat_rate_formula = "0.01*x"
@@ -27,3 +27,7 @@ ship_level_formula = "1"
gap_min_seconds = 15 gap_min_seconds = 15
gap_max_seconds = 45 gap_max_seconds = 45
spawn_duration_seconds = 10 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]
push_expand_columns = 20 push_expand_columns = 20
scaling_factor = 1.2 boss_advance_seconds = 60
[waves] [waves]
threat_rate_formula = "1*x - 30" threat_rate_formula = "x"
ship_level_formula = "1 + x / 120" ship_level_formula = "1 + x / 10"
gap_min_seconds = 15 gap_min_seconds = 15
gap_max_seconds = 45 gap_max_seconds = 45
spawn_duration_seconds = 10 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: 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. - **buildings.toml** — building block cost and construction time per building type.
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. - **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). - **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-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-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-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). - 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 ## 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-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-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-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-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-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-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-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-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`. - 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. - 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 ## 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-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-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). - 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-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-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-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-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: - 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>` - **New unlock**: `Schematic unlocked: <Ship Name>`

View File

@@ -310,10 +310,10 @@ void ArenaView::drawStations(QPainter& painter)
void ArenaView::drawShips(QPainter& painter) void ArenaView::drawShips(QPainter& painter)
{ {
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent, m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent>( FactionComponent, HealthComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si, [&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const FacingComponent& facing, const PositionComponent& pos, const FacingComponent& facing,
const FactionComponent& /*fac*/) const FactionComponent& fac, const HealthComponent& h)
{ {
const std::map<std::string, ShipVisuals>::const_iterator it = const std::map<std::string, ShipVisuals>::const_iterator it =
m_visuals->ships.find(si.schematicId); m_visuals->ships.find(si.schematicId);
@@ -337,6 +337,18 @@ void ArenaView::drawShips(QPainter& painter)
painter.setPen(QPen(it->second.outline, 1)); painter.setPen(QPen(it->second.outline, 1));
painter.setBrush(it->second.fill); painter.setBrush(it->second.fill);
painter.drawPolygon(tri); 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.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.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.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.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.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.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.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) if (cfg.waves.gapMinSeconds > cfg.waves.gapMaxSeconds)
{ {

View File

@@ -18,21 +18,25 @@ struct WorldExpansion
int costBuildingBlocks; int costBuildingBlocks;
}; };
// Push scaling (REQ-PSH-*). // Push effects (REQ-PSH-*, REQ-WAV-BOSS-ADVANCE).
struct WorldPush struct WorldPush
{ {
int pushExpandColumns; int pushExpandColumns;
double scalingFactor; double bossAdvanceSeconds; // boss countdown advanced by this much per push
}; };
// Wave scheduling (REQ-WAV-*). // Wave scheduling (REQ-WAV-*).
struct WorldWaves struct WorldWaves
{ {
Formula threatRateFormula; // threat/s 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 elapsed game-time seconds Formula shipLevelFormula; // enemy ship level as a function of boss wave counter x
double gapMinSeconds; double gapMinSeconds;
double gapMaxSeconds; double gapMaxSeconds;
double spawnDurationSeconds; 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 struct WorldConfig

View File

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

View File

@@ -57,6 +57,8 @@ public:
int buildingBlocksStock() const; int buildingBlocksStock() const;
bool isGameOver() const; bool isGameOver() const;
double threatLevel() const; double threatLevel() const;
int bossWaveCounter() const;
Tick bossCountdownTicks() const;
// Schematic state queries. // Schematic state queries.
int schematicLevel(const std::string& shipId) const; 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_config(config)
, m_rng(rng) , m_rng(rng)
{ {
// Draw the initial inter-wave gap (REQ-WAV-GAP, REQ-WAV-GRACE-PERIOD). m_bossCountdownTicks = secondsToTicks(config.world.waves.bossCountdownSeconds);
m_nextEventTick = drawGapTicks(); m_normalGapRemainingTicks = drawGapTicks();
} }
void WaveSystem::tickWaveScheduler(Tick currentTick, ShipSystem& ships, void WaveSystem::tickWaveScheduler(Tick currentTick, ShipSystem& ships,
int worldHeightTiles) 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) triggerBossWave(currentTick, worldHeightTiles);
{
return;
}
// Gap expired: compose the next wave.
m_pendingSpawns = composeWave(currentTick, worldHeightTiles);
m_waveActive = true;
} }
// Spawn any ships whose scheduled tick has arrived. // 3. Advance post-boss quiet window.
std::vector<SpawnEntry> remaining; if (m_postBossQuietRemainingTicks > 0)
remaining.reserve(m_pendingSpawns.size());
for (const SpawnEntry& entry : m_pendingSpawns)
{ {
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, --m_normalGapRemainingTicks;
/*isEnemy=*/true, entry.layout);
} }
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; std::vector<SpawnEntry> remaining;
m_nextEventTick = currentTick + drawGapTicks(); 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 x = static_cast<double>(m_bossWaveCounter);
const double rate = m_config.world.waves.threatRateFormula.evaluate(elapsedSeconds); const double rate = m_config.world.waves.threatRateFormula.evaluate(x);
if (rate > 0.0) 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; ++m_generation;
} }
@@ -76,22 +100,66 @@ int WaveSystem::generation() const
return m_generation; return m_generation;
} }
int WaveSystem::bossWaveCounter() const
{
return m_bossWaveCounter;
}
Tick WaveSystem::bossCountdownTicks() const
{
return m_bossCountdownTicks;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Private helpers // Private helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick, bool WaveSystem::isInQuietWindow() const
int worldHeightTiles) {
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>( 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. // Build eligible ship list with their costs at the current level.
struct EligibleShip struct EligibleShip
{ {
std::string schematicId; std::string schematicId;
double cost; double cost;
std::vector<PlacedModule> defaultModules; std::vector<PlacedModule> defaultModules;
}; };
std::vector<EligibleShip> eligible; std::vector<EligibleShip> eligible;
@@ -113,17 +181,13 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick,
return {}; 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. // Enemy spawn buffer X range for the current generation.
const float leftX = static_cast<float>( const float leftX = static_cast<float>(
m_config.world.regions.playerBufferWidth m_config.world.regions.playerBufferWidth
+ m_config.world.regions.contestZoneWidth + m_config.world.regions.contestZoneWidth
+ m_generation * m_config.world.push.pushExpandColumns); + 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_real_distribution<float> xDist(leftX, rightX);
std::uniform_int_distribution<int> yDist(0, worldHeightTiles - 1); 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); 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 std::size_t chosenIdx = fitting[static_cast<std::size_t>(pick(m_rng))];
const EligibleShip& chosen = eligible[chosenIdx]; const EligibleShip& chosen = eligible[chosenIdx];
budget -= chosen.cost; budget -= chosen.cost;
SpawnEntry entry; SpawnEntry entry;
entry.schematicId = chosen.schematicId; entry.schematicId = chosen.schematicId;
entry.level = shipLevel; entry.level = shipLevel;
entry.spawnAt = 0; // set below after all picks are done entry.spawnAt = 0; // set below after all picks are done
entry.position = QVector2D(xDist(m_rng), entry.position = QVector2D(xDist(m_rng),
static_cast<float>(yDist(m_rng)) + 0.5f); static_cast<float>(yDist(m_rng)) + 0.5f);
entry.layout.placedModules = chosen.defaultModules; entry.layout.placedModules = chosen.defaultModules;
picked.push_back(entry); picked.push_back(entry);
} }
// Carry leftover budget forward to the next wave.
m_threatLevel += budget;
// Spread spawn times evenly across spawnDurationSeconds. // Spread spawn times evenly across spawnDurationSeconds.
const int count = static_cast<int>(picked.size()); const int count = static_cast<int>(picked.size());
if (count == 1) if (count == 1)

View File

@@ -19,18 +19,20 @@ class WaveSystem
public: public:
WaveSystem(const GameConfig& config, std::mt19937& rng); WaveSystem(const GameConfig& config, std::mt19937& rng);
// Tick step 1: start a new wave when the current gap has expired; spawn // Tick step 1: advance the boss countdown and normal wave gap; trigger
// any ships in the pending list whose scheduled tick has arrived. // 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, void tickWaveScheduler(Tick currentTick, ShipSystem& ships,
int worldHeightTiles); int worldHeightTiles);
// Tick step 2: accumulate threat from the rate formula, scaled by the // Tick step 2: accumulate threat from the rate formula evaluated at the
// current push multiplier (REQ-WAV-THREAT-RATE, REQ-PSH-ACCUMULATION). // current boss wave counter (REQ-WAV-THREAT-RATE).
void tickThreatAccumulation(Tick currentTick); void tickThreatAccumulation();
// Called by Simulation (tick step 9) when the current enemy-station set // Called by Simulation (tick step 9) when the current enemy-station set
// is fully destroyed: multiplies the push scaling and increments generation. // is fully destroyed: advances the boss countdown and increments generation
void applyPush(); // (REQ-WAV-BOSS-ADVANCE, REQ-PSH-STATION-STATS).
void onEnemyStationsDestroyed();
double threatLevel() const; double threatLevel() const;
@@ -38,20 +40,32 @@ public:
// incremented by 1 after each push — REQ-PSH-STATION-STATS). // incremented by 1 after each push — REQ-PSH-STATION-STATS).
int generation() const; 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: private:
struct SpawnEntry struct SpawnEntry
{ {
std::string schematicId; std::string schematicId;
int level; int level;
Tick spawnAt; Tick spawnAt;
QVector2D position; QVector2D position;
ShipLayoutConfig layout; ShipLayoutConfig layout;
}; };
// Compose the next wave from the current threat budget, returning timed // Select ships from the given threat budget until no eligible ship fits.
// spawn entries spread across spawnDurationSeconds. Leaves any unspent // Reduces budget in-place to the remaining (carry-over) amount.
// budget in m_threatLevel (carry-over, REQ-WAV-TRIGGER). std::vector<SpawnEntry> selectWaveShips(double& budget, Tick currentTick,
std::vector<SpawnEntry> composeWave(Tick currentTick, int worldHeightTiles); 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]. // Draw a random gap duration in ticks from [gapMin, gapMax].
Tick drawGapTicks(); Tick drawGapTicks();
@@ -59,11 +73,19 @@ private:
const GameConfig& m_config; const GameConfig& m_config;
std::mt19937& m_rng; std::mt19937& m_rng;
double m_threatLevel = 0.0; double m_threatLevel = 0.0;
double m_pushScalingMultiplier = 1.0; int m_generation = 0;
int m_generation = 0;
bool m_waveActive = false; // Boss wave cycle (REQ-WAV-BOSS-COUNTER, REQ-WAV-BOSS-COUNTDOWN).
Tick m_nextEventTick = 0; // absolute tick when the current gap expires int m_bossWaveCounter = 1;
std::vector<SpawnEntry> m_pendingSpawns; 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.playerBufferWidth == 10);
REQUIRE(cfg.world.regions.enemyBufferWidth == 15); REQUIRE(cfg.world.regions.enemyBufferWidth == 15);
REQUIRE(cfg.world.expansion.columnsPerExpansion == 10); 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. // Spot-check that a config-derived formula computes as expected.
// threat_rate_formula = "1*x - 30": zero at x=30, 30 at x=60. // threat_rate_formula = "x": evaluates to the input value.
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(30.0) == Approx(0.0)); REQUIRE(cfg.world.waves.threatRateFormula.evaluate(1.0) == Approx(1.0));
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(60.0) == Approx(30.0)); REQUIRE(cfg.world.waves.threatRateFormula.evaluate(5.0) == Approx(5.0));
// buildings.toml // buildings.toml
REQUIRE(cfg.buildings.buildings.size() >= 8); REQUIRE(cfg.buildings.buildings.size() >= 8);
@@ -222,11 +222,11 @@ cost_building_blocks = 200
[push] [push]
push_expand_columns = 20 push_expand_columns = 20
scaling_factor = 1.2 boss_advance_seconds = 60
[waves] [waves]
threat_rate_formula = "1 +" threat_rate_formula = "1 +"
ship_level_formula = "1 + x / 120" ship_level_formula = "1 + x / 10"
gap_min_seconds = 15 gap_min_seconds = 15
gap_max_seconds = 45 gap_max_seconds = 45
spawn_duration_seconds = 10 spawn_duration_seconds = 10

View File

@@ -29,73 +29,33 @@ static GameConfig loadConfig()
// Threat accumulation // 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(); const GameConfig cfg = loadConfig();
std::mt19937 rng(42); std::mt19937 rng(42);
WaveSystem ws(cfg, rng); WaveSystem ws(cfg, rng);
// threat_rate_formula = "1*x - 30", which is <= 0 for x <= 30. // threat_rate_formula = "x", boss wave counter starts at 1 → rate = 1 threat/s.
const int ticks30s = static_cast<int>(secondsToTicks(30.0)); // After 1 second: threat ≈ 1.0.
for (int i = 0; i < ticks30s; ++i) 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]") 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);
// 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]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
std::mt19937 rng(42); std::mt19937 rng(42);
WaveSystem ws(cfg, rng); WaveSystem ws(cfg, rng);
REQUIRE(ws.generation() == 0); REQUIRE(ws.generation() == 0);
ws.applyPush(); ws.onEnemyStationsDestroyed();
REQUIRE(ws.generation() == 1); REQUIRE(ws.generation() == 1);
ws.applyPush(); ws.onEnemyStationsDestroyed();
REQUIRE(ws.generation() == 2); 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. // The maximum gap is gapMaxSeconds = 45s → 1350 ticks.
// Run 1500 ticks to guarantee at least one wave has triggered. // 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)); const int limit = static_cast<int>(secondsToTicks(50.0));
bool foundEnemyShip = false;
for (int i = 0; i < limit; ++i) for (int i = 0; i < limit; ++i)
{ {
sim.tick(); sim.tick();
} if (!foundEnemyShip)
bool foundEnemyShip = false;
sim.admin().forEach<ShipIdentityComponent, FactionComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/, const FactionComponent& f)
{ {
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); REQUIRE(foundEnemyShip);
} }

View File

@@ -240,7 +240,9 @@ void GameWorldView::onFrame()
// Emit state update for header bar / build grid // Emit state update for header bar / build grid
emit stateUpdated(m_sim->currentTick(), emit stateUpdated(m_sim->currentTick(),
m_sim->buildingBlocksStock(), m_sim->buildingBlocksStock(),
m_gameSpeedMultiplier); m_gameSpeedMultiplier,
m_sim->bossWaveCounter(),
m_sim->bossCountdownTicks());
// Game over check // Game over check
if (m_sim->isGameOver() && !m_gameOverShown) if (m_sim->isGameOver() && !m_gameOverShown)
@@ -837,10 +839,10 @@ void GameWorldView::drawStations(QPainter& painter)
void GameWorldView::drawShips(QPainter& painter) void GameWorldView::drawShips(QPainter& painter)
{ {
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent, m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent>( FactionComponent, HealthComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si, [&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const FacingComponent& facing, const PositionComponent& pos, const FacingComponent& facing,
const FactionComponent& /*fac*/) const FactionComponent& fac, const HealthComponent& h)
{ {
const std::map<std::string, ShipVisuals>::const_iterator it = const std::map<std::string, ShipVisuals>::const_iterator it =
m_visuals->ships.find(si.schematicId); m_visuals->ships.find(si.schematicId);
@@ -864,6 +866,18 @@ void GameWorldView::drawShips(QPainter& painter)
painter.setPen(QPen(it->second.outline, 1)); painter.setPen(QPen(it->second.outline, 1));
painter.setBrush(it->second.fill); painter.setBrush(it->second.fill);
painter.drawPolygon(tri); 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; m_gameSpeedMultiplier = multiplier;
emit stateUpdated(m_sim->currentTick(), emit stateUpdated(m_sim->currentTick(),
m_sim->buildingBlocksStock(), m_sim->buildingBlocksStock(),
m_gameSpeedMultiplier); m_gameSpeedMultiplier,
m_sim->bossWaveCounter(),
m_sim->bossCountdownTicks());
} }
void GameWorldView::resetForNewGame() void GameWorldView::resetForNewGame()

View File

@@ -47,7 +47,7 @@ public:
signals: signals:
void selectionChanged(const std::vector<BuildingId>& ids); 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 gameOver();
void builderModeExited(); void builderModeExited();
void blueprintModeExited(); void blueprintModeExited();

View File

@@ -22,9 +22,11 @@ HeaderBar::HeaderBar(QWidget* parent)
m_timeLabel = new QLabel("00:00", this); m_timeLabel = new QLabel("00:00", this);
m_blocksLabel = new QLabel("Blocks: 0", 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_timeLabel);
layout->addWidget(m_blocksLabel); layout->addWidget(m_blocksLabel);
layout->addStretch(); layout->addStretch();
layout->addWidget(m_bossLabel);
const char* labels[] = { "0x", "0.5x", "1x", "2x", "4x" }; const char* labels[] = { "0x", "0.5x", "1x", "2x", "4x" };
QSignalMapper* mapper = new QSignalMapper(this); QSignalMapper* mapper = new QSignalMapper(this);
@@ -43,7 +45,8 @@ HeaderBar::HeaderBar(QWidget* parent)
setFixedHeight(sizeHint().height()); 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 totalSeconds = static_cast<int>(ticksToSeconds(tick));
const int minutes = totalSeconds / 60; 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)); 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) for (int i = 0; i < kSpeedCount; ++i)
{ {
const bool active = (std::abs(kSpeeds[i] - gameSpeed) < 0.001); const bool active = (std::abs(kSpeeds[i] - gameSpeed) < 0.001);

View File

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