// Klang: KI-generierte Samples (ElevenLabs, assets/sounds/*.mp3) mit // WebAudio abgespielt; fehlt eine Datei, greift ein kleiner Synth-Fallback. // Dazu saisonale Atmosphäre (Sommerwiese/Winterwind) als Loop. // AudioContext entsteht erst nach der ersten Nutzer-Geste (iOS/Safari). const SAMPLES = ['click', 'build', 'demolish', 'success', 'error', 'card', 'milestone', 'win', 'ambience_summer', 'ambience_winter', 'bite', 'sip', 'splash', 'tool', 'cash']; export const audio = { ctx: null, soundOn: true, musicOn: true, _buffers: {}, // Name → AudioBuffer _raw: {}, // Name → ArrayBuffer (vor dem Decodieren) _ambience: null, // laufender Ambience-Loop {src, gain, name} _season: 'Sommer', // MP3s sofort laden (ohne AudioContext), decodiert wird bei ensure() preload() { for (const name of SAMPLES) { fetch(`assets/sounds/${name}.mp3`) .then(r => (r.ok ? r.arrayBuffer() : null)) .then(buf => { if (buf) { this._raw[name] = buf; this._decode(name); } }) .catch(() => {}); } }, _decode(name) { if (!this.ctx || !this._raw[name] || this._buffers[name]) return; this.ctx.decodeAudioData(this._raw[name].slice(0)) .then(b => { this._buffers[name] = b; // Ambience ggf. nachstarten, sobald sie decodiert ist if (name === this._ambienceName() && this.musicOn && !this._ambience) { this._startAmbience(); } }) .catch(() => {}); }, ensure() { if (!this.ctx) { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return false; this.ctx = new AC(); for (const name of Object.keys(this._raw)) this._decode(name); } if (this.ctx.state === 'suspended') this.ctx.resume(); return true; }, // ---------- Effekte ---------- play(name, gain = 0.5) { if (!this.soundOn || !this.ensure()) return; const buf = this._buffers[name]; if (buf) { const src = this.ctx.createBufferSource(); const g = this.ctx.createGain(); g.gain.value = gain; src.buffer = buf; src.connect(g).connect(this.ctx.destination); src.start(); } else { this._synthFallback(name); } }, // kurzer, leiser Interaktions-Ton (Tower-Defense-„Treffer"), pitch = Faktor pling(pitch = 1) { if (!this.soundOn || !this.ensure()) return; this._tone(620 * pitch, 0.09, 'triangle', 0.035); }, // Dienstleistungs-Geräusch (Apfelbiss, Kaffee, Werkzeug …), dezent fx(name) { if (!this.soundOn || !this.ensure()) return; const buf = this._buffers[name]; if (buf) { const src = this.ctx.createBufferSource(); const g = this.ctx.createGain(); g.gain.value = 0.28; src.buffer = buf; src.connect(g).connect(this.ctx.destination); src.start(); } else { // Synth-Fallback je Art switch (name) { case 'bite': this._tone(140, 0.08, 'square', 0.04); break; case 'sip': this._tone(320, 0.14, 'sine', 0.03); break; case 'splash': this._tone(900, 0.10, 'sine', 0.03); this._tone(500, 0.12, 'sine', 0.02, 0.03); break; case 'tool': this._tone(220, 0.05, 'sawtooth', 0.03); this._tone(260, 0.05, 'sawtooth', 0.03, 0.06); break; case 'cash': this._tone(880, 0.06, 'triangle', 0.04); this._tone(1240, 0.10, 'triangle', 0.035, 0.06); break; default: this.pling(); } } }, click() { this.play('click', 0.35); }, success() { this.play('success', 0.5); }, error() { this.play('error', 0.45); }, chime() { this.play('card', 0.45); }, card() { this.chime(); }, build() { this.play('build', 0.55); }, demolish() { this.play('demolish', 0.55); }, milestone() { this.play('milestone', 0.55); }, win() { this.play('win', 0.6); }, _tone(freq, dur, type = 'sine', gain = 0.08, delay = 0) { const t0 = this.ctx.currentTime + delay; const osc = this.ctx.createOscillator(); const g = this.ctx.createGain(); osc.type = type; osc.frequency.value = freq; g.gain.setValueAtTime(0, t0); g.gain.linearRampToValueAtTime(gain, t0 + 0.02); g.gain.exponentialRampToValueAtTime(0.001, t0 + dur); osc.connect(g).connect(this.ctx.destination); osc.start(t0); osc.stop(t0 + dur + 0.05); }, _synthFallback(name) { switch (name) { case 'click': this._tone(660, 0.08, 'triangle', 0.05); break; case 'success': case 'build': case 'milestone': case 'win': this._tone(523, 0.12, 'triangle', 0.07); this._tone(784, 0.18, 'triangle', 0.07, 0.09); break; case 'error': case 'demolish': this._tone(180, 0.22, 'sawtooth', 0.05); break; default: this._tone(880, 0.3, 'sine', 0.05); } }, // ---------- Saisonale Atmosphäre (läuft, wenn Musik an ist) ---------- _ambienceName() { return this._season === 'Winter' ? 'ambience_winter' : 'ambience_summer'; }, setSeason(season) { if (season === this._season) return; this._season = season; if (this.musicOn && this.ctx) this._startAmbience(); // Wechsel mit Fade }, _startAmbience() { const name = this._ambienceName(); const buf = this._buffers[name]; if (!buf) return; this._stopAmbience(); const src = this.ctx.createBufferSource(); const g = this.ctx.createGain(); src.buffer = buf; src.loop = true; g.gain.setValueAtTime(0, this.ctx.currentTime); g.gain.linearRampToValueAtTime(0.18, this.ctx.currentTime + 2.5); src.connect(g).connect(this.ctx.destination); src.start(); this._ambience = { src, gain: g, name }; }, _stopAmbience() { if (!this._ambience) return; const { src, gain } = this._ambience; gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + 1.2); setTimeout(() => { try { src.stop(); } catch (e) { /* schon gestoppt */ } }, 1400); this._ambience = null; }, startMusic() { if (!this.musicOn || !this.ensure()) return; this._startAmbience(); }, toggleMusic() { this.musicOn = !this.musicOn; if (this.musicOn) this.startMusic(); else this._stopAmbience(); return this.musicOn; }, toggleSound() { this.soundOn = !this.soundOn; return this.soundOn; }, }; audio.preload();