boss waves

This commit is contained in:
2026-06-03 21:30:38 +02:00
parent 457fc47c75
commit b5185b0906
10 changed files with 214 additions and 162 deletions

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;
}
@@ -80,18 +104,52 @@ int WaveSystem::generation() const
// 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 +171,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 +201,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)