slop game oder so
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user