Arcane Tycoon — Heroes & Magic theme park tycoon game
Complete browser game inspired by OpenRCT2 with fantasy twist: - Custom roller coaster designer with physics-based ratings + on-ride POV - 10 animated rides, 7 shops, 16 scenery items, path network & guest AI - Heroes guild vs monster invasions (5 classes, XP/gear/bosses) - Magic spell system (8 spells), research tree, economy/marketing/loans - Day-night cycle, weather, park rating, awards, 4 scenarios - Save/load slots + autosave, procedural WebAudio SFX/music - Isometric canvas renderer, minimap, diagnostics overlay - Test suites: smoke(13), linkcheck, inputcheck, framecheck, rendercheck, flow
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
// ============ audio.js — procedural WebAudio SFX & ambient music ============
|
||||
let AC = null;
|
||||
let masterGain = null, musicGain = null, sfxGain = null;
|
||||
let musicTimer = null;
|
||||
let enabled = true;
|
||||
const settings = { master: 0.7, music: 0.5, sfx: 0.8 };
|
||||
|
||||
export function initAudio() {
|
||||
if (AC) return;
|
||||
try {
|
||||
AC = new (window.AudioContext || window.webkitAudioContext)();
|
||||
masterGain = AC.createGain();
|
||||
musicGain = AC.createGain();
|
||||
sfxGain = AC.createGain();
|
||||
musicGain.connect(masterGain);
|
||||
sfxGain.connect(masterGain);
|
||||
masterGain.connect(AC.destination);
|
||||
applyVolumes();
|
||||
} catch (e) { console.warn('Audio unavailable', e); enabled = false; }
|
||||
}
|
||||
export function audioSupported() { return !!AC; }
|
||||
export function setVolumes(v) {
|
||||
Object.assign(settings, v);
|
||||
try { localStorage.setItem('at_audio', JSON.stringify(settings)); } catch { }
|
||||
applyVolumes();
|
||||
}
|
||||
export function getVolumes() { return { ...settings }; }
|
||||
function applyVolumes() {
|
||||
if (!AC) return;
|
||||
masterGain.gain.value = settings.master;
|
||||
musicGain.gain.value = settings.music * 0.5;
|
||||
sfxGain.gain.value = settings.sfx;
|
||||
}
|
||||
|
||||
// resume on first user gesture
|
||||
export function unlockAudio() {
|
||||
initAudio();
|
||||
if (AC && AC.state === 'suspended') AC.resume();
|
||||
}
|
||||
|
||||
function tone(freq, dur, type = 'sine', vol = 0.3, dest, slideTo) {
|
||||
if (!AC || !enabled) return;
|
||||
const o = AC.createOscillator();
|
||||
const g = AC.createGain();
|
||||
o.type = type;
|
||||
o.frequency.value = freq;
|
||||
if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), AC.currentTime + dur);
|
||||
g.gain.setValueAtTime(vol, AC.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, AC.currentTime + dur);
|
||||
o.connect(g); g.connect(dest || sfxGain);
|
||||
o.start(); o.stop(AC.currentTime + dur + 0.02);
|
||||
}
|
||||
function noise(dur, vol = 0.2, filterFreq = 800) {
|
||||
if (!AC || !enabled) return;
|
||||
const len = AC.sampleRate * dur;
|
||||
const buf = AC.createBuffer(1, len, AC.sampleRate);
|
||||
const d = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
|
||||
const src = AC.createBufferSource();
|
||||
src.buffer = buf;
|
||||
const f = AC.createBiquadFilter();
|
||||
f.type = 'lowpass'; f.frequency.value = filterFreq;
|
||||
const g = AC.createGain();
|
||||
g.gain.setValueAtTime(vol, AC.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, AC.currentTime + dur);
|
||||
src.connect(f); f.connect(g); g.connect(sfxGain);
|
||||
src.start();
|
||||
}
|
||||
|
||||
export const sfx = {
|
||||
click() { tone(660, .06, 'square', .12); },
|
||||
place() { noise(.08, .25, 500); tone(220, .1, 'triangle', .2); },
|
||||
demolish() { noise(.22, .3, 300); },
|
||||
cash() { tone(880, .09, 'sine', .18); setTimeout(() => tone(1320, .12, 'sine', .16), 70); },
|
||||
error() { tone(180, .18, 'sawtooth', .15, null, 120); },
|
||||
openRide() { [440, 554, 659, 880].forEach((f, i) => setTimeout(() => tone(f, .14, 'triangle', .15), i * 90)); },
|
||||
whoosh() { noise(.5, .18, 900); },
|
||||
spell() {
|
||||
if (!AC) return;
|
||||
[660, 830, 990, 1245].forEach((f, i) => setTimeout(() => tone(f, .3, 'sine', .12), i * 60));
|
||||
},
|
||||
hit() { noise(.07, .28, 700); tone(140, .08, 'square', .14); },
|
||||
monsterRoar() { tone(90, .5, 'sawtooth', .25, null, 50); noise(.4, .2, 250); },
|
||||
victory() { [523, 659, 784, 1046].forEach((f, i) => setTimeout(() => tone(f, .35, 'triangle', .2), i * 160)); },
|
||||
defeat() { [400, 340, 280, 200].forEach((f, i) => setTimeout(() => tone(f, .4, 'sawtooth', .15), i * 200)); },
|
||||
levelup() { [600, 750, 900].forEach((f, i) => setTimeout(() => tone(f, .12, 'square', .1), i * 80)); },
|
||||
};
|
||||
|
||||
// ------- generative ambient music: gentle fantasy pad loop -------
|
||||
const SCALE = [261.63, 293.66, 329.63, 392.00, 440.00, 523.25]; // C pentatonic-ish
|
||||
let musicOn = false;
|
||||
export function startMusic() {
|
||||
if (!AC || musicOn) return;
|
||||
musicOn = true;
|
||||
const stepFn = () => {
|
||||
if (!musicOn) return;
|
||||
// soft chord every ~2s
|
||||
const root = SCALE[Math.floor(Math.random() * 3)];
|
||||
const third = SCALE[Math.floor(Math.random() * SCALE.length)];
|
||||
tone(root / 2, 2.4, 'sine', .10, musicGain);
|
||||
tone(third, 2.2, 'triangle', .05, musicGain);
|
||||
if (Math.random() < 0.4) tone(root * 2, 1.8, 'sine', .04, musicGain);
|
||||
musicTimer = setTimeout(stepFn, 1800 + Math.random() * 900);
|
||||
};
|
||||
stepFn();
|
||||
}
|
||||
export function stopMusic() {
|
||||
musicOn = false;
|
||||
if (musicTimer) clearTimeout(musicTimer);
|
||||
}
|
||||
export function isMusicOn() { return musicOn; }
|
||||
Reference in New Issue
Block a user