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)
This commit is contained in:
neon-survivors-dev
2026-08-23 07:01:22 +00:00
commit 796880375e
26 changed files with 7447 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
'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); }
};