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
+129
View File
@@ -0,0 +1,129 @@
/**
* Zugriff auf die JSON-API des Flask-Backends (ohne Phaser).
*
* Fehler werden nie geworfen, sondern als Ergebnisobjekt zurückgegeben: Ein
* ausgefallener Highscore-Server darf das Spiel nicht unterbrechen.
* @module systems/ApiClient
*/
import { HIGHSCORE } from '../config.js';
/**
* @typedef {Object} HighscoreEntry
* @property {number} rank
* @property {string} name
* @property {number} score
* @property {number} wave
* @property {string} created_at
*/
/**
* @typedef {Object} SubmitResult
* @property {boolean} ok
* @property {number} [rank]
* @property {HighscoreEntry[]} [highscores]
* @property {string} [error] Fehlercode der API oder „network“.
* @property {string} [message] Lesbare Meldung.
* @property {Record<string, string>} [fields] Feldfehler bei Validierung.
*/
export class ApiClient {
/**
* @param {{highscores: string, runs: string}} endpoints
* @param {typeof fetch} [fetchImpl] Für Tests austauschbar.
* @param {number} [timeoutMs]
*/
constructor(endpoints, fetchImpl = globalThis.fetch?.bind(globalThis), timeoutMs = HIGHSCORE.requestTimeoutMs) {
this.endpoints = endpoints;
this.fetchImpl = fetchImpl;
this.timeoutMs = timeoutMs;
}
/**
* @param {string} url
* @param {RequestInit} [init]
* @returns {Promise<{status: number, body: any}>}
*/
async request(url, init = {}) {
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timer = controller ? setTimeout(() => controller.abort(), this.timeoutMs) : null;
try {
const response = await this.fetchImpl(url, {
...init,
signal: controller?.signal,
credentials: 'same-origin',
headers: { Accept: 'application/json', ...(init.headers ?? {}) },
});
let body = null;
try {
body = await response.json();
} catch {
body = null;
}
return { status: response.status, body };
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* Meldet eine neue Spielrunde an.
* @returns {Promise<string|null>} Run-Token oder null, wenn der Server nicht erreichbar ist.
*/
async startRun() {
try {
const { status, body } = await this.request(this.endpoints.runs, { method: 'POST' });
return status === 201 && typeof body?.run_token === 'string' ? body.run_token : null;
} catch {
return null;
}
}
/**
* @returns {Promise<{ok: boolean, highscores: HighscoreEntry[]}>}
*/
async fetchHighscores() {
try {
const { status, body } = await this.request(this.endpoints.highscores);
if (status === 200 && Array.isArray(body?.highscores)) {
return { ok: true, highscores: body.highscores };
}
} catch {
// Netzwerkfehler oder Timeout – unten als Fehlschlag gemeldet.
}
return { ok: false, highscores: [] };
}
/**
* Reicht einen Highscore ein.
* @param {{name: string, score: number, wave: number, durationS: number, runToken: string}} entry
* @returns {Promise<SubmitResult>}
*/
async submitHighscore(entry) {
const payload = {
name: entry.name,
score: Math.max(0, Math.floor(entry.score)),
wave: Math.max(1, Math.floor(entry.wave)),
duration_s: Math.max(0, Math.floor(entry.durationS)),
run_token: entry.runToken,
};
try {
const { status, body } = await this.request(this.endpoints.highscores, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (status === 201) {
return { ok: true, rank: body?.rank, highscores: body?.highscores ?? [] };
}
return {
ok: false,
error: body?.error ?? `http_${status}`,
message: body?.message ?? 'Unbekannter Fehler.',
fields: body?.fields,
};
} catch {
return { ok: false, error: 'network', message: 'Keine Verbindung zum Server.' };
}
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Aufbau der Arena: Boden, Dekoration und Begrenzung.
*
* Mit geladenen Kenney-Kacheln entsteht ein Erdboden mit Felsen, fremdartiger Flora
* und Flecken; ohne sie ein einfaches Schachbrett-Platzhaltermuster. Dekoration hat
* keine Physik – sie soll nur den Boden lebendig machen, nicht den Weg versperren.
* @module systems/Arena
*/
import { COLORS, GAME } from '../config.js';
import { pick, randFloat, randInt } from '../logic/rng.js';
import { GROUND_FRAMES, GROUND_SHEET } from './Assets.js';
import { TEXTURE_KEYS } from './Placeholders.js';
/** Abdunkelung des Bodens, damit Figuren und Projektile sich klar abheben. */
const GROUND_TINT = 0x6f6458;
/** Anzahl Deko-Objekte je Sorte (für eine 2400×1600-Arena abgestimmt). */
const DECOR = Object.freeze({ dirtPatches: 180, rocks: 45, bushes: 28, splats: 14 });
/** Flora und Flecken in „außerirdischen“ Farben statt Erdgrün. */
const ALIEN_TINTS = Object.freeze([0x6d5a8f, 0x3f7f78, 0x7d5f86]);
const SPLAT_TINT = 0x2f4a1a;
/** Freiraum um die Startposition, damit dort nichts die Sicht stört. */
const CLEAR_RADIUS = 180;
/**
* @param {Phaser.Scene} scene
* @param {() => number} rng
*/
export function buildArena(scene, rng) {
const { width, height } = GAME.world;
const depth = GAME.depth.ground;
if (scene.textures.exists(GROUND_SHEET.key)) {
const key = GROUND_SHEET.key;
const tile = GROUND_SHEET.frame;
scene.add.tileSprite(0, 0, width, height, key, GROUND_FRAMES.dirtA).setOrigin(0).setDepth(depth).setTint(GROUND_TINT);
// Zweite Erdsorte im Kachelraster verstreut: bricht das sichtbare Wiederholungsmuster.
for (let i = 0; i < DECOR.dirtPatches; i += 1) {
const x = randInt(rng, 0, Math.floor(width / tile) - 1) * tile;
const y = randInt(rng, 0, Math.floor(height / tile) - 1) * tile;
scene.add.image(x, y, key, GROUND_FRAMES.dirtB).setOrigin(0).setDepth(depth).setTint(GROUND_TINT);
}
const place = (frame, count, decorate) => {
for (let i = 0; i < count; i += 1) {
const point = randomPoint(rng, width, height);
const image = scene.add.image(point.x, point.y, key, frame(rng)).setDepth(depth + 1);
image.setAngle(randFloat(rng, 0, 360));
decorate(image);
}
};
place((r) => pick(r, GROUND_FRAMES.rocks), DECOR.rocks, (img) => img.setScale(randFloat(rng, 0.45, 0.95)).setTint(0x8d9196));
place((r) => pick(r, GROUND_FRAMES.bushes), DECOR.bushes, (img) => img.setScale(randFloat(rng, 0.5, 1.1)).setTint(pick(rng, ALIEN_TINTS)));
place(() => GROUND_FRAMES.splat, DECOR.splats, (img) => img.setScale(randFloat(rng, 0.8, 1.6)).setTint(SPLAT_TINT).setAlpha(0.7));
} else {
scene.add.tileSprite(0, 0, width, height, TEXTURE_KEYS.ground).setOrigin(0).setDepth(depth);
}
// Emblem der Vereinigten Erde als Bodenmarkierung der Landezone.
scene.add
.image(width / 2, height / 2, TEXTURE_KEYS.emblem)
.setDisplaySize(300, 300)
.setAlpha(0.14)
.setDepth(depth + 1);
const border = scene.add.graphics().setDepth(depth + 2);
border.lineStyle(10, COLORS.crimson, 0.9);
border.strokeRect(5, 5, width - 10, height - 10);
border.lineStyle(3, COLORS.gold, 0.9);
border.strokeRect(14, 14, width - 28, height - 28);
}
/** Zufallspunkt in der Arena außerhalb der freien Startzone. */
function randomPoint(rng, width, height) {
for (;;) {
const x = randFloat(rng, 30, width - 30);
const y = randFloat(rng, 30, height - 30);
if (Math.hypot(x - width / 2, y - height / 2) > CLEAR_RADIUS) return { x, y };
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Liste aller Grafikdateien und ihr Laden mit Rückfallebene.
*
* Jede Datei ist optional: Schlägt das Laden fehl (Datei fehlt, Netzwerkfehler),
* erzeugt `Placeholders.js` unter demselben Schlüssel eine Ersatzgrafik. Quellen und
* Lizenzen aller Dateien stehen in docs/ASSETS.md.
* @module systems/Assets
*/
/** Eigene Vektorgrafiken (SVG, CC0). Größe = Rastergröße im Spiel in Pixeln. */
export const SPRITES = Object.freeze([
{ key: 'player', file: 'sprites/president.svg', size: 64 },
{ key: 'enemy-krabbler', file: 'sprites/krabbler.svg', size: 36 },
{ key: 'enemy-sprungkralle', file: 'sprites/sprungkralle.svg', size: 48 },
{ key: 'enemy-brutmutter', file: 'sprites/brutmutter.svg', size: 84 },
{ key: 'enemy-nieter', file: 'sprites/nieter.svg', size: 36 },
{ key: 'enemy-blechsoldat', file: 'sprites/blechsoldat.svg', size: 48 },
{ key: 'enemy-walzmeister', file: 'sprites/walzmeister.svg', size: 88 },
{ key: 'turret', file: 'sprites/turret.svg', size: 52 },
{ key: 'supply-pod', file: 'sprites/supply-pod.svg', size: 48 },
{ key: 'emblem', file: 'sprites/emblem.svg', size: 200 },
]);
/** Kenney Particle Pack (CC0), auf 128 px verkleinert. */
export const PARTICLES = Object.freeze([
{ key: 'fx-smoke', file: 'kenney/particles/smoke.png' },
{ key: 'fx-fire', file: 'kenney/particles/fire.png' },
{ key: 'fx-spark', file: 'kenney/particles/spark.png' },
{ key: 'fx-scorch', file: 'kenney/particles/scorch.png' },
{ key: 'fx-muzzle', file: 'kenney/particles/muzzle.png' },
{ key: 'fx-glow', file: 'kenney/particles/glow.png' },
]);
/**
* Kenney Top-down Shooter (CC0): Ausschnitt aus dem Tilesheet als Streifen mit
* 64-px-Kacheln. Die Reihenfolge der Kacheln steht in GROUND_FRAMES.
*/
export const GROUND_SHEET = Object.freeze({ key: 'ground-tiles', file: 'kenney/tiles/ground_strip.png', frame: 64 });
/** Kachelindizes im Bodenstreifen. */
export const GROUND_FRAMES = Object.freeze({
dirtA: 0,
dirtB: 1,
rocks: Object.freeze([2, 3, 4]),
bushes: Object.freeze([5, 6]),
splat: 7,
});
/**
* Stellt alle Dateien in die Lade-Warteschlange.
* @param {Phaser.Scene} scene
* @param {string} staticBase URL des static-Ordners (mit abschließendem /).
*/
export function queueAssets(scene, staticBase) {
scene.load.setPath(`${staticBase}assets/`);
for (const sprite of SPRITES) {
scene.load.svg(sprite.key, sprite.file, { width: sprite.size, height: sprite.size });
}
for (const particle of PARTICLES) {
scene.load.image(particle.key, particle.file);
}
scene.load.spritesheet(GROUND_SHEET.key, GROUND_SHEET.file, {
frameWidth: GROUND_SHEET.frame,
frameHeight: GROUND_SHEET.frame,
});
scene.load.setPath('');
}
/**
* @param {Phaser.Scene} scene
* @param {string} key
* @returns {boolean} true, wenn die echte Grafik (nicht der Platzhalter) geladen ist.
*/
export function hasTexture(scene, key) {
return scene.textures.exists(key);
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Klangdateien (Kenney, CC0) und Abspieloptionen je Effekt.
*
* Mehrere Dateien pro Effekt werden zufällig abgewechselt, damit Dauerfeuer nicht
* monoton klingt. Fehlt eine Datei oder kann der Browser OGG nicht dekodieren,
* springt der Synthesizer in `Synth.js` ein.
* @module systems/AudioAssets
*/
/** Pfad relativ zu `static/assets/`. */
export const AUDIO_PATH = 'kenney/audio/';
/** @type {Readonly<Record<string, readonly string[]>>} */
export const SOUND_FILES = Object.freeze({
'shot-carbine': ['laserSmall_000.ogg', 'laserSmall_001.ogg', 'laserSmall_002.ogg'],
'shot-shotgun': ['laserLarge_000.ogg', 'laserLarge_001.ogg'],
'shot-pistol': ['laserRetro_000.ogg', 'laserRetro_001.ogg'],
'turret-shot': ['laserRetro_002.ogg'],
'enemy-shot': ['laserSmall_003.ogg', 'laserSmall_004.ogg'],
'hit-flesh': ['impactSoft_medium_000.ogg', 'impactSoft_medium_001.ogg', 'impactSoft_medium_002.ogg'],
'hit-metal': ['impactMetal_light_000.ogg', 'impactMetal_light_001.ogg', 'impactMetal_light_002.ogg'],
'death-swarm': ['slime_000.ogg', 'slime_001.ogg'],
'death-legion': ['explosionCrunch_000.ogg', 'explosionCrunch_001.ogg'],
'explosion-small': ['explosionCrunch_002.ogg', 'explosionCrunch_003.ogg'],
'explosion-big': ['lowFrequency_explosion_000.ogg'],
'player-hit': ['impactPunch_heavy_000.ogg', 'impactPunch_heavy_001.ogg'],
'player-death': ['lowFrequency_explosion_001.ogg'],
reload: ['impactPlate_light_000.ogg'],
'reload-done': ['click_001.ogg'],
switch: ['select_001.ogg'],
'combo-step': ['tick_001.ogg'],
'combo-fail': ['error_001.ogg'],
'orbital-call': ['confirmation_001.ogg'],
pickup: ['maximize_001.ogg'],
brood: ['slime_001.ogg'],
'ui-click': ['click_001.ogg'],
'ui-confirm': ['confirmation_002.ogg'],
'wave-start': ['jingles_HIT15.ogg'],
'wave-cleared': ['jingles_STEEL07.ogg'],
alarm: ['jingles_HIT11.ogg'],
'game-over': ['jingles_PIZZI07.ogg'],
// Nur synthetisch (kein passendes kurzes Sample): Ausweichrolle, Mörserpfeifen.
dodge: [],
mortar: [],
});
/**
* Lautstärke (0..1), Tonhöhe (1 = original) und Tonhöhenstreuung je Effekt.
* Jingles laufen über den Musikkanal, damit „Musik leiser“ auch sie betrifft.
* @type {Readonly<Record<string, {volume?: number, rate?: number, spread?: number, channel?: 'sfx'|'music'}>>}
*/
export const SOUND_OPTIONS = Object.freeze({
'shot-carbine': { volume: 0.35, spread: 0.08 },
'shot-shotgun': { volume: 0.5, rate: 0.8, spread: 0.06 },
'shot-pistol': { volume: 0.4, spread: 0.06 },
'turret-shot': { volume: 0.22, rate: 1.4, spread: 0.1 },
'enemy-shot': { volume: 0.22, rate: 0.7, spread: 0.1 },
'hit-flesh': { volume: 0.35, spread: 0.15 },
'hit-metal': { volume: 0.3, spread: 0.15 },
'death-swarm': { volume: 0.5, spread: 0.15 },
'death-legion': { volume: 0.4, rate: 1.2, spread: 0.1 },
'explosion-small': { volume: 0.6, spread: 0.1 },
'explosion-big': { volume: 1 },
'player-hit': { volume: 0.6, spread: 0.1 },
'player-death': { volume: 1, rate: 0.8 },
reload: { volume: 0.45 },
'reload-done': { volume: 0.4 },
switch: { volume: 0.4 },
'combo-step': { volume: 0.5 },
'combo-fail': { volume: 0.5 },
'orbital-call': { volume: 0.6 },
pickup: { volume: 0.6 },
brood: { volume: 0.5, rate: 0.7 },
'ui-click': { volume: 0.4 },
'ui-confirm': { volume: 0.5 },
'wave-start': { volume: 0.8, channel: 'music' },
'wave-cleared': { volume: 0.8, channel: 'music' },
alarm: { volume: 0.8, channel: 'music' },
'game-over': { volume: 0.9, channel: 'music' },
dodge: { volume: 0.35 },
mortar: { volume: 0.3 },
});
/** @returns {string[]} Alle benötigten Dateien ohne Duplikate. */
export function allSoundFiles() {
return [...new Set(Object.values(SOUND_FILES).flat())];
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Audio: Lautstärkekanäle (Gesamt, Musik, Effekte), Klangdateien, synthetischer
* Ersatz und Hintergrundmusik – alles über die Web Audio API.
*
* Browser erlauben Ton erst nach einer Nutzerinteraktion. Die Dateien werden deshalb
* vorab nur als Bytes geladen und erst in `unlock()` (erster Klick/Tastendruck)
* dekodiert. Ohne Web Audio bleibt das Spiel stumm, aber spielbar.
* @module systems/AudioManager
*/
import { AUDIO } from '../config.js';
import { allSoundFiles, AUDIO_PATH, SOUND_FILES, SOUND_OPTIONS } from './AudioAssets.js';
import { MarchMusic } from './Music.js';
import { createNoiseBuffer, SYNTH_RECIPES } from './Synth.js';
/** Obergrenze gleichzeitig klingender Effekte – schützt vor Übersteuern und CPU-Spitzen. */
const MAX_VOICES = 24;
/** Lautstärke der Musik während der Pause (Anteil). */
const PAUSE_DUCK = 0.35;
export class AudioManager {
/**
* @param {import('./Settings.js').Settings} settings
* @param {{staticBase?: string, fetchImpl?: typeof fetch}} [options]
*/
constructor(settings, options = {}) {
this.settings = settings;
this.baseUrl = `${options.staticBase ?? '/static/'}assets/${AUDIO_PATH}`;
this.fetchImpl = options.fetchImpl ?? globalThis.fetch?.bind(globalThis);
/** @type {AudioContext|null} */
this.context = null;
/** @type {{master: GainNode, music: GainNode, sfx: GainNode, duck: GainNode}|null} */
this.gains = null;
/** @type {Map<string, Promise<ArrayBuffer|null>>} Rohdaten je Datei (vor dem Entsperren). */
this.raw = new Map();
/** @type {Map<string, AudioBuffer>} Dekodierte Klänge je Datei. */
this.buffers = new Map();
/** @type {Map<string, number>} Letzte Abspielzeit je Effekt (gegen Übersteuern). */
this.lastPlayed = new Map();
this.activeVoices = 0;
/** @type {MarchMusic|null} */
this.music = null;
this.musicWanted = false;
this.noiseBuffer = null;
settings.onVolumeChange(() => this.applyVolumes());
}
/** Lädt die Klangdateien als Bytes vor (ohne AudioContext, also vor jeder Interaktion möglich). */
preload() {
if (!this.fetchImpl) return;
for (const file of allSoundFiles()) {
if (this.raw.has(file)) continue;
const request = this.fetchImpl(this.baseUrl + file)
.then((response) => (response.ok ? response.arrayBuffer() : null))
.catch(() => null);
this.raw.set(file, request);
}
}
/** Erzeugt den AudioContext nach der ersten Nutzerinteraktion und dekodiert die Klänge. */
unlock() {
if (this.context) {
if (this.context.state === 'suspended') this.context.resume().catch(() => {});
return;
}
const Ctor = globalThis.AudioContext ?? globalThis.webkitAudioContext;
if (!Ctor) return;
try {
this.context = new Ctor();
} catch {
this.context = null;
return;
}
const ctx = this.context;
const master = ctx.createGain();
const duck = ctx.createGain();
const music = ctx.createGain();
const sfx = ctx.createGain();
music.connect(duck).connect(master);
sfx.connect(master);
master.connect(ctx.destination);
this.gains = { master, music, sfx, duck };
this.noiseBuffer = createNoiseBuffer(ctx);
this.applyVolumes();
this.decodeAll();
if (this.musicWanted) this.startMusic();
}
/** @private */
decodeAll() {
for (const [file, request] of this.raw) {
request.then((bytes) => {
if (!bytes || !this.context) return;
// decodeAudioData übernimmt den Puffer – eine Kopie hält die Rohdaten wiederverwendbar.
this.context
.decodeAudioData(bytes.slice(0))
.then((buffer) => this.buffers.set(file, buffer))
.catch(() => {
// Format nicht unterstützt (z. B. OGG in alten Safari-Versionen): Synthesizer übernimmt.
});
});
}
}
/** Überträgt die Einstellungen auf die Gain-Knoten. */
applyVolumes() {
if (!this.gains || !this.context) return;
const t = this.context.currentTime;
this.gains.master.gain.setTargetAtTime(this.settings.getVolume('master'), t, 0.02);
this.gains.music.gain.setTargetAtTime(this.settings.getVolume('music'), t, 0.02);
this.gains.sfx.gain.setTargetAtTime(this.settings.getVolume('sfx'), t, 0.02);
}
/**
* Prüft Mindestabstand und Stimmenlimit.
* @param {string} name
* @returns {boolean}
*/
shouldPlay(name) {
if (!this.context || !this.gains || this.context.state !== 'running') return false;
if (this.activeVoices >= MAX_VOICES) return false;
const now = performance.now();
const last = this.lastPlayed.get(name) ?? -Infinity;
if (now - last < AUDIO.sfxMinIntervalMs) return false;
this.lastPlayed.set(name, now);
return true;
}
/**
* Spielt einen Effekt: Klangdatei, falls dekodiert, sonst synthetischer Ersatz.
* @param {string} name Schlüssel aus SOUND_FILES.
*/
play(name) {
if (!this.shouldPlay(name)) return;
const ctx = /** @type {AudioContext} */ (this.context);
const gains = /** @type {NonNullable<AudioManager['gains']>} */ (this.gains);
const options = SOUND_OPTIONS[name] ?? {};
const destination = options.channel === 'music' ? gains.music : gains.sfx;
const volume = options.volume ?? 1;
const variants = (SOUND_FILES[name] ?? []).map((file) => this.buffers.get(file)).filter(Boolean);
this.activeVoices += 1;
const release = () => {
this.activeVoices = Math.max(0, this.activeVoices - 1);
};
if (variants.length > 0) {
const source = ctx.createBufferSource();
source.buffer = variants[Math.floor(Math.random() * variants.length)];
// Leichte Tonhöhenstreuung: derselbe Effekt klingt nie exakt gleich.
const spread = options.spread ?? 0;
source.playbackRate.value = (options.rate ?? 1) * (1 + (Math.random() * 2 - 1) * spread);
const gain = ctx.createGain();
gain.gain.value = volume;
source.connect(gain).connect(destination);
source.onended = release;
source.start();
return;
}
const recipe = SYNTH_RECIPES[name];
if (recipe && this.noiseBuffer) recipe(ctx, destination, this.noiseBuffer, volume);
// Synthetische Klänge sind kurz; die Stimme wird nach einer Sekunde freigegeben.
setTimeout(release, 1000);
}
/** Startet die Marschmusik (oder merkt sie vor, bis Audio entsperrt ist). */
startMusic() {
this.musicWanted = true;
if (!this.context || !this.gains || !this.noiseBuffer || this.music) return;
this.music = new MarchMusic(this.context, this.gains.music, this.noiseBuffer);
this.music.start();
}
stopMusic() {
this.musicWanted = false;
this.music?.stop();
this.music = null;
}
/**
* Senkt die Musik während der Pause ab.
* @param {boolean} ducked
*/
duck(ducked) {
if (!this.gains || !this.context) return;
this.gains.duck.gain.setTargetAtTime(ducked ? PAUSE_DUCK : 1, this.context.currentTime, 0.1);
}
}
+237
View File
@@ -0,0 +1,237 @@
/**
* Visuelle Rückmeldung: Explosionen, Funken, Rauch, Brandspuren, Mündungsfeuer,
* schwebende Texte und Bildschirmwackeln.
*
* Rückmeldung ist kein Luxus: Ohne Treffer-Aufblitzen und Funken fühlt sich Schießen
* „hohl“ an und man sieht nicht, ob man überhaupt trifft. Sind die Kenney-Partikel
* nicht geladen, fällt alles auf einfache Kreise zurück.
* @module systems/Effects
*/
import { COLORS, FX, GAME } from '../config.js';
import { UI } from '../ui/theme.js';
import { TEXTURE_KEYS } from './Placeholders.js';
/** Kenney-Partikel sind 128 px groß; auf diese Größen wird skaliert. */
const PARTICLE_SOURCE_SIZE = 128;
/** Höchstzahl gleichzeitig sichtbarer Brandspuren (älteste verschwinden zuerst). */
const MAX_SCORCH_MARKS = 40;
const SCORCH_LIFETIME_MS = 20000;
const MUZZLE_FLASH_MS = 70;
export class Effects {
/** @param {Phaser.Scene} scene */
constructor(scene) {
this.scene = scene;
const has = (key) => scene.textures.exists(key);
this.hasKenney = has('fx-spark') && has('fx-smoke') && has('fx-fire');
/** @type {Phaser.GameObjects.Image[]} */
this.scorchMarks = [];
// Gemeinsame Emitter: deutlich günstiger als ein Emitter pro Treffer.
const sparkKey = has('fx-spark') ? 'fx-spark' : TEXTURE_KEYS.particle;
const sparkScale = has('fx-spark') ? 14 / PARTICLE_SOURCE_SIZE : 0.9;
this.sparks = scene.add.particles(0, 0, sparkKey, {
lifespan: 380,
speed: { min: 80, max: 280 },
scale: { start: sparkScale, end: 0 },
alpha: { start: 1, end: 0 },
rotate: { min: 0, max: 360 },
blendMode: 'ADD',
emitting: false,
});
this.sparks.setDepth(GAME.depth.effects);
if (this.hasKenney) {
this.fire = scene.add.particles(0, 0, 'fx-fire', {
lifespan: 450,
speed: { min: 20, max: 140 },
scale: { start: 0.35, end: 0 },
alpha: { start: 0.9, end: 0 },
rotate: { min: 0, max: 360 },
blendMode: 'ADD',
emitting: false,
});
this.smoke = scene.add.particles(0, 0, 'fx-smoke', {
lifespan: 1100,
speed: { min: 10, max: 60 },
scale: { start: 0.3, end: 0.9 },
alpha: { start: 0.45, end: 0 },
rotate: { min: 0, max: 360 },
tint: 0x555555,
emitting: false,
});
this.fire.setDepth(GAME.depth.effects);
this.smoke.setDepth(GAME.depth.effects - 1);
}
}
/**
* Funkenregen an einer Stelle.
* @param {number} x
* @param {number} y
* @param {number} color
* @param {number} [count]
*/
burst(x, y, color, count = 8) {
this.sparks.setParticleTint(color);
this.sparks.explode(count, x, y);
}
/**
* Explosion mit Feuer, Rauch, Lichtblitz und Brandspur. Der Ring zeigt den
* tatsächlichen Wirkungsradius, damit Spieler lernen, wie weit Schaden reicht.
* @param {number} x
* @param {number} y
* @param {number} radius
* @param {number} color
*/
explosion(x, y, radius, color) {
const ring = this.scene.add.circle(x, y, radius, color, 0.22).setDepth(GAME.depth.effects);
ring.setStrokeStyle(4, color, 0.9);
ring.setScale(0.15);
this.scene.tweens.add({
targets: ring,
scale: 1,
alpha: 0,
duration: 380,
ease: 'Cubic.easeOut',
onComplete: () => ring.destroy(),
});
if (this.hasKenney) {
const amount = Math.min(28, Math.round(radius / 6));
this.fire.explode(amount, x, y);
this.smoke.explode(Math.ceil(amount / 2), x, y);
this.glow(x, y, radius * 1.6, color);
this.scorch(x, y, radius);
}
this.burst(x, y, color, Math.min(40, Math.round(radius / 5)));
this.burst(x, y, COLORS.cream, 8);
this.shake('explosion');
}
/** Kurzer, additiver Lichtblitz. */
glow(x, y, size, color) {
if (!this.scene.textures.exists('fx-glow')) return;
const flash = this.scene.add
.image(x, y, 'fx-glow')
.setDisplaySize(size, size)
.setTint(color)
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(GAME.depth.effects);
this.scene.tweens.add({ targets: flash, alpha: 0, duration: 260, onComplete: () => flash.destroy() });
}
/** Brandspur auf dem Boden, die langsam verblasst. */
scorch(x, y, radius) {
if (!this.scene.textures.exists('fx-scorch')) return;
const mark = this.scene.add
.image(x, y, 'fx-scorch')
.setDisplaySize(radius * 2.2, radius * 2.2)
.setAngle(Math.random() * 360)
.setTint(0x1a1410)
.setAlpha(0.55)
.setDepth(GAME.depth.ground + 2);
this.scorchMarks.push(mark);
if (this.scorchMarks.length > MAX_SCORCH_MARKS) this.scorchMarks.shift()?.destroy();
this.scene.tweens.add({
targets: mark,
alpha: 0,
delay: SCORCH_LIFETIME_MS,
duration: 2000,
onComplete: () => {
this.scorchMarks = this.scorchMarks.filter((m) => m !== mark);
mark.destroy();
},
});
}
/**
* Aufsteigender, verblassender Text (Punkte, Meldungen am Ort).
* @param {number} x
* @param {number} y
* @param {string} text
* @param {string} [color]
* @param {number} [fontSize]
*/
floatingText(x, y, text, color = '#f2c14e', fontSize = 18) {
const label = this.scene.add
.text(x, y, text, {
fontFamily: UI.uiFont,
fontStyle: 'bold',
fontSize: `${fontSize}px`,
color,
stroke: '#0d1321',
strokeThickness: 4,
})
.setOrigin(0.5)
.setDepth(GAME.depth.effects);
this.scene.tweens.add({
targets: label,
y: y - 36,
alpha: 0,
duration: FX.damageNumberMs,
ease: 'Quad.easeOut',
onComplete: () => label.destroy(),
});
}
/**
* Kamera wackeln lassen.
* @param {'hit'|'explosion'} kind
*/
shake(kind) {
const cfg = FX.screenShake[kind];
this.scene.cameras.main.shake(cfg.durationMs, cfg.intensity);
}
/**
* Lässt ein Sprite kurz weiß aufblitzen (Treffer-Feedback).
* @param {Phaser.GameObjects.Sprite} sprite
*/
flash(sprite) {
sprite.setTintFill(0xffffff);
this.scene.time.delayedCall(FX.hitFlashMs, () => {
if (sprite.active) sprite.clearTint();
});
}
/**
* Nachbild einer Figur (Ausweichrolle): verdeutlicht Richtung und Tempo der Bewegung.
* @param {Phaser.GameObjects.Sprite} sprite
*/
afterimage(sprite) {
const ghost = this.scene.add
.image(sprite.x, sprite.y, sprite.texture.key)
.setRotation(sprite.rotation)
.setAlpha(0.45)
.setTint(COLORS.gold)
.setDepth(sprite.depth - 1);
this.scene.tweens.add({ targets: ghost, alpha: 0, scale: 1.15, duration: 260, onComplete: () => ghost.destroy() });
}
/**
* Mündungsfeuer an der Waffenspitze.
* @param {number} x
* @param {number} y
* @param {number} angle Schussrichtung (Bogenmaß).
* @param {number} [color]
*/
muzzle(x, y, angle, color = COLORS.playerBullet) {
if (this.scene.textures.exists('fx-muzzle')) {
// Die Kenney-Grafik zeigt nach oben – um 90° drehen, damit sie in Schussrichtung zeigt.
const flash = this.scene.add
.image(x, y, 'fx-muzzle')
.setOrigin(0.5, 0.9)
.setRotation(angle + Math.PI / 2)
.setScale(0.26)
.setTint(color)
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(GAME.depth.effects);
this.scene.tweens.add({ targets: flash, alpha: 0, duration: MUZZLE_FLASH_MS, onComplete: () => flash.destroy() });
} else {
this.burst(x, y, color, 3);
}
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Bodenmarkierung für verzögerte Einschläge (Orbital-Befehle, Mörser).
*
* Die Markierung zeigt den tatsächlichen Wirkungsradius und füllt sich bis zum
* Einschlag – so kann der Spieler Gefahr erkennen und ausweichen.
* @module systems/GroundMarker
*/
import { GAME } from '../config.js';
import { UI } from '../ui/theme.js';
export class GroundMarker {
/**
* @param {Phaser.Scene} scene
* @param {number} x
* @param {number} y
* @param {number} radius
* @param {number} color
* @param {number} startAt Spielzeit bei Erstellung.
* @param {number} impactAt Spielzeit des Einschlags.
* @param {{label?: string, beam?: boolean}} [options]
*/
constructor(scene, x, y, radius, color, startAt, impactAt, options = {}) {
this.scene = scene;
this.x = x;
this.y = y;
this.radius = radius;
this.color = color;
this.startAt = startAt;
this.impactAt = impactAt;
this.beam = options.beam ?? false;
this.graphics = scene.add.graphics().setDepth(GAME.depth.markers);
this.label = options.label
? scene.add
.text(x, y, '', {
fontFamily: UI.uiFont,
fontStyle: 'bold',
fontSize: '16px',
color: '#f4ecd8',
stroke: '#0d1321',
strokeThickness: 4,
})
.setOrigin(0.5)
.setDepth(GAME.depth.markers + 1)
: null;
this.labelText = options.label ?? '';
}
/**
* Zeichnet den aktuellen Zustand.
* @param {number} now
*/
update(now) {
const total = Math.max(1, this.impactAt - this.startAt);
const progress = Math.min(1, Math.max(0, (now - this.startAt) / total));
// Puls wird zum Einschlag hin schneller – intuitive Warnung ohne Text.
const pulse = 0.5 + 0.5 * Math.sin(now / (120 - 80 * progress));
const g = this.graphics;
g.clear();
g.fillStyle(this.color, 0.12 + 0.1 * pulse);
g.fillCircle(this.x, this.y, this.radius);
g.fillStyle(this.color, 0.35);
g.fillCircle(this.x, this.y, this.radius * progress);
g.lineStyle(3, this.color, 0.6 + 0.4 * pulse);
g.strokeCircle(this.x, this.y, this.radius);
if (this.beam) {
g.lineStyle(2 + 6 * progress, this.color, 0.25 + 0.5 * progress);
g.lineBetween(this.x, this.y - 900, this.x, this.y);
}
if (this.label) {
const seconds = Math.max(0, (this.impactAt - now) / 1000).toFixed(1).replace('.', ',');
this.label.setText(`${this.labelText} ${seconds}`);
}
}
/** @param {number} now @returns {boolean} */
isDue(now) {
return now >= this.impactAt;
}
destroy() {
this.graphics.destroy();
this.label?.destroy();
}
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Spielt den Marsch aus `logic/marchPattern.js` mit synthetischen Instrumenten.
*
* Zeitplanung nach dem Muster „Vorausplanen mit zwei Uhren“: Ein grober JavaScript-
* Timer plant alle 25 ms die Noten der nächsten ~120 ms exakt auf der Audio-Uhr ein.
* Direkt im Timer zu spielen würde hörbar wackeln, weil JavaScript-Timer ungenau sind.
* @module systems/Music
*/
import { eventsAtStep, midiToFrequency, STEP_SECONDS, TOTAL_STEPS } from '../logic/marchPattern.js';
const SCHEDULER_INTERVAL_MS = 25;
const LOOKAHEAD_SECONDS = 0.12;
/** Grundlautstärke der Musik vor dem Musik-Regler. */
const MUSIC_LEVEL = 0.55;
/** Liegt die Planung weiter zurück (Tab im Hintergrund), wird neu synchronisiert statt nachgeholt. */
const MAX_LAG_SECONDS = 0.25;
export class MarchMusic {
/**
* @param {AudioContext} ctx
* @param {AudioNode} destination Musik-Bus.
* @param {AudioBuffer} noiseBuffer
*/
constructor(ctx, destination, noiseBuffer) {
this.ctx = ctx;
this.noiseBuffer = noiseBuffer;
this.out = ctx.createGain();
this.out.gain.value = MUSIC_LEVEL;
this.out.connect(destination);
this.timer = null;
this.step = 0;
this.nextTime = 0;
}
start() {
if (this.timer !== null) return;
this.step = 0;
this.nextTime = this.ctx.currentTime + 0.1;
this.timer = setInterval(() => this.schedule(), SCHEDULER_INTERVAL_MS);
}
/** Blendet aus und gibt die Knoten frei. */
stop() {
if (this.timer !== null) clearInterval(this.timer);
this.timer = null;
const now = this.ctx.currentTime;
this.out.gain.setTargetAtTime(0, now, 0.08);
const out = this.out;
setTimeout(() => out.disconnect(), 600);
}
/** @private */
schedule() {
const now = this.ctx.currentTime;
if (this.nextTime < now - MAX_LAG_SECONDS) this.nextTime = now + 0.05;
while (this.nextTime < now + LOOKAHEAD_SECONDS) {
for (const event of eventsAtStep(this.step)) this.play(event, this.nextTime);
this.nextTime += STEP_SECONDS;
this.step = (this.step + 1) % TOTAL_STEPS;
}
}
/**
* @param {import('../logic/marchPattern.js').MusicEvent} event
* @param {number} time Audio-Zeit.
* @private
*/
play(event, time) {
const length = event.lengthSteps * STEP_SECONDS;
switch (event.instrument) {
case 'kick':
this.voice('sine', 150, time, 0.22, 0.9 * event.velocity, 45);
break;
case 'snare':
this.snare(time, 0.35 * event.velocity);
break;
case 'bass':
this.voice('triangle', midiToFrequency(event.notes[0]), time, length * 0.9, 0.35 * event.velocity);
break;
case 'stab':
for (const note of event.notes) {
this.voice('sawtooth', midiToFrequency(note), time, length * 0.7, 0.05 * event.velocity, undefined, 1400);
}
break;
case 'lead':
this.voice('square', midiToFrequency(event.notes[0]), time, length * 0.92, 0.06 * event.velocity, undefined, 2600);
break;
default:
break;
}
}
/** @private */
voice(type, freq, time, duration, volume, freqEnd, lowpass) {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, time);
if (freqEnd) osc.frequency.exponentialRampToValueAtTime(freqEnd, time + duration);
gain.gain.setValueAtTime(0.0001, time);
gain.gain.exponentialRampToValueAtTime(volume, time + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, time + duration);
let node = osc.connect(gain);
if (lowpass) {
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = lowpass;
node = gain.connect(filter);
}
node.connect(this.out);
osc.start(time);
osc.stop(time + duration + 0.05);
}
/** @private */
snare(time, volume) {
const source = this.ctx.createBufferSource();
source.buffer = this.noiseBuffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'highpass';
filter.frequency.value = 1500;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(volume, time);
gain.gain.exponentialRampToValueAtTime(0.0001, time + 0.12);
source.connect(filter).connect(gain).connect(this.out);
source.start(time, Math.random() * 0.5);
source.stop(time + 0.15);
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Orbital-Befehle: Eingabe (Halte-Taste + Richtungsfolge), Abklingzeiten,
* Bodenmarkierung und verzögerter Einschlag.
*
* Die Eingabelogik steckt in `logic/comboInput.js`, die Abklingzeiten in
* `logic/cooldowns.js`; hier wird beides mit Phaser-Tastatur und Grafik verbunden.
* @module systems/OrbitalSystem
*/
import { COLORS, GAME, ORBITAL } from '../config.js';
import { codeToDirection, ComboInput } from '../logic/comboInput.js';
import { CooldownTracker } from '../logic/cooldowns.js';
import { clamp, distance } from '../logic/math.js';
import { GroundMarker } from './GroundMarker.js';
import { oncePerEvent } from './keyboard.js';
/** Farbe der Markierung je Befehl. */
const MARKER_COLORS = Object.freeze({
strike: COLORS.strike,
supply: COLORS.supply,
turret: COLORS.turret,
});
/**
* @typedef {typeof ORBITAL.commands[number]} OrbitalCommand
*/
/**
* @typedef {Object} OrbitalCallbacks
* @property {() => {x: number, y: number}} getPlayer
* @property {() => {x: number, y: number}} getAimPoint Mausposition in Weltkoordinaten.
* @property {(command: OrbitalCommand, x: number, y: number) => void} onImpact
* @property {(kind: 'step'|'fail'|'deploy'|'notReady', command?: OrbitalCommand, remainingMs?: number) => void} onFeedback
*/
export class OrbitalSystem {
/**
* @param {Phaser.Scene} scene
* @param {OrbitalCallbacks} callbacks
*/
constructor(scene, callbacks) {
this.scene = scene;
this.callbacks = callbacks;
this.commands = ORBITAL.commands;
this.combo = new ComboInput(this.commands);
this.cooldowns = new CooldownTracker();
/** @type {{command: OrbitalCommand, marker: GroundMarker}[]} */
this.pending = [];
this.now = 0;
this.holdKey = scene.input.keyboard.addKey(ORBITAL.holdKey);
// Pfeiltasten abfangen, sonst scrollt der Browser die Seite.
scene.input.keyboard.addCapture('UP,DOWN,LEFT,RIGHT');
this.onKeyDown = oncePerEvent((event) => this.handleKeyDown(event));
scene.input.keyboard.on('keydown', this.onKeyDown);
}
/** @returns {boolean} true, solange ein Befehl eingegeben wird (Bewegung pausiert). */
get isInputActive() {
return this.combo.active;
}
/**
* @param {number} now Spielzeit in ms.
*/
update(now) {
this.now = now;
if (this.holdKey.isDown && !this.combo.active) this.combo.begin();
else if (!this.holdKey.isDown && this.combo.active) this.combo.end();
for (let i = this.pending.length - 1; i >= 0; i -= 1) {
const entry = this.pending[i];
if (entry.marker.isDue(now)) {
entry.marker.destroy();
this.pending.splice(i, 1);
this.callbacks.onImpact(entry.command, entry.marker.x, entry.marker.y);
} else {
entry.marker.update(now);
}
}
}
/**
* @param {KeyboardEvent} event
* @private
*/
handleKeyDown(event) {
// Gedrückt gehaltene Tasten erzeugen Wiederholungen – die zählen nicht als Eingabe.
if (event.repeat) return;
// Q und erste Richtung können im selben Frame ankommen, bevor update() die
// Eingabe gestartet hat – dann hier starten, sonst ginge die erste Richtung verloren.
if (!this.combo.active && this.holdKey.isDown) this.combo.begin();
if (!this.combo.active) return;
const direction = codeToDirection(event.code);
if (!direction) return;
const result = this.combo.push(direction);
if (result.status === 'progress') this.callbacks.onFeedback('step');
else if (result.status === 'fail') this.callbacks.onFeedback('fail');
else if (result.status === 'match') this.request(/** @type {string} */ (result.commandId));
}
/**
* Fordert einen Befehl an (sofern bereit) und setzt die Markierung ans Ziel.
* @param {string} commandId
* @returns {boolean}
*/
request(commandId) {
const command = this.commands.find((c) => c.id === commandId);
if (!command) return false;
const now = this.now;
if (!this.cooldowns.isReady(command.id, now)) {
this.callbacks.onFeedback('notReady', command, this.cooldowns.remaining(command.id, now));
return false;
}
const target = this.clampTarget(this.callbacks.getAimPoint());
this.cooldowns.trigger(command.id, command.cooldownMs, now);
const marker = new GroundMarker(
this.scene,
target.x,
target.y,
command.radius,
MARKER_COLORS[command.id] ?? COLORS.gold,
now,
now + command.delayMs,
{ label: command.shortName, beam: true },
);
marker.update(now);
this.pending.push({ command, marker });
this.callbacks.onFeedback('deploy', command);
return true;
}
/**
* Begrenzt das Ziel auf die maximale Reichweite und die Arena.
* @param {{x: number, y: number}} aim
* @returns {{x: number, y: number}}
*/
clampTarget(aim) {
const player = this.callbacks.getPlayer();
const d = distance(player, aim);
let { x, y } = aim;
if (d > ORBITAL.maxTargetRange) {
const f = ORBITAL.maxTargetRange / d;
x = player.x + (aim.x - player.x) * f;
y = player.y + (aim.y - player.y) * f;
}
const margin = GAME.spawnMargin;
return {
x: clamp(x, margin, GAME.world.width - margin),
y: clamp(y, margin, GAME.world.height - margin),
};
}
/**
* Daten für die HUD-Anzeige.
* @param {number} now
*/
hudState(now) {
return {
active: this.combo.active,
commands: this.commands.map((c) => ({
id: c.id,
name: c.shortName,
sequence: c.sequence,
progress: this.combo.progressFor(c.id),
ready: this.cooldowns.isReady(c.id, now),
remainingMs: this.cooldowns.remaining(c.id, now),
cooldownProgress: this.cooldowns.progress(c.id, now),
})),
};
}
/** Aufräumen beim Beenden der Szene. */
destroy() {
this.scene.input.keyboard?.off('keydown', this.onKeyDown);
for (const entry of this.pending) entry.marker.destroy();
this.pending = [];
}
}
+121
View File
@@ -0,0 +1,121 @@
/**
* HTML-Overlays über dem Spiel-Canvas (Disclaimer, Pause, Namenseingabe).
*
* Warum HTML statt Canvas-Text: Formulare, Schieberegler und längere Texte sind so
* für Screenreader und Tastatur bedienbar, und Browser-Features (Autokorrektur aus,
* Textauswahl) funktionieren ohne Nachbau.
*
* Sicherheit: Dynamische Inhalte werden ausschließlich per `textContent` gesetzt,
* niemals per `innerHTML` – Spielernamen aus der Highscore-Liste können also
* kein HTML einschleusen.
* @module systems/Overlay
*/
/**
* Erzeugt ein Element mit Klasse und optionalem Text.
* @param {string} tag
* @param {string} [className]
* @param {string} [text]
* @returns {HTMLElement}
*/
export function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
/**
* @typedef {Object} OverlayButton
* @property {string} label
* @property {() => void} onClick
* @property {boolean} [secondary]
* @property {string} [testId] data-testid für End-to-End-Tests.
*/
/**
* @typedef {Object} OverlayOptions
* @property {string} [title]
* @property {Node[]} [content]
* @property {OverlayButton[]} [buttons]
* @property {string} [id]
* @property {() => void} [onEscape] Wird bei Esc aufgerufen (z. B. Schließen/Fortsetzen).
*/
export class Overlay {
/**
* Öffnet ein Overlay im Element #overlay-root.
* @param {OverlayOptions} options
*/
constructor(options) {
const root = document.getElementById('overlay-root') ?? document.body;
this.previousFocus = document.activeElement;
this.onEscape = options.onEscape;
this.element = el('div', 'overlay');
if (options.id) this.element.id = options.id;
this.element.setAttribute('role', 'dialog');
this.element.setAttribute('aria-modal', 'true');
this.panel = el('div', 'overlay__panel');
this.element.append(this.panel);
if (options.title) {
const heading = el('h2', 'disclaimer__title', options.title);
heading.id = `${options.id ?? 'overlay'}-title`;
this.element.setAttribute('aria-labelledby', heading.id);
this.panel.append(heading);
}
for (const node of options.content ?? []) this.panel.append(node);
this.actions = el('div', 'overlay__actions');
for (const button of options.buttons ?? []) this.addButton(button);
if (this.actions.childElementCount > 0) this.panel.append(this.actions);
this.handleKeyDown = (event) => {
// Tasten im Overlay gehören dem Overlay: Ohne stopPropagation würde z. B. das
// Enter, mit dem man den Disclaimer bestätigt, danach auch das Menü auslösen.
event.stopPropagation();
if (event.key === 'Escape' && this.onEscape) {
event.preventDefault();
this.onEscape();
}
};
this.element.addEventListener('keydown', this.handleKeyDown);
root.append(this.element);
// Fokus ins Overlay, damit Tastaturnutzer sofort bedienen können.
const focusTarget = this.panel.querySelector('input, button, a');
if (focusTarget instanceof HTMLElement) focusTarget.focus();
else {
this.panel.tabIndex = -1;
this.panel.focus();
}
}
/**
* @param {OverlayButton} button
* @returns {HTMLButtonElement}
*/
addButton(button) {
const node = /** @type {HTMLButtonElement} */ (el('button', `button${button.secondary ? ' button--secondary' : ''}`, button.label));
node.type = 'button';
if (button.testId) node.dataset.testid = button.testId;
node.addEventListener('click', () => button.onClick());
this.actions.append(node);
return node;
}
/** Entfernt das Overlay und gibt den Fokus zurück an das Spiel. */
close() {
this.element.removeEventListener('keydown', this.handleKeyDown);
this.element.remove();
const game = document.querySelector('#game canvas');
if (game instanceof HTMLElement) {
game.tabIndex = -1;
game.focus({ preventScroll: true });
} else if (this.previousFocus instanceof HTMLElement) {
this.previousFocus.focus({ preventScroll: true });
}
}
}
+212
View File
@@ -0,0 +1,212 @@
/**
* Erzeugt alle Platzhalter-Texturen zur Laufzeit aus einfachen Formen.
*
* Damit ist das Spiel ohne eine einzige Bilddatei vollständig spielbar. Echte
* Grafiken (Phase „Assets“) werden unter denselben Schlüsseln geladen; fehlt eine
* Datei oder schlägt das Laden fehl, springt hier der Platzhalter ein.
*
* Konvention: Alle Figuren „schauen“ nach rechts (Winkel 0), damit
* `setRotation(zielwinkel)` ohne Versatz funktioniert.
* @module systems/Placeholders
*/
import { COLORS, ENEMY_TYPES, PLAYER } from '../config.js';
/** Alle Texturschlüssel, die das Spiel verwendet. */
export const TEXTURE_KEYS = Object.freeze({
player: 'player',
enemy: (typeId) => `enemy-${typeId}`,
bulletPlayer: 'bullet-player',
bulletEnemy: 'bullet-enemy',
bulletTurret: 'bullet-turret',
turret: 'turret',
supplyPod: 'supply-pod',
ground: 'ground',
particle: 'particle',
crosshair: 'crosshair',
emblem: 'emblem',
});
/**
* Zeichnet mit einer temporären Graphics-Instanz und speichert das Ergebnis als Textur.
* Bereits vorhandene Texturen (z. B. geladene Grafiken) werden nicht überschrieben.
* @param {Phaser.Scene} scene
* @param {string} key
* @param {number} width
* @param {number} height
* @param {(g: Phaser.GameObjects.Graphics) => void} draw
*/
function makeTexture(scene, key, width, height, draw) {
if (scene.textures.exists(key)) return;
const g = scene.make.graphics({ x: 0, y: 0 }, false);
draw(g);
g.generateTexture(key, width, height);
g.destroy();
}
function drawPlayer(g) {
const s = PLAYER.radius * 2 + 12;
const c = s / 2;
// Umhang (hinten, also links) in den Farben der Vereinigten Erde.
g.fillStyle(COLORS.playerCape, 1);
g.fillTriangle(c - 4, c - 16, c - 4, c + 16, 2, c);
g.fillStyle(COLORS.gold, 1);
g.fillRect(c - 8, c - 2, 6, 4);
// Rüstung
g.fillStyle(COLORS.player, 1);
g.fillCircle(c, c, PLAYER.radius - 2);
g.lineStyle(3, COLORS.gold, 1);
g.strokeCircle(c, c, PLAYER.radius - 2);
// Geschlossener Helm mit Visier – bewusst ohne Gesicht.
g.fillStyle(COLORS.navy, 1);
g.fillRoundedRect(c + 2, c - 7, 9, 14, 3);
// Waffe
g.fillStyle(COLORS.navy, 1);
g.fillRect(c + 6, c + 4, s / 2 - 6, 5);
}
function drawSwarm(g, size, variant) {
const c = size / 2;
const r = size / 2 - 3;
g.lineStyle(2, COLORS.swarmDark, 1);
// Beine
for (let i = -1; i <= 1; i += 1) {
g.lineBetween(c + i * r * 0.4, c - r * 0.5, c + i * r * 0.55, 1);
g.lineBetween(c + i * r * 0.4, c + r * 0.5, c + i * r * 0.55, size - 1);
}
g.fillStyle(COLORS.swarm, 1);
g.fillEllipse(c - r * 0.15, c, r * 1.6, r * 1.2);
g.fillStyle(COLORS.swarmDark, 1);
g.fillCircle(c + r * 0.6, c, r * 0.35);
if (variant === 'medium') {
// Sprungkralle: kräftige Klauen vorne
g.lineStyle(3, COLORS.swarmDark, 1);
g.lineBetween(c + r * 0.6, c - r * 0.3, size - 1, c - r * 0.6);
g.lineBetween(c + r * 0.6, c + r * 0.3, size - 1, c + r * 0.6);
}
if (variant === 'elite') {
// Brutmutter: Eiersack
g.fillStyle(0xd9c27a, 1);
for (let i = 0; i < 5; i += 1) g.fillCircle(c - r * 0.6 + (i % 2) * 6, c - 10 + i * 5, 5);
}
}
function drawLegion(g, size, variant) {
const c = size / 2;
const r = size / 2 - 3;
g.fillStyle(COLORS.legionDark, 1);
if (variant === 'elite') {
// Walzmeister: Ketten links und rechts
g.fillRect(4, 2, size - 8, 8);
g.fillRect(4, size - 10, size - 8, 8);
}
g.fillStyle(COLORS.legion, 1);
if (variant === 'small') {
g.fillCircle(c, c, r);
} else {
g.fillRoundedRect(c - r, c - r * 0.8, r * 1.7, r * 1.6, 5);
}
g.lineStyle(2, COLORS.legionDark, 1);
g.strokeCircle(c, c, r * 0.55);
// Rotes „Auge“
g.fillStyle(COLORS.enemyBullet, 1);
g.fillCircle(c + r * 0.35, c, Math.max(2, r * 0.18));
// Waffenlauf(e)
g.fillStyle(COLORS.legionDark, 1);
if (variant === 'elite') {
g.fillRect(c + r * 0.5, c - 10, r * 0.55, 6);
g.fillRect(c + r * 0.5, c + 4, r * 0.55, 6);
} else {
g.fillRect(c + r * 0.5, c - 2, r * 0.55, 4);
}
}
/**
* Erzeugt alle Platzhalter, die noch nicht als echte Grafik geladen wurden.
* @param {Phaser.Scene} scene
*/
export function createPlaceholderTextures(scene) {
const playerSize = PLAYER.radius * 2 + 12;
makeTexture(scene, TEXTURE_KEYS.player, playerSize, playerSize, drawPlayer);
for (const type of Object.values(ENEMY_TYPES)) {
const size = type.radius * 2 + 6;
makeTexture(scene, TEXTURE_KEYS.enemy(type.id), size, size, (g) =>
type.faction === 'swarm' ? drawSwarm(g, size, type.tier) : drawLegion(g, size, type.tier),
);
}
makeTexture(scene, TEXTURE_KEYS.bulletPlayer, 14, 6, (g) => {
g.fillStyle(COLORS.playerBullet, 1);
g.fillRoundedRect(0, 0, 14, 6, 3);
});
makeTexture(scene, TEXTURE_KEYS.bulletTurret, 14, 6, (g) => {
g.fillStyle(COLORS.turretBullet, 1);
g.fillRoundedRect(0, 0, 14, 6, 3);
});
makeTexture(scene, TEXTURE_KEYS.bulletEnemy, 12, 12, (g) => {
g.fillStyle(COLORS.enemyBullet, 1);
g.fillCircle(6, 6, 6);
g.fillStyle(0xffd0c0, 1);
g.fillCircle(6, 6, 2.5);
});
makeTexture(scene, TEXTURE_KEYS.turret, 48, 48, (g) => {
g.fillStyle(COLORS.navy, 1);
g.fillCircle(24, 24, 20);
g.lineStyle(3, COLORS.turret, 1);
g.strokeCircle(24, 24, 20);
g.fillStyle(COLORS.turret, 1);
g.fillRect(24, 20, 24, 8);
});
makeTexture(scene, TEXTURE_KEYS.supplyPod, 44, 44, (g) => {
// Kapsel mit Geschenkband – bewusst kein Kreuz (Rotkreuz-Zeichen ist geschützt).
g.fillStyle(COLORS.supply, 1);
g.fillRoundedRect(4, 4, 36, 36, 8);
g.fillStyle(COLORS.cream, 1);
g.fillRoundedRect(9, 9, 26, 26, 5);
g.fillStyle(COLORS.gold, 1);
g.fillRect(20, 9, 4, 26);
g.fillRect(9, 20, 26, 4);
g.fillCircle(22, 22, 4);
});
makeTexture(scene, TEXTURE_KEYS.ground, 128, 128, (g) => {
g.fillStyle(COLORS.ground, 1);
g.fillRect(0, 0, 128, 128);
g.fillStyle(COLORS.groundAlt, 1);
g.fillRect(0, 0, 64, 64);
g.fillRect(64, 64, 64, 64);
g.lineStyle(1, 0x000000, 0.18);
g.strokeRect(0, 0, 128, 128);
});
makeTexture(scene, TEXTURE_KEYS.particle, 8, 8, (g) => {
g.fillStyle(0xffffff, 1);
g.fillCircle(4, 4, 4);
});
makeTexture(scene, TEXTURE_KEYS.crosshair, 32, 32, (g) => {
g.lineStyle(2, COLORS.gold, 1);
g.strokeCircle(16, 16, 9);
g.lineBetween(16, 0, 16, 9);
g.lineBetween(16, 23, 16, 32);
g.lineBetween(0, 16, 9, 16);
g.lineBetween(23, 16, 32, 16);
});
makeTexture(scene, TEXTURE_KEYS.emblem, 160, 160, (g) => {
// Emblem der Vereinigten Erde: Planet mit Umlaufbahn (eigener Entwurf).
g.fillStyle(COLORS.navy, 1);
g.fillCircle(80, 80, 76);
g.lineStyle(6, COLORS.gold, 1);
g.strokeCircle(80, 80, 76);
g.fillStyle(COLORS.crimson, 1);
g.fillCircle(80, 80, 34);
g.lineStyle(6, COLORS.gold, 1);
g.strokeEllipse(80, 80, 132, 44);
g.fillStyle(COLORS.cream, 1);
g.fillCircle(136, 62, 8);
});
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Dauerhafte Einstellungen im Browser (localStorage) – ohne Phaser.
*
* localStorage kann fehlen oder Ausnahmen werfen (privater Modus, blockierte
* Website-Daten, volle Quote). Das Spiel muss dann trotzdem laufen: Werte bleiben
* in diesem Fall nur für die aktuelle Sitzung im Speicher.
* @module systems/Settings
*/
import { AUDIO, HIGHSCORE, STORAGE_KEYS } from '../config.js';
import { clamp } from '../logic/math.js';
/** @typedef {'master'|'music'|'sfx'} VolumeChannel */
/** @type {readonly VolumeChannel[]} */
export const VOLUME_CHANNELS = Object.freeze(['master', 'music', 'sfx']);
/**
* Schlüssel-Wert-Speicher mit JSON und Fallback auf den Arbeitsspeicher.
*/
export class SafeStorage {
/** @param {Storage|null|undefined} backend */
constructor(backend) {
this.backend = backend ?? null;
/** @type {Map<string, string>} */
this.memory = new Map();
}
/**
* Erzeugt einen Speicher für den Browser; der Zugriff auf `localStorage` selbst
* kann bereits eine Ausnahme werfen (z. B. bei gesperrten Cookies).
* @returns {SafeStorage}
*/
static forBrowser() {
try {
return new SafeStorage(globalThis.localStorage);
} catch {
return new SafeStorage(null);
}
}
/**
* @template T
* @param {string} key
* @param {T} fallback
* @returns {T}
*/
get(key, fallback) {
let raw = this.memory.get(key) ?? null;
try {
raw = this.backend?.getItem(key) ?? raw;
} catch {
// Browser verweigert den Zugriff – Speicherwert verwenden.
}
if (raw === null) return fallback;
try {
return JSON.parse(raw);
} catch {
return fallback;
}
}
/**
* @param {string} key
* @param {unknown} value
* @returns {boolean} true, wenn dauerhaft gespeichert wurde.
*/
set(key, value) {
const raw = JSON.stringify(value);
this.memory.set(key, raw);
try {
this.backend?.setItem(key, raw);
return this.backend !== null;
} catch {
return false;
}
}
}
/**
* Spielereinstellungen: Disclaimer-Bestätigung, Lautstärken, letzter Name.
*/
export class Settings {
/** @param {SafeStorage} storage */
constructor(storage) {
this.storage = storage;
/** @type {Set<(channel: VolumeChannel, value: number) => void>} */
this.volumeListeners = new Set();
const stored = storage.get(STORAGE_KEYS.volumes, {});
/** @type {Record<VolumeChannel, number>} */
this.volumes = { ...AUDIO.defaultVolumes };
for (const channel of VOLUME_CHANNELS) {
const value = stored?.[channel];
if (typeof value === 'number' && Number.isFinite(value)) {
this.volumes[channel] = clamp(value, 0, 1);
}
}
}
/** @returns {boolean} */
isDisclaimerAccepted() {
return this.storage.get(STORAGE_KEYS.disclaimerAccepted, false) === true;
}
acceptDisclaimer() {
this.storage.set(STORAGE_KEYS.disclaimerAccepted, true);
}
/**
* @param {VolumeChannel} channel
* @returns {number} 0..1
*/
getVolume(channel) {
return this.volumes[channel];
}
/**
* Effektive Lautstärke eines Kanals (inkl. Gesamtlautstärke).
* @param {'music'|'sfx'} channel
* @returns {number}
*/
effectiveVolume(channel) {
return this.volumes.master * this.volumes[channel];
}
/**
* @param {VolumeChannel} channel
* @param {number} value 0..1, wird begrenzt.
*/
setVolume(channel, value) {
if (!VOLUME_CHANNELS.includes(channel)) return;
const safe = Number.isFinite(value) ? clamp(value, 0, 1) : this.volumes[channel];
this.volumes[channel] = safe;
this.storage.set(STORAGE_KEYS.volumes, this.volumes);
for (const listener of this.volumeListeners) listener(channel, safe);
}
/**
* @param {(channel: VolumeChannel, value: number) => void} listener
* @returns {() => void} Funktion zum Abmelden.
*/
onVolumeChange(listener) {
this.volumeListeners.add(listener);
return () => this.volumeListeners.delete(listener);
}
/** @returns {string} */
getLastName() {
const name = this.storage.get(STORAGE_KEYS.lastName, '');
return typeof name === 'string' ? name.slice(0, HIGHSCORE.nameMaxLength) : '';
}
/** @param {string} name */
setLastName(name) {
this.storage.set(STORAGE_KEYS.lastName, String(name).slice(0, HIGHSCORE.nameMaxLength));
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Synthetische Klänge über die Web Audio API – ohne eine einzige Audiodatei.
*
* Dient als Ersatz, wenn Klangdateien fehlen oder der Browser OGG nicht dekodiert,
* und liefert Effekte, für die es kein passendes Sample gibt (Rolle, Mörserpfeifen).
* @module systems/Synth
*/
/**
* Erzeugt eine Sekunde weißes Rauschen (wird für Rausch-Effekte wiederverwendet).
* @param {BaseAudioContext} ctx
* @returns {AudioBuffer}
*/
export function createNoiseBuffer(ctx) {
const buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i += 1) data[i] = Math.random() * 2 - 1;
return buffer;
}
/**
* Hüllkurve: schneller Anstieg, exponentielles Abklingen. Exponentiell klingt
* natürlicher als linear, darf aber nie genau 0 erreichen (daher 0.0001).
* @param {GainNode} gain
* @param {number} start
* @param {number} peak
* @param {number} attack
* @param {number} duration
*/
function envelope(gain, start, peak, attack, duration) {
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), start + attack);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
}
/**
* Einzelner Ton mit optionalem Tonhöhenverlauf.
* @param {AudioContext} ctx
* @param {AudioNode} destination
* @param {{type?: OscillatorType, freq: number, freqEnd?: number, duration: number, volume: number, attack?: number, delay?: number}} p
*/
export function tone(ctx, destination, p) {
const start = ctx.currentTime + (p.delay ?? 0);
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = p.type ?? 'sine';
osc.frequency.setValueAtTime(p.freq, start);
if (p.freqEnd) osc.frequency.exponentialRampToValueAtTime(p.freqEnd, start + p.duration);
envelope(gain, start, p.volume, p.attack ?? 0.005, p.duration);
osc.connect(gain).connect(destination);
osc.start(start);
osc.stop(start + p.duration + 0.05);
}
/**
* Gefiltertes Rauschen (Knall, Zischen, Wusch).
* @param {AudioContext} ctx
* @param {AudioNode} destination
* @param {AudioBuffer} noiseBuffer
* @param {{filter?: BiquadFilterType, freq: number, freqEnd?: number, q?: number, duration: number, volume: number, attack?: number}} p
*/
export function noise(ctx, destination, noiseBuffer, p) {
const start = ctx.currentTime;
const source = ctx.createBufferSource();
source.buffer = noiseBuffer;
const filter = ctx.createBiquadFilter();
filter.type = p.filter ?? 'lowpass';
filter.Q.value = p.q ?? 1;
filter.frequency.setValueAtTime(p.freq, start);
if (p.freqEnd) filter.frequency.exponentialRampToValueAtTime(p.freqEnd, start + p.duration);
const gain = ctx.createGain();
envelope(gain, start, p.volume, p.attack ?? 0.004, p.duration);
source.connect(filter).connect(gain).connect(destination);
source.start(start, Math.random() * 0.5);
source.stop(start + p.duration + 0.05);
}
/** Tonfolge (Fanfaren, Hinweise). */
function arpeggio(ctx, dest, freqs, step, type, volume) {
freqs.forEach((freq, i) => tone(ctx, dest, { type, freq, duration: step * 1.6, volume, delay: i * step }));
}
/**
* Rezepte je Effektname. `v` ist die Lautstärke (0..1) des Effekts.
* @type {Record<string, (ctx: AudioContext, dest: AudioNode, noiseBuffer: AudioBuffer, v: number) => void>}
*/
export const SYNTH_RECIPES = {
'shot-carbine': (c, d, n, v) => {
tone(c, d, { type: 'square', freq: 1400, freqEnd: 300, duration: 0.08, volume: 0.25 * v });
noise(c, d, n, { filter: 'highpass', freq: 2500, duration: 0.06, volume: 0.2 * v });
},
'shot-shotgun': (c, d, n, v) => noise(c, d, n, { freq: 3000, freqEnd: 300, duration: 0.25, volume: 0.6 * v }),
'shot-pistol': (c, d, _n, v) => tone(c, d, { type: 'square', freq: 900, freqEnd: 200, duration: 0.1, volume: 0.3 * v }),
'turret-shot': (c, d, _n, v) => tone(c, d, { type: 'triangle', freq: 1800, freqEnd: 600, duration: 0.06, volume: 0.25 * v }),
'enemy-shot': (c, d, _n, v) => tone(c, d, { type: 'sawtooth', freq: 500, freqEnd: 150, duration: 0.12, volume: 0.15 * v }),
'hit-flesh': (c, d, n, v) => noise(c, d, n, { filter: 'bandpass', freq: 700, q: 2, duration: 0.07, volume: 0.5 * v }),
'hit-metal': (c, d, _n, v) => tone(c, d, { type: 'triangle', freq: 2200, freqEnd: 1600, duration: 0.09, volume: 0.25 * v }),
'death-swarm': (c, d, n, v) => noise(c, d, n, { filter: 'bandpass', freq: 900, freqEnd: 200, q: 3, duration: 0.3, volume: 0.6 * v }),
'death-legion': (c, d, n, v) => noise(c, d, n, { freq: 1800, freqEnd: 200, duration: 0.35, volume: 0.6 * v }),
'explosion-small': (c, d, n, v) => {
noise(c, d, n, { freq: 1200, freqEnd: 120, duration: 0.6, volume: 0.8 * v });
tone(c, d, { freq: 110, freqEnd: 40, duration: 0.35, volume: 0.6 * v });
},
'explosion-big': (c, d, n, v) => {
noise(c, d, n, { freq: 900, freqEnd: 60, duration: 1.4, volume: 1 * v });
tone(c, d, { freq: 80, freqEnd: 30, duration: 0.9, volume: 0.9 * v });
},
'player-hit': (c, d, n, v) => {
tone(c, d, { type: 'square', freq: 220, freqEnd: 90, duration: 0.15, volume: 0.35 * v });
noise(c, d, n, { filter: 'bandpass', freq: 500, duration: 0.1, volume: 0.4 * v });
},
'player-death': (c, d, n, v) => {
noise(c, d, n, { freq: 700, freqEnd: 50, duration: 1.8, volume: 1 * v });
tone(c, d, { type: 'sawtooth', freq: 300, freqEnd: 40, duration: 1.6, volume: 0.3 * v });
},
reload: (c, d, n, v) => noise(c, d, n, { filter: 'bandpass', freq: 2500, q: 4, duration: 0.08, volume: 0.5 * v }),
'reload-done': (c, d, _n, v) => tone(c, d, { type: 'square', freq: 1200, duration: 0.04, volume: 0.2 * v }),
switch: (c, d, _n, v) => tone(c, d, { type: 'triangle', freq: 700, duration: 0.05, volume: 0.3 * v }),
'combo-step': (c, d, _n, v) => tone(c, d, { type: 'sine', freq: 880, duration: 0.05, volume: 0.35 * v }),
'combo-fail': (c, d, _n, v) => tone(c, d, { type: 'square', freq: 180, freqEnd: 120, duration: 0.18, volume: 0.25 * v }),
'orbital-call': (c, d, _n, v) => arpeggio(c, d, [523, 659, 784, 1047], 0.06, 'triangle', 0.3 * v),
pickup: (c, d, _n, v) => arpeggio(c, d, [659, 784, 988, 1319], 0.05, 'sine', 0.35 * v),
brood: (c, d, n, v) => noise(c, d, n, { filter: 'bandpass', freq: 400, freqEnd: 900, q: 5, duration: 0.4, volume: 0.5 * v }),
'ui-click': (c, d, _n, v) => tone(c, d, { type: 'sine', freq: 1000, duration: 0.03, volume: 0.3 * v }),
'ui-confirm': (c, d, _n, v) => arpeggio(c, d, [784, 1047], 0.06, 'triangle', 0.3 * v),
'wave-start': (c, d, _n, v) => arpeggio(c, d, [392, 523, 659, 784], 0.12, 'sawtooth', 0.12 * v),
'wave-cleared': (c, d, _n, v) => arpeggio(c, d, [523, 659, 784, 1047, 1319], 0.1, 'square', 0.1 * v),
alarm: (c, d, _n, v) => {
for (let i = 0; i < 3; i += 1) {
tone(c, d, { type: 'square', freq: 660, duration: 0.14, volume: 0.15 * v, delay: i * 0.3 });
tone(c, d, { type: 'square', freq: 880, duration: 0.14, volume: 0.15 * v, delay: i * 0.3 + 0.15 });
}
},
'game-over': (c, d, _n, v) => arpeggio(c, d, [392, 349, 311, 262], 0.22, 'triangle', 0.3 * v),
dodge: (c, d, n, v) => noise(c, d, n, { filter: 'bandpass', freq: 400, freqEnd: 2400, q: 1.5, duration: 0.25, volume: 0.5 * v, attack: 0.05 }),
// Pfeifen passend zur Mörser-Vorwarnzeit: von hoch nach tief.
mortar: (c, d, _n, v) => tone(c, d, { type: 'sine', freq: 1500, freqEnd: 450, duration: 1.3, volume: 0.2 * v, attack: 0.1 }),
};
+30
View File
@@ -0,0 +1,30 @@
/**
* Hilfen für Phaser-Tastaturereignisse.
* @module systems/keyboard
*/
/**
* Lässt jedes KeyboardEvent nur einmal zum Handler durch.
*
* Warum: Phaser 3.90 verarbeitet bei jedem neuen DOM-Tastaturereignis die gesamte
* Warteschlange des aktuellen Frames erneut und leert sie erst am Frame-Ende. Treffen
* drei oder mehr Tastenereignisse innerhalb eines Frames ein (schnelles Tippen, oder
* die automatische Wiederholung der gehaltenen Q-Taste während einer Befehlseingabe),
* meldet Phaser dasselbe `keydown` mehrfach. Für Orbital-Befehle hieße das: Aus
* „↓“ wird „↓ ↓“ und die Eingabe schlägt fehl. Das Ereignisobjekt selbst ist dabei
* identisch – daran erkennen wir Wiederholungen zuverlässig.
*
* @template {KeyboardEvent} E
* @param {(event: E) => void} handler
* @returns {(event: E) => void}
*/
export function oncePerEvent(handler) {
const seen = new WeakSet();
return (event) => {
if (event && typeof event === 'object') {
if (seen.has(event)) return;
seen.add(event);
}
handler(event);
};
}