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)
This commit is contained in:
+209
@@ -0,0 +1,209 @@
|
||||
/* =========================================================================
|
||||
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();
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user