Home / Modules / Gameplay / WaveSpawnDirector

WaveSpawnDirector

Pure logic

Schedules and spawns enemy waves, escalates spawn pressure, and advances waves as active units are cleared.

CategoryGameplay
Dependency tierPure logic
Related modulesnone
Demo scenearena-combat · Featured in this demo scene (coming soon)
Take just this module
modules/gameplay/WaveSpawnDirector.js

Copy it together with its internal dependencies, preserving the relative directory structure.

Source

import { clamp } from '../math/ScalarUtils.js';
import { DEFAULT_PRNG } from '../math/RandomUtils.js';

const DEFAULT_UNLOCK_RULES = [
  { waveNumber: 1, type: 'DEFAULT' },
];

const DEFAULT_TYPE_WEIGHTS = {
  DEFAULT: 1,
};

const resolveWeight = (value, waveNumber) => (
  typeof value === 'function' ? value(waveNumber) : value
);


export class WaveSpawnDirector {
  constructor({
    baseWaveSize = 3,
    growthPerWave = 1.5,
    maxWaveSize = 500,
    unlockRules = DEFAULT_UNLOCK_RULES,
    typeWeights = DEFAULT_TYPE_WEIGHTS,
    maxSpawnsPerStep = 100,
    startWaveNumber = 1,
    waveAutoStart = true,
    prng = DEFAULT_PRNG
  }) {
    this.baseWaveSize = baseWaveSize;
    this.growthPerWave = growthPerWave;
    this.maxWaveSize = maxWaveSize;
    this.unlockRules = [...unlockRules].sort((a, b) => a.waveNumber - b.waveNumber);
    this.typeWeights = { ...typeWeights };
    this.maxSpawnsPerStep = maxSpawnsPerStep;
    this.startWaveNumber = startWaveNumber;
    this.waveAutoStart = waveAutoStart;
    this.prng = prng;
    this.activeUnits = 0;

    this.reset(this.startWaveNumber);
    if (this.waveAutoStart) this.startWave(this.startWaveNumber);
  }

  reset(startWaveNumber = this.startWaveNumber) {
    this.waveNumber = startWaveNumber;
    this.inProgress = false;
    this.unitsToSpawn = 0;
    this.unitsSpawned = 0;
    this.lastSpawnedType = null;
    this.activeUnits = 0;
  }

  startWave(waveNumber = this.waveNumber) {
    this.waveNumber = waveNumber;
    this.unitsToSpawn = this.getWaveSize(waveNumber);
    this.unitsSpawned = 0;
    this.lastSpawnedType = null;
    this.inProgress = true;

    return {
      waveNumber: this.waveNumber,
      unitsToSpawn: this.unitsToSpawn,
      availableTypes: this.getAvailableTypes(this.waveNumber),
    };
  }

  step({ activeUnits }) {
    this.activeUnits = activeUnits;

    this.completeIfDone(activeUnits);
    if (!this.inProgress && this.waveAutoStart) this.startWave(this.waveNumber);

    return {
      spawns: this.planSpawns(),
    };
  }

  setActiveUnits(activeUnits) {
    this.activeUnits = activeUnits;
  }

  getWaveSize(waveNumber = this.waveNumber) {
    const raw = this.baseWaveSize + (waveNumber - 1) * this.growthPerWave;
    return clamp(Math.floor(raw), 1, this.maxWaveSize);
  }

  getAvailableTypes(waveNumber = this.waveNumber) {
    return this.unlockRules
      .filter((rule) => waveNumber >= rule.waveNumber)
      .map((rule) => rule.type);
  }

  selectType(waveNumber = this.waveNumber) {
    const available = this.getAvailableTypes(waveNumber);
    const entries = [];
    let total = 0;

    for (const type of available) {
      const weight = resolveWeight(this.typeWeights[type] ?? 0, waveNumber);
      if (weight <= 0) continue;
      entries.push({ type, weight });
      total += weight;
    }

    if (entries.length === 0) return available[0];

    let pick = this.prng.random() * total;
    for (const entry of entries) {
      pick -= entry.weight;
      if (pick <= 0) return entry.type;
    }
    return entries[entries.length - 1].type;
  }

  planSpawns() {
    if (!this.inProgress || this.unitsSpawned >= this.unitsToSpawn) return [];

    const spawns = [];
    let guard = this.maxSpawnsPerStep;

    while (this.unitsSpawned < this.unitsToSpawn && guard > 0) {
      const type = this.selectType(this.waveNumber);
      const spawn = {
        type,
        waveNumber: this.waveNumber,
        spawnIndex: this.unitsSpawned,
        spawnCount: this.unitsToSpawn,
      };

      this.unitsSpawned += 1;
      this.lastSpawnedType = type;
      spawns.push(spawn);
      guard -= 1;
    }

    return spawns;
  }

  completeIfDone(activeUnits) {
    if (!this.inProgress || this.unitsSpawned < this.unitsToSpawn || activeUnits > 0) return null;

    const completedWaveNumber = this.waveNumber;
    this.inProgress = false;
    this.waveNumber += 1;

    return {
      completedWaveNumber,
      nextWaveNumber: this.waveNumber,
    };
  }

  snapshot() {
    return {
      waveNumber: this.waveNumber,
      inProgress: this.inProgress,
      unitsToSpawn: this.unitsToSpawn,
      unitsSpawned: this.unitsSpawned,
      pending: 0,
      activeUnits: this.activeUnits,
      lastSpawnedType: this.lastSpawnedType,
    };
  }
}