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
+99
View File
@@ -0,0 +1,99 @@
/**
* NEON SURVIVORS — Leaderboard Worker (Cloudflare Workers + KV)
*
* Deploy:
* 1. wrangler kv:namespace create LB
* 2. paste the returned id into wrangler.toml
* 3. wrangler deploy
*
* Endpoints:
* GET /top?scope=daily:20260822|stage:frostcave:2&n=10
* POST /submit { name, time, kills, gold, lv, stageId, grade, endless, daily, win, day, sig }
*
* `sig` is a lightweight checksum (same algorithm as the client) that deters
* casual tampering. True server authority would require session validation.
*/
const SALT = 'NSv1-lb-salt-v1';
async function lbSig(obj) {
const data = new TextEncoder().encode(SALT + JSON.stringify(obj));
let h = 2166136261 >>> 0;
const view = data;
for (let i = 0; i < view.length; i++) {
h ^= view[i];
h = Math.imul(h, 16777619) >>> 0;
}
// second avalanche pass so short payloads spread
h ^= h >>> 13; h = Math.imul(h, 1274126177) >>> 0; h ^= h >>> 16;
return ('0000000' + h.toString(36)).slice(-8);
}
function jsonRes(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: {
'content-type': 'application/json; charset=utf-8',
'access-control-allow-origin': '*',
'access-control-allow-headers': 'content-type',
'access-control-allow-methods': 'GET,POST,OPTIONS'
}
});
}
export default {
async fetch(req, env) {
const url = new URL(req.url);
if (req.method === 'OPTIONS') return new Response(null, { status: 204 });
if (url.pathname === '/top' && req.method === 'GET') {
const scope = (url.searchParams.get('scope') || '').slice(0, 48);
const n = Math.min(50, Math.max(1, parseInt(url.searchParams.get('n') || '10', 10)));
if (!scope) return jsonRes({ ok: false, error: 'scope' }, 400);
const raw = await env.LB.get('b:' + scope);
const rows = raw ? JSON.parse(raw) : [];
return jsonRes({ ok: true, scope, rows: rows.slice(0, n) });
}
if (url.pathname === '/submit' && req.method === 'POST') {
let b;
try { b = await req.json(); } catch (e) { return jsonRes({ ok: false, error: 'json' }, 400); }
const time = Math.floor(+b.time || 0);
const entry = {
n: String(b.name || 'Anon').slice(0, 16),
t: time,
k: Math.max(0, Math.min(999999, Math.floor(+b.kills || 0))),
g: Math.max(0, Math.min(9999999, Math.floor(+b.gold || 0))),
lv: Math.max(1, Math.min(200, Math.floor(+b.lv || 1))),
w: b.win ? 1 : 0,
st: String(b.stageId || '').slice(0, 24),
gr: Math.max(0, Math.min(3, +b.grade | 0))
};
// scope: daily board uses UTC day; stage boards keyed by stage+grade
const day = new Date().toISOString().slice(0, 10).replace(/-/g, '');
let scope;
if (b.daily) scope = 'daily:' + day;
else if (time >= 60) scope = 'stage:' + (entry.st || 'graveyard') + ':' + entry.gr;
else return jsonRes({ ok: false, error: 'too_short' }, 400);
// verify checksum over the signed fields only
const payload = {
name: b.name, time, kills: entry.k, gold: entry.g, lv: entry.lv,
stageId: b.stageId, grade: entry.gr, endless: !!b.endless, daily: !!b.daily,
win: !!b.win, day
};
if ((await lbSig(payload)) !== b.sig) return jsonRes({ ok: false, error: 'sig' }, 403);
if (time < 30 || time > 86400) return jsonRes({ ok: false, error: 'range' }, 400);
const key = 'b:' + scope;
const raw = await env.LB.get(key);
const rows = raw ? JSON.parse(raw) : [];
rows.push(entry);
rows.sort((a, z) => z.t - a.t || z.k - a.k);
await env.LB.put(key, JSON.stringify(rows.slice(0, 100)));
return jsonRes({ ok: true, rank: rows.findIndex(r => r === entry) + 1 });
}
return jsonRes({ ok: false, error: 'route' }, 404);
}
};