// ============================================================ // AUDIO — procedural Chinese-inspired score (WebAudio) // Guqin-style Karplus-Strong plucks, taiko drums, wind // ============================================================ const PENT = [0, 2, 4, 7, 9]; // major pentatonic const BASE = 220; // A3 export class GameAudio { constructor() { this.ctx = null; this.enabled = true; this.musicVol = 0.5; this.sfxVol = 0.7; this.mode = null; this._melodyTimer = null; this._windNodes = null; } init() { if (this.ctx) return; try { this.ctx = new (window.AudioContext || window.webkitAudioContext)(); this.master = this.ctx.createGain(); this.master.gain.value = 0.9; this.master.connect(this.ctx.destination); this.musicGain = this.ctx.createGain(); this.musicGain.gain.value = this.musicVol; this.musicGain.connect(this.master); this.sfxGain = this.ctx.createGain(); this.sfxGain.gain.value = this.sfxVol; this.sfxGain.connect(this.master); } catch { this.ctx = null; } } setMusicVol(v) { this.musicVol = v; if (this.musicGain) this.musicGain.gain.value = v; } setSfxVol(v) { this.sfxVol = v; if (this.sfxGain) this.sfxGain.gain.value = v; } // ---------- pluck (Karplus-Strong-ish via noise burst + filtered feedback delay) ---------- pluck(freq, when = 0, dur = 1.8, gain = 0.5, bright = 0.35) { if (!this.ctx) return; const t0 = this.ctx.currentTime + when; const sr = this.ctx.sampleRate; const N = Math.floor(sr * dur); const buf = this.ctx.createBuffer(1, N, sr); const data = buf.getChannelData(0); // excitation const period = Math.max(2, Math.floor(sr / freq)); for (let i = 0; i < period && i < N; i++) { data[i] = Math.random() * 2 - 1; } // string loop with damping let last = 0; const damp = 0.996 - bright * 0.12; for (let i = period; i < N; i++) { const s = (data[i - period] + data[i - period + 1 >= N ? i - 1 : i - period + 1]) * 0.5 * damp; data[i] = s + last * 0.0001; // tiny body resonance last = s; } const src = this.ctx.createBufferSource(); src.buffer = buf; const g = this.ctx.createGain(); g.gain.value = gain; g.gain.setValueAtTime(gain, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); const lp = this.ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 2600; lp.Q.value = 0.4; src.connect(lp).connect(g).connect(this.musicGain); src.start(t0); src.stop(t0 + dur + 0.05); } noteFreq(deg, oct = 0) { const semis = PENT[((deg % 5) + 5) % 5] + 12 * (oct + Math.floor(deg / 5)); return BASE * Math.pow(2, semis / 12); } // ---------- melody scheduler ---------- playMusic(mode) { this.init(); if (!this.ctx || this.mode === mode) return; this.mode = mode; clearInterval(this._melodyTimer); this.stopWind(); if (mode === "off") { this.musicGain?.gain.setTargetAtTime(0, this.ctx.currentTime, 0.4); return; } this.musicGain?.gain.setTargetAtTime(this.musicVol, this.ctx.currentTime, 0.3); if (mode === "map" || mode === "menu") { this.startWind(0.05); const tempo = mode === "menu" ? 3400 : 2600; const phrase = () => { if (!this.enabled || !this.ctx) return; // gentle wandering pentatonic phrase let deg = Math.floor(Math.random() * 5); const steps = 3 + Math.floor(Math.random() * 4); for (let i = 0; i < steps; i++) { deg += [-2, -1, 1, 1, 2, 3][Math.floor(Math.random() * 6)]; const f = this.noteFreq(deg, deg > 6 ? 0 : 1); this.pluck(f, i * (tempo / 1000) * (0.6 + Math.random() * 0.5), 2.4, 0.32 + Math.random() * 0.15, 0.25); } // low drone pluck occasionally if (Math.random() < 0.6) this.pluck(this.noteFreq(0, -1), 0, 4.5, 0.22, 0.05); }; phrase(); this._melodyTimer = setInterval(phrase, tempo); } else if (mode === "battle") { this.startWind(0.09); // war drums pattern const beat = () => { if (!this.enabled || !this.ctx || this.mode !== "battle") return; this.drum(0, 0.9); setTimeout(() => this.drum(0, 0.5), 380); setTimeout(() => this.drum(0, 0.75), 760); }; beat(); this._melodyTimer = setInterval(beat, 1900); // tense low strings this.pluck(this.noteFreq(0, -2), 0, 6, 0.3, 0.02); this.pluck(this.noteFreq(3, -2), 2.1, 6, 0.24, 0.02); } else if (mode === "victory") { const seq = [0, 2, 4, 7, 9, 7, 9]; seq.forEach((d, i) => this.pluck(this.noteFreq(d, 1), i * 0.16, 2.6, 0.4, 0.3)); this.pluck(this.noteFreq(0, -1), 0.1, 5, 0.3, 0.04); this.gong(0.9); } } // ---------- percussion ---------- drum(when = 0, gain = 0.8, pitch = 72) { if (!this.ctx) return; const t0 = this.ctx.currentTime + when; const osc = this.ctx.createOscillator(); osc.type = "sine"; osc.frequency.setValueAtTime(pitch * 2.2, t0); osc.frequency.exponentialRampToValueAtTime(pitch * 0.55, t0 + 0.28); const g = this.ctx.createGain(); g.gain.setValueAtTime(gain * 0.9, t0); g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.42); // skin noise transient const nb = this.ctx.createBufferSource(); const nbuf = this.ctx.createBuffer(1, 2000, this.ctx.sampleRate); const nd = nbuf.getChannelData(0); for (let i = 0; i < nd.length; i++) nd[i] = (Math.random() * 2 - 1) * (1 - i / nd.length); nb.buffer = nbuf; const ng = this.ctx.createGain(); ng.gain.value = gain * 0.35; nb.connect(ng).connect(this.sfxGain); osc.connect(g).connect(this.sfxGain); osc.start(t0); osc.stop(t0 + 0.45); nb.start(t0); } gong(when = 0, gain = 0.5) { if (!this.ctx) return; const t0 = this.ctx.currentTime + when; for (const f of [146, 213, 297, 402]) { const o = this.ctx.createOscillator(); o.type = "triangle"; o.frequency.value = f * (1 + Math.random() * 0.01); const g = this.ctx.createGain(); g.gain.setValueAtTime(0, t0); g.gain.linearRampToValueAtTime(gain / 4, t0 + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 3.4); o.connect(g).connect(this.sfxGain); o.start(t0); o.stop(t0 + 3.5); } } horn(when = 0, gain = 0.4) { if (!this.ctx) return; const t0 = this.ctx.currentTime + when; const o = this.ctx.createOscillator(); o.type = "sawtooth"; o.frequency.setValueAtTime(98, t0); o.frequency.linearRampToValueAtTime(110, t0 + 0.5); const lp = this.ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 900; const g = this.ctx.createGain(); g.gain.setValueAtTime(0, t0); g.gain.linearRampToValueAtTime(gain, t0 + 0.12); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 1.4); o.connect(lp).connect(g).connect(this.sfxGain); o.start(t0); o.stop(t0 + 1.5); } click() { if (!this.ctx) return; const t0 = this.ctx.currentTime; const o = this.ctx.createOscillator(); o.type = "square"; o.frequency.value = 1400; const g = this.ctx.createGain(); g.gain.setValueAtTime(0.06, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.07); o.connect(g).connect(this.sfxGain); o.start(t0); o.stop(t0 + 0.08); } swordClash() { if (!this.ctx) return; const t0 = this.ctx.currentTime; const nb = this.ctx.createBufferSource(); const buf = this.ctx.createBuffer(1, 4000, this.ctx.sampleRate); const d = buf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 2); nb.buffer = buf; const bp = this.ctx.createBiquadFilter(); bp.type = "bandpass"; bp.frequency.value = 3200; bp.Q.value = 2; const g = this.ctx.createGain(); g.gain.value = 0.25; nb.connect(bp).connect(g).connect(this.sfxGain); nb.start(t0); } // ---------- wind ambience ---------- startWind(gainV = 0.06) { if (!this.ctx || this._windNodes) return; const sr = this.ctx.sampleRate; const buf = this.ctx.createBuffer(1, sr * 4, sr); const d = buf.getChannelData(0); let v = 0; for (let i = 0; i < d.length; i++) { v += (Math.random() * 2 - 1 - v) * 0.02; d[i] = v * 3; } const src = this.ctx.createBufferSource(); src.buffer = buf; src.loop = true; const lp = this.ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 480; const g = this.ctx.createGain(); g.gain.value = gainV; src.connect(lp).connect(g).connect(this.musicGain); src.start(); this._windNodes = { src, g }; } stopWind() { if (this._windNodes) { try { this._windNodes.src.stop(); } catch { } this._windNodes = null; } } toggleEnabled() { this.enabled = !this.enabled; if (this.ctx) { this.master.gain.setTargetAtTime(this.enabled ? 0.9 : 0, this.ctx.currentTime, 0.1); } return this.enabled; } } export const audio = new GameAudio();