Files
neon-survivors/js/audio.js
T
neon-survivors-dev 796880375e NEON SURVIVORS v1.1 — full-featured bullet-heaven survivor game
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)
2026-08-23 07:01:22 +00:00

196 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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); }
};