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>
189 lines
6.1 KiB
JavaScript
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;
|
|
},
|
|
};
|