Files
leggenda-del-regolo/src/engine/soundscape.js
T
manzoloandClaude Fable 5.1 f1de62f01e «Fai due tiri» al campo da basket del Circolo 4 Maggio
Minigioco nuovo (idea di Andrea, 27 ottobre): vista di lato, Andrea da solo con dieci palloni; il mouse o il dito
danno l'angolo, tenendo premuto sale la forza, lasciando la palla vola in parabola, rimbalza sul ferro (davanti e
dietro dell'anello), sul tabellone e per terra, entra anche sporca; da sotto non vale. A ogni canestro si arretra
di una postazione fino a dietro la linea da tre (tre punti). Tre livelli: quanti canestri su dieci per la gloria
(3, 5, 7) e quanta parabola mostra la guida (quasi tutta, l'inizio, niente). Sfondo del campo e foglio di Andrea col
pallone di Codex; suoni di palla, ferro, tabellone e retina; stessi comandi delle bocce (pannello, tastiera, pressione);
dialogo di scelta dal campo di Via Cipriani, battute provvisorie e voci; test del motore (da ogni postazione esiste
un tiro che entra) e prova nel browser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdBYE8csKd4tcFvwntT
2026-10-27 23:16:35 +01:00

162 lines
9.2 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); }
// I segni del tressette si sentono: due nocche sul tavolo, la carta strusciata, la carta lasciata cadere.
else if (kind === 'card-busso') for (const d of [0, 0.16]) { this.tone(150, 90, 0.07, 0.16, d, pan); this.tone(320, 180, 0.03, 0.06, d, pan); }
else if (kind === 'card-striscio') this.noiseSound(0.6, 1500, 0.14, { paper: true, pan });
else if (kind === 'card-volo') { this.noiseSound(0.12, 4200, 0.08, { paper: true, pan }); this.tone(260, 140, 0.05, 0.09, 0.12, 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 }); }
// Il basket: il tiro, la palla che batte per terra, il ferro, il tabellone, la retina.
else if (kind === 'basket-throw') this.noiseSound(0.08, 1200, 0.05, { pan });
else if (kind === 'basket-bounce') { this.tone(190, 95, 0.11, 0.05 + strength * 0.12, 0, pan); this.noiseSound(0.06, 500, 0.05, { pan }); }
else if (kind === 'basket-rim') { this.tone(1500, 1100, 0.14, 0.04 + strength * 0.1, 0, pan); this.tone(2300, 1900, 0.08, 0.03, 0, pan); }
else if (kind === 'basket-board') { this.tone(280, 170, 0.09, 0.05 + strength * 0.08, 0, pan); this.noiseSound(0.07, 900, 0.07, { pan }); }
else if (kind === 'basket-swish') this.noiseSound(0.28, 3200, 0.1, { paper: true, pan });
else if (kind === 'basket-in') { this.tone(1300, 1000, 0.08, 0.04, 0, pan); this.noiseSound(0.22, 2800, 0.08, { paper: true, 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]);
}
}