Files
leggenda-del-regolo/src/engine/soundscape.js
T

151 lines
7.9 KiB
JavaScript

// Web Audio: vento/acqua filtrati e piccoli eventi sintetizzati. Nessun download,
// nessun timer in sottofondo; il ciclo del gioco decide quando suonare e quando fermarsi.
import { soundscapes } from '../story/soundscapes.js';
export class Soundscape {
constructor({ volume = 0.45, createContext = () => new (globalThis.AudioContext ?? globalThis.webkitAudioContext)(), random = Math.random } = {}) {
this.volume = volume;
this.createContext = createContext;
this.random = random;
this.context = null;
this.nodes = new Set();
this.active = false;
this.muted = false;
this.key = null;
this.elapsed = 0;
this.next = 4;
this.bed = null;
}
unlock() {
try {
if (!this.context) {
this.context = this.createContext();
this.master = this.context.createGain();
this.master.gain.value = 0;
this.master.connect(this.context.destination);
const rate = this.context.sampleRate;
this.noise = this.context.createBuffer(1, rate * 4, rate);
const data = this.noise.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = this.random() * 2 - 1;
}
if (this.context.state === 'suspended') this.context.resume().catch(() => {});
} catch {
// Anche un'inizializzazione parziale fallita deve lasciare giocabile l'avventura.
this.master?.disconnect(); this.context = null; this.master = null; this.noise = null;
}
}
get audible() { return !!this.context && this.active && !this.muted && this.volume > 0; }
configure({ active = this.active, muted = this.muted, volume = this.volume, duck = 1 } = {}) {
this.active = active;
this.muted = muted;
this.volume = Math.max(0, Math.min(1, volume));
if (!this.context) return;
const gain = this.audible ? this.volume * duck : 0;
this.master.gain.cancelScheduledValues(this.context.currentTime);
if (!gain) {
this.master.gain.setValueAtTime(0, this.context.currentTime);
this.clear();
} else this.master.gain.setTargetAtTime(gain, this.context.currentTime, 0.12);
}
clear() {
for (const node of [...this.nodes]) node.stop();
this.bed = null;
}
// Tutti i nodi, inclusi filtro e gain, vengono scollegati alla fine del suono.
connect(source, gain, filter = null, pan = 0) {
const panner = this.context.createStereoPanner?.();
if (panner) panner.pan.value = pan;
const chain = [source, filter, gain, panner, this.master].filter(Boolean);
for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
const node = { stop: () => {
if (!this.nodes.delete(node)) return;
try { source.stop(); } catch { /* già finito */ }
chain.slice(0, -1).forEach(n => n.disconnect());
} };
source.onended = node.stop;
this.nodes.add(node);
return node;
}
noiseSound(duration, frequency, level, { loop = false, pan = 0, paper = false } = {}) {
const c = this.context, now = c.currentTime;
const source = c.createBufferSource(), filter = c.createBiquadFilter(), gain = c.createGain();
source.buffer = this.noise; source.loop = loop;
filter.type = paper ? 'bandpass' : 'lowpass'; filter.frequency.value = frequency; filter.Q.value = 0.5;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(level, now + (loop ? 1.3 : duration * 0.15));
if (!loop) gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
const node = this.connect(source, gain, filter, pan);
source.start();
if (!loop) source.stop(now + duration);
return { node, gain, level };
}
tone(frequency, end, duration, level, delay = 0, pan = 0) {
const c = this.context, at = c.currentTime + delay, source = c.createOscillator(), gain = c.createGain();
source.type = 'sine';
source.frequency.setValueAtTime(frequency, at);
source.frequency.exponentialRampToValueAtTime(end, at + duration);
gain.gain.setValueAtTime(0, at);
gain.gain.linearRampToValueAtTime(level, at + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, at + duration);
this.connect(source, gain, null, pan);
source.start(at); source.stop(at + duration + 0.03);
}
play(kind, { pan = (this.random() - 0.5) * 1.3, strength = 0.5 } = {}) {
if (!this.audible) return;
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
else if (kind === 'card-slide') this.noiseSound(0.18, 2100, 0.13, { paper: true, pan });
else if (kind === 'card-tap') { this.noiseSound(0.065, 1400, 0.17, { pan }); this.tone(220, 100, 0.06, 0.06, 0, pan); }
else if (kind === 'card-shuffle') {
this.noiseSound(0.55, 3100, 0.16, { paper: true, pan });
for (let i = 0; i < 6; i++) this.tone(900, 300, 0.035, 0.035, i * 0.07, pan);
}
else if (kind === 'bocce-roll') this.noiseSound(0.7, 650, 0.06 + strength * 0.08, { pan });
else if (kind === 'bocce-hit') { this.tone(760, 320, 0.075, 0.04 + strength * 0.07, 0, pan); this.noiseSound(0.09, 1600, 0.04, { pan }); }
else if (kind === 'bocce-rail') this.noiseSound(0.16, 380, 0.04 + strength * 0.08, { pan });
else if (kind === 'biliardo-cue') { this.tone(1900, 900, 0.05, 0.03 + strength * 0.05, 0, pan); this.noiseSound(0.05, 2400, 0.03, { pan }); }
else if (kind === 'biliardo-hit') { this.tone(2600, 1500, 0.045, 0.03 + strength * 0.08, 0, pan); this.noiseSound(0.04, 3200, 0.03, { pan }); }
else if (kind === 'biliardo-rail') this.noiseSound(0.12, 520, 0.03 + strength * 0.06, { pan });
else if (kind === 'biliardo-pin') { this.tone(3200, 2200, 0.03, 0.04 + strength * 0.05, 0, pan); this.noiseSound(0.08, 1800, 0.035, { pan }); }
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
else if (kind === 'tavolo-lontano') {
// Su un altro tavolo: il colpo di stecca, poi la biglia che ne prende un'altra, piano, da lontano.
this.tone(1700, 900, 0.04, 0.025, 0, pan);
this.tone(2500, 1600, 0.04, 0.03, 0.35 + this.random() * 0.5, pan);
}
else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
else if (kind === 'bird') {
for (let i = 0; i < 3; i++) this.tone(2100 + this.random() * 600, 3500, 0.13, 0.035, i * 0.22, pan);
} else if (kind === 'cricket') {
for (let i = 0; i < 5; i++) this.tone(3900, 4200, 0.055, 0.016, i * 0.12, pan);
} else if (kind === 'bell' || kind === 'glass') {
const base = kind === 'bell' ? 440 : 1420, duration = kind === 'bell' ? 3.8 : 0.65;
for (const [ratio, level] of [[1, 0.035], [2.76, 0.012], [4.07, 0.006]])
this.tone(base * ratio, base * ratio, duration, level, 0, pan);
}
}
update(profile, phase, dt) {
if (!this.audible) return;
const key = `${profile}:${phase}`, def = soundscapes[profile];
if (!def) return;
if (this.key !== key) {
this.clear(); this.key = key; this.elapsed = 0; this.next = 4 + this.random() * 5;
}
if (!this.bed && def.bed) this.bed = this.noiseSound(0, def.bed === 'water' ? 1700 : 340, def.level, { loop: true });
this.elapsed += dt;
if (this.bed && this.elapsed > 1.5) this.bed.gain.gain.setTargetAtTime(def.level * (0.8 + 0.2 * Math.sin(this.elapsed * 0.7)), this.context.currentTime, 0.6);
if (this.elapsed < this.next) return;
const events = phase === 'sera' ? def.night : def.day;
this.play(events[Math.floor(this.random() * events.length)]);
this.next = this.elapsed + def.gap[0] + this.random() * (def.gap[1] - def.gap[0]);
}
}