slop game oder so
This commit is contained in:
@@ -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.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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())];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user