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:
@@ -0,0 +1,97 @@
|
||||
// Generates PWA icons without external deps: neon diamond on dark bg.
|
||||
'use strict';
|
||||
const zlib = require('zlib');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function crc32(buf) {
|
||||
let table = crc32.table;
|
||||
if (!table) {
|
||||
table = crc32.table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
}
|
||||
let c = 0xFFFFFFFF;
|
||||
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
|
||||
return (c ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length);
|
||||
const t = Buffer.from(type, 'ascii');
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([t, data])));
|
||||
return Buffer.concat([len, t, data, crc]);
|
||||
}
|
||||
|
||||
function png(w, h, rgba) {
|
||||
const stride = w * 4;
|
||||
const raw = Buffer.alloc((stride + 1) * h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
raw[y * (stride + 1)] = 0;
|
||||
rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 6;
|
||||
const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
return Buffer.concat([
|
||||
sig,
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
|
||||
chunk('IEND', Buffer.alloc(0))
|
||||
]);
|
||||
}
|
||||
|
||||
function lerp(a, b, t) { return a + (b - a) * t; }
|
||||
|
||||
function makeIcon(size) {
|
||||
const px = Buffer.alloc(size * size * 4);
|
||||
const cx = size / 2, cy = size / 2;
|
||||
const maxD = Math.hypot(cx, cy);
|
||||
const diaR = size * 0.34;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const i = (y * size + x) * 4;
|
||||
const dx = x - cx, dy = y - cy;
|
||||
const d = Math.hypot(dx, dy) / maxD;
|
||||
|
||||
// background: vertical dark gradient
|
||||
let r = lerp(10, 5, y / size);
|
||||
let g = lerp(10, 5, y / size);
|
||||
let b = lerp(31, 12, y / size);
|
||||
|
||||
// soft purple glow ring
|
||||
const glow = Math.max(0, 1 - d * 2.2) ** 2;
|
||||
r += 124 * glow * 0.35; g += 92 * glow * 0.35; b += 255 * glow * 0.4;
|
||||
|
||||
// neon diamond
|
||||
const ad = Math.abs(dx) + Math.abs(dy);
|
||||
if (ad < diaR) {
|
||||
const t = ad / diaR;
|
||||
// gradient edge cyan -> center violet -> white core
|
||||
if (t > 0.82) { r = 224; g = 250; b = 255; } // rim highlight
|
||||
else if (t > 0.55) { r = 0; g = 229; b = 255; } // cyan edge
|
||||
else if (t > 0.25) { r = 124; g = 92; b = 255; } // violet body
|
||||
else { r = 233; g = 213; b = 255; } // white core
|
||||
// subtle scanline shading
|
||||
if (((x + y) >> 3) % 2 === 0 && t <= 0.82) { r *= 0.92; g *= 0.92; b *= 0.95; }
|
||||
}
|
||||
px[i] = Math.min(255, r | 0); px[i + 1] = Math.min(255, g | 0);
|
||||
px[i + 2] = Math.min(255, b | 0); px[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return png(size, size, px);
|
||||
}
|
||||
|
||||
const outDir = path.join(__dirname, '..', 'icons');
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir);
|
||||
for (const s of [192, 512]) {
|
||||
const f = path.join(outDir, `icon-${s}.png`);
|
||||
fs.writeFileSync(f, makeIcon(s));
|
||||
console.log('wrote', f, fs.statSync(f).size, 'bytes');
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// dev tool: scan for calls to members/functions that are never defined
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const files = fs.readdirSync('js').filter(f => f.endsWith('.js'));
|
||||
let all = '';
|
||||
for (const f of files) all += fs.readFileSync('js/' + f, 'utf8') + '\n';
|
||||
|
||||
const defs = new Set();
|
||||
for (const m of all.matchAll(/(?:^|\n)[ \t]*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^()]*\)\s*\{/g)) defs.add(m[1]);
|
||||
for (const m of all.matchAll(/\bfunction\s+([A-Za-z_$][\w$]*)/g)) defs.add(m[1]);
|
||||
for (const m of all.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) defs.add(m[1]);
|
||||
|
||||
const allow = new Set([
|
||||
'push','pop','shift','unshift','splice','slice','concat','join','map','filter','forEach',
|
||||
'reduce','find','findIndex','some','every','includes','indexOf','sort','fill','keys',
|
||||
'values','entries','from','isArray','assign','create','freeze','getOwnPropertyNames',
|
||||
'setItem','getItem','removeItem','clear','log','warn','error','now','floor','ceil','round',
|
||||
'abs','min','max','sqrt','pow','sin','cos','tan','atan','atan2','random','hypot','sign','exp',
|
||||
'add','has','delete','get','set',
|
||||
'translate','rotate','scale','save','restore','beginPath','closePath','moveTo','lineTo','arc',
|
||||
'arcTo','quadraticCurveTo','bezierCurveTo','rect','fill','stroke','clip','fillText','strokeText',
|
||||
'measureText','drawImage','createLinearGradient','createRadialGradient','createPattern','fillRect',
|
||||
'strokeRect','clearRect','setTransform','resetTransform','ellipse',
|
||||
'play','pause','resume','connect','start','stop','setValueAtTime','linearRampToValueAtTime',
|
||||
'exponentialRampToValueAtTime','createGain','createOscillator','createBuffer','createBufferSource',
|
||||
'createBiquadFilter',
|
||||
'addEventListener','removeEventListener','preventDefault','stopPropagation','appendChild',
|
||||
'removeChild','remove','querySelector','querySelectorAll','getElementById','createElement',
|
||||
'toggle','contains','setAttribute','getAttribute','insertBefore','closest','matches',
|
||||
'test','exec','replace','split','trim','toLowerCase','toUpperCase','charAt','charCodeAt',
|
||||
'padStart','padEnd','repeat','match','matchAll','toString','toFixed','stringify','parse',
|
||||
'requestAnimationFrame','setTimeout','setInterval','clearTimeout','clearInterval','fetch',
|
||||
'then','catch','finally','bind','call','apply'
|
||||
]);
|
||||
|
||||
let bad = 0;
|
||||
for (const m of all.matchAll(/\b(UI|Sys|R|Snd|Store|GAME)\.([A-Za-z_$][\w$]*)\s*\(/g)) {
|
||||
const meth = m[2];
|
||||
if (!allow.has(meth) && !defs.has(meth)) { console.log('MISSING:', m[1] + '.' + meth); bad++; }
|
||||
}
|
||||
console.log(bad === 0 ? 'No missing member calls OK' : bad + ' issues');
|
||||
|
||||
for (const f of ['render.js', 'systems.js', 'ui.js', 'audio.js', 'save.js']) {
|
||||
const src = fs.readFileSync('js/' + f, 'utf8');
|
||||
for (const m of src.matchAll(/this\.(_?[A-Za-z_$][\w$]*)\s*\(/g)) {
|
||||
if (!defs.has(m[1])) console.log('MISSING this.' + m[1] + ' in ' + f);
|
||||
}
|
||||
}
|
||||
console.log('this-method scan done');
|
||||
Reference in New Issue
Block a user