Files
neon-survivors/js/util.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

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;
};
}