| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const MANIFEST_URL = '/sounds/manifest.json'; |
|
|
| |
| |
| |
| |
| |
| const CONFIG = { |
| day: { loop: true, gain: 0.35, gate: 'day' }, |
| night: { loop: true, gain: 0.35, gate: 'night' }, |
| wind: { loop: true, gain: 0.18, gate: 'always' }, |
| water: { loop: true, gain: 0.22, gate: 'always' }, |
| bubbles: { loop: false, gain: 0.45, gate: 'always', minGap: 3, maxGap: 9 }, |
| birds: { loop: false, gain: 0.55, gate: 'day', minGap: 5, maxGap: 15 }, |
| crane: { loop: false, gain: 0.55, gate: 'day', minGap: 40, maxGap: 90 }, |
| goat: { loop: false, gain: 0.50, gate: 'day', minGap: 25, maxGap: 60 }, |
| |
| rain: { loop: true, gain: 0.50, gate: 'weather', stages: [2, 4] }, |
| 'storm-wind': { loop: true, gain: 0.55, gate: 'weather', stages: [3, 4] }, |
| 'snow-wind': { loop: true, gain: 0.45, gate: 'weather', stages: [5, 6] }, |
| |
| thunder: { loop: false, gain: 0.85, gate: 'always' }, |
| winner: { loop: false, gain: 0.80, gate: 'always' }, |
| award: { loop: false, gain: 0.75, gate: 'always' }, |
| |
| penguin: { loop: false, gain: 0.55, gate: 'weather', stages: [5, 6], minGap: 12, maxGap: 35 }, |
| }; |
|
|
| export class SoundLibrary { |
| constructor() { |
| this.enabled = true; |
| this.started = false; |
| this._clock = null; |
| |
| this._pool = {}; |
| |
| this._active = {}; |
| |
| this._nextAt = {}; |
| this._elapsed = 0; |
| this._raf = null; |
| this._weatherStage = 0; |
| this._loadPromise = this._load(); |
| } |
|
|
| setClock(clock) { this._clock = clock; } |
|
|
| setWeatherStage(idx) { this._weatherStage = idx | 0; } |
|
|
| setEnabled(on) { |
| this.enabled = !!on; |
| if (!this.enabled) { |
| for (const cat in this._active) { |
| const a = this._active[cat]; |
| if (a) { try { a.pause(); } catch (e) {} } |
| } |
| } else if (this.started) { |
| this._kickLoops(); |
| } |
| } |
|
|
| |
| fire(category) { |
| if (!this.enabled || !this.started) return; |
| this._playOneShot(category); |
| } |
|
|
| async _load() { |
| let manifest; |
| try { |
| const res = await fetch(MANIFEST_URL, { cache: 'no-store' }); |
| if (!res.ok) throw new Error(`manifest ${res.status}`); |
| manifest = await res.json(); |
| } catch (e) { |
| console.warn('[SoundLibrary] manifest.json missing or unreadable — no sounds will play.', e); |
| return; |
| } |
|
|
| let total = 0; |
| for (const cat of Object.keys(CONFIG)) { |
| const files = Array.isArray(manifest[cat]) ? manifest[cat] : []; |
| this._pool[cat] = files.map(name => { |
| const url = `/sounds/${cat}/${name}`; |
| const a = new Audio(url); |
| a.preload = 'auto'; |
| a.crossOrigin = 'anonymous'; |
| a.addEventListener('error', () => console.warn(`[SoundLibrary] failed to load ${url}`)); |
| return a; |
| }); |
| total += this._pool[cat].length; |
| } |
| console.log(`[SoundLibrary] loaded ${total} clips across ${Object.keys(this._pool).length} categories.`); |
| } |
|
|
| |
| async start() { |
| if (this.started) return; |
| await this._loadPromise; |
| this.started = true; |
| if (!this.enabled) return; |
| this._kickLoops(); |
| this._lastTickMs = performance.now(); |
| const loop = () => { |
| const now = performance.now(); |
| const dt = Math.min(0.25, (now - this._lastTickMs) / 1000); |
| this._lastTickMs = now; |
| this._tick(dt); |
| this._raf = requestAnimationFrame(loop); |
| }; |
| this._raf = requestAnimationFrame(loop); |
| } |
|
|
| stop() { |
| if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } |
| for (const cat in this._active) { |
| const a = this._active[cat]; |
| if (a) { try { a.pause(); a.currentTime = 0; } catch (e) {} } |
| } |
| this._active = {}; |
| this.started = false; |
| } |
|
|
| |
|
|
| _kickLoops() { |
| |
| for (const cat of Object.keys(CONFIG)) { |
| if (!CONFIG[cat].loop) continue; |
| const pool = this._pool[cat] || []; |
| if (!pool.length) continue; |
| if (this._active[cat]) continue; |
| const a = pool[Math.floor(Math.random() * pool.length)]; |
| a.loop = true; |
| a.volume = 0; |
| const p = a.play(); |
| if (p && p.catch) p.catch(() => {}); |
| this._active[cat] = a; |
| } |
| } |
|
|
| _tick(dt) { |
| this._elapsed += dt; |
| if (!this.enabled) return; |
|
|
| |
| const isDay = this._isDay(); |
| this._fade('wind', CONFIG.wind.gain, dt); |
| this._fade('water', CONFIG.water.gain, dt); |
| this._fade('day', isDay ? CONFIG.day.gain : 0, dt); |
| this._fade('night', isDay ? 0 : CONFIG.night.gain, dt); |
| |
| for (const cat of Object.keys(CONFIG)) { |
| const cfg = CONFIG[cat]; |
| if (!cfg.loop || cfg.gate !== 'weather') continue; |
| const active = cfg.stages.includes(this._weatherStage); |
| this._fade(cat, active ? cfg.gain : 0, dt); |
| } |
|
|
| |
| |
| for (const cat of Object.keys(CONFIG)) { |
| const cfg = CONFIG[cat]; |
| if (cfg.loop) continue; |
| if (cfg.minGap === undefined) continue; |
| if (!this._pool[cat] || !this._pool[cat].length) continue; |
| if (cfg.gate === 'day' && !isDay) continue; |
| if (cfg.gate === 'night' && isDay) continue; |
| if (cfg.gate === 'weather' && !(cfg.stages || []).includes(this._weatherStage)) continue; |
| if (this._nextAt[cat] === undefined) { |
| this._nextAt[cat] = this._elapsed + this._rand(cfg.minGap, cfg.maxGap); |
| continue; |
| } |
| if (this._elapsed >= this._nextAt[cat]) { |
| this._playOneShot(cat); |
| this._nextAt[cat] = this._elapsed + this._rand(cfg.minGap, cfg.maxGap); |
| } |
| } |
| } |
|
|
| _fade(cat, target, dt) { |
| const a = this._active[cat]; |
| if (!a) return; |
| const speed = 0.4; |
| const step = speed * dt; |
| if (a.volume < target) a.volume = Math.min(target, a.volume + step); |
| else if (a.volume > target) a.volume = Math.max(target, a.volume - step); |
| } |
|
|
| _playOneShot(cat) { |
| const pool = this._pool[cat] || []; |
| if (!pool.length) return; |
| const src = pool[Math.floor(Math.random() * pool.length)]; |
| |
| const a = src.cloneNode(true); |
| a.volume = CONFIG[cat].gain; |
| const p = a.play(); |
| if (p && p.catch) p.catch(() => {}); |
| } |
|
|
| _isDay() { |
| if (this._clock?.isDay) return this._clock.isDay(); |
| |
| return true; |
| } |
|
|
| _rand(min, max) { return min + Math.random() * (max - min); } |
| } |
|
|