slop game oder so

This commit is contained in:
2026-09-24 17:57:54 +02:00
commit 4c997eee21
166 changed files with 14976 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
/**
* 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}`);
}
}