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
+132
View File
@@ -0,0 +1,132 @@
/**
* Eingabe der Orbital-Befehle als Richtungsfolge (ohne Phaser).
*
* Solange die Halte-Taste gedrückt ist, sammelt `ComboInput` Richtungen. Nach jeder
* Eingabe wird geprüft, ob der Puffer noch Anfang mindestens eines Befehls ist:
* - passt nichts mehr → „fail“, Puffer wird geleert (man kann direkt neu ansetzen)
* - Folge vollständig → „match“ mit Befehls-ID
* - sonst → „progress“
* Ob ein Befehl gerade abklingt, entscheidet der Aufrufer – hier geht es nur um Folgen.
* @module logic/comboInput
*/
/** @typedef {'up'|'down'|'left'|'right'} Direction */
/** @typedef {{id: string, sequence: readonly Direction[]}} ComboCommand */
/** @type {readonly Direction[]} */
export const DIRECTIONS = Object.freeze(['up', 'down', 'left', 'right']);
/**
* Physische Tastencodes (KeyboardEvent.code) → Richtung. `code` statt `key`, damit
* WASD auch auf AZERTY- oder QWERTZ-Tastaturen an derselben Stelle liegt.
*/
const CODE_TO_DIRECTION = Object.freeze({
ArrowUp: 'up',
KeyW: 'up',
ArrowDown: 'down',
KeyS: 'down',
ArrowLeft: 'left',
KeyA: 'left',
ArrowRight: 'right',
KeyD: 'right',
});
/**
* @param {string} code KeyboardEvent.code
* @returns {Direction|null}
*/
export function codeToDirection(code) {
return CODE_TO_DIRECTION[code] ?? null;
}
/**
* @param {readonly string[]} prefix
* @param {readonly string[]} sequence
* @returns {boolean}
*/
export function isPrefix(prefix, sequence) {
if (prefix.length > sequence.length) return false;
return prefix.every((dir, i) => sequence[i] === dir);
}
/**
* Prüft die Befehlsliste: gültige Richtungen, eindeutige IDs und keine Folge, die
* Anfang einer anderen ist (sonst wäre die längere Folge nie erreichbar).
* @param {readonly ComboCommand[]} commands
* @throws {Error} bei ungültiger Konfiguration.
*/
export function validateCommands(commands) {
const ids = new Set();
for (const command of commands) {
if (ids.has(command.id)) throw new Error(`Doppelte Befehls-ID: ${command.id}`);
ids.add(command.id);
if (command.sequence.length === 0) throw new Error(`Leere Folge bei ${command.id}`);
for (const dir of command.sequence) {
if (!DIRECTIONS.includes(dir)) throw new Error(`Ungültige Richtung „${dir}“ bei ${command.id}`);
}
}
for (const a of commands) {
for (const b of commands) {
if (a !== b && isPrefix(a.sequence, b.sequence)) {
throw new Error(`Folge von ${a.id} ist Anfang der Folge von ${b.id}`);
}
}
}
}
export class ComboInput {
/** @param {readonly ComboCommand[]} commands */
constructor(commands) {
validateCommands(commands);
this.commands = commands;
/** @type {Direction[]} */
this.buffer = [];
this.active = false;
}
/** Halte-Taste gedrückt: Eingabe beginnt mit leerem Puffer. */
begin() {
this.active = true;
this.buffer = [];
}
/** Halte-Taste losgelassen: halbe Eingaben verfallen. */
end() {
this.active = false;
this.buffer = [];
}
/**
* Verarbeitet eine Richtung.
* @param {string} direction
* @returns {{status: 'inactive'|'ignored'|'fail'|'progress'|'match', commandId?: string, candidates?: string[]}}
*/
push(direction) {
if (!this.active) return { status: 'inactive' };
if (!DIRECTIONS.includes(/** @type {Direction} */ (direction))) return { status: 'ignored' };
this.buffer.push(/** @type {Direction} */ (direction));
const candidates = this.commands.filter((c) => isPrefix(this.buffer, c.sequence));
if (candidates.length === 0) {
this.buffer = [];
return { status: 'fail' };
}
const exact = candidates.find((c) => c.sequence.length === this.buffer.length);
if (exact) {
this.buffer = [];
return { status: 'match', commandId: exact.id };
}
return { status: 'progress', candidates: candidates.map((c) => c.id) };
}
/**
* Wie viele Schritte eines Befehls bereits korrekt eingegeben sind (für die HUD-Anzeige).
* @param {string} commandId
* @returns {number}
*/
progressFor(commandId) {
const command = this.commands.find((c) => c.id === commandId);
if (!command || !isPrefix(this.buffer, command.sequence)) return 0;
return this.buffer.length;
}
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Abklingzeiten für Fähigkeiten (Ausweichrolle, Orbital-Befehle) ohne Phaser.
* Arbeitet mit der Spielzeit des GameScene, die während der Pause stillsteht –
* Abklingzeiten laufen in der Pause also nicht heimlich weiter.
* @module logic/cooldowns
*/
import { clamp } from './math.js';
export class CooldownTracker {
constructor() {
/** @type {Map<string, number>} */
this.readyAt = new Map();
/** @type {Map<string, number>} */
this.durations = new Map();
}
/**
* Startet die Abklingzeit von `id`.
* @param {string} id
* @param {number} durationMs
* @param {number} now Spielzeit in ms.
*/
trigger(id, durationMs, now) {
this.readyAt.set(id, now + durationMs);
this.durations.set(id, durationMs);
}
/**
* @param {string} id
* @param {number} now
* @returns {boolean} true, wenn die Fähigkeit benutzt werden kann (auch wenn nie ausgelöst).
*/
isReady(id, now) {
return (this.readyAt.get(id) ?? -Infinity) <= now;
}
/**
* @param {string} id
* @param {number} now
* @returns {number} Restzeit in ms (0 wenn bereit).
*/
remaining(id, now) {
return Math.max(0, (this.readyAt.get(id) ?? now) - now);
}
/**
* Fortschritt für Ladebalken im HUD.
* @param {string} id
* @param {number} now
* @returns {number} 0 = gerade ausgelöst, 1 = bereit.
*/
progress(id, now) {
const duration = this.durations.get(id);
if (!duration) return 1;
return clamp(1 - this.remaining(id, now) / duration, 0, 1);
}
/**
* Setzt eine oder alle Abklingzeiten zurück.
* @param {string} [id]
*/
reset(id) {
if (id === undefined) {
this.readyAt.clear();
this.durations.clear();
} else {
this.readyAt.delete(id);
this.durations.delete(id);
}
}
}
+225
View File
@@ -0,0 +1,225 @@
/**
* Entscheidungslogik der Gegner (ohne Phaser): Bewegung pro Verhalten,
* Sprungangriff, Schuss-Salven, Brut. Die Entities rufen diese Funktionen jeden
* Frame auf und setzen das Ergebnis in Physik und Grafik um.
*
* Zustände liegen in kleinen, veränderlichen „Brain“-Objekten pro Gegner. Das ist
* bewusst kein unveränderlicher Stil: Bei 60 Gegnern und 60 FPS sollen pro Frame
* keine Objektberge für den Garbage Collector entstehen.
* @module logic/enemyAi
*/
import { degToRad, distance, normalize, scale, sub } from './math.js';
import { encirclePoint, flankPoint, keepDistance, seek } from './steering.js';
/** @typedef {import('./math.js').Vec2} Vec2 */
/**
* @typedef {Object} Brain
* @property {number} slotAngle Anflugwinkel im Schwarm (Bogenmaß).
* @property {1|-1} strafeDirection Richtung beim seitlichen Ausweichen.
* @property {number} nextStrafeSwitchAt
* @property {1|-1} flankSide Seite, von der flankiert wird.
* @property {'chase'|'windup'|'leap'} leapState
* @property {number} leapStateUntil
* @property {number} nextLeapAt
* @property {Vec2} leapDirection
* @property {AttackState} attack
* @property {AttackState} mortar
* @property {number} nextBroodAt
*/
/**
* @typedef {Object} AttackState
* @property {number} nextAttackAt
* @property {number} burstRemaining
* @property {number} nextShotAt
*/
/**
* Neuer Angriffszustand. `firstAttackAt` verzögert den ersten Schuss, damit frisch
* gespawnte Gegner nicht sofort und alle gleichzeitig feuern.
* @param {number} firstAttackAt
* @returns {AttackState}
*/
export function createAttackState(firstAttackAt = 0) {
return { nextAttackAt: firstAttackAt, burstRemaining: 0, nextShotAt: 0 };
}
/**
* Neues Brain mit zufälligen, aber reproduzierbaren Eigenheiten.
* @param {import('./rng.js').Rng} rng
* @param {number} now
* @param {{firstAttackDelayMs?: number}} [options]
* @returns {Brain}
*/
export function createBrain(rng, now, options = {}) {
const firstAttackAt = now + (options.firstAttackDelayMs ?? 0) * (0.5 + rng());
return {
slotAngle: rng() * Math.PI * 2,
strafeDirection: rng() < 0.5 ? 1 : -1,
nextStrafeSwitchAt: now,
flankSide: rng() < 0.5 ? 1 : -1,
leapState: 'chase',
leapStateUntil: 0,
nextLeapAt: now,
leapDirection: { x: 0, y: 0 },
attack: createAttackState(firstAttackAt),
mortar: createAttackState(firstAttackAt + (options.firstAttackDelayMs ?? 0)),
nextBroodAt: now + (options.firstAttackDelayMs ?? 0),
};
}
/**
* Wechselt gelegentlich die Ausweichrichtung, damit Fernkämpfer nicht vorhersehbar kreisen.
* @param {Brain} brain
* @param {number} now
* @param {{min: number, max: number}} switchMs
* @param {import('./rng.js').Rng} rng
*/
export function updateStrafe(brain, now, switchMs, rng) {
if (now >= brain.nextStrafeSwitchAt) {
brain.strafeDirection = brain.strafeDirection === 1 ? -1 : 1;
brain.nextStrafeSwitchAt = now + switchMs.min + rng() * (switchMs.max - switchMs.min);
}
}
/**
* Schwarm: aus der Distanz den eigenen Platz im Umzingelungskreis ansteuern,
* in Reichweite direkt angreifen. So kommt ein Rudel von mehreren Seiten.
* @param {Brain} brain
* @param {Vec2} self
* @param {Vec2} player
* @param {number} speed
* @param {{engageRange: number, encircleRadius: number}} type
* @returns {Vec2}
*/
export function swarmVelocity(brain, self, player, speed, type) {
if (distance(self, player) <= type.engageRange) {
return seek(self, player, speed);
}
return seek(self, encirclePoint(player, brain.slotAngle, type.encircleRadius), speed);
}
/**
* Sprungkralle: verfolgen → ansetzen (stillstehen, Vorwarnung) → springen → verfolgen.
* Die Sprungrichtung wird beim Ansetzen festgelegt: Wer die Vorwarnung sieht, kann
* ausweichen. Das ist die „Fairness“ des Angriffs.
* @param {Brain} brain
* @param {Vec2} self
* @param {Vec2} player
* @param {number} now
* @param {number} speed
* @param {{triggerRange: number, windupMs: number, speed: number, durationMs: number, cooldownMs: number}} leap
* @returns {{velocity: Vec2, telegraph: boolean, leaping: boolean}}
*/
export function leaperStep(brain, self, player, now, speed, leap) {
if (brain.leapState === 'windup') {
if (now < brain.leapStateUntil) {
return { velocity: { x: 0, y: 0 }, telegraph: true, leaping: false };
}
brain.leapState = 'leap';
brain.leapStateUntil = now + leap.durationMs;
}
if (brain.leapState === 'leap') {
if (now < brain.leapStateUntil) {
return { velocity: scale(brain.leapDirection, leap.speed), telegraph: false, leaping: true };
}
brain.leapState = 'chase';
brain.nextLeapAt = now + leap.cooldownMs;
}
// chase
if (distance(self, player) <= leap.triggerRange && now >= brain.nextLeapAt) {
brain.leapState = 'windup';
brain.leapStateUntil = now + leap.windupMs;
brain.leapDirection = normalize(sub(player, self));
return { velocity: { x: 0, y: 0 }, telegraph: true, leaping: false };
}
return { velocity: seek(self, player, speed), telegraph: false, leaping: false };
}
/**
* Fernkämpfer auf Abstand (Nieter-Drohne, Walzmeister).
* @param {Brain} brain
* @param {Vec2} self
* @param {Vec2} player
* @param {number} speed
* @param {{min: number, max: number}} range
* @returns {Vec2}
*/
export function kiteVelocity(brain, self, player, speed, range) {
return keepDistance(self, player, range, speed, brain.strafeDirection);
}
/**
* Flankierer (Blechsoldat): steuert eine Position seitlich des Spielers an und
* hält dort Abstand. Nah an der Flankenposition geht er ins Abstandhalten über.
* @param {Brain} brain
* @param {Vec2} self
* @param {Vec2} player
* @param {number} speed
* @param {{flankAngleDeg: number, flankDistance: number, range: {min: number, max: number}}} type
* @returns {Vec2}
*/
export function flankVelocity(brain, self, player, speed, type) {
const target = flankPoint(player, self, degToRad(type.flankAngleDeg) * brain.flankSide, type.flankDistance);
// Ist die Flanke (fast) erreicht, wie ein Fernkämpfer weiterkämpfen.
if (distance(self, target) < type.range.min * 0.5) {
return keepDistance(self, player, type.range, speed, brain.flankSide);
}
return seek(self, target, speed);
}
/**
* Taktgeber für Schüsse und Salven.
* @param {AttackState} state
* @param {number} now
* @param {number} distanceToPlayer
* @param {{cooldownMs: number, burst?: number, burstIntervalMs?: number, maxDistance: number}} attack
* @returns {boolean} true, wenn in diesem Frame geschossen werden soll.
*/
export function attackStep(state, now, distanceToPlayer, attack) {
if (state.burstRemaining > 0) {
if (now >= state.nextShotAt) {
state.burstRemaining -= 1;
state.nextShotAt = now + (attack.burstIntervalMs ?? 0);
return true;
}
return false;
}
if (now >= state.nextAttackAt && distanceToPlayer <= attack.maxDistance) {
state.burstRemaining = (attack.burst ?? 1) - 1;
state.nextShotAt = now + (attack.burstIntervalMs ?? 0);
state.nextAttackAt = now + attack.cooldownMs;
return true;
}
return false;
}
/**
* Winkel aller Projektile einer Salve, gleichmäßig über `spreadDeg` verteilt.
* @param {number} baseAngle Bogenmaß (Richtung zum Ziel).
* @param {number} count
* @param {number} spreadDeg Gesamtbreite des Fächers in Grad.
* @returns {number[]}
*/
export function volleyAngles(baseAngle, count, spreadDeg) {
if (count <= 1) return [baseAngle];
const spread = degToRad(spreadDeg);
const step = spread / (count - 1);
return Array.from({ length: count }, (_, i) => baseAngle - spread / 2 + i * step);
}
/**
* Brutmutter: Wie viele Krabbler jetzt schlüpfen sollen.
* @param {Brain} brain
* @param {number} now
* @param {number} aliveChildren Aktuell lebende Nachkommen dieser Brutmutter.
* @param {{intervalMs: number, spawnCount: number, maxChildren: number}} brood
* @returns {number}
*/
export function broodStep(brain, now, aliveChildren, brood) {
if (now < brain.nextBroodAt) return 0;
brain.nextBroodAt = now + brood.intervalMs;
return Math.max(0, Math.min(brood.spawnCount, brood.maxChildren - aliveChildren));
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Formatierung und Eingabeprüfung für Anzeige und Highscore-Formular (ohne Phaser).
* @module logic/format
*/
import { DIRECTION_SYMBOLS, HIGHSCORE } from '../config.js';
const scoreFormatter = new Intl.NumberFormat('de-DE');
/**
* Punktestand mit Tausendertrennzeichen („12.345“).
* @param {number} score
* @returns {string}
*/
export function formatScore(score) {
return scoreFormatter.format(Math.max(0, Math.floor(score)));
}
/**
* Dauer als „m:ss“.
* @param {number} ms
* @returns {string}
*/
export function formatDuration(ms) {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = String(totalSeconds % 60).padStart(2, '0');
return `${minutes}:${seconds}`;
}
/**
* Multiplikator mit deutschem Dezimalkomma („×1,5“).
* @param {number} multiplier
* @returns {string}
*/
export function formatMultiplier(multiplier) {
return `×${String(Math.round(multiplier * 10) / 10).replace('.', ',')}`;
}
/**
* Abklingzeit in Sekunden für das HUD („12“, „0,8“).
* @param {number} ms
* @returns {string}
*/
export function formatCooldown(ms) {
if (ms <= 0) return '';
const seconds = ms / 1000;
return seconds >= 1 ? String(Math.ceil(seconds)) : seconds.toFixed(1).replace('.', ',');
}
/**
* Richtungsfolge als Pfeile („↑ → ↓ ↓“).
* @param {readonly string[]} sequence
* @param {string} [separator]
* @returns {string}
*/
export function formatSequence(sequence, separator = ' ') {
return sequence.map((dir) => DIRECTION_SYMBOLS[dir] ?? '?').join(separator);
}
/** Wie auf dem Server (app/validation.py): alles außer Buchstaben, Ziffern und wenigen Zeichen. */
const DISALLOWED_NAME_CHARS = /[^\p{L}\p{N}_ .\-'!?]/gu;
/** Steuer-, Format-, Surrogat- und private Zeichen (Unicode-Kategorie C). */
const CONTROL_CHARS = /\p{C}/gu;
/**
* Bereinigt einen Namen genauso wie das Backend, damit der Spieler vor dem Absenden
* sieht, was gespeichert wird. Der Server prüft trotzdem selbst.
* @param {string} raw
* @returns {string}
*/
export function sanitizeName(raw) {
return String(raw)
.normalize('NFKC')
.replace(CONTROL_CHARS, '')
.replace(DISALLOWED_NAME_CHARS, '')
.split(/\s+/u)
.filter(Boolean)
.join(' ');
}
/**
* Prüft einen Namen für das Highscore-Formular.
* @param {string} raw
* @returns {{ok: true, name: string} | {ok: false, error: string}}
*/
export function validateName(raw) {
const name = sanitizeName(raw);
if (!name) return { ok: false, error: 'Bitte einen Namen mit Buchstaben oder Ziffern eingeben.' };
if ([...name].length > HIGHSCORE.nameMaxLength) {
return { ok: false, error: `Höchstens ${HIGHSCORE.nameMaxLength} Zeichen.` };
}
return { ok: true, name };
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Notenmuster der Hintergrundmusik: ein pompöser Marsch (eigene Komposition, CC0),
* der die Propaganda-Stimmung überzeichnet. Ohne Web-Audio-Abhängigkeit, damit sich
* Rhythmus und Harmonie testen lassen; gespielt wird er von `systems/Music.js`.
*
* Raster: 16 Schritte (Sechzehntel) pro Takt, 8 Takte, Viervierteltakt.
* @module logic/marchPattern
*/
export const TEMPO_BPM = 112;
export const STEPS_PER_BAR = 16;
export const BARS = 8;
export const TOTAL_STEPS = STEPS_PER_BAR * BARS;
/** Dauer eines Sechzehntels in Sekunden. */
export const STEP_SECONDS = 60 / TEMPO_BPM / 4;
/** Akkordgrundtöne (MIDI) je Takt: I I IV V I IV V I in C-Dur. */
export const CHORD_ROOTS = Object.freeze([48, 48, 53, 55, 48, 53, 55, 48]);
/** Dur-Dreiklang über dem Grundton. */
const MAJOR_TRIAD = Object.freeze([0, 4, 7]);
/**
* Fanfarenmelodie: [Takt, Schritt, MIDI-Ton, Länge in Schritten].
* @type {readonly (readonly [number, number, number, number])[]}
*/
const MELODY = Object.freeze([
[0, 0, 67, 3], [0, 3, 67, 1], [0, 4, 72, 4], [0, 8, 76, 6], [0, 14, 72, 2],
[1, 0, 74, 4], [1, 4, 72, 4], [1, 8, 71, 4], [1, 12, 67, 4],
[2, 0, 69, 3], [2, 3, 69, 1], [2, 4, 72, 4], [2, 8, 77, 6], [2, 14, 76, 2],
[3, 0, 74, 4], [3, 4, 71, 4], [3, 8, 67, 8],
[4, 0, 67, 3], [4, 3, 67, 1], [4, 4, 72, 4], [4, 8, 76, 4], [4, 12, 79, 4],
[5, 0, 81, 6], [5, 6, 77, 2], [5, 8, 76, 4], [5, 12, 72, 4],
[6, 0, 74, 3], [6, 3, 76, 1], [6, 4, 74, 4], [6, 8, 71, 4], [6, 12, 67, 4],
[7, 0, 72, 8],
]);
/**
* @typedef {Object} MusicEvent
* @property {'kick'|'snare'|'bass'|'stab'|'lead'} instrument
* @property {number[]} notes MIDI-Töne (leer bei Schlagzeug).
* @property {number} lengthSteps
* @property {number} velocity 0..1
*/
/**
* Alle Ereignisse eines Schritts.
* @param {number} step 0..TOTAL_STEPS-1 (größere Werte werden umgebrochen).
* @returns {MusicEvent[]}
*/
export function eventsAtStep(step) {
const s = ((step % TOTAL_STEPS) + TOTAL_STEPS) % TOTAL_STEPS;
const bar = Math.floor(s / STEPS_PER_BAR);
const pos = s % STEPS_PER_BAR;
const root = CHORD_ROOTS[bar];
const events = [];
// „Umm“ auf 1 und 3 (Bass + große Trommel), „ta“ auf 2 und 4 (Akkord + kleine Trommel).
if (pos === 0 || pos === 8) {
events.push({ instrument: 'kick', notes: [], lengthSteps: 2, velocity: 1 });
events.push({ instrument: 'bass', notes: [pos === 0 ? root - 12 : root - 5], lengthSteps: 4, velocity: 0.9 });
}
if (pos === 4 || pos === 12) {
events.push({ instrument: 'snare', notes: [], lengthSteps: 1, velocity: 0.8 });
events.push({ instrument: 'stab', notes: MAJOR_TRIAD.map((i) => root + 12 + i), lengthSteps: 2, velocity: 0.8 });
}
// Trommelwirbel in der zweiten Hälfte des letzten Takts leitet die Wiederholung ein.
if (bar === BARS - 1 && pos >= 8 && pos !== 12) {
events.push({ instrument: 'snare', notes: [], lengthSteps: 1, velocity: 0.35 + (pos - 8) * 0.07 });
}
for (const [mBar, mStep, note, length] of MELODY) {
if (mBar === bar && mStep === pos) {
events.push({ instrument: 'lead', notes: [note], lengthSteps: length, velocity: 0.8 });
}
}
return events;
}
/**
* Frequenz eines MIDI-Tons (A4 = 69 = 440 Hz).
* @param {number} midi
* @returns {number}
*/
export function midiToFrequency(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Kleine Vektor- und Zahlenhelfer ohne Phaser-Abhängigkeit.
* Vektoren sind einfache Objekte `{x, y}`; die Funktionen verändern ihre Eingaben nie.
* @module logic/math
*/
/** @typedef {{x: number, y: number}} Vec2 */
/**
* Begrenzt `value` auf das Intervall [min, max].
* @param {number} value
* @param {number} min
* @param {number} max
* @returns {number}
*/
export function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
/**
* Lineare Interpolation zwischen `a` und `b`.
* @param {number} a
* @param {number} b
* @param {number} t 0..1
* @returns {number}
*/
export function lerp(a, b, t) {
return a + (b - a) * t;
}
/** @param {number} deg @returns {number} */
export function degToRad(deg) {
return (deg * Math.PI) / 180;
}
/** @param {number} x @param {number} y @returns {Vec2} */
export function vec(x = 0, y = 0) {
return { x, y };
}
/** @param {Vec2} a @param {Vec2} b @returns {Vec2} */
export function add(a, b) {
return { x: a.x + b.x, y: a.y + b.y };
}
/** @param {Vec2} a @param {Vec2} b @returns {Vec2} */
export function sub(a, b) {
return { x: a.x - b.x, y: a.y - b.y };
}
/** @param {Vec2} v @param {number} s @returns {Vec2} */
export function scale(v, s) {
return { x: v.x * s, y: v.y * s };
}
/** @param {Vec2} v @returns {number} */
export function length(v) {
return Math.hypot(v.x, v.y);
}
/** @param {Vec2} a @param {Vec2} b @returns {number} */
export function distance(a, b) {
return Math.hypot(b.x - a.x, b.y - a.y);
}
/**
* Einheitsvektor in Richtung `v`. Der Nullvektor bleibt der Nullvektor –
* so muss kein Aufrufer Division durch null abfangen.
* @param {Vec2} v
* @returns {Vec2}
*/
export function normalize(v) {
const len = length(v);
return len > 0 ? { x: v.x / len, y: v.y / len } : { x: 0, y: 0 };
}
/**
* Kürzt `v` auf höchstens `max` Länge, die Richtung bleibt erhalten.
* @param {Vec2} v
* @param {number} max
* @returns {Vec2}
*/
export function clampLength(v, max) {
const len = length(v);
return len > max && len > 0 ? scale(v, max / len) : { x: v.x, y: v.y };
}
/**
* Dreht `v` um `angle` (Bogenmaß, im Uhrzeigersinn bei y nach unten wie im Canvas).
* @param {Vec2} v
* @param {number} angle
* @returns {Vec2}
*/
export function rotate(v, angle) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return { x: v.x * cos - v.y * sin, y: v.x * sin + v.y * cos };
}
/**
* Winkel des Vektors von `from` nach `to` (Bogenmaß, 0 = nach rechts).
* @param {Vec2} from
* @param {Vec2} to
* @returns {number}
*/
export function angleTo(from, to) {
return Math.atan2(to.y - from.y, to.x - from.x);
}
/** @param {number} angle @returns {Vec2} Einheitsvektor für den Winkel. */
export function fromAngle(angle) {
return { x: Math.cos(angle), y: Math.sin(angle) };
}
/**
* Prüft, ob ein Punkt in einem Kreis liegt (inkl. Radius eines Objekts).
* @param {Vec2} point
* @param {Vec2} center
* @param {number} radius
* @param {number} [objectRadius=0] Ausdehnung des Objekts, damit große Gegner am Rand zählen.
* @returns {boolean}
*/
export function inCircle(point, center, radius, objectRadius = 0) {
return distance(point, center) <= radius + objectRadius;
}
/**
* Richtungsvektor aus gedrückten Tasten; diagonal wird normalisiert, damit man
* schräg nicht schneller läuft (klassischer 8-Wege-Fehler).
* @param {{up: boolean, down: boolean, left: boolean, right: boolean}} keys
* @returns {Vec2}
*/
export function directionFromKeys(keys) {
const x = (keys.right ? 1 : 0) - (keys.left ? 1 : 0);
const y = (keys.down ? 1 : 0) - (keys.up ? 1 : 0);
return normalize({ x, y });
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Deterministischer Zufallszahlengenerator.
*
* Warum nicht Math.random: Mit einem Seed lässt sich eine Welle reproduzieren –
* das macht die Wellengenerierung testbar und Balancing-Fehler nachstellbar.
* @module logic/rng
*/
/** @typedef {() => number} Rng Liefert Zahlen im Intervall [0, 1). */
/**
* Mulberry32: klein, schnell und für Spielzwecke gut genug verteilt.
* @param {number} seed Beliebige Ganzzahl.
* @returns {Rng}
*/
export function createRng(seed) {
let state = seed >>> 0;
return function next() {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Ganzzahl im geschlossenen Intervall [min, max].
* @param {Rng} rng
* @param {number} min
* @param {number} max
* @returns {number}
*/
export function randInt(rng, min, max) {
return min + Math.floor(rng() * (max - min + 1));
}
/**
* Gleitkommazahl im Intervall [min, max).
* @param {Rng} rng
* @param {number} min
* @param {number} max
* @returns {number}
*/
export function randFloat(rng, min, max) {
return min + rng() * (max - min);
}
/**
* Zufälliges Element einer nicht-leeren Liste.
* @template T
* @param {Rng} rng
* @param {readonly T[]} items
* @returns {T}
*/
export function pick(rng, items) {
if (items.length === 0) {
throw new Error('pick() braucht mindestens ein Element');
}
return items[Math.floor(rng() * items.length)];
}
/**
* Seed aus der aktuellen Zeit, für echte Spielrunden.
* @returns {number}
*/
export function timeSeed() {
return (Date.now() ^ Math.floor(Math.random() * 0xffffffff)) >>> 0;
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Punkte, Serien und Multiplikator (ohne Phaser).
*
* Regeln: Jeder Abschuss innerhalb von `streakWindowMs` nach dem letzten setzt die
* Serie fort. Alle `stepKills` Abschüsse steigt der Multiplikator um `stepValue`
* bis `maxMultiplier`. Ein Treffer am Präsidenten beendet die Serie – das belohnt
* sauberes Spielen statt blindem Draufhalten.
* @module logic/scoring
*/
import { SCORING } from '../config.js';
/**
* Multiplikator für eine Serienlänge.
* @param {number} streak Anzahl Abschüsse in Folge (inkl. des aktuellen).
* @param {typeof SCORING} [cfg]
* @returns {number}
*/
export function multiplierForStreak(streak, cfg = SCORING) {
if (streak <= 0) return 1;
return Math.min(cfg.maxMultiplier, 1 + Math.floor(streak / cfg.stepKills) * cfg.stepValue);
}
/**
* Bonus für eine überstandene Welle (ohne Multiplikator).
* @param {number} waveNumber
* @param {typeof SCORING} [cfg]
* @returns {number}
*/
export function waveBonus(waveNumber, cfg = SCORING) {
return waveNumber * cfg.waveBonusPerWave;
}
/**
* Führt Punktestand, Serie und Statistik einer Spielrunde.
*/
export class ScoreKeeper {
/** @param {typeof SCORING} [cfg] */
constructor(cfg = SCORING) {
this.cfg = cfg;
this.score = 0;
this.kills = 0;
this.streak = 0;
this.bestStreak = 0;
this.lastKillAt = -Infinity;
}
/**
* Läuft die Serie zum Zeitpunkt `now` noch?
* @param {number} now Spielzeit in ms.
* @returns {boolean}
*/
isStreakActive(now) {
return this.streak > 0 && now - this.lastKillAt <= this.cfg.streakWindowMs;
}
/**
* Aktueller Multiplikator (1, wenn die Serie abgelaufen ist).
* @param {number} now
* @returns {number}
*/
multiplierAt(now) {
return this.isStreakActive(now) ? multiplierForStreak(this.streak, this.cfg) : 1;
}
/**
* Verbleibender Anteil des Serienfensters (1 = gerade getroffen, 0 = abgelaufen).
* @param {number} now
* @returns {number}
*/
streakTimeLeft(now) {
if (!this.isStreakActive(now)) return 0;
return 1 - (now - this.lastKillAt) / this.cfg.streakWindowMs;
}
/**
* Verbucht einen Abschuss.
* @param {number} basePoints Punkte des Gegnertyps.
* @param {number} now Spielzeit in ms.
* @returns {{awarded: number, multiplier: number, streak: number}}
*/
registerKill(basePoints, now) {
if (!this.isStreakActive(now)) {
this.streak = 0;
}
this.streak += 1;
this.kills += 1;
this.bestStreak = Math.max(this.bestStreak, this.streak);
this.lastKillAt = now;
const multiplier = multiplierForStreak(this.streak, this.cfg);
const awarded = Math.round(basePoints * multiplier);
this.score += awarded;
return { awarded, multiplier, streak: this.streak };
}
/** Beendet die laufende Serie (z. B. wenn der Präsident getroffen wird). */
breakStreak() {
this.streak = 0;
}
/**
* Fügt Bonuspunkte ohne Multiplikator hinzu.
* @param {number} points
* @returns {number} Tatsächlich gutgeschriebene Punkte.
*/
addBonus(points) {
const awarded = Math.max(0, Math.round(points));
this.score += awarded;
return awarded;
}
/**
* Momentaufnahme für HUD und Game-Over-Bildschirm.
* @param {number} now
*/
snapshot(now) {
const active = this.isStreakActive(now);
return {
score: this.score,
kills: this.kills,
streak: active ? this.streak : 0,
multiplier: this.multiplierAt(now),
streakTimeLeft: this.streakTimeLeft(now),
bestStreak: this.bestStreak,
};
}
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Steuerungsverhalten („Steering Behaviors“) für die Gegner-KI, ohne Phaser.
*
* Jede Funktion liefert eine Wunschgeschwindigkeit. Die Entities mischen mehrere
* davon (z. B. Verfolgen + Abstand zu Nachbarn) und begrenzen das Ergebnis auf ihr
* Tempo. Die drei Grundmuster aus dem Design:
* - Verfolgen → seek
* - Abstand → keepDistance (mit seitlichem Ausweichen im Wohlfühlbereich)
* - Flankieren → flankPoint / encirclePoint als Ziel für seek
* @module logic/steering
*/
import { add, angleTo, clampLength, distance, fromAngle, length, normalize, scale, sub } from './math.js';
/** @typedef {import('./math.js').Vec2} Vec2 */
/** Anteil der Grundgeschwindigkeit beim seitlichen Ausweichen. */
export const DEFAULT_STRAFE_FACTOR = 0.6;
/**
* Direkt auf ein Ziel zu.
* @param {Vec2} position
* @param {Vec2} target
* @param {number} speed
* @returns {Vec2}
*/
export function seek(position, target, speed) {
return scale(normalize(sub(target, position)), speed);
}
/**
* Direkt von einer Bedrohung weg.
* @param {Vec2} position
* @param {Vec2} threat
* @param {number} speed
* @returns {Vec2}
*/
export function flee(position, threat, speed) {
return scale(seek(position, threat, speed), -1);
}
/**
* Abstoßung von Nachbarn, damit Gruppen nicht zu einem Klumpen verschmelzen.
* Nähere Nachbarn stoßen stärker ab (linear bis 0 am Rand des Radius).
* @param {Vec2} position
* @param {Iterable<Vec2>} neighbors Andere Gegner (der eigene Eintrag darf enthalten sein).
* @param {number} radius
* @returns {Vec2} Unskalierter Richtungsvektor (Länge ≈ Anzahl/Stärke der Überlappungen).
*/
export function separation(position, neighbors, radius) {
let push = { x: 0, y: 0 };
for (const other of neighbors) {
if (other === position) continue;
const d = distance(position, other);
if (d >= radius) continue;
// Exakt übereinander: feste Richtung statt NaN, damit sich die beiden trennen.
const away = d > 0 ? normalize(sub(position, other)) : { x: 1, y: 0 };
push = add(push, scale(away, 1 - d / radius));
}
return push;
}
/**
* Hält einen Abstand zwischen `band.min` und `band.max` zum Ziel ein.
* Zu weit weg → annähern, zu nah → zurückweichen, dazwischen → seitlich ausweichen,
* damit Fernkämpfer kein stehendes Ziel sind.
* @param {Vec2} position
* @param {Vec2} target
* @param {{min: number, max: number}} band
* @param {number} speed
* @param {1|-1} strafeDirection 1 = im Uhrzeigersinn, -1 = dagegen.
* @param {number} [strafeFactor]
* @returns {Vec2}
*/
export function keepDistance(position, target, band, speed, strafeDirection, strafeFactor = DEFAULT_STRAFE_FACTOR) {
const d = distance(position, target);
const toTarget = normalize(sub(target, position));
if (d > band.max) return scale(toTarget, speed);
if (d < band.min) return scale(toTarget, -speed);
const tangent = { x: -toTarget.y * strafeDirection, y: toTarget.x * strafeDirection };
return scale(tangent, speed * strafeFactor);
}
/**
* Flankenposition: Punkt auf einem Kreis um das Ziel, gegenüber der aktuellen
* Blickachse Ziel→Gegner um `angleOffset` gedreht. Der Gegner läuft also nicht
* frontal an, sondern schwenkt seitlich ein.
* @param {Vec2} target
* @param {Vec2} position
* @param {number} angleOffset Bogenmaß; Vorzeichen bestimmt die Seite.
* @param {number} radius
* @returns {Vec2}
*/
export function flankPoint(target, position, angleOffset, radius) {
const baseAngle = angleTo(target, position);
return add(target, scale(fromAngle(baseAngle + angleOffset), radius));
}
/**
* Umzingelungspunkt: fester Winkel („Platz im Kreis“) um das Ziel. Verteilt man
* die Winkel auf einen Schwarm, greift er von allen Seiten an.
* @param {Vec2} target
* @param {number} slotAngle Bogenmaß.
* @param {number} radius
* @returns {Vec2}
*/
export function encirclePoint(target, slotAngle, radius) {
return add(target, scale(fromAngle(slotAngle), radius));
}
/**
* Gewichtete Summe mehrerer Steuerungsvektoren, begrenzt auf `maxSpeed`.
* @param {{vector: Vec2, weight: number}[]} parts
* @param {number} maxSpeed
* @returns {Vec2}
*/
export function combine(parts, maxSpeed) {
const sum = parts.reduce((acc, p) => add(acc, scale(p.vector, p.weight)), { x: 0, y: 0 });
return clampLength(sum, maxSpeed);
}
/**
* Nähert die aktuelle Geschwindigkeit der Wunschgeschwindigkeit an. Ohne diese
* Glättung würden Gegner bei jedem Richtungswechsel ruckartig umspringen.
* @param {Vec2} current
* @param {Vec2} desired
* @param {number} responsiveness 0..1 (1 = sofort).
* @returns {Vec2}
*/
export function steerTowards(current, desired, responsiveness) {
return {
x: current.x + (desired.x - current.x) * responsiveness,
y: current.y + (desired.y - current.y) * responsiveness,
};
}
/**
* Ist ein Vektor (fast) null? Nützlich für Animationen („steht still“).
* @param {Vec2} v
* @param {number} [epsilon]
* @returns {boolean}
*/
export function isNearlyZero(v, epsilon = 1e-3) {
return length(v) < epsilon;
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Ablaufsteuerung der Wellen (ohne Phaser): Pause → Welle → Spawns → Welle geschafft.
*
* Der Director kennt keine Sprites. Er ruft `spawnGroup` auf, wenn eine Gruppe
* erscheinen soll, und fragt `aliveCount` ab, um zu erkennen, wann eine Welle
* geschafft ist. So lässt sich der gesamte Ablauf ohne Browser testen.
* @module logic/waveDirector
*/
import { WAVES } from '../config.js';
import { generateWave } from './waves.js';
/** @typedef {import('./waves.js').WavePlan} WavePlan */
/** @typedef {import('./waves.js').SpawnGroup} SpawnGroup */
/**
* @typedef {Object} WaveDirectorOptions
* @property {import('./rng.js').Rng} rng
* @property {(group: SpawnGroup, plan: WavePlan) => void} spawnGroup
* @property {() => number} aliveCount
* @property {(plan: WavePlan) => void} [onWaveStart]
* @property {(waveNumber: number) => void} [onWaveCleared]
* @property {typeof WAVES} [cfg]
*/
export class WaveDirector {
/** @param {WaveDirectorOptions} options */
constructor(options) {
this.rng = options.rng;
this.spawnGroup = options.spawnGroup;
this.aliveCount = options.aliveCount;
this.onWaveStart = options.onWaveStart ?? (() => {});
this.onWaveCleared = options.onWaveCleared ?? (() => {});
this.cfg = options.cfg ?? WAVES;
/** @type {'idle'|'intermission'|'active'} */
this.state = 'idle';
this.waveNumber = 0;
/** @type {WavePlan|null} */
this.plan = null;
this.nextWaveAt = 0;
this.nextGroupIndex = 0;
/**
* Uhr der laufenden Welle. Sie steht still, solange zu viele Gegner leben –
* so stauen sich Spawns nicht auf, wenn der Spieler in Bedrängnis ist.
*/
this.waveClock = 0;
}
/**
* Startet den Ablauf; die erste Welle folgt nach `firstWaveDelayMs`.
* @param {number} now Spielzeit in ms.
*/
start(now) {
this.state = 'intermission';
this.waveNumber = 0;
this.nextWaveAt = now + this.cfg.firstWaveDelayMs;
}
/**
* Muss jeden Frame aufgerufen werden.
* @param {number} now Spielzeit in ms.
* @param {number} deltaMs Seit dem letzten Aufruf vergangene Spielzeit.
*/
update(now, deltaMs) {
if (this.state === 'intermission' && now >= this.nextWaveAt) {
this.beginWave();
return;
}
if (this.state !== 'active' || !this.plan) return;
if (this.aliveCount() < this.cfg.maxAliveEnemies) {
this.waveClock += deltaMs;
}
const { groups } = this.plan;
while (this.nextGroupIndex < groups.length && groups[this.nextGroupIndex].atMs <= this.waveClock) {
this.spawnGroup(groups[this.nextGroupIndex], this.plan);
this.nextGroupIndex += 1;
}
if (this.nextGroupIndex >= groups.length && this.aliveCount() === 0) {
const cleared = this.waveNumber;
this.state = 'intermission';
this.nextWaveAt = now + this.cfg.intermissionMs;
this.onWaveCleared(cleared);
}
}
/** @private */
beginWave() {
this.waveNumber += 1;
this.plan = generateWave(this.waveNumber, this.rng, this.cfg);
this.nextGroupIndex = 0;
this.waveClock = 0;
this.state = 'active';
this.onWaveStart(this.plan);
}
/** @returns {number} Gegner, die in dieser Welle noch erscheinen werden. */
get pendingEnemies() {
if (this.state !== 'active' || !this.plan) return 0;
return this.plan.groups.slice(this.nextGroupIndex).reduce((sum, g) => sum + g.count, 0);
}
/**
* @param {number} now
* @returns {number} Restzeit der Pause in ms (0 während einer Welle).
*/
intermissionRemaining(now) {
return this.state === 'intermission' ? Math.max(0, this.nextWaveAt - now) : 0;
}
}
+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}`);
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Waffenzustand: Magazin, Reserve, Feuerrate, Nachladen (ohne Phaser).
* @module logic/weapon
*/
import { degToRad } from './math.js';
/**
* @typedef {Object} WeaponDef
* @property {string} id
* @property {string} name
* @property {number} magazineSize
* @property {number|null} reserveStart null = unbegrenzt
* @property {number|null} reserveMax null = unbegrenzt
* @property {number} fireIntervalMs
* @property {number} reloadMs
* @property {number} damage
* @property {number} pellets
* @property {number} spreadDeg
* @property {number} bulletSpeed
* @property {number} rangePx
* @property {boolean} automatic
*/
export class WeaponState {
/** @param {WeaponDef} def */
constructor(def) {
this.def = def;
this.magazine = def.magazineSize;
/** @type {number} Bei unbegrenzter Reserve Infinity. */
this.reserve = def.reserveStart === null ? Infinity : def.reserveStart;
/** Spielzeit, zu der das Nachladen fertig ist; 0 = lädt nicht nach. */
this.reloadingUntil = 0;
this.reloadStartedAt = 0;
this.lastShotAt = -Infinity;
}
/** @returns {boolean} */
get hasInfiniteReserve() {
return this.def.reserveMax === null;
}
/** @returns {boolean} Weder Magazin noch Reserve – Waffe ist leer. */
get isEmpty() {
return this.magazine === 0 && this.reserve === 0;
}
/** @returns {boolean} */
get isReloading() {
return this.reloadingUntil > 0;
}
/**
* Schließt ein fälliges Nachladen ab. Muss jeden Frame aufgerufen werden.
* @param {number} now
* @returns {boolean} true, wenn in diesem Aufruf fertig nachgeladen wurde.
*/
update(now) {
if (this.isReloading && now >= this.reloadingUntil) {
const needed = this.def.magazineSize - this.magazine;
const taken = Math.min(needed, this.reserve);
this.magazine += taken;
this.reserve -= taken;
this.reloadingUntil = 0;
return true;
}
return false;
}
/**
* @param {number} now
* @returns {boolean}
*/
canFire(now) {
return !this.isReloading && this.magazine > 0 && now - this.lastShotAt >= this.def.fireIntervalMs;
}
/**
* Feuert einen Schuss, falls möglich.
* @param {number} now
* @param {() => number} rng Zufall für die Streuung.
* @returns {number[]|null} Winkelabweichungen der Projektile (Bogenmaß) oder null.
*/
fire(now, rng) {
if (!this.canFire(now)) return null;
this.magazine -= 1;
this.lastShotAt = now;
const offsets = pelletOffsets(this.def.pellets, this.def.spreadDeg, rng);
// Leeres Magazin lädt automatisch nach – niemand will im Gefecht R suchen.
if (this.magazine === 0) this.startReload(now);
return offsets;
}
/**
* Beginnt das Nachladen.
* @param {number} now
* @returns {boolean} false, wenn es nichts nachzuladen gibt.
*/
startReload(now) {
if (this.isReloading || this.magazine >= this.def.magazineSize || this.reserve <= 0) {
return false;
}
this.reloadStartedAt = now;
this.reloadingUntil = now + this.def.reloadMs;
return true;
}
/** Bricht das Nachladen ab (z. B. beim Waffenwechsel); nichts wird verbraucht. */
cancelReload() {
this.reloadingUntil = 0;
}
/**
* @param {number} now
* @returns {number} 0..1, 1 wenn nicht nachgeladen wird.
*/
reloadProgress(now) {
if (!this.isReloading) return 1;
return Math.min(1, (now - this.reloadStartedAt) / this.def.reloadMs);
}
/** Füllt die Reserve bis zum Maximum auf (Nachschubkapsel). */
refill() {
if (!this.hasInfiniteReserve) {
this.reserve = /** @type {number} */ (this.def.reserveMax);
}
}
}
/**
* Streuung der Projektile eines Schusses. Mehrere Projektile (Schrot) werden
* gleichmäßig über den Fächer verteilt und leicht verwackelt, damit sich das Muster
* nicht wiederholt; ein Einzelprojektil streut zufällig innerhalb des Fächers.
* @param {number} pellets
* @param {number} spreadDeg
* @param {() => number} rng
* @returns {number[]}
*/
export function pelletOffsets(pellets, spreadDeg, rng) {
const spread = degToRad(spreadDeg);
if (pellets <= 1) {
return [(rng() - 0.5) * spread];
}
const step = spread / (pellets - 1);
return Array.from({ length: pellets }, (_, i) => -spread / 2 + i * step + (rng() - 0.5) * step * 0.5);
}