Files
slop-game-opus5.5/app/static/js/logic/waves.js
T
2026-09-24 17:57:54 +02:00

231 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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}`);
}
}