231 lines
7.6 KiB
JavaScript
231 lines
7.6 KiB
JavaScript
/**
|
||
* 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}`);
|
||
}
|
||
}
|