reikhelm/game-web/audio.js
Parley Hatch 5813ee3b5a feat(game): REIKHELM: DESCENT — playable first-person roguelike
The pieces finally meet: procgen dungeons + the baked EoB atlas pipeline +
the Vigor combat engine become one game. Three fixed pre-baked floors
(seeds 7 → 69 → 59, 208 frames), monsters/loot/encounters rerolled per run
and composited over the empty scenes; the Ember Sovereign waits at the
bottom.

- game-wasm: run state machine in Rust (manifest nav graph as dungeon
  truth, seeded spawning by theme/depth, embedded combat-core Encounter,
  vigor/fatigue persistence between fights, rest/potions/relics/flee,
  permadeath + victory). 8 native tests incl. a whole-run bot and a
  ScriptedPlayer boss-winnability probe used for tuning.
- game-web: no-framework front-end — title/death/victory screens, baked
  frames with screen-blend monster portraits, combat overlay (dual vigor
  bars, cast telegraphs, cooldown sweeps, floaters), minimap with fog,
  all SFX synthesized in WebAudio.
- balance: floor-scaled levels with a hero's edge over trash, no packs on
  floor 1, boss tuned by sim sweep (~35%/55% win at skill 50/80).
- campaign atlases now versioned (tools/.gitignore exception); boss
  portrait added to the Z-Image roster.

Verified end-to-end in-browser: an autopilot beat the full game through
the real UI (10 fights, 2 flees, Sovereign down), and died plenty before
that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:30:50 -06:00

189 lines
6.1 KiB
JavaScript

