/** * Wellengenerierung (ohne Phaser). * * Idee: Jede Welle hat ein Punktebudget, das mit der Wellennummer wächst. Gegner * „kosten“ Budget (config ENEMY_TYPES.cost). Der Generator kauft zuerst Eliten * (in jeder n-ten Welle), dann mittlere Gegner (Anteil steigt mit der Welle) und * füllt den Rest mit kleinen Gegnern auf. So wird es gleichmäßig schwerer, ohne * dass man jede Welle von Hand bauen muss. * @module logic/waves */ import { ENEMY_TYPES, WAVES } from '../config.js'; import { pick, randFloat, randInt } from './rng.js'; /** @typedef {import('./rng.js').Rng} Rng */ /** @typedef {'north'|'south'|'east'|'west'} Edge */ /** * @typedef {Object} SpawnGroup * @property {string} type Gegnertyp-ID aus ENEMY_TYPES. * @property {number} count Anzahl Gegner in dieser Gruppe. * @property {number} atMs Spawn-Zeitpunkt relativ zum Wellenstart. * @property {Edge} edge Arenarand, an dem die Gruppe erscheint. */ /** * @typedef {Object} WavePlan * @property {number} number * @property {number} budget * @property {string[]} factions * @property {SpawnGroup[]} groups Nach atMs aufsteigend sortiert. * @property {number} totalEnemies Summe aller Gruppen (ohne ausgebrütete Krabbler). * @property {number} hpMultiplier * @property {number} speedMultiplier */ /** @type {readonly Edge[]} */ export const EDGES = Object.freeze(['north', 'south', 'east', 'west']); /** Anteil des Budgets, der auch in Elite-Wellen für Fußvolk reserviert bleibt. */ const MIN_FODDER_SHARE = 0.3; /** Position der Eliten im Spawn-Ablauf (0 = zuerst, 1 = zuletzt): nicht sofort, nicht am Ende. */ const ELITE_TIMELINE_POSITION = 0.4; /** * Budget der Welle `n`. * @param {number} n Wellennummer ab 1. * @param {typeof WAVES} [cfg] * @returns {number} */ export function waveBudget(n, cfg = WAVES) { return Math.round(cfg.firstBudget + cfg.budgetGrowth * Math.pow(n - 1, cfg.budgetExponent)); } /** * Welche Fraktionen in Welle `n` angreifen. * @param {number} n * @param {typeof WAVES} [cfg] * @returns {string[]} */ export function waveFactions(n, cfg = WAVES) { if (n < cfg.legionFromWave) return ['swarm']; if (n < cfg.mixedFromWave) return ['legion']; return ['swarm', 'legion']; } /** * Anzahl der Elite-Gegner in Welle `n`. * @param {number} n * @param {typeof WAVES} [cfg] * @returns {number} */ export function eliteCount(n, cfg = WAVES) { if (n % cfg.eliteEvery !== 0) return 0; return Math.min(cfg.maxElitesPerWave, n / cfg.eliteEvery); } /** * Lebenspunkte- und Tempo-Multiplikatoren für späte Wellen. * @param {number} n * @param {typeof WAVES} [cfg] * @returns {{hp: number, speed: number}} */ export function waveMultipliers(n, cfg = WAVES) { const steps = Math.max(0, n - cfg.scalingFromWave + 1); return { hp: 1 + steps * cfg.hpScalePerWave, speed: Math.min(cfg.speedScaleMax, 1 + steps * cfg.speedScalePerWave), }; } /** * Alle Typ-IDs einer Fraktion und Stufe. * @param {string} faction * @param {'small'|'medium'|'elite'} tier * @param {typeof ENEMY_TYPES} [types] * @returns {string[]} */ export function typesOf(faction, tier, types = ENEMY_TYPES) { return Object.values(types) .filter((t) => t.faction === faction && t.tier === tier) .map((t) => t.id); } /** * Kauft Gruppen einer Stufe, bis das Budget nicht mehr reicht. * @returns {{groups: {type: string, count: number}[], leftover: number}} */ function buyGroups(rng, factions, tier, budget, types) { const groups = []; let remaining = budget; for (;;) { const affordable = factions .flatMap((f) => typesOf(f, tier, types)) .filter((id) => types[id].cost <= remaining); if (affordable.length === 0) break; const type = types[pick(rng, affordable)]; const maxAffordable = Math.floor(remaining / type.cost); const count = Math.min(randInt(rng, type.groupSize.min, type.groupSize.max), maxAffordable); groups.push({ type: type.id, count }); remaining -= count * type.cost; } return { groups, leftover: remaining }; } /** * Mischt eine Liste (Fisher-Yates) mit dem übergebenen Zufallsgenerator. * @template T * @param {Rng} rng * @param {T[]} items * @returns {T[]} */ function shuffled(rng, items) { const copy = items.slice(); for (let i = copy.length - 1; i > 0; i -= 1) { const j = Math.floor(rng() * (i + 1)); [copy[i], copy[j]] = [copy[j], copy[i]]; } return copy; } /** * Erzeugt den Plan für Welle `n`. * @param {number} n Wellennummer ab 1. * @param {Rng} rng Zufallsgenerator (für Tests mit festem Seed). * @param {typeof WAVES} [cfg] * @param {typeof ENEMY_TYPES} [types] * @returns {WavePlan} */ export function generateWave(n, rng, cfg = WAVES, types = ENEMY_TYPES) { if (!Number.isInteger(n) || n < 1) { throw new RangeError(`Ungültige Wellennummer: ${n}`); } const budget = waveBudget(n, cfg); const factions = waveFactions(n, cfg); // Eliten zuerst – sie sind der Höhepunkt der Welle. In gemischten Wellen wechselt // die Fraktion der Eliten von Elite-Welle zu Elite-Welle (erste Elite-Welle: // Schwärmer, weil die Wellen davor der Blechlegion gehörten). const elites = []; let remaining = budget; const eliteRound = n / cfg.eliteEvery - 1; for (let i = 0; i < eliteCount(n, cfg); i += 1) { const faction = factions[(eliteRound + i) % factions.length]; const eliteType = typesOf(faction, 'elite', types)[0]; elites.push({ type: eliteType, count: 1 }); remaining -= types[eliteType].cost; } // Auch neben Eliten braucht es Fußvolk, sonst wirkt die Welle leer. remaining = Math.max(remaining, Math.ceil(budget * MIN_FODDER_SHARE)); const mediumShare = Math.min( cfg.mediumShareMax, cfg.mediumShareBase + cfg.mediumSharePerWave * (n - 1), ); const medium = buyGroups(rng, factions, 'medium', Math.floor(remaining * mediumShare), types); const smallBudget = remaining - Math.floor(remaining * mediumShare) + medium.leftover; const small = buyGroups(rng, factions, 'small', smallBudget, types); // Reihenfolge: Fußvolk gemischt, Eliten etwa bei 40 % des Ablaufs. const ordered = shuffled(rng, [...small.groups, ...medium.groups]); const eliteIndex = Math.floor(ordered.length * ELITE_TIMELINE_POSITION); ordered.splice(eliteIndex, 0, ...elites); // Spätere Wellen kommen dichter hintereinander. const delayFactor = Math.max(cfg.groupDelayMinFactor, 1 - cfg.groupDelayFactorPerWave * (n - 1)); let atMs = 0; const groups = ordered.map((group, index) => { if (index > 0) { atMs += Math.round(randFloat(rng, cfg.groupDelayMs.min, cfg.groupDelayMs.max) * delayFactor); } return { ...group, atMs, edge: pick(rng, EDGES) }; }); const multipliers = waveMultipliers(n, cfg); return { number: n, budget, factions, groups, totalEnemies: groups.reduce((sum, g) => sum + g.count, 0), hpMultiplier: multipliers.hp, speedMultiplier: multipliers.speed, }; } /** * Zufälliger Spawnpunkt am gegebenen Arenarand, knapp innerhalb der Welt. * @param {Edge} edge * @param {{width: number, height: number}} world * @param {number} margin Abstand zum Rand. * @param {Rng} rng * @returns {{x: number, y: number}} */ export function spawnPointOnEdge(edge, world, margin, rng) { const along = (size) => randFloat(rng, margin, size - margin); switch (edge) { case 'north': return { x: along(world.width), y: margin }; case 'south': return { x: along(world.width), y: world.height - margin }; case 'west': return { x: margin, y: along(world.height) }; case 'east': return { x: world.width - margin, y: along(world.height) }; default: throw new RangeError(`Unbekannter Rand: ${edge}`); } }