// Colonna sonora: un brano alla volta, in loop, con dissolvenza quando cambia. // Quale brano suona lo decidono i dati (story.music e scene.music). Si prova a partire subito; se il browser non lo // permette prima di un gesto del giocatore, il brano riparte al primo clic o tasto (unlock). import { versioned } from '../version.js'; // Il brano del momento: titolo, fine, oppure quello della scena (o quello comune del gioco). Sotto il cartello // d'apertura nessuno: lì suona la sua musichetta, una volta sola (main.js). // Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo); // altrimenti quello generale del paese. // Un primo piano con la sua musica (i tavoli da carte, col valzer) la porta con sé finché resta aperto. // Una scena con le console accese (`stages`, ognuna col suo `when`) non ha un brano solo ma una miscela: ogni console // suona il suo, più forte quanto più Andrea (`at`) le è vicino (mix). Le console possono stare anche fuori dal quadro: // è la musica della piazza accanto, che arriva da lontano. // `ok(cond)` dice se una condizione della storia vale adesso (game.js, check). export function trackFor(story, { screen, scene, phase, closeup, at, ok = () => false }) { const m = story.music ?? {}; if (screen === 'splash') return null; if (screen === 'title' || screen === 'intro') return m.title ?? null; if (screen === 'ending') return m.ending ?? m.game ?? null; if (closeup && story.closeups?.[closeup]?.music) return story.closeups[closeup].music; const sc = story.scenes[scene]; const stages = (sc?.stages ?? []).filter(st => ok(st.when)); if (stages.length) return mix(stages, at ?? sc.entry ?? [320, 300]); return sc?.music ?? (sc?.indoor ? null : phase?.music) ?? m.game ?? null; } // Il volume di ogni console: pieno fino a `near` pixel, poi cala fino a `far` (non sotto un filo, `floor`: la festa // si sente sempre). Sotto una console (entro `near`, sfumando fino a una volta e mezzo) le altre calano fino al loro // filo: lì si sente bene la sola musica di quella (Andrea, 31 ottobre, in cima a Via dell'Antica Posta). La più forte // per prima. export function mix(stages, [x, y]) { const near = st => st.near ?? 90, floor = st => st.floor ?? 0.08; const d = stages.map(st => Math.hypot(x - st.at[0], (y - st.at[1]) * 1.5)); const under = stages.map((st, i) => Math.max(0, Math.min(1, (near(st) * 1.5 - d[i]) / (near(st) * 0.5)))); return stages.map((st, i) => { const far = st.far ?? 520, k = Math.max(0, Math.min(1, (d[i] - near(st)) / (far - near(st)))); const others = Math.max(0, ...under.filter((_, j) => j !== i)); return { src: st.music, gain: Math.max(floor(st), (1 - k) ** 1.6 * (1 - others)) }; }).sort((a, b) => b.gain - a.gain); } const FADE = 1.2; // Dove il volume non si può cambiare (iOS), almeno il muto e lo zero funzionano: `muted` lì si rispetta. Lì, nella // miscela delle console, suona solo la più forte (`alone`): le altre a volume pieno coprirebbero tutto. function setLevel(a, alone = false) { a.volume = a.level; a.muted = a.level === 0 || (alone && a.level < 1 && Math.abs(a.volume - a.level) > 0.01); } export class Music { constructor({ muted = false, volume = 0.6 } = {}) { this.muted = muted; this.volume = volume; this.blocked = null; this.want = null; this.tracks = new Map(); // src -> { src, audio, gain }: quelli voluti e quelli che sfumano this.wanted = []; // i src voluti adesso, il più forte per primo this.duck = 1; // 1 = pieno; meno di 1 mentre parla qualcuno this.hold = false; // in pausa: il brano sfuma e si ferma, poi riprende da dov'era this.away = false; // pagina nascosta (sul telefono si è passati a un'altra app): tutto fermo subito } // Il brano principale (il più forte) e quelli che stanno sfumando. get current() { return this.tracks.get(this.wanted[0]) ?? null; } get old() { return [...this.tracks.values()].filter(t => !this.wanted.includes(t.src)).map(t => t.audio); } // Nascosta la pagina il browser non chiama più update (niente fotogrammi): i brani si fermano qui, di colpo. // Tornati, update li fa ripartire da dov'erano (se non c'è la pausa). setAway(away) { this.away = away; if (away) for (const t of this.tracks.values()) t.audio.pause(); } // Chiamata a ogni fotogramma con il brano voluto (o la miscela delle console, [{ src, gain }]): avvia, cambia e sfuma. update(want, dt) { this.want = want; const list = want == null ? [] : typeof want === 'string' ? [{ src: want, gain: 1 }] : want; this.wanted = list.map(w => w.src); for (const w of list) { const t = this.tracks.get(w.src) ?? { src: w.src, audio: this.start(w.src) }; t.gain = w.gain; this.tracks.set(w.src, t); } // Ogni brano voluto sale (o scende) verso il suo volume; quelli lasciati sfumano fino a zero, e lì si fermano. // Il livello si tiene in `level`, non si rilegge da audio.volume: su iPhone e iPad quello è di sola lettura e // resta sempre 1, e un brano vecchio non arrivava mai a zero (in chiesa l'organo suonava insieme al paese). const step = dt / FADE, full = this.muted || this.hold ? 0 : this.volume * this.duck; for (const t of [...this.tracks.values()]) { const a = t.audio, on = this.wanted.includes(t.src), target = on ? full * t.gain : 0; a.level = a.level < target ? Math.min(target, a.level + step) : Math.max(target, a.level - step); setLevel(a, list.length > 1 && t.src !== this.wanted[0]); if (!on) { if (a.level === 0) { a.pause(); this.tracks.delete(t.src); } continue; } if (this.hold && a.level === 0 && !a.paused) a.pause(); else if (!this.hold && !this.away && a.paused && a !== this.blocked) a.play().catch(() => { this.blocked = a; }); } } start(src) { const audio = new Audio(versioned(src)); audio.loop = true; audio.level = 0; setLevel(audio); audio.play().catch(() => { this.blocked = audio; }); // senza permesso si riprova al primo gesto return audio; } unlock() { const a = this.blocked; this.blocked = null; if (a && [...this.tracks.values()].some(t => t.audio === a && this.wanted.includes(t.src))) a.play().catch(() => {}); } toggle() { this.muted = !this.muted; return this.muted; } setVolume(v) { this.volume = Math.max(0, Math.min(1, v)); if (this.volume > 0) this.muted = false; return this.volume; } settings() { return { volume: this.volume, muted: this.muted }; } }