IMPERIUM ARCANUM — web-based Caesar 3-like city builder with heroes, magic & monsters

- 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)
This commit is contained in:
deepseek
2026-08-23 07:00:05 +00:00
commit b642726428
38 changed files with 7288 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
// ─── 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;
}