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
+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);
}
}