- Full classic layer: roads/walkers, 11-tier housing evolution, food chain, industry chains, trade routes, taxes/wages, ratings, fires & collapse - Fantasy layer: heroes (XP/levels/auras), mana & 7 spells, monsters/invasions, walls/towers/legions/golems, campaign of 5 scenarios + endless sandbox - Canvas2D isometric engine with procedural sprites, zero runtime deps - Save/load slots + autosave, WebAudio synth music/SFX, HUD/menus/minimap - Tests: headless sim smoke test + Playwright E2E (build/combat/save)
130 lines
5.9 KiB
TypeScript
130 lines
5.9 KiB
TypeScript
// ─── Audio: WebAudio synthesized SFX + ambient music ───────────────────────
|
|
import { Sfx } from '../render/fx';
|
|
|
|
let ctx: AudioContext | null = null;
|
|
let master: GainNode | null = null;
|
|
let musicGain: GainNode | null = null;
|
|
let musicTimer: number | null = null;
|
|
export const audioState = { enabled: true, music: true, volume: 0.7, started: false };
|
|
|
|
function ac(): AudioContext | null {
|
|
if (!audioState.enabled) return null;
|
|
if (!ctx) {
|
|
try {
|
|
ctx = new (window.AudioContext || (window as never as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
|
master = ctx.createGain();
|
|
master.gain.value = audioState.volume;
|
|
master.connect(ctx.destination);
|
|
musicGain = ctx.createGain();
|
|
musicGain.gain.value = 0.35;
|
|
musicGain.connect(master);
|
|
} catch { return null; }
|
|
}
|
|
if (ctx.state === 'suspended') void ctx.resume();
|
|
return ctx;
|
|
}
|
|
|
|
export function unlockAudio() { ac(); audioState.started = true; }
|
|
|
|
interface ToneOpts { freq: number; dur?: number; type?: OscillatorType; vol?: number; slide?: number; delay?: number; attack?: number; }
|
|
function tone(o: ToneOpts) {
|
|
const c = ac(); if (!c || !master) return;
|
|
const t0 = c.currentTime + (o.delay ?? 0);
|
|
const osc = c.createOscillator();
|
|
osc.type = o.type ?? 'square';
|
|
osc.frequency.setValueAtTime(o.freq, t0);
|
|
if (o.slide) osc.frequency.exponentialRampToValueAtTime(Math.max(20, o.freq + o.slide), t0 + (o.dur ?? 0.2));
|
|
const g = c.createGain();
|
|
g.gain.setValueAtTime(0.0001, t0);
|
|
g.gain.exponentialRampToValueAtTime(o.vol ?? 0.2, t0 + (o.attack ?? 0.01));
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + (o.dur ?? 0.2));
|
|
osc.connect(g); g.connect(master);
|
|
osc.start(t0); osc.stop(t0 + (o.dur ?? 0.2) + 0.05);
|
|
}
|
|
function noise(dur: number, vol = 0.25, filterFreq = 800, delay = 0) {
|
|
const c = ac(); if (!c || !master) return;
|
|
const t0 = c.currentTime + delay;
|
|
const len = Math.max(1, Math.floor(c.sampleRate * dur));
|
|
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 f = c.createBiquadFilter();
|
|
f.type = 'lowpass'; f.frequency.value = filterFreq;
|
|
const g = c.createGain(); g.gain.value = vol;
|
|
src.connect(f); f.connect(g); g.connect(master);
|
|
src.start(t0);
|
|
}
|
|
|
|
const SFX: Record<string, () => void> = {
|
|
build: () => { tone({ freq: 160, dur: 0.12, type: 'triangle', vol: 0.3 }); noise(0.08, 0.15, 500); },
|
|
demolish: () => { noise(0.25, 0.3, 400); tone({ freq: 90, dur: 0.2, type: 'sawtooth', vol: 0.18, slide: -40 }); },
|
|
coin: () => { tone({ freq: 880, dur: 0.07, type: 'square', vol: 0.12 }); tone({ freq: 1320, dur: 0.09, type: 'square', vol: 0.1, delay: 0.06 }); },
|
|
click: () => tone({ freq: 660, dur: 0.04, type: 'square', vol: 0.07 }),
|
|
clash: () => { noise(0.06, 0.22, 2600); tone({ freq: 2200, dur: 0.05, vol: 0.06, type: 'triangle', slide: -600 }); },
|
|
arrow: () => tone({ freq: 900, dur: 0.08, type: 'sawtooth', vol: 0.08, slide: -300 }),
|
|
zap: () => { tone({ freq: 1400, dur: 0.12, type: 'sawtooth', vol: 0.14, slide: -900 }); },
|
|
fire: () => noise(0.5, 0.12, 700),
|
|
crumble: () => { noise(0.4, 0.35, 300); tone({ freq: 70, dur: 0.35, type: 'sawtooth', vol: 0.2, slide: -30 }); },
|
|
smash: () => noise(0.12, 0.28, 900),
|
|
horn: () => { tone({ freq: 196, dur: 0.5, type: 'sawtooth', vol: 0.16 }); tone({ freq: 262, dur: 0.55, type: 'sawtooth', vol: 0.13, delay: 0.18 }); },
|
|
monster: () => { tone({ freq: 90, dur: 0.7, type: 'sawtooth', vol: 0.24, slide: -45 }); noise(0.6, 0.14, 350, 0.1); },
|
|
heal: () => { [523, 659, 784].forEach((f, i) => tone({ freq: f, dur: 0.25, type: 'sine', vol: 0.12, delay: i * 0.09 })); },
|
|
fanfare: () => { [392, 523, 659, 784].forEach((f, i) => tone({ freq: f, dur: 0.32, type: 'triangle', vol: 0.16, delay: i * 0.13 })); },
|
|
evolve: () => { tone({ freq: 523, dur: 0.1, type: 'sine', vol: 0.12 }); tone({ freq: 784, dur: 0.14, type: 'sine', vol: 0.12, delay: 0.09 }); },
|
|
firestorm: () => { noise(0.7, 0.4, 1200); tone({ freq: 60, dur: 0.6, type: 'sawtooth', vol: 0.26, slide: -25 }); },
|
|
};
|
|
|
|
// ── ambient music: slow lyre-ish arpeggio over drone ──
|
|
const SCALE = [196.0, 233.1, 261.6, 293.7, 349.2, 392.0, 466.2]; // G minor pentatonic-ish
|
|
let step = 0;
|
|
function musicStep() {
|
|
const c = ac(); if (!c || !musicGain) return;
|
|
const root = SCALE[step % SCALE.length];
|
|
// pluck
|
|
const t0 = c.currentTime;
|
|
for (const mult of [1, 2]) {
|
|
const osc = c.createOscillator();
|
|
osc.type = mult === 1 ? 'triangle' : 'sine';
|
|
osc.frequency.value = root * mult;
|
|
const g = c.createGain();
|
|
g.gain.setValueAtTime(0.0001, t0);
|
|
g.gain.exponentialRampToValueAtTime(mult === 1 ? 0.16 : 0.05, t0 + 0.02);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 1.4);
|
|
osc.connect(g); g.connect(musicGain);
|
|
osc.start(t0); osc.stop(t0 + 1.5);
|
|
}
|
|
if (step % 8 === 0) { // low drone swell
|
|
const osc = c.createOscillator();
|
|
osc.type = 'sine';
|
|
osc.frequency.value = SCALE[(step >> 3) % 3] / 2;
|
|
const g = c.createGain();
|
|
g.gain.setValueAtTime(0.0001, t0);
|
|
g.gain.linearRampToValueAtTime(0.08, t0 + 1.2);
|
|
g.gain.linearRampToValueAtTime(0.0001, t0 + 4.5);
|
|
osc.connect(g); g.connect(musicGain);
|
|
osc.start(t0); osc.stop(t0 + 4.7);
|
|
}
|
|
step++;
|
|
}
|
|
|
|
export function startMusic() {
|
|
if (musicTimer !== null || !audioState.music) return;
|
|
musicTimer = window.setInterval(() => { if (audioState.music && audioState.enabled && document.visibilityState === 'visible') musicStep(); }, 1400);
|
|
}
|
|
export function stopMusic() { if (musicTimer !== null) { clearInterval(musicTimer); musicTimer = null; } }
|
|
|
|
export function playSfx(name: string) {
|
|
if (!audioState.enabled) return;
|
|
SFX[name]?.();
|
|
}
|
|
|
|
// wire the global hook used by renderer/sim
|
|
Sfx.play = (name: string) => playSfx(name);
|
|
|
|
export function setVolume(v: number) {
|
|
audioState.volume = v;
|
|
if (master) master.gain.value = v;
|
|
}
|