Vanilla JS + Canvas, zero dependencies, offline-first PWA. Gameplay: - 11 weapons x8 levels + 11 evolutions (chest-based), incl. timed mines - 13 passives, crit system with directional hit-sparks & hit-stop - 10 characters w/ unique mods + unlock conditions, gold cosmetic skins - 3 biomes (Neon Graveyard / Frozen Hollow / Magma Rift) each with own spawn tables, boss plans and music flavor; Endless mode + surges; 4 difficulty grades; breakable crystal-lamp props - Elite random affixes (Swift/Sturdy/Volatile), 4 bosses, win flow - Achievements (23) w/ gold rewards, run history, daily seeded challenge Tech: - Cinematic canvas main-menu scene, game-feel FX suite (trails, muzzle, status tints, low-HP pulse), viewport culling + particle pooling - WebAudio synth SFX + generative per-biome soundtrack - Gamepad support, remappable keys, touch joystick, fullscreen - i18n VI/EN, localStorage saves w/ export-import codes - Cloudflare Workers leaderboard scaffold (KV) w/ signed submits - Headless integrity test-suite (node test/integrity.js)
196 lines
8.5 KiB
JavaScript
196 lines
8.5 KiB
JavaScript
'use strict';
|
||
/* ============================================================
|
||
NEON SURVIVORS — audio.js : WebAudio synth SFX + generative music
|
||
No audio files needed; everything is synthesized.
|
||
============================================================ */
|
||
|
||
const Snd = {
|
||
ctx: null,
|
||
master: null,
|
||
musicG: null,
|
||
sfxG: null,
|
||
|
||
// music sequencer state
|
||
_step: 0,
|
||
_nextTime: 0,
|
||
_timer: null,
|
||
intensity: 0.2, // 0..1 — drives music layers
|
||
_xpCombo: 0,
|
||
_xpComboT: 0,
|
||
|
||
init() {
|
||
if (this.ctx) { if (this.ctx.state === 'suspended') this.ctx.resume(); return; }
|
||
const AC = window.AudioContext || window.webkitAudioContext;
|
||
if (!AC) return;
|
||
this.ctx = new AC();
|
||
this.master = this.ctx.createGain();
|
||
this.musicG = this.ctx.createGain();
|
||
this.sfxG = this.ctx.createGain();
|
||
this.master.connect(this.ctx.destination);
|
||
this.musicG.connect(this.master);
|
||
this.sfxG.connect(this.master);
|
||
this.applyVolumes();
|
||
this._startMusic();
|
||
},
|
||
|
||
applyVolumes() {
|
||
if (!this.ctx) return;
|
||
const s = Store.s();
|
||
this.master.gain.value = s.volMaster;
|
||
this.musicG.gain.value = s.volMusic * 0.9;
|
||
this.sfxG.gain.value = s.volSfx;
|
||
},
|
||
|
||
midi(n) { return 440 * Math.pow(2, (n - 69) / 12); },
|
||
|
||
/** Generic oscillator blip. */
|
||
tone(o) {
|
||
if (!this.ctx) return;
|
||
const t0 = (o.delay || 0) + this.ctx.currentTime;
|
||
const osc = this.ctx.createOscillator();
|
||
const g = this.ctx.createGain();
|
||
osc.type = o.type || 'square';
|
||
osc.frequency.setValueAtTime(o.f || 440, t0);
|
||
if (o.f2) osc.frequency.exponentialRampToValueAtTime(Math.max(1, o.f2), t0 + (o.dur || .1));
|
||
const vol = o.vol !== undefined ? o.vol : .2;
|
||
const dur = o.dur || .1;
|
||
g.gain.setValueAtTime(0.0001, t0);
|
||
g.gain.linearRampToValueAtTime(vol, t0 + (o.attack || .004));
|
||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||
osc.connect(g); g.connect(o.dest || this.sfxG);
|
||
osc.start(t0); osc.stop(t0 + dur + .05);
|
||
},
|
||
|
||
/** Filtered noise burst. */
|
||
noise(o) {
|
||
if (!this.ctx) return;
|
||
const dur = o.dur || .15;
|
||
const t0 = (o.delay || 0) + this.ctx.currentTime;
|
||
const len = Math.max(1, Math.floor(this.ctx.sampleRate * dur));
|
||
const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
|
||
const d = buf.getChannelData(0);
|
||
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
|
||
const src = this.ctx.createBufferSource();
|
||
src.buffer = buf;
|
||
const flt = this.ctx.createBiquadFilter();
|
||
flt.type = o.hp ? 'highpass' : 'lowpass';
|
||
flt.frequency.setValueAtTime(o.f || 1200, t0);
|
||
if (o.f2) flt.frequency.exponentialRampToValueAtTime(Math.max(20, o.f2), t0 + dur);
|
||
const g = this.ctx.createGain();
|
||
g.gain.setValueAtTime(o.vol !== undefined ? o.vol : .2, t0);
|
||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||
src.connect(flt); flt.connect(g); g.connect(this.sfxG);
|
||
src.start(t0); src.stop(t0 + dur + .02);
|
||
},
|
||
|
||
/** Play named SFX (throttled per name). */
|
||
play(name, force) {
|
||
if (!this.ctx) return;
|
||
if (!force && !throttled('sfx_' + name, 45)) return;
|
||
switch (name) {
|
||
case 'shoot': this.tone({ f: rand(640, 760), f2: 260, type: 'triangle', dur: .06, vol: .05 }); break;
|
||
case 'hit': this.noise({ dur: .05, vol: .09, f: 1900 }); break;
|
||
case 'crit': this.tone({ f: 1568, type: 'sine', dur: .06, vol: .12 });
|
||
this.tone({ f: 2093, type: 'sine', dur: .09, vol: .1, delay: .045 }); break;
|
||
case 'kill': this.noise({ dur: .08, vol: .12, f: 520, f2: 160 });
|
||
this.tone({ f: 300, f2: 90, type: 'sine', dur: .08, vol: .11 }); break;
|
||
case 'hurt': this.tone({ f: 170, f2: 70, type: 'sawtooth', dur: .22, vol: .3 });
|
||
this.noise({ dur: .12, vol: .18, f: 700 }); break;
|
||
case 'xp': {
|
||
const now = performance.now();
|
||
if (now - this._xpComboT > 900) this._xpCombo = 0;
|
||
this._xpComboT = now;
|
||
this._xpCombo = (this._xpCombo + 1) % 8;
|
||
this.tone({ f: 660 * Math.pow(2, this._xpCombo / 12), type: 'sine', dur: .07, vol: .09 }); break;
|
||
}
|
||
case 'coin': this.tone({ f: 1318, type: 'square', dur: .05, vol: .08 });
|
||
this.tone({ f: 1760, type: 'square', dur: .09, vol: .08, delay: .055 }); break;
|
||
case 'heal': [523, 659, 784].forEach((f, i) => this.tone({ f, type: 'sine', dur: .16, vol: .12, delay: i * .07 })); break;
|
||
case 'levelup':[392, 494, 587, 784].forEach((f, i) => this.tone({ f, type: 'triangle', dur: .22, vol: .18, delay: i * .09 })); break;
|
||
case 'chest': [659, 830, 988, 1318].forEach((f, i) => this.tone({ f, type: 'sine', dur: .2, vol: .14, delay: i * .08 }));
|
||
this.noise({ dur: .4, vol: .06, f: 6000, hp: true }); break;
|
||
case 'evolve': this.tone({ f: 300, f2: 1400, type: 'sawtooth', dur: .5, vol: .16 });
|
||
[523, 659, 784, 1046].forEach((f, i) => this.tone({ f, type: 'triangle', dur: .3, vol: .14, delay: .25 + i * .07 })); break;
|
||
case 'boom': this.noise({ dur: .38, vol: .4, f: 900, f2: 90 });
|
||
this.tone({ f: 130, f2: 36, type: 'sine', dur: .32, vol: .35 }); break;
|
||
case 'zap': this.tone({ f: 1500, f2: 90, type: 'sawtooth', dur: .13, vol: .16 }); break;
|
||
case 'freeze': this.noise({ dur: .14, vol: .12, f: 4200, hp: true });
|
||
this.tone({ f: 1800, f2: 2400, type: 'sine', dur: .1, vol: .06 }); break;
|
||
case 'roar': this.tone({ f: 58, type: 'sawtooth', dur: .7, vol: .34 });
|
||
this.tone({ f: 61, type: 'sawtooth', dur: .7, vol: .3 });
|
||
this.noise({ dur: .5, vol: .15, f: 300 }); break;
|
||
case 'click': this.tone({ f: 900, type: 'square', dur: .03, vol: .1 }); break;
|
||
case 'death': this.tone({ f: 220, f2: 40, type: 'sawtooth', dur: .9, vol: .35 }); break;
|
||
case 'win': [523, 659, 784, 1046, 1318].forEach((f, i) => this.tone({ f, type: 'triangle', dur: .35, vol: .18, delay: i * .12 })); break;
|
||
}
|
||
},
|
||
|
||
/* ---------------- generative dark-synthwave loop ---------------- */
|
||
_startMusic() {
|
||
if (this._timer) clearInterval(this._timer);
|
||
this._step = 0;
|
||
this._nextTime = this.ctx.currentTime + .1;
|
||
this._timer = setInterval(() => this._schedule(), 30);
|
||
},
|
||
|
||
_schedule() {
|
||
if (!this.ctx || document.hidden) return;
|
||
const fl = this.stageFlavor || {};
|
||
const spb = 60 / (118 * (fl.tempo || 1)) / 4; // 16th-note @118bpm × stage tempo
|
||
while (this._nextTime < this.ctx.currentTime + .14) {
|
||
this._playStep(this._step, this._nextTime);
|
||
this._step = (this._step + 1) % 64;
|
||
this._nextTime += spb;
|
||
}
|
||
},
|
||
|
||
/** Per-stage musical flavor: semitone shift + tempo multiplier. */
|
||
setFlavor(f) { this.stageFlavor = f || null; },
|
||
|
||
_mnote(freq, when, dur, vol, type, dest) {
|
||
const osc = this.ctx.createOscillator();
|
||
const g = this.ctx.createGain();
|
||
osc.type = type; osc.frequency.value = freq;
|
||
g.gain.setValueAtTime(0.0001, when);
|
||
g.gain.linearRampToValueAtTime(vol, when + .01);
|
||
g.gain.exponentialRampToValueAtTime(0.0001, when + dur);
|
||
osc.connect(g); g.connect(dest);
|
||
osc.start(when); osc.stop(when + dur + .03);
|
||
},
|
||
|
||
_playStep(step, when) {
|
||
const I = this.intensity;
|
||
const fl = this.stageFlavor || {};
|
||
const chords = [45, 41, 48, 43].map(c => c + (fl.shift || 0)); // Am F C G, stage-shifted
|
||
const root = chords[(step >> 4) & 3];
|
||
|
||
// Bass on syncopated steps
|
||
if (step % 8 === 0 || step % 8 === 3 || step % 8 === 6) {
|
||
this._mnote(this.midi(root - 12), when, .17, .16, 'square', this.musicG);
|
||
}
|
||
// Kick
|
||
if (I > .25 && step % 8 === 0) this._mnote(110, when, .12, .22 * I, 'sine', this.musicG);
|
||
// Hats
|
||
if (I > .15 && step % 2 === 1) {
|
||
// cheap hat via short high sine tick
|
||
this._mnote(8000, when, .02, .04 * Math.min(1, I * 1.6), 'square', this.musicG);
|
||
}
|
||
// Lead pentatonic random walk
|
||
const pent = [0, 3, 5, 7, 10];
|
||
const leadDensity = .18 + I * .5;
|
||
if (Math.random() < leadDensity && step % 2 === 0) {
|
||
const oct = chance(.25) ? 24 : 12;
|
||
const note = root + oct + pick(pent);
|
||
this._mnote(this.midi(note), when, .14, .07 + I * .05, 'triangle', this.musicG);
|
||
}
|
||
// Pad chord swell each bar
|
||
if (step % 16 === 0) {
|
||
[0, 3, 7].forEach(iv => this._mnote(this.midi(root + iv), when, 1.6, .028, 'sawtooth', this.musicG));
|
||
}
|
||
// Boss tension drone
|
||
if (I > .75 && step % 16 === 8) this._mnote(this.midi(root - 24), when, .3, .12, 'sawtooth', this.musicG);
|
||
},
|
||
|
||
setIntensity(v) { this.intensity = clamp(v, 0, 1); }
|
||
};
|