File size: 8,419 Bytes
173e683 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | /**
* SoundLibrary β file-based ambient + event sound system.
*
* Reads /sounds/manifest.json, loads every listed file as an Audio element,
* then plays them programmatically based on time-of-day and scene events.
*
* Continuous (looped, crossfaded):
* day fades in / out across the day arc
* night fades in / out across the night arc
* wind always on, low volume
* water always on, low volume
*
* One-shots (random scheduling, time-gated):
* bubbles every 3 β 9 sec, anytime
* birds every 5 β 15 sec, day only
* crane every 40 β 90 sec, day only (also fired by Crane.update)
* goat every 25 β 60 sec, day only
*
* Drop new files into /public/sounds/<category>/ and add the filename
* to /public/sounds/manifest.json β see public/sounds/README.txt.
*/
const MANIFEST_URL = '/sounds/manifest.json';
// Per-category playback config.
// loop β true for continuous beds
// gain β base volume 0..1
// gate β 'day' | 'night' | 'always'
// minGap/maxGap β seconds between one-shots
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 },
// Weather-stage continuous beds β playable only when the active stage matches.
rain: { loop: true, gain: 0.50, gate: 'weather', stages: [2, 4] }, // drizzle, thunder
'storm-wind': { loop: true, gain: 0.55, gate: 'weather', stages: [3, 4] }, // mist, thunder
'snow-wind': { loop: true, gain: 0.45, gate: 'weather', stages: [5, 6] }, // first snow, ice age
// One-shots fired from scene events.
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 honks β random while penguins are on screen (Snow + Ice Age only).
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;
/** category -> Audio[] (one per file listed in manifest) */
this._pool = {};
/** category -> currently-playing Audio (looped beds only) */
this._active = {};
/** category -> next scheduled fire time (seconds since start) */
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();
}
}
/** Public hook so other scene code (e.g. Crane) can trigger a one-shot. */
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.`);
}
/** Call once after the first user gesture so autoplay is unlocked. */
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;
}
// ---- internals ----
_kickLoops() {
// Every loop-category gets a backing Audio kicked at volume 0; _tick fades it in/out.
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;
// Adjust looped bed volumes by time-of-day + weather stage.
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);
// Weather-stage beds
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);
}
// Schedule one-shots β any non-loop category with minGap/maxGap is
// randomly scheduled, gated by time-of-day or weather stage.
for (const cat of Object.keys(CONFIG)) {
const cfg = CONFIG[cat];
if (cfg.loop) continue;
if (cfg.minGap === undefined) continue; // event-only one-shots (fire())
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; // gain units per second
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)];
// Clone so overlapping plays don't cut each other.
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();
// Fallback if no clock β assume day.
return true;
}
_rand(min, max) { return min + Math.random() * (max - min); }
}
|