256 lines
7.9 KiB
C++
256 lines
7.9 KiB
C++
#include "WaveSystem.h"
|
|
|
|
#include <algorithm>
|
|
|
|
#include "ShipSystem.h"
|
|
|
|
WaveSystem::WaveSystem(const GameConfig& config, std::mt19937& rng)
|
|
: m_config(config)
|
|
, m_rng(rng)
|
|
{
|
|
m_bossCountdownTicks = secondsToTicks(config.world.waves.bossCountdownSeconds);
|
|
m_normalGapRemainingTicks = drawGapTicks();
|
|
}
|
|
|
|
void WaveSystem::tickWaveScheduler(Tick currentTick, ShipSystem& ships,
|
|
int worldHeightTiles)
|
|
{
|
|
// 1. Advance boss countdown.
|
|
--m_bossCountdownTicks;
|
|
|
|
// 2. Trigger boss wave when countdown expires.
|
|
if (m_bossCountdownTicks <= 0)
|
|
{
|
|
triggerBossWave(currentTick, worldHeightTiles);
|
|
}
|
|
|
|
// 3. Advance post-boss quiet window.
|
|
if (m_postBossQuietRemainingTicks > 0)
|
|
{
|
|
--m_postBossQuietRemainingTicks;
|
|
}
|
|
|
|
// 4. Normal wave gap: only advances outside quiet windows and between waves.
|
|
if (!isInQuietWindow() && !m_normalWaveActive)
|
|
{
|
|
if (m_normalGapRemainingTicks > 0)
|
|
{
|
|
--m_normalGapRemainingTicks;
|
|
}
|
|
if (m_normalGapRemainingTicks == 0)
|
|
{
|
|
triggerNormalWave(currentTick, worldHeightTiles);
|
|
}
|
|
}
|
|
|
|
// 5. Spawn any ships (from either queue) whose scheduled tick has arrived.
|
|
auto spawnDue = [&](std::vector<SpawnEntry>& queue)
|
|
{
|
|
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()
|
|
{
|
|
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 * kTickDurationSeconds;
|
|
}
|
|
}
|
|
|
|
void WaveSystem::onEnemyStationsDestroyed()
|
|
{
|
|
const Tick advance = secondsToTicks(m_config.world.push.bossAdvanceSeconds);
|
|
m_bossCountdownTicks = std::max(Tick{0}, m_bossCountdownTicks - advance);
|
|
++m_generation;
|
|
}
|
|
|
|
double WaveSystem::threatLevel() const
|
|
{
|
|
return m_threatLevel;
|
|
}
|
|
|
|
int WaveSystem::generation() const
|
|
{
|
|
return m_generation;
|
|
}
|
|
|
|
int WaveSystem::bossWaveCounter() const
|
|
{
|
|
return m_bossWaveCounter;
|
|
}
|
|
|
|
Tick WaveSystem::bossCountdownTicks() const
|
|
{
|
|
return m_bossCountdownTicks;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Private helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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 int shipLevel = std::max(1, static_cast<int>(
|
|
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::vector<PlacedModule> defaultModules;
|
|
};
|
|
std::vector<EligibleShip> eligible;
|
|
for (const ShipDef& def : m_config.ships.ships)
|
|
{
|
|
const double cost = def.threat.costFormula.evaluate(static_cast<double>(shipLevel));
|
|
if (cost > 0.0)
|
|
{
|
|
EligibleShip es;
|
|
es.schematicId = def.id;
|
|
es.cost = cost;
|
|
es.defaultModules = def.defaultModules;
|
|
eligible.push_back(es);
|
|
}
|
|
}
|
|
|
|
if (eligible.empty())
|
|
{
|
|
return {};
|
|
}
|
|
|
|
// 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;
|
|
|
|
std::uniform_real_distribution<float> xDist(leftX, rightX);
|
|
std::uniform_int_distribution<int> yDist(0, worldHeightTiles - 1);
|
|
|
|
std::vector<SpawnEntry> picked;
|
|
|
|
while (true)
|
|
{
|
|
// Collect indices of ships whose cost fits the remaining budget.
|
|
std::vector<std::size_t> fitting;
|
|
for (std::size_t i = 0; i < eligible.size(); ++i)
|
|
{
|
|
if (eligible[i].cost <= budget)
|
|
{
|
|
fitting.push_back(i);
|
|
}
|
|
}
|
|
if (fitting.empty())
|
|
{
|
|
break;
|
|
}
|
|
|
|
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];
|
|
|
|
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;
|
|
picked.push_back(entry);
|
|
}
|
|
|
|
// Spread spawn times evenly across spawnDurationSeconds.
|
|
const int count = static_cast<int>(picked.size());
|
|
if (count == 1)
|
|
{
|
|
picked[0].spawnAt = currentTick;
|
|
}
|
|
else if (count > 1)
|
|
{
|
|
const Tick spawnDurationTicks =
|
|
secondsToTicks(m_config.world.waves.spawnDurationSeconds);
|
|
for (int i = 0; i < count; ++i)
|
|
{
|
|
picked[static_cast<std::size_t>(i)].spawnAt =
|
|
currentTick + static_cast<Tick>(i) * spawnDurationTicks / (count - 1);
|
|
}
|
|
}
|
|
|
|
return picked;
|
|
}
|
|
|
|
Tick WaveSystem::drawGapTicks()
|
|
{
|
|
const Tick minTicks = secondsToTicks(m_config.world.waves.gapMinSeconds);
|
|
const Tick maxTicks = secondsToTicks(m_config.world.waves.gapMaxSeconds);
|
|
std::uniform_int_distribution<Tick> dist(minTicks, maxTicks);
|
|
return dist(m_rng);
|
|
}
|