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)
205 lines
6.6 KiB
JavaScript
205 lines
6.6 KiB
JavaScript
'use strict';
|
|
/* ============================================================
|
|
NEON SURVIVORS — util.js : math helpers, RNG, spatial grid
|
|
============================================================ */
|
|
const TAU = Math.PI * 2;
|
|
|
|
/* ---- seeded RNG layer ----
|
|
* All game randomness flows through rng(). Normal runs use Math.random;
|
|
* Daily Challenges call seedRng(n) for a deterministic day seed.
|
|
*/
|
|
let __rngState = null;
|
|
function seedRng(seed) { __rngState = (seed === null || seed === undefined) ? null : mulberry32(seed >>> 0); }
|
|
function rng() { return __rngState ? __rngState() : Math.random(); }
|
|
|
|
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
|
|
const lerp = (a, b, t) => a + (b - a) * t;
|
|
const rand = (a = 1, b) => b === undefined ? rng() * a : a + rng() * (b - a);
|
|
const randi = (a, b) => Math.floor(rand(a, b + 1));
|
|
const pick = (arr) => arr[(rng() * arr.length) | 0];
|
|
const chance = (p) => rng() < p;
|
|
const dist2 = (ax, ay, bx, by) => { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; };
|
|
const len2 = (x, y) => Math.sqrt(x * x + y * y);
|
|
|
|
function shuffle(a) {
|
|
for (let i = a.length - 1; i > 0; i--) {
|
|
const j = (rng() * (i + 1)) | 0;
|
|
const tmp = a[i]; a[i] = a[j]; a[j] = tmp;
|
|
}
|
|
return a;
|
|
}
|
|
|
|
/** Pick one item from [{...}] weighted by wf(item). */
|
|
function weightedPick(items, wf) {
|
|
let sum = 0;
|
|
for (const it of items) sum += wf(it);
|
|
let r = rng() * sum;
|
|
for (const it of items) { r -= wf(it); if (r <= 0) return it; }
|
|
return items[items.length - 1];
|
|
}
|
|
|
|
/** Pick up to k distinct items (shuffled copy). */
|
|
function sampleN(arr, k) {
|
|
const c = arr.slice();
|
|
shuffle(c);
|
|
return c.slice(0, Math.max(0, Math.min(k, c.length)));
|
|
}
|
|
|
|
function fmtTime(sec) {
|
|
sec = Math.max(0, Math.floor(sec));
|
|
const m = (sec / 60) | 0, s = sec % 60;
|
|
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
|
}
|
|
|
|
function fmtNum(n) {
|
|
n = Math.floor(n);
|
|
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
if (n >= 1e4) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
return String(n);
|
|
}
|
|
|
|
/** Swap-remove: order not preserved, O(1). */
|
|
function removeItem(arr, i) {
|
|
arr[i] = arr[arr.length - 1];
|
|
arr.pop();
|
|
}
|
|
|
|
/** 'YYYYMMDD' in UTC — the Daily Challenge key. */
|
|
function dailyKey() {
|
|
const d = new Date();
|
|
return d.getUTCFullYear() * 10000 + (d.getUTCMonth() + 1) * 100 + d.getUTCDate();
|
|
}
|
|
|
|
/** Deterministic per-day setup for the Daily Challenge. */
|
|
function dailySetup() {
|
|
const k = dailyKey();
|
|
let h = (k * 2654435761) >>> 0;
|
|
h ^= h >>> 13; h = (h * 1274126177) >>> 0; h ^= h >>> 16;
|
|
const charIds = Object.keys(CHARS);
|
|
const stageIds = Object.keys(STAGES);
|
|
return {
|
|
seed: h,
|
|
char: charIds[h % charIds.length],
|
|
stage: stageIds[(h >> 3) % stageIds.length],
|
|
grade: 1,
|
|
endless: false,
|
|
daily: true
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Leaderboard checksum — MUST mirror server/lb-worker.js lbSig().
|
|
* Two-pass FNV-1a over UTF-8 bytes with a shared salt.
|
|
*/
|
|
function lbSig(obj) {
|
|
const SALT = 'NSv1-lb-salt-v1';
|
|
const bytes = new TextEncoder().encode(SALT + JSON.stringify(obj));
|
|
let h = 2166136261 >>> 0;
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
h ^= bytes[i];
|
|
h = Math.imul(h, 16777619) >>> 0;
|
|
}
|
|
h ^= h >>> 13; h = Math.imul(h, 1274126177) >>> 0; h ^= h >>> 16;
|
|
return ('0000000' + h.toString(36)).slice(-8);
|
|
}
|
|
|
|
/**
|
|
* Uniform-grid broadphase for circle collisions.
|
|
* Rebuild each frame: clear() -> insert(e) for each enemy -> query(x,y,r,out).
|
|
*/
|
|
class SpatialGrid {
|
|
constructor(cellSize = 96) { this.cs = cellSize; this.map = new Map(); }
|
|
clear() { this.map.clear(); }
|
|
_key(cx, cy) { return cx * 73856093 ^ cy * 19349663; }
|
|
insert(e) {
|
|
const cs = this.cs;
|
|
const x0 = Math.floor((e.x - e.r) / cs), x1 = Math.floor((e.x + e.r) / cs);
|
|
const y0 = Math.floor((e.y - e.r) / cs), y1 = Math.floor((e.y + e.r) / cs);
|
|
for (let cy = y0; cy <= y1; cy++)
|
|
for (let cx = x0; cx <= x1; cx++) {
|
|
const k = this._key(cx, cy);
|
|
let bucket = this.map.get(k);
|
|
if (!bucket) { bucket = []; this.map.set(k, bucket); }
|
|
bucket.push(e);
|
|
}
|
|
}
|
|
/**
|
|
* Fills `out` with entities whose cells overlap the query circle.
|
|
* Stamp dedupe is bumped PER CALL so back-to-back queries in the same
|
|
* frame never swallow results from each other.
|
|
*/
|
|
query(x, y, r, out) {
|
|
out.length = 0;
|
|
const cs = this.cs;
|
|
const stamp = ++SpatialGrid._stamp;
|
|
const x0 = Math.floor((x - r) / cs), x1 = Math.floor((x + r) / cs);
|
|
const y0 = Math.floor((y - r) / cs), y1 = Math.floor((y + r) / cs);
|
|
for (let cy = y0; cy <= y1; cy++)
|
|
for (let cx = x0; cx <= x1; cx++) {
|
|
const bucket = this.map.get(this._key(cx, cy));
|
|
if (!bucket) continue;
|
|
for (let i = 0; i < bucket.length; i++) {
|
|
const e = bucket[i];
|
|
if (e._stamp !== stamp) {
|
|
e._stamp = stamp;
|
|
out.push(e);
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
}
|
|
SpatialGrid._stamp = 0;
|
|
|
|
/** Simple throttle helper: returns true at most once per `ms` for a given slot. */
|
|
const throttleMap = new Map();
|
|
function throttled(slot, ms) {
|
|
const now = performance.now();
|
|
const last = throttleMap.get(slot) || -1e9;
|
|
if (now - last >= ms) { throttleMap.set(slot, now); return true; }
|
|
return false;
|
|
}
|
|
|
|
/** '#rrggbb' / '#rgb' -> 'rgba(r,g,b,a)' (returns input unchanged if not hex). */
|
|
function colA(hex, a) {
|
|
if (typeof hex !== 'string' || hex[0] !== '#') return hex;
|
|
if (hex.length === 4) {
|
|
const r = parseInt(hex[1] + hex[1], 16);
|
|
const g = parseInt(hex[2] + hex[2], 16);
|
|
const b = parseInt(hex[3] + hex[3], 16);
|
|
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
|
|
}
|
|
if (hex.length !== 7) return hex;
|
|
const n = parseInt(hex.slice(1), 16);
|
|
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
|
|
}
|
|
|
|
/**
|
|
* Pooled particle spawn. Uses G.partPool when available, respects G.partCap.
|
|
* opts.big grants overflow headroom for important bursts.
|
|
*/
|
|
function part(G, x, y, col, opts) {
|
|
opts = opts || {};
|
|
const cap = G.partCap !== undefined ? G.partCap : 600;
|
|
if (G.parts.length >= cap + (opts.big ? 60 : 0)) return null;
|
|
let p;
|
|
if (G.partPool && G.partPool.length) {
|
|
p = G.partPool.pop().set(x, y, col, opts);
|
|
} else {
|
|
p = new Particle(x, y, col, opts);
|
|
}
|
|
G.parts.push(p);
|
|
return p;
|
|
}
|
|
|
|
// Tiny seeded RNG (mulberry32) — used for deterministic tests.
|
|
function mulberry32(seed) {
|
|
let a = seed >>> 0;
|
|
return function () {
|
|
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|