// audio.js — procedural sound for REIKHELM: DESCENT. No assets: every cue is
// synthesized on a shared AudioContext (oscillators + filtered noise), so the
// game ships as code only. Context unlocks on the first user gesture.
let ctx = null;
let master = null;
let ambient = null;
function ac() {
if (!ctx) {
ctx = new (window.AudioContext || window.webkitAudioContext)();
master = ctx.createGain();
master.gain.value = 0.5;
master.connect(ctx.destination);
}
if (ctx.state === 'suspended') ctx.resume();
return ctx;
}
// One reusable noise buffer (1s of white noise).
let noiseBuf = null;
function noise() {
const c = ac();
if (!noiseBuf) {
noiseBuf = c.createBuffer(1, c.sampleRate, c.sampleRate);
const d = noiseBuf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
}
const src = c.createBufferSource();
src.buffer = noiseBuf;
return src;
}
// A tone with an exponential decay envelope.
function tone({ freq = 440, type = 'sine', dur = 0.2, vol = 0.3, when = 0, slide = 0 }) {
const c = ac();
const t0 = c.currentTime + when;
const osc = c.createOscillator();
const g = c.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t0);
if (slide) osc.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), t0 + dur);
g.gain.setValueAtTime(vol, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g).connect(master);
osc.start(t0);
osc.stop(t0 + dur + 0.02);
}
// A burst of filtered noise with a decay envelope.
function thump({ cutoff = 400, dur = 0.15, vol = 0.4, when = 0, q = 1, type = 'lowpass' }) {
const c = ac();
const t0 = c.currentTime + when;
const src = noise();
const f = c.createBiquadFilter();
f.type = type;
f.frequency.value = cutoff;
f.Q.value = q;
const g = c.createGain();
g.gain.setValueAtTime(vol, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
src.connect(f).connect(g).connect(master);
src.start(t0);
src.stop(t0 + dur + 0.02);
}
export const sfx = {
unlock() { ac(); },
step() {
thump({ cutoff: 220 + Math.random() * 80, dur: 0.12, vol: 0.35 });
thump({ cutoff: 150, dur: 0.08, vol: 0.2, when: 0.05 });
},
bump() {
thump({ cutoff: 120, dur: 0.2, vol: 0.5 });
tone({ freq: 65, type: 'triangle', dur: 0.18, vol: 0.3 });
},
turn() {
thump({ cutoff: 500, dur: 0.05, vol: 0.12 });
},
rest() {
tone({ freq: 196, type: 'sine', dur: 0.4, vol: 0.06, slide: 30 });
},
loot() {
tone({ freq: 880, type: 'triangle', dur: 0.1, vol: 0.2 });
tone({ freq: 1320, type: 'triangle', dur: 0.15, vol: 0.2, when: 0.07 });
},
relic() {
[523, 659, 784, 1046].forEach((f, i) => tone({ freq: f, type: 'triangle', dur: 0.3, vol: 0.18, when: i * 0.09 }));
},
potion() {
tone({ freq: 300, type: 'sine', dur: 0.25, vol: 0.2, slide: 260 });
},
encounter() {
tone({ freq: 110, type: 'sawtooth', dur: 0.5, vol: 0.25, slide: -45 });
thump({ cutoff: 800, dur: 0.4, vol: 0.25, when: 0.05 });
tone({ freq: 55, type: 'triangle', dur: 0.7, vol: 0.3, when: 0.1 });
},
growl() {
tone({ freq: 70, type: 'sawtooth', dur: 0.5, vol: 0.07, slide: -20 });
},
hit() {
thump({ cutoff: 900, dur: 0.1, vol: 0.4, type: 'bandpass', q: 1.5 });
tone({ freq: 180, type: 'square', dur: 0.07, vol: 0.12 });
},
heavyHit() {
thump({ cutoff: 500, dur: 0.25, vol: 0.55, type: 'bandpass' });
tone({ freq: 90, type: 'sawtooth', dur: 0.3, vol: 0.3, slide: -30 });
},
playerHit() {
thump({ cutoff: 300, dur: 0.2, vol: 0.5 });
tone({ freq: 140, type: 'square', dur: 0.12, vol: 0.15, slide: -40 });
},
cast() {
tone({ freq: 440, type: 'sine', dur: 0.35, vol: 0.12, slide: 320 });
},
fizzle() {
tone({ freq: 600, type: 'sawtooth', dur: 0.25, vol: 0.15, slide: -480 });
},
daze() {
tone({ freq: 660, type: 'triangle', dur: 0.6, vol: 0.2, slide: -200 });
tone({ freq: 663, type: 'triangle', dur: 0.6, vol: 0.2, slide: -210 });
},
guard() {
thump({ cutoff: 2500, dur: 0.08, vol: 0.2, type: 'highpass' });
tone({ freq: 520, type: 'square', dur: 0.06, vol: 0.08 });
},
recovery() {
tone({ freq: 392, type: 'sine', dur: 0.4, vol: 0.18, slide: 130 });
},
death() {
tone({ freq: 220, type: 'sawtooth', dur: 1.4, vol: 0.3, slide: -170 });
thump({ cutoff: 200, dur: 1.0, vol: 0.4, when: 0.1 });
},
monsterDeath() {
tone({ freq: 160, type: 'sawtooth', dur: 0.7, vol: 0.25, slide: -110 });
thump({ cutoff: 350, dur: 0.5, vol: 0.3, when: 0.05 });
},
fled() {
[400, 320, 260].forEach((f, i) => thump({ cutoff: f, dur: 0.1, vol: 0.3, when: i * 0.09 }));
},
descend() {
[200, 160, 120, 90].forEach((f, i) => {
thump({ cutoff: f * 2, dur: 0.25, vol: 0.35, when: i * 0.22 });
tone({ freq: f, type: 'triangle', dur: 0.3, vol: 0.15, when: i * 0.22 });
});
},
victory() {
[392, 523, 659, 784, 1046].forEach((f, i) =>
tone({ freq: f, type: 'triangle', dur: 0.5, vol: 0.2, when: i * 0.13 }));
thump({ cutoff: 4000, dur: 1.2, vol: 0.08, when: 0.6, type: 'highpass' });
},
// A faint dungeon bed: low drone + slow filtered rumble. Idempotent.
ambientOn() {
const c = ac();
if (ambient) return;
const g = c.createGain();
g.gain.value = 0.05;
const drone = c.createOscillator();
drone.type = 'sine';
drone.frequency.value = 55;
const drone2 = c.createOscillator();
drone2.type = 'sine';
drone2.frequency.value = 55.7; // beat frequency shimmer
const n = noise();
n.loop = true;
const f = c.createBiquadFilter();
f.type = 'lowpass';
f.frequency.value = 90;
const ng = c.createGain();
ng.gain.value = 0.4;
drone.connect(g);
drone2.connect(g);
n.connect(f).connect(ng).connect(g);
g.connect(master);
drone.start();
drone2.start();
n.start();
ambient = { g, drone, drone2, n };
},
ambientOff() {
if (!ambient) return;
const { g, drone, drone2, n } = ambient;
g.gain.linearRampToValueAtTime(0, ac().currentTime + 0.6);
setTimeout(() => { drone.stop(); drone2.stop(); n.stop(); }, 700);
ambient = null;
},
};