Files
wuxia-100-days-after/src/05_audio.js
T
deepseek 3b1c7a72da WUXIA: 100 Days After — full game
Single-file ink-painting wuxia sect-survival RPG.
Lead a sect through 100 days: martial arts combos, cultivation,
tactical combat, faction war, chronicle endings.

- src/ 13 modules (data, sim, combat, render, UI, app shell)
- wuxia.html self-contained build (no dependencies)
- tools/build.js bundler
- tests: headless 100-day sims + jsdom UI smoke + real-browser
  Chromium click-through (playwright)
2026-08-23 07:01:52 +00:00

210 lines
12 KiB
JavaScript

/* =========================================================================
Procedural audio: Chinese-pentatonic generative score + SFX (WebAudio)
========================================================================= */
(function () {
if (W.HEADLESS) { W.audio = { init() { }, setMood() { }, sfx() { }, setVol() { }, tick() { } }; return; }
const A = W.audio = {
ctx: null, master: null, musicG: null, sfxG: null,
mood: null, moodName: '', started: false,
vol: { music: 0.6, sfx: 0.8 },
nextBeat: 0, beat: 0, timer: null,
};
const MOODS = {
title: { bpm: 52, root: 220, scale: [0, 2, 4, 7, 9, 12, 14], pluck: 0.5, erhu: 0.35, flute: 0, drum: 0, bright: 0.5 },
sect: { bpm: 56, root: 196, scale: [0, 2, 4, 7, 9, 12], pluck: 0.55, erhu: 0.3, flute: 0.15, drum: 0, bright: 0.6 },
travel: { bpm: 66, root: 220, scale: [0, 3, 5, 7, 10, 12], pluck: 0.6, erhu: 0.3, flute: 0.1, drum: 0.08, bright: 0.55 },
town: { bpm: 72, root: 233, scale: [0, 2, 4, 7, 9, 12], pluck: 0.65, erhu: 0.25, flute: 0.2, drum: 0.1, bright: 0.7 },
night: { bpm: 44, root: 174, scale: [0, 3, 5, 7, 10], pluck: 0.3, erhu: 0.4, flute: 0.35, drum: 0, bright: 0.25 },
event: { bpm: 50, root: 185, scale: [0, 2, 3, 7, 8], pluck: 0.25, erhu: 0.5, flute: 0.1, drum: 0.05, bright: 0.35 },
combat: { bpm: 132, root: 220, scale: [0, 3, 5, 7, 10, 12], pluck: 0.5, erhu: 0.2, flute: 0, drum: 0.5, bright: 0.5 },
dark: { bpm: 40, root: 146, scale: [0, 1, 5, 6, 10], pluck: 0.2, erhu: 0.45, flute: 0.1, drum: 0.05, bright: 0.15 },
};
function ensure() {
if (A.ctx) return true;
try {
const AC = window.AudioContext || window.webkitAudioContext;
A.ctx = new AC();
A.master = A.ctx.createGain(); A.master.gain.value = 0.9; A.master.connect(A.ctx.destination);
A.musicG = A.ctx.createGain(); A.musicG.gain.value = A.vol.music; A.musicG.connect(A.master);
A.sfxG = A.ctx.createGain(); A.sfxG.gain.value = A.vol.sfx; A.sfxG.connect(A.master);
// noise buffer
const len = A.ctx.sampleRate * 2;
A.noiseBuf = A.ctx.createBuffer(1, len, A.ctx.sampleRate);
const d = A.noiseBuf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
return true;
} catch (e) { return false; }
}
A.init = function () {
if (!ensure()) return;
if (A.ctx.state === 'suspended') A.ctx.resume();
if (!A.started) { A.started = true; A.nextBeat = A.ctx.currentTime + 0.1; A.timer = setInterval(A.tick, 120); }
};
A.setVol = function (music, sfx) {
if (music != null) A.vol.music = music;
if (sfx != null) A.vol.sfx = sfx;
if (A.musicG) A.musicG.gain.value = A.vol.music;
if (A.sfxG) A.sfxG.gain.value = A.vol.sfx;
};
A.setMood = function (name) {
if (!MOODS[name] || A.moodName === name) return;
A.moodName = name; A.mood = MOODS[name]; A.beat = 0;
};
/* ------- instruments ------- */
function pluck(freq, t, dur, g, bright) {
const c = A.ctx, o1 = c.createOscillator(), o2 = c.createOscillator(), f = c.createBiquadFilter(), gn = c.createGain();
o1.type = 'triangle'; o2.type = 'sine'; o1.frequency.value = freq; o2.frequency.value = freq * 2.001;
f.type = 'lowpass'; f.frequency.value = 900 + bright * 2600; f.Q.value = 0.7;
gn.gain.setValueAtTime(0.0001, t);
gn.gain.exponentialRampToValueAtTime(g, t + 0.008);
gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o1.connect(f); o2.connect(f); f.connect(gn); gn.connect(A.musicG);
o1.start(t); o2.start(t); o1.stop(t + dur + 0.05); o2.stop(t + dur + 0.05);
}
function erhu(freq, t, dur, g) {
const c = A.ctx, o = c.createOscillator(), f = c.createBiquadFilter(), gn = c.createGain(), lfo = c.createOscillator(), lg = c.createGain();
o.type = 'sawtooth'; o.frequency.setValueAtTime(freq * 0.98, t);
o.frequency.linearRampToValueAtTime(freq, t + Math.min(0.12, dur * 0.3));
f.type = 'lowpass'; f.frequency.value = 1500; f.Q.value = 2;
lfo.frequency.value = 5.2; lg.gain.value = freq * 0.008;
lfo.connect(lg); lg.connect(o.frequency);
gn.gain.setValueAtTime(0.0001, t);
gn.gain.linearRampToValueAtTime(g, t + dur * 0.25);
gn.gain.linearRampToValueAtTime(0.0001, t + dur);
o.connect(f); f.connect(gn); gn.connect(A.musicG);
o.start(t); lfo.start(t); o.stop(t + dur + 0.05); lfo.stop(t + dur + 0.05);
}
function flute(freq, t, dur, g) {
const c = A.ctx, o = c.createOscillator(), gn = c.createGain(), f = c.createBiquadFilter(), n = c.createBufferSource(), nf = c.createBiquadFilter(), ng = c.createGain();
o.type = 'sine'; o.frequency.value = freq;
f.type = 'lowpass'; f.frequency.value = 1800;
gn.gain.setValueAtTime(0.0001, t);
gn.gain.linearRampToValueAtTime(g, t + dur * 0.3);
gn.gain.linearRampToValueAtTime(0.0001, t + dur);
n.buffer = A.noiseBuf; nf.type = 'bandpass'; nf.frequency.value = freq * 2; ng.gain.value = g * 0.15;
o.connect(f); f.connect(gn); gn.connect(A.musicG);
n.connect(nf); nf.connect(ng); ng.connect(A.musicG);
o.start(t); n.start(t); o.stop(t + dur + 0.05); n.stop(t + dur + 0.05);
}
function drum(t, g, low) {
const c = A.ctx, o = c.createOscillator(), gn = c.createGain(), n = c.createBufferSource(), nf = c.createBiquadFilter(), ng = c.createGain();
o.type = 'sine'; o.frequency.setValueAtTime(low ? 90 : 160, t); o.frequency.exponentialRampToValueAtTime(40, t + 0.18);
gn.gain.setValueAtTime(g, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
o.connect(gn); gn.connect(A.musicG); o.start(t); o.stop(t + 0.25);
n.buffer = A.noiseBuf; nf.type = 'bandpass'; nf.frequency.value = low ? 200 : 900; ng.gain.setValueAtTime(g * 0.5, t); ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.12);
n.connect(nf); nf.connect(ng); ng.connect(A.musicG); n.start(t); n.stop(t + 0.15);
}
A.tick = function () {
if (!A.ctx || !A.mood || A.ctx.state !== 'running') return;
const m = A.mood, spb = 60 / m.bpm, now = A.ctx.currentTime;
while (A.nextBeat < now + 0.4) {
const t = A.nextBeat, b = A.beat;
const sc = m.scale, deg = sc[Math.floor(W.rng() * sc.length)];
const oct = W.chance(0.2) ? 2 : 1;
if (W.chance(m.pluck * 0.8)) pluck(m.root * oct * Math.pow(2, deg / 12), t, spb * W.rf(1.5, 3), 0.16, m.bright);
if (b % 4 === 0 && W.chance(m.erhu)) erhu(m.root * Math.pow(2, sc[Math.floor(W.rng() * 3)] / 12), t, spb * W.rf(2.5, 4.5), 0.075);
if (b % 8 === 4 && W.chance(m.flute)) flute(m.root * 2 * Math.pow(2, sc[0] / 12), t, spb * 3, 0.05);
if (m.drum > 0) {
if (b % 4 === 0) drum(t, 0.12 * m.drum * 2, true);
if (b % 2 === 1 && W.chance(0.5)) drum(t, 0.05 * m.drum * 2, false);
}
A.nextBeat += spb; A.beat++;
}
};
/* ------- SFX ------- */
const SFX = {
click(t) { blip(t, 1800, 0.05, 0.12, 'square'); },
hover(t) { blip(t, 2400, 0.03, 0.05, 'sine'); },
seal(t) { thud(t, 0.25, 120); noise(t, 0.1, 3000, 0.1); },
brush(t) { noise(t, 0.22, 1400, 0.12, 'bandpass'); },
paper(t) { noise(t, 0.15, 5000, 0.06, 'highpass'); },
coin(t) { blip(t, 1300, 0.09, 0.12, 'triangle'); blip(t + 0.07, 1750, 0.12, 0.1, 'triangle'); },
eat(t) { noise(t, 0.08, 800, 0.08, 'lowpass'); },
sword(t) { noise(t, 0.12, 4200, 0.16, 'highpass'); blip(t, 2800, 0.15, 0.08, 'sawtooth', true); },
clash(t) { blip(t, 3200, 0.18, 0.14, 'square', true); noise(t, 0.1, 5000, 0.14, 'highpass'); },
hit(t) { thud(t, 0.3, 150); noise(t, 0.08, 900, 0.12, 'lowpass'); },
crit(t) { thud(t, 0.4, 100); noise(t, 0.16, 2500, 0.2, 'bandpass'); blip(t + 0.03, 220, 0.25, 0.15, 'sawtooth', true); },
block(t) { blip(t, 500, 0.1, 0.14, 'square'); noise(t, 0.06, 2000, 0.08, 'bandpass'); },
heal(t) { arpeggio(t, [523, 659, 784], 0.09, 0.09, 'sine'); },
poison(t) { noise(t, 0.3, 400, 0.1, 'lowpass'); blip(t + 0.1, 180, 0.2, 0.08, 'sawtooth', true); },
thunder(t) { noise(t, 0.5, 1200, 0.3, 'lowpass'); thud(t, 0.4, 70); },
bell(t) { bell(t, 440, 0.3); },
gong(t) { bell(t, 98, 0.5); bell(t + 0.02, 147, 0.4, 0.5); },
levelup(t) { arpeggio(t, [392, 523, 659, 784], 0.1, 0.12, 'triangle'); },
whoosh(t) { noise(t, 0.25, 900, 0.12, 'bandpass', 0.5); },
death(t) { thud(t, 0.4, 80); blip(t + 0.1, 140, 0.5, 0.1, 'sawtooth', true); },
win(t) { arpeggio(t, [523, 659, 784, 1046], 0.12, 0.14, 'triangle'); bell(t + 0.5, 1046, 0.2, 0.3); },
lose(t) { arpeggio(t, [392, 330, 262, 196], 0.16, 0.12, 'sine'); thud(t + 0.7, 0.3, 70); },
rain_on(t) { },
};
function blip(t, freq, dur, g, type, slide) {
const c = A.ctx, o = c.createOscillator(), gn = c.createGain(), f = c.createBiquadFilter();
o.type = type || 'sine'; o.frequency.setValueAtTime(freq, t);
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq * 0.25), t + dur);
f.type = 'lowpass'; f.frequency.value = 6000;
gn.gain.setValueAtTime(g, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(f); f.connect(gn); gn.connect(A.sfxG); o.start(t); o.stop(t + dur + 0.05);
}
function thud(t, g, freq) {
const c = A.ctx, o = c.createOscillator(), gn = c.createGain();
o.type = 'sine'; o.frequency.setValueAtTime(freq * 2.2, t); o.frequency.exponentialRampToValueAtTime(freq * 0.5, t + 0.16);
gn.gain.setValueAtTime(g, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + 0.2);
o.connect(gn); gn.connect(A.sfxG); o.start(t); o.stop(t + 0.25);
}
function noise(t, dur, freq, g, type, sweep) {
const c = A.ctx, n = c.createBufferSource(), f = c.createBiquadFilter(), gn = c.createGain();
n.buffer = A.noiseBuf; n.loop = true;
f.type = type || 'bandpass'; f.frequency.setValueAtTime(freq, t); f.Q.value = 0.8;
if (sweep) f.frequency.exponentialRampToValueAtTime(freq * (0.4 + sweep), t + dur);
gn.gain.setValueAtTime(0.0001, t); gn.gain.linearRampToValueAtTime(g, t + dur * 0.2); gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
n.connect(f); f.connect(gn); gn.connect(A.sfxG); n.start(t); n.stop(t + dur + 0.05);
}
function bell(t, freq, g, mul) {
const c = A.ctx, partials = [1, 2.01, 2.9, 4.2], base = g || 0.2;
for (let i = 0; i < partials.length; i++) {
const o = c.createOscillator(), gn = c.createGain();
o.type = 'sine'; o.frequency.value = freq * partials[i] * (mul || 1);
const gg = base / (i + 1.5);
gn.gain.setValueAtTime(gg, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + 1.6 - i * 0.3);
o.connect(gn); gn.connect(A.sfxG); o.start(t); o.stop(t + 1.8);
}
}
function arpeggio(t, freqs, step, g, type) {
freqs.forEach((f, i) => blip(t + i * step, f, step * 2.2, g, type || 'sine'));
}
A.sfx = function (name) {
if (!A.ctx || A.ctx.state !== 'running' || !SFX[name]) return;
try { SFX[name](A.ctx.currentTime + 0.001); } catch (e) { }
};
// ambient loops (rain/wind) as filtered noise with slow LFO
let ambSrc = null, ambFilter = null, ambGain = null, ambKind = '';
A.ambient = function (kind) {
if (!A.ctx) return;
if (kind === ambKind) return;
ambKind = kind;
if (ambSrc) { try { ambSrc.stop(); } catch (e) { } ambSrc = null; }
if (!kind) return;
const c = A.ctx;
ambSrc = c.createBufferSource(); ambSrc.buffer = A.noiseBuf; ambSrc.loop = true;
ambFilter = c.createBiquadFilter(); ambGain = c.createGain();
if (kind === 'rain') { ambFilter.type = 'bandpass'; ambFilter.frequency.value = 2600; ambFilter.Q.value = 0.4; ambGain.gain.value = 0.055; }
else if (kind === 'storm') { ambFilter.type = 'bandpass'; ambFilter.frequency.value = 1500; ambFilter.Q.value = 0.3; ambGain.gain.value = 0.09; }
else if (kind === 'wind') { ambFilter.type = 'lowpass'; ambFilter.frequency.value = 500; ambGain.gain.value = 0.05; }
else if (kind === 'river') { ambFilter.type = 'bandpass'; ambFilter.frequency.value = 5200; ambFilter.Q.value = 0.2; ambGain.gain.value = 0.03; }
else return;
ambSrc.connect(ambFilter); ambFilter.connect(ambGain); ambGain.connect(A.master);
ambSrc.start();
};
})();