Wildlife Kingdom — original browser zoo-management sim
- Procedural canvas art: 15 species × 4 poses, 15 buildings, modular terrain, UI icons - Habitat enclosure detection, biome suitability, staff/guest AI pathfinding - Economy: tickets, shops, wages, star rating, research tiers, 15 missions - Day/night cycle, particles, WebAudio soundtrack & synth SFX - Mouse + touch controls, responsive UI, localStorage autosave - Verified: 31/31 logic tests (test/smoke.js) · 12/12 render audits (test/qa.html)
This commit is contained in:
+139
@@ -0,0 +1,139 @@
|
||||
/* ============================================================
|
||||
Wildlife Kingdom — js/audio.js
|
||||
Procedural WebAudio: gentle marimba soundtrack + synthesized
|
||||
SFX. No audio files, fully original. Global: WK.AudioSys
|
||||
============================================================ */
|
||||
(function(){
|
||||
'use strict';
|
||||
const WK = window.WK;
|
||||
|
||||
const A = {
|
||||
ctx:null, master:null, musicBus:null, sfxBus:null,
|
||||
enabled:true, musicOn:true, scene:'menu',
|
||||
timer:null, nextNote:0, step:0,
|
||||
};
|
||||
|
||||
function ensure(){
|
||||
if(A.ctx){ if(A.ctx.state==='suspended') A.ctx.resume(); return true; }
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if(!AC) return false;
|
||||
A.ctx = new AC();
|
||||
A.master = A.ctx.createGain(); A.master.gain.value=0.9; A.master.connect(A.ctx.destination);
|
||||
A.musicBus = A.ctx.createGain(); A.musicBus.gain.value = 0.5; A.musicBus.connect(A.master);
|
||||
A.sfxBus = A.ctx.createGain(); A.sfxBus.gain.value = 0.85; A.sfxBus.connect(A.master);
|
||||
loadPrefs();
|
||||
startScheduler();
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadPrefs(){
|
||||
try{
|
||||
const p = JSON.parse(localStorage.getItem('wk.audio')||'{}');
|
||||
if(p.enabled===false) A.enabled=false;
|
||||
if(p.musicOn===false) A.musicOn=false;
|
||||
}catch(e){}
|
||||
}
|
||||
function savePrefs(){
|
||||
try{ localStorage.setItem('wk.audio', JSON.stringify({enabled:A.enabled,musicOn:A.musicOn})); }catch(e){}
|
||||
}
|
||||
|
||||
/* ---------- tiny synth helpers ---------- */
|
||||
function tone(opt){
|
||||
// {f, f2, t='sine', dur, vol, bus, at, attack, release}
|
||||
const c=A.ctx; if(!c) return;
|
||||
const at = opt.at || c.currentTime;
|
||||
const o=c.createOscillator(), g=c.createGain();
|
||||
o.type=opt.t||'sine';
|
||||
o.frequency.setValueAtTime(opt.f,at);
|
||||
if(opt.f2) o.frequency.exponentialRampToValueAtTime(Math.max(20,opt.f2), at+(opt.dur||0.2));
|
||||
const v=(opt.vol==null?0.2:opt.vol);
|
||||
g.gain.setValueAtTime(0.0001,at);
|
||||
g.gain.linearRampToValueAtTime(v, at+(opt.attack||0.008));
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, at+(opt.dur||0.2));
|
||||
o.connect(g); g.connect(opt.bus||A.sfxBus);
|
||||
o.start(at); o.stop(at+(opt.dur||0.2)+0.05);
|
||||
}
|
||||
function noiseHit(opt){
|
||||
// {dur,vol,f,q,bus,at}
|
||||
const c=A.ctx; if(!c) return;
|
||||
const at=opt.at||c.currentTime;
|
||||
const len=Math.max(1,Math.floor(c.sampleRate*(opt.dur||0.15)));
|
||||
const buf=c.createBuffer(1,len,c.sampleRate);
|
||||
const d=buf.getChannelData(0);
|
||||
for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*(1-i/len);
|
||||
const src=c.createBufferSource(); src.buffer=buf;
|
||||
const flt=c.createBiquadFilter(); flt.type='bandpass'; flt.frequency.value=opt.f||800; flt.Q.value=opt.q||1;
|
||||
const g=c.createGain(); g.gain.value=opt.vol==null?0.25:opt.vol;
|
||||
src.connect(flt); flt.connect(g); g.connect(opt.bus||A.sfxBus);
|
||||
src.start(at);
|
||||
}
|
||||
|
||||
/* ---------- SFX vocabulary (all synthesized) ---------- */
|
||||
const SFX = {
|
||||
click(){ tone({f:660,f2:880,t:'triangle',dur:0.07,vol:0.12}); },
|
||||
place(){ noiseHit({dur:0.09,vol:0.3,f:420,q:0.8}); tone({f:180,f2:120,t:'sine',dur:0.12,vol:0.22}); },
|
||||
dig(){ noiseHit({dur:0.14,vol:0.22,f:300,q:0.7}); },
|
||||
coin(){ tone({f:988,t:'square',dur:0.06,vol:0.08}); tone({f:1319,t:'square',dur:0.16,vol:0.09,at:A.ctx&&A.ctx.currentTime+0.06}); },
|
||||
error(){ tone({f:220,f2:160,t:'sawtooth',dur:0.18,vol:0.12}); },
|
||||
pop(){ tone({f:520,f2:1040,t:'sine',dur:0.09,vol:0.15}); },
|
||||
cheer(){ [523,659,784,1047].forEach((f,i)=>tone({f,t:'triangle',dur:0.18,vol:0.1,at:A.ctx&&A.ctx.currentTime+i*0.07})); },
|
||||
roar(){ tone({f:150,f2:70,t:'sawtooth',dur:0.5,vol:0.14}); noiseHit({dur:0.4,vol:0.1,f:200,q:0.6}); },
|
||||
chirp(){ tone({f:1400,f2:2100,t:'sine',dur:0.12,vol:0.1}); tone({f:1800,f2:2400,t:'sine',dur:0.1,vol:0.08,at:A.ctx&&A.ctx.currentTime+0.1}); },
|
||||
splash(){ noiseHit({dur:0.3,vol:0.28,f:900,q:0.4}); },
|
||||
trash(){ noiseHit({dur:0.12,vol:0.25,f:250,q:0.9}); },
|
||||
unlock(){ [392,523,659,784].forEach((f,i)=>tone({f,t:'sine',dur:0.25,vol:0.11,at:A.ctx&&A.ctx.currentTime+i*0.09})); },
|
||||
};
|
||||
WK.AudioSys = {
|
||||
ensure,
|
||||
get enabled(){return A.enabled;},
|
||||
get musicOn(){return A.musicOn;},
|
||||
sfx(name){
|
||||
if(!A.enabled) return;
|
||||
if(!ensure()) return;
|
||||
const fn=SFX[name]; if(fn) fn();
|
||||
},
|
||||
setScene(s){ A.scene=s; },
|
||||
toggleSound(){ A.enabled=!A.enabled; savePrefs(); if(A.enabled) ensure(); WK.UI&&WK.UI.refreshSoundBtn&&WK.UI.refreshSoundBtn(); return A.enabled; },
|
||||
toggleMusic(){ A.musicOn=!A.musicOn; savePrefs(); return A.musicOn; },
|
||||
};
|
||||
|
||||
/* ---------- music scheduler ----------
|
||||
Relaxed pentatonic marimba + warm pad, randomized gentle walk.
|
||||
Menu: slower & dreamier. Game: slightly brighter tempo. */
|
||||
const PENTA=[0,2,4,7,9];
|
||||
function nfreq(semi, base){ return base*Math.pow(2,semi/12); }
|
||||
|
||||
function startScheduler(){
|
||||
if(A.timer) return;
|
||||
A.timer=setInterval(()=>{
|
||||
if(!A.ctx || !A.enabled || !A.musicOn) return;
|
||||
const ahead = 0.45;
|
||||
const menu = A.scene==='menu';
|
||||
const stepDur = menu? 60/68/2 : 60/76/2; // eighth notes
|
||||
const root = menu? 220 : 246.94; // A3 / B3
|
||||
while(A.nextNote < A.ctx.currentTime + ahead){
|
||||
const t=Math.max(A.nextNote, A.ctx.currentTime+0.02);
|
||||
const bar=Math.floor(A.step/8), beat=A.step%8;
|
||||
// pad chord every bar
|
||||
if(beat===0){
|
||||
const deg=PENTA[(bar*2)%5];
|
||||
[0,7,12].forEach(iv=>tone({f:nfreq(deg+iv,root/2)*2,t:'sine',dur:stepDur*8,vol:0.035,bus:A.musicBus,at:t,attack:0.6}));
|
||||
tone({f:nfreq(PENTA[bar%5],root)/2,t:'triangle',dur:stepDur*3.6,vol:0.10,bus:A.musicBus,at:t,attack:0.01});
|
||||
}
|
||||
// pluck melody: sparse random walk on pentatonic
|
||||
const density = menu?0.42:0.55;
|
||||
if(WK.hash2(A.step,bar,7) < density){
|
||||
const idx = Math.floor(WK.hash2(A.step*13,bar,3)*PENTA.length);
|
||||
const oct = (WK.hash2(A.step,bar*3,11)<0.3)?12:0;
|
||||
tone({f:nfreq(PENTA[idx]+oct,root),t:'triangle',dur:stepDur*2.4,vol:0.085,bus:A.musicBus,at:t});
|
||||
if(WK.hash2(A.step,bar,23)<0.25)
|
||||
tone({f:nfreq(PENTA[idx]+oct+12,root)*2,t:'sine',dur:stepDur*1.6,vol:0.04,bus:A.musicBus,at:t+stepDur*0.5});
|
||||
}
|
||||
// soft shaker
|
||||
if(beat%2===1 && !menu) noiseHit({dur:0.03,vol:0.018,f:6000,q:1.2,bus:A.musicBus,at:t});
|
||||
A.nextNote = t + stepDur;
|
||||
A.step++;
|
||||
}
|
||||
},120);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user