Files
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

984 lines
36 KiB
JavaScript

'use strict';
/* ============================================================
NEON SURVIVORS — render.js : all canvas drawing
============================================================ */
const R = {
_vig: null,
draw(G) {
const cv = G.cv, ctx = G.ctx;
const W = cv.width / G.dpr, H = cv.height / G.dpr;
const s = Store.s();
const q = s.particles;
// ---- background ----
ctx.fillStyle = '#05050c';
ctx.fillRect(0, 0, W, H);
// camera + shake
let sx = 0, sy = 0;
if (G.shake > 0.2 && s.shake) {
sx = rand(-G.shake, G.shake);
sy = rand(-G.shake, G.shake);
}
const camX = G.cam.x - W / 2 + sx;
const camY = G.cam.y - H / 2 + sy;
// viewport bounds for culling (margin covers glow/auras)
this._vx0 = camX - 70; this._vy0 = camY - 70;
this._vx1 = camX + W + 70; this._vy1 = camY + H + 70;
this._bg(ctx, camX, camY, W, H);
ctx.save();
ctx.translate(-camX, -camY);
this._pickups(G, ctx);
this._enemies(G, ctx, q);
this._projectiles(G, ctx, q);
this._ebullets(G, ctx);
if (!G.playerHidden) this._player(G, ctx);
this._effects(G, ctx);
this._particles(G, ctx, q);
ctx.restore();
// ---- screen-space overlays ----
this._texts(G, ctx, camX, camY);
this._bossArrow(G, ctx, W, H);
if (G.hurtFlash > 0) {
ctx.fillStyle = `rgba(255,40,70,${G.hurtFlash * 0.55})`;
ctx.fillRect(0, 0, W, H);
}
this._lowHp(G, ctx, W, H);
this._vignette(ctx, W, H);
},
/** Pulsing red edge when the player drops below 30% HP. */
_lowHp(G, ctx, W, H) {
const P = G.player;
if (!P || P.maxhp <= 0) return;
const ratio = clamp(P.hp / P.maxhp, 0, 1);
if (ratio >= 0.3) return;
if (!this._low || this._lowW !== W || this._lowH !== H) {
const c = document.createElement('canvas');
c.width = W; c.height = H;
const cc = c.getContext('2d');
const g = cc.createRadialGradient(W / 2, H / 2, Math.min(W, H) * .32, W / 2, H / 2, Math.max(W, H) * .7);
g.addColorStop(0, 'rgba(255,30,60,0)');
g.addColorStop(1, 'rgba(255,20,50,0.55)');
cc.fillStyle = g;
cc.fillRect(0, 0, W, H);
this._low = c; this._lowW = W; this._lowH = H;
}
const pulse = 0.45 + 0.45 * Math.sin(G.time * 7);
ctx.globalAlpha = clamp((0.3 - ratio) / 0.3, 0, 1) * (0.35 + pulse * 0.5);
ctx.drawImage(this._low, 0, 0);
ctx.globalAlpha = 1;
},
/* ---------- background ---------- */
_bg(ctx, camX, camY, W, H) {
const bg = (window.GAME && GAME.stage && GAME.stage.bg) || {};
// grid
const step = 140;
ctx.strokeStyle = bg.grid || 'rgba(90,90,160,0.10)';
ctx.lineWidth = 1;
const x0 = Math.floor(camX / step) * step, y0 = Math.floor(camY / step) * step;
ctx.beginPath();
for (let x = x0; x < camX + W + step; x += step) {
ctx.moveTo(x - camX, 0); ctx.lineTo(x - camX, H);
}
for (let y = y0; y < camY + H + step; y += step) {
ctx.moveTo(0, y - camY); ctx.lineTo(W, y - camY);
}
ctx.stroke();
// deterministic glowing motes (parallax feel via world-anchored cells)
const rgbOf = h => {
if (typeof h !== 'string' || h[0] !== '#') return h || '124,92,255';
const n = parseInt(h.slice(1), 16);
return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
};
const m1 = rgbOf(bg.mote && bg.mote[0]);
const m2 = rgbOf(bg.mote && bg.mote[1]);
const cstep = 320;
const cx0 = Math.floor(camX / cstep), cy0 = Math.floor(camY / cstep);
for (let cy = cy0; cy < cy0 + H / cstep + 2; cy++) {
for (let cx = cx0; cx < cx0 + W / cstep + 2; cx++) {
const hsh = ((cx * 374761393 + cy * 668265263) ^ 1274126177) >>> 0;
if (hsh % 3 !== 0) continue;
const px = cx * cstep + (hsh % cstep) - camX;
const py = cy * cstep + ((hsh >> 8) % cstep) - camY;
const r = 1.5 + (hsh % 3);
ctx.fillStyle = hsh % 7 === 0 ? `rgba(${m2},0.20)` : `rgba(${m1},0.16)`;
ctx.beginPath();
ctx.arc(px, py, r, 0, TAU);
ctx.fill();
}
}
},
/* ---------- pickups ---------- */
_pickups(G, ctx) {
for (const pk of G.pickups) {
if (pk.x < this._vx0 || pk.x > this._vx1 || pk.y < this._vy0 || pk.y > this._vy1) continue;
const bob = Math.sin(pk.t * 4) * 2;
if (pk.type === 'gem') {
ctx.save();
ctx.translate(pk.x, pk.y + bob);
ctx.rotate(pk.t * 1.5);
ctx.shadowColor = pk.col; ctx.shadowBlur = 8;
ctx.fillStyle = pk.col;
const s = pk.v >= 25 ? 9 : pk.v >= 5 ? 7.5 : 6;
ctx.beginPath();
ctx.moveTo(0, -s); ctx.lineTo(s * .7, 0); ctx.lineTo(0, s); ctx.lineTo(-s * .7, 0);
ctx.closePath(); ctx.fill();
ctx.restore();
} else if (pk.type === 'coin') {
ctx.save();
ctx.translate(pk.x, pk.y + bob);
ctx.shadowColor = '#ffd24a'; ctx.shadowBlur = 7;
ctx.fillStyle = '#ffd24a';
ctx.beginPath(); ctx.arc(0, 0, 6, 0, TAU); ctx.fill();
ctx.fillStyle = '#a67400';
ctx.beginPath(); ctx.arc(0, 0, 3, 0, TAU); ctx.fill();
ctx.restore();
} else {
ctx.save();
ctx.translate(pk.x, pk.y + bob * 1.5);
ctx.font = '20px serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const icon = { chicken: '🍗', magnet: '🧲', bomb: '💣', chest: '🎁' }[pk.type] || '❓';
if (pk.type === 'chest') {
const sc = 1 + Math.sin(pk.t * 6) * 0.08;
ctx.scale(sc, sc);
ctx.shadowColor = '#ffd24a'; ctx.shadowBlur = 14;
}
ctx.fillText(icon, 0, 0);
ctx.restore();
}
}
},
/* ---------- enemies ---------- */
_enemies(G, ctx, q) {
for (const e of G.enemies) {
if (e.x < this._vx0 - e.r || e.x > this._vx1 + e.r || e.y < this._vy0 - e.r || e.y > this._vy1 + e.r) continue;
ctx.save();
ctx.translate(e.x, e.y);
const flash = e.flash > 0;
const col = flash ? '#ffffff' : e.col;
if (q !== 'low' && !flash) {
ctx.shadowColor = e.boss ? col : 'transparent';
ctx.shadowBlur = e.boss ? 22 : 0;
}
if (e.elite || e.boss) {
ctx.strokeStyle = e.boss
? 'rgba(255,80,110,.9)'
: (e.affixCol ? colA(e.affixCol, .95) : 'rgba(255,210,74,.95)');
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, e.r + 5 + Math.sin(G.time * 5) * 1.5, 0, TAU);
ctx.stroke();
}
const shape = e.def.shape;
ctx.fillStyle = col;
switch (shape) {
case 'bat': {
const flap = Math.sin(G.time * 14 + e.wob) * 0.5;
ctx.beginPath(); ctx.arc(0, 0, e.r * .62, 0, TAU); ctx.fill();
for (const d of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(d * e.r * .4, 0);
ctx.lineTo(d * e.r * 1.5, -e.r * (.5 + flap));
ctx.lineTo(d * e.r * 1.2, e.r * .45);
ctx.closePath(); ctx.fill();
}
break;
}
case 'zombie': {
this._roundRect(ctx, -e.r * .75, -e.r * .9, e.r * 1.5, e.r * 1.8, 4); ctx.fill();
ctx.beginPath(); ctx.arc(0, -e.r * 1.05, e.r * .52, 0, TAU); ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,.35)';
ctx.fillRect(-e.r * .55, -e.r * .15, e.r * 1.1, 3);
break;
}
case 'imp': {
ctx.beginPath();
ctx.moveTo(0, -e.r); ctx.lineTo(e.r * .85, e.r * .75); ctx.lineTo(-e.r * .85, e.r * .75);
ctx.closePath(); ctx.fill();
ctx.fillStyle = '#ffe066';
ctx.fillRect(-e.r * .5, -e.r * .85, 2.5, 5);
ctx.fillRect(e.r * .3, -e.r * .85, 2.5, 5);
break;
}
case 'archer': {
ctx.beginPath(); ctx.arc(0, 0, e.r * .72, 0, TAU); ctx.fill();
ctx.strokeStyle = '#caa96b'; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.arc(e.r * .9, 0, e.r * .85, -Math.PI / 2.4, Math.PI / 2.4); ctx.stroke();
break;
}
case 'brute': {
this._roundRect(ctx, -e.r, -e.r * .95, e.r * 2, e.r * 1.9, 7); ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,.3)';
this._roundRect(ctx, -e.r * .6, -e.r * .5, e.r * 1.2, e.r * .28, 3); ctx.fill();
break;
}
case 'ghost': {
ctx.globalAlpha = 0.65 + Math.sin(G.time * 3 + e.wob) * 0.25;
ctx.beginPath();
ctx.arc(0, -e.r * .25, e.r * .8, Math.PI, 0);
const bot = e.r * .75;
ctx.lineTo(e.r * .8, bot);
for (let k = 3; k >= -3; k--) {
ctx.lineTo(k / 3 * e.r * .8, bot + (k % 2 ? 5 : -3));
}
ctx.closePath(); ctx.fill();
ctx.globalAlpha = 1;
break;
}
case 'spit': {
ctx.beginPath(); ctx.arc(0, 0, e.r * .8, 0, TAU); ctx.fill();
ctx.fillStyle = '#3f7a00';
ctx.beginPath(); ctx.arc(0, -e.r * .2, e.r * .32, 0, TAU); ctx.fill();
break;
}
case 'garg': {
ctx.rotate(Math.sin(G.time * 2 + e.wob) * .06);
this._roundRect(ctx, -e.r * .7, -e.r * .8, e.r * 1.4, e.r * 1.6, 5); ctx.fill();
for (const d of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(d * e.r * .5, -e.r * .6);
ctx.lineTo(d * e.r * 1.35, -e.r * 1.05);
ctx.lineTo(d * e.r * .75, e.r * .1);
ctx.closePath(); ctx.fill();
}
if (e.state === 1) {
ctx.fillStyle = '#ff4d4d';
ctx.fillRect(-e.r * .38, -e.r * .45, 4, 4);
ctx.fillRect(e.r * .18, -e.r * .45, 4, 4);
}
break;
}
/* ---- bosses ---- */
case 'wolf': {
ctx.rotate(e.state === 2 ? 0 : Math.sin(G.time * 6) * .05);
if (e.state === 1 && flash === false) ctx.fillStyle = Math.floor(G.time * 12) % 2 ? '#ff8899' : col;
ctx.beginPath(); ctx.arc(0, 0, e.r, 0, TAU); ctx.fill();
for (const d of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(d * e.r * .45, -e.r * .8);
ctx.lineTo(d * e.r * .75, -e.r * 1.5);
ctx.lineTo(d * e.r * .95, -e.r * .6);
ctx.closePath(); ctx.fill();
}
ctx.fillStyle = '#ffef5c';
ctx.fillRect(-e.r * .5, -e.r * .3, 6, 6);
ctx.fillRect(e.r * .22, -e.r * .3, 6, 6);
break;
}
case 'lich': {
ctx.beginPath();
ctx.moveTo(0, -e.r * 1.15);
ctx.lineTo(e.r * .95, e.r); ctx.lineTo(-e.r * .95, e.r);
ctx.closePath(); ctx.fill();
ctx.fillStyle = '#22223a';
ctx.beginPath(); ctx.arc(0, -e.r * .45, e.r * .42, 0, TAU); ctx.fill();
ctx.fillStyle = '#b388ff';
ctx.fillRect(-e.r * .3, -e.r * .55, 5, 7);
ctx.fillRect(e.r * .12, -e.r * .55, 5, 7);
ctx.fillStyle = '#8a5cff';
ctx.beginPath(); ctx.arc(Math.sin(G.time * 3) * e.r, -e.r * 1.3, 5, 0, TAU); ctx.fill();
break;
}
case 'behemoth': {
ctx.beginPath(); ctx.arc(0, 0, e.r, 0, TAU); ctx.fill();
ctx.strokeStyle = col; ctx.lineWidth = 7; ctx.lineCap = 'round';
for (const d of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(d * e.r * .55, -e.r * .75);
ctx.quadraticCurveTo(d * e.r * 1.25, -e.r * 1.35, d * e.r * .8, -e.r * 1.7);
ctx.stroke();
}
ctx.fillStyle = '#ffe066';
ctx.beginPath(); ctx.arc(-e.r * .3, -e.r * .2, 5, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(e.r * .3, -e.r * .2, 5, 0, TAU); ctx.fill();
break;
}
case 'death': {
ctx.beginPath();
ctx.moveTo(0, -e.r * 1.25);
ctx.lineTo(e.r, e.r * 1.05); ctx.lineTo(-e.r, e.r * 1.05);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#cfcfff'; ctx.lineWidth = 3.5;
ctx.beginPath(); ctx.arc(e.r * 1.15, -e.r * .2, e.r * .8, -2.4, -0.4); ctx.stroke();
ctx.fillStyle = '#ff2740';
ctx.beginPath(); ctx.arc(0, -e.r * .62, e.r * .3, 0, TAU); ctx.fill();
break;
}
case 'prop': {
// floating crystal lamp on a stone base
const pulse = 0.78 + 0.22 * Math.sin(G.time * 3 + e.wob);
ctx.fillStyle = 'rgba(16,24,44,0.95)';
ctx.beginPath();
ctx.ellipse(0, e.r * 0.9, e.r * 0.85, e.r * 0.34, 0, 0, TAU);
ctx.fill();
if (q !== 'low') { ctx.shadowColor = '#8fdcff'; ctx.shadowBlur = 14; }
const s = e.r * pulse;
const bob = Math.sin(G.time * 2.2 + e.wob) * 2.5;
ctx.translate(0, bob);
ctx.fillStyle = '#bfeaff';
ctx.beginPath();
ctx.moveTo(0, -s); ctx.lineTo(s * 0.62, 0);
ctx.lineTo(0, s); ctx.lineTo(-s * 0.62, 0);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.3;
ctx.stroke();
break;
}
default: {
ctx.beginPath(); ctx.arc(0, 0, e.r, 0, TAU); ctx.fill();
}
}
// ---- status feedback ----
if (e.freezeT > 0) {
ctx.globalAlpha = .5;
ctx.fillStyle = '#9fdcff';
ctx.beginPath(); ctx.arc(0, 0, e.r * .95, 0, TAU); ctx.fill();
ctx.globalAlpha = .9;
ctx.strokeStyle = '#e8fbff'; ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(-e.r * .5, -e.r * .5); ctx.lineTo(e.r * .5, e.r * .5);
ctx.moveTo(e.r * .5, -e.r * .5); ctx.lineTo(-e.r * .5, e.r * .5);
ctx.stroke();
ctx.globalAlpha = 1;
} else if (e.slowT > 0) {
ctx.globalAlpha = .25;
ctx.fillStyle = '#8fd0ff';
ctx.beginPath(); ctx.arc(0, 0, e.r * 1.05, 0, TAU); ctx.fill();
ctx.globalAlpha = 1;
}
if (e.burnT > 0) {
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = .15 + Math.sin(G.time * 20 + e.wob) * .07;
ctx.fillStyle = '#ff7b39';
ctx.beginPath(); ctx.arc(0, 0, e.r * 1.15, 0, TAU); ctx.fill();
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
if (e.stunT > 0) {
const sa = G.time * 8 + e.wob;
ctx.fillStyle = '#ffe066';
for (let k = 0; k < 3; k++) {
const aa = sa + k * TAU / 3;
ctx.beginPath();
ctx.arc(Math.cos(aa) * e.r * .55, -e.r - 7 + Math.sin(aa) * 2.5, 1.8, 0, TAU);
ctx.fill();
}
}
// hp bar for elites/bosses
if ((e.elite || e.boss) && e.hp < e.maxhp) {
const bw = e.r * 2.4;
ctx.fillStyle = 'rgba(0,0,0,.6)';
ctx.fillRect(-bw / 2, -e.r - 16, bw, 5);
ctx.fillStyle = e.boss ? '#ff2740' : '#ffd24a';
ctx.fillRect(-bw / 2, -e.r - 16, bw * clamp(e.hp / e.maxhp, 0, 1), 5);
}
ctx.restore();
}
},
/* ---------- projectiles ---------- */
_projectiles(G, ctx, q) {
for (const p of G.projs) {
if (p.x < this._vx0 || p.x > this._vx1 || p.y < this._vy0 || p.y > this._vy1) continue;
ctx.save();
ctx.translate(p.x, p.y);
// motion trail for fast straight shots
const spd = len2(p.vx || 0, p.vy || 0);
if (p.mode === 'straight' && spd > 60) {
const tx = -p.vx / spd, ty = -p.vy / spd;
const tl = Math.min(30, spd * 0.05);
const tg = ctx.createLinearGradient(0, 0, tx * tl, ty * tl);
tg.addColorStop(0, colA(p.col, 0.75));
tg.addColorStop(1, colA(p.col, 0));
ctx.strokeStyle = tg;
ctx.lineWidth = p.r * 1.3;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(tx * tl, ty * tl);
ctx.stroke();
}
if (q !== 'low') { ctx.shadowColor = p.col; ctx.shadowBlur = 10; }
ctx.fillStyle = p.col;
switch (p.kind) {
case 'mine': case 'e_mine': {
// dark puck with a blinking fuse light
ctx.fillStyle = '#2a2033';
ctx.beginPath(); ctx.arc(0, 0, p.r, 0, TAU); ctx.fill();
ctx.strokeStyle = colA(p.col, .9);
ctx.lineWidth = 1.6;
ctx.stroke();
const blink = 0.5 + 0.5 * Math.sin(p.t * (p.life < 2 ? 18 : 6));
if (q !== 'low') { ctx.shadowColor = '#ff4d6d'; ctx.shadowBlur = 8 * blink + 2; }
ctx.fillStyle = blink > .5 ? '#ff4d6d' : '#7a2740';
ctx.beginPath(); ctx.arc(0, -p.r * .35, p.r * .34, 0, TAU); ctx.fill();
break;
}
case 'wand': case 'e_wand':
ctx.rotate(Math.atan2(p.vy, p.vx));
this._roundRect(ctx, -8, -2.5, 16, 5, 2.5); ctx.fill();
break;
case 'knife': case 'e_knife':
ctx.rotate(Math.atan2(p.vy, p.vx));
ctx.beginPath();
ctx.moveTo(9, 0); ctx.lineTo(-6, -3.5); ctx.lineTo(-3, 0); ctx.lineTo(-6, 3.5);
ctx.closePath(); ctx.fill();
break;
case 'orb': case 'e_orb': {
ctx.beginPath(); ctx.arc(0, 0, p.r, 0, TAU); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.7)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(0, 0, p.r * .55, 0, TAU); ctx.stroke();
break;
}
case 'axe': case 'e_axe':
ctx.rotate(p.rot);
ctx.fillRect(-2.5, -p.r * 1.1, 5, p.r * 2.2);
ctx.beginPath();
ctx.moveTo(0, -p.r * 1.05);
ctx.quadraticCurveTo(p.r * 1.15, -p.r * .55, 0, p.r * .1);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(0, -p.r * 1.05);
ctx.quadraticCurveTo(-p.r * 1.15, -p.r * .55, 0, p.r * .1);
ctx.closePath(); ctx.fill();
break;
case 'fire': case 'e_fire': {
const g = ctx.createRadialGradient(0, 0, 1, 0, 0, p.r * 1.6);
g.addColorStop(0, '#fff3b0'); g.addColorStop(.5, p.col); g.addColorStop(1, 'rgba(255,60,0,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(0, 0, p.r * 1.6, 0, TAU); ctx.fill();
break;
}
case 'frost': case 'e_frost':
ctx.rotate(p.t * 9);
for (let k = 0; k < 3; k++) {
ctx.rotate(TAU / 3);
ctx.beginPath();
ctx.moveTo(0, -p.r * 1.4); ctx.lineTo(2.5, 0); ctx.lineTo(-2.5, 0);
ctx.closePath(); ctx.fill();
}
break;
case 'cross': case 'e_cross':
ctx.rotate(p.rot);
const arm = p.r * 1.5, th = p.r * .48;
this._roundRect(ctx, -th / 2, -arm, th, arm * 2, 2); ctx.fill();
this._roundRect(ctx, -arm * .72, -arm * .38, arm * 1.44, th, 2); ctx.fill();
break;
default:
ctx.beginPath(); ctx.arc(0, 0, p.r, 0, TAU); ctx.fill();
}
ctx.restore();
}
},
_ebullets(G, ctx) {
for (const b of G.ebullets) {
if (b.x < this._vx0 || b.x > this._vx1 || b.y < this._vy0 || b.y > this._vy1) continue;
ctx.save();
ctx.translate(b.x, b.y);
ctx.shadowColor = b.col; ctx.shadowBlur = 8;
ctx.fillStyle = b.col;
ctx.beginPath(); ctx.arc(0, 0, b.r, 0, TAU); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(0, 0, b.r * .4, 0, TAU); ctx.fill();
ctx.restore();
}
},
/* ---------- player ---------- */
_player(G, ctx) {
const P = G.player;
ctx.save();
ctx.translate(P.x, P.y);
// magnet radius hint (subtle)
ctx.strokeStyle = 'rgba(124,92,255,0.10)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(0, 0, P.magR, 0, TAU); ctx.stroke();
// invulnerability flicker
if (P.invuln > 0 && Math.floor(G.time * 20) % 2 === 0) ctx.globalAlpha = 0.45;
if (Store.s().particles !== 'low') {
ctx.shadowColor = P.col; ctx.shadowBlur = 18;
}
// body
const g = ctx.createRadialGradient(-3, -4, 2, 0, 0, P.r + 2);
g.addColorStop(0, '#ffffff');
g.addColorStop(.35, P.col);
g.addColorStop(1, '#20203a');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(0, 0, P.r, 0, TAU); ctx.fill();
ctx.shadowBlur = 0;
// face direction
ctx.fillStyle = '#0b0b18';
const fx = P.faceX, fy = P.faceY;
ctx.beginPath(); ctx.arc(fx * 5 - fy * 3.2, fy * 5 + fx * 3.2 - 1, 2.1, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(fx * 5 + fy * 3.2, fy * 5 - fx * 3.2 - 1, 2.1, 0, TAU); ctx.fill();
ctx.restore();
// regen sparkle
if (P.regen > 0 && chance(0.08)) {
part(G, P.x + rand(-10, 10), P.y + rand(-10, 4), '#7dff9e', { sp: 24, life: .5, size: 2 });
}
},
/* ---------- effects ---------- */
_effects(G, ctx) {
for (const ef of G.effects) {
const prog = clamp(ef.t / ef.dur, 0, 1);
ctx.save();
ctx.translate(ef.x, ef.y);
switch (ef.type) {
case 'slash': {
ctx.globalAlpha = 1 - prog;
ctx.rotate(ef.data.ang || 0);
const grad = ctx.createLinearGradient(-ef.data.w / 2, 0, ef.data.w / 2, 0);
grad.addColorStop(0, 'rgba(255,255,255,0)');
grad.addColorStop(.5, ef.data.col);
grad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = grad;
const h = ef.data.h * (0.6 + prog * 0.6);
this._roundRect(ctx, -ef.data.w / 2, -h / 2, ef.data.w, h, h / 2);
ctx.fill();
break;
}
case 'bolt': {
ctx.globalAlpha = 1 - prog;
ctx.strokeStyle = ef.data.col;
ctx.lineWidth = 4 * (1 - prog) + 1;
ctx.shadowColor = ef.data.col; ctx.shadowBlur = 12;
ctx.beginPath();
let yy = 0;
ctx.moveTo(rand(-4, 4), yy);
while (yy < ef.data.ty - ef.y - 20) {
yy += rand(24, 46);
ctx.lineTo(rand(-16, 16), Math.min(yy, ef.data.ty - ef.y));
}
ctx.lineTo(0, ef.data.ty - ef.y);
ctx.stroke();
break;
}
case 'arc': {
ctx.globalAlpha = 1 - prog;
ctx.strokeStyle = ef.data.col;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(0, 0);
const mx = (ef.data.tx - ef.x) / 2 + rand(-30, 30);
const my = (ef.data.ty - ef.y) / 2 + rand(-30, 30);
ctx.quadraticCurveTo(mx, my, ef.data.tx - ef.x, ef.data.ty - ef.y);
ctx.stroke();
break;
}
case 'ring': {
ctx.globalAlpha = 1 - prog;
ctx.strokeStyle = ef.data.col;
ctx.lineWidth = 3 * (1 - prog) + 1;
ctx.beginPath(); ctx.arc(0, 0, ef.data.r * (0.4 + prog * 0.6), 0, TAU); ctx.stroke();
break;
}
case 'nova': {
ctx.globalAlpha = 1 - prog;
ctx.strokeStyle = ef.data.col;
ctx.lineWidth = 5 * (1 - prog) + 1;
ctx.beginPath(); ctx.arc(0, 0, ef.data.r * prog, 0, TAU); ctx.stroke();
break;
}
case 'explosion': {
ctx.globalAlpha = (1 - prog) * 0.95;
const rr = ef.data.r * (0.35 + prog * 0.65);
const g = ctx.createRadialGradient(0, 0, rr * .15, 0, 0, rr);
g.addColorStop(0, '#fff8d6');
g.addColorStop(.55, ef.data.col);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(0, 0, rr, 0, TAU); ctx.fill();
break;
}
case 'aura': {
ctx.globalAlpha = 0.13 + Math.sin(G.time * 6) * 0.03;
ctx.fillStyle = ef.data.col;
ctx.beginPath(); ctx.arc(0, 0, ef.data.r, 0, TAU); ctx.fill();
ctx.globalAlpha = 0.5;
ctx.strokeStyle = ef.data.col;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(0, 0, ef.data.r, 0, TAU); ctx.stroke();
break;
}
case 'zone': {
ctx.globalAlpha = 0.28 * (1 - prog * 0.5);
ctx.fillStyle = '#ff6b35';
ctx.beginPath(); ctx.arc(0, 0, ef.data.r, 0, TAU); ctx.fill();
ctx.globalAlpha = 0.7 * (1 - prog);
ctx.fillStyle = '#ffd166';
for (let k = 0; k < 4; k++) {
const a = G.time * 2 + k * 1.7;
ctx.beginPath();
ctx.arc(Math.cos(a) * ef.data.r * .5, Math.sin(a * 1.3) * ef.data.r * .5, 3.5, 0, TAU);
ctx.fill();
}
break;
}
case 'shock': {
ctx.globalAlpha = 1 - prog;
ctx.strokeStyle = ef.data.col;
ctx.lineWidth = 10 * (1 - prog) + 2;
ctx.beginPath(); ctx.arc(0, 0, ef.data.r, 0, TAU); ctx.stroke();
break;
}
case 'impact': {
// crit starburst: spikes + white core
ctx.rotate(ef.data.ang || 0);
ctx.globalAlpha = 1 - prog;
const iR = (ef.data.r || 12) * (0.5 + prog * 0.8);
ctx.strokeStyle = ef.data.col || '#ffd24a';
ctx.lineWidth = 2.2 * (1 - prog) + .5;
ctx.lineCap = 'round';
ctx.beginPath();
for (let k = 0; k < 4; k++) {
const a = k * Math.PI / 2 + .4;
ctx.moveTo(Math.cos(a) * iR * .3, Math.sin(a) * iR * .3);
ctx.lineTo(Math.cos(a) * iR, Math.sin(a) * iR);
}
ctx.stroke();
ctx.globalAlpha = (1 - prog) * .9;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.arc(0, 0, iR * .42, 0, TAU); ctx.stroke();
break;
}
case 'muzzle': {
// weapon fire flash at the barrel
ctx.rotate(ef.data.ang || 0);
ctx.globalAlpha = 1 - prog;
ctx.fillStyle = '#fff8d6';
if (Store.s().particles !== 'low') { ctx.shadowColor = '#ffe066'; ctx.shadowBlur = 8; }
const mR = 13 * (1 - prog * 0.55);
ctx.beginPath();
for (let k = 0; k < 4; k++) {
const a = k * Math.PI / 2;
ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(a - .25) * mR * .45, Math.sin(a - .25) * mR * .45);
ctx.lineTo(Math.cos(a) * mR, Math.sin(a) * mR);
ctx.lineTo(Math.cos(a + .25) * mR * .45, Math.sin(a + .25) * mR * .45);
ctx.closePath();
}
ctx.fill();
break;
}
}
ctx.restore();
}
},
/* ---------- particles / floating text ---------- */
_particles(G, ctx, q) {
const maxDraw = q === 'low' ? 150 : q === 'med' ? 300 : 600;
const n = Math.min(G.parts.length, maxDraw);
for (let i = 0; i < n; i++) {
const p = G.parts[i];
ctx.globalAlpha = clamp(p.life / p.maxLife, 0, 1);
ctx.fillStyle = p.col;
ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
}
ctx.globalAlpha = 1;
},
_texts(G, ctx, camX, camY) {
ctx.textAlign = 'center';
for (let i = G.texts.length - 1; i >= 0; i--) {
const ft = G.texts[i];
ft.life -= G.lastDt;
ft.y -= (ft.vy || 34) * G.lastDt;
if (ft.life <= 0) { removeItem(G.texts, i); continue; }
// scale punch on spawn (crits / heals / big events)
const age = ft.maxLife - ft.life;
let scale = 1;
if (ft.pop > 1 && age < 0.09) scale = ft.pop - (ft.pop - 1) * (age / 0.09);
else if (ft.pop > 1 && ft.life < 0.12) scale = 1 - (0.12 - ft.life); // shrink out
ctx.globalAlpha = clamp(ft.life / 0.35, 0, 1);
ctx.save();
ctx.translate(ft.x - camX, ft.y - camY);
ctx.scale(scale, scale);
ctx.font = (ft.big ? '800 17px' : '700 13px') + ' "Segoe UI",sans-serif';
ctx.strokeStyle = 'rgba(0,0,0,.85)';
ctx.lineWidth = 3;
ctx.strokeText(ft.txt, 0, 0);
ctx.fillStyle = ft.col;
ctx.fillText(ft.txt, 0, 0);
ctx.restore();
}
ctx.globalAlpha = 1;
},
/* ---------- offscreen boss indicator ---------- */
_bossArrow(G, ctx, W, H) {
const b = G.boss;
if (!b || b.dead) return;
const dx = b.x - G.cam.x, dy = b.y - G.cam.y;
if (Math.abs(dx) < W / 2 - 60 && Math.abs(dy) < H / 2 - 60) return;
const a = Math.atan2(dy, dx);
const ex = clamp(W / 2 + Math.cos(a) * W, 26, W - 26);
const ey = clamp(H / 2 + Math.sin(a) * H, 70, H - 26);
ctx.save();
ctx.translate(ex, ey);
ctx.rotate(a);
ctx.fillStyle = 'rgba(255,60,90,.9)';
ctx.beginPath();
ctx.moveTo(14, 0); ctx.lineTo(-8, -9); ctx.lineTo(-8, 9);
ctx.closePath(); ctx.fill();
ctx.restore();
},
/* ================= cinematic main-menu scene ================= */
drawMenuScene(G, dt) {
const ctx = G.ctx;
const W = G.cv.width / G.dpr, H = G.cv.height / G.dpr;
const M = G._menu || (G._menu = { t: 0, walkers: [], spawnAcc: 1.2, motes: null, sweep: 0 });
M.t += dt;
const t = M.t;
const horY = H * 0.66;
// ---- sky ----
let sky = this._menuSky || (this._menuSky = { h: 0 });
if (sky.h !== H || !this._menuSkyC) {
const c = document.createElement('canvas'); c.width = 2; c.height = 256;
const cc = c.getContext('2d');
const g = cc.createLinearGradient(0, 0, 0, 256);
g.addColorStop(0, '#04040b');
g.addColorStop(.55, '#0c0a24');
g.addColorStop(1, '#241a4e');
cc.fillStyle = g; cc.fillRect(0, 0, 2, 256);
this._menuSkyC = c;
}
ctx.drawImage(this._menuSkyC, 0, 0, 2, 256, 0, 0, W, H);
// nebula blobs
for (let i = 0; i < 2; i++) {
const nx = W * (0.22 + i * 0.55) + Math.sin(t * 0.05 + i * 3) * W * 0.05;
const ny = H * (0.18 + i * 0.16);
const nr = Math.min(W, H) * (0.42 - i * 0.1);
const ng = ctx.createRadialGradient(nx, ny, 0, nx, ny, nr);
ng.addColorStop(0, i ? 'rgba(0,229,255,0.07)' : 'rgba(124,92,255,0.10)');
ng.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = ng;
ctx.fillRect(nx - nr, ny - nr, nr * 2, nr * 2);
}
// ---- stars (deterministic twinkle) ----
ctx.fillStyle = '#fff';
for (let i = 0; i < 70; i++) {
const hsh = _strHash('star' + i);
const sx = (hsh % 1000) / 1000 * W;
const sy = ((hsh >> 10) % 1000) / 1000 * horY * 0.96;
const tw = 0.35 + 0.65 * Math.abs(Math.sin(t * (0.6 + (hsh % 5) * 0.25) + i));
ctx.globalAlpha = tw * 0.85;
const r = 0.5 + (hsh % 13) / 10;
ctx.fillRect(sx, sy, r, r);
}
ctx.globalAlpha = 1;
// ---- moon ----
const mr = Math.min(W, H) * 0.14;
const mx = W * 0.78, my = H * 0.20 + Math.sin(t * 0.3) * 6;
if (Store.s().particles !== 'low') {
ctx.shadowColor = 'rgba(199,180,255,.9)';
ctx.shadowBlur = 46;
}
const mg = ctx.createRadialGradient(mx - mr * .3, my - mr * .35, mr * .15, mx, my, mr);
mg.addColorStop(0, '#f3edff');
mg.addColorStop(.7, '#b9a8ff');
mg.addColorStop(1, '#6e57c9');
ctx.fillStyle = mg;
ctx.beginPath(); ctx.arc(mx, my, mr, 0, TAU); ctx.fill();
ctx.shadowBlur = 0;
// craters
ctx.fillStyle = 'rgba(80,60,150,.28)';
[[-.3,-.1,.18],[.22,.25,.12],[-.05,.38,.09],[.34,-.28,.08]].forEach(c => {
ctx.beginPath(); ctx.arc(mx + c[0] * mr, my + c[1] * mr, c[2] * mr, 0, TAU); ctx.fill();
});
// ---- skyline silhouettes ----
const bld = (layer, colr, seedOff, maxH) => {
ctx.fillStyle = colr;
const bw = W / 14;
for (let i = -1; i < 15; i++) {
const hs = _strHash('bld' + layer + '_' + i);
const bh = maxH * (0.35 + (hs % 100) / 100 * 0.65);
const x = i * bw - bw * 0.5 + ((hs >> 7) % 20) - 10;
ctx.fillRect(x, horY - bh, bw * (0.62 + (hs % 30) / 100), bh + 4);
if (layer === 1 && (hs % 3 === 0)) {
// a few lit windows
ctx.fillStyle = 'rgba(255,209,102,' + (0.25 + 0.2 * Math.sin(t * 2 + i)) + ')';
for (let wy = 0; wy < 3; wy++)
ctx.fillRect(x + bw * 0.2, horY - bh + 8 + wy * 9, 2.5, 3.5);
ctx.fillStyle = colr;
}
}
};
bld(0, '#191238', 0, H * 0.11);
bld(1, '#0d0a20', 50, H * 0.075);
// horizon glow band
const hg = ctx.createLinearGradient(0, horY - 26, 0, horY + 26);
hg.addColorStop(0, 'rgba(124,92,255,0)');
hg.addColorStop(.5, 'rgba(160,120,255,0.55)');
hg.addColorStop(1, 'rgba(0,229,255,0)');
ctx.fillStyle = hg;
ctx.fillRect(0, horY - 26, W, 52);
// ---- perspective floor grid ----
ctx.strokeStyle = 'rgba(124,92,255,0.30)';
ctx.lineWidth = 1;
const vpx = W / 2;
ctx.beginPath();
for (let k = -14; k <= 14; k++) {
const xb = vpx + k * (W * 0.09);
ctx.moveTo(vpx + k * 8, horY);
ctx.lineTo(xb * 1.02 - vpx * 0.02 + (xb - vpx) * 0.0, H + 40);
}
ctx.stroke();
ctx.strokeStyle = 'rgba(0,229,255,0.20)';
ctx.beginPath();
const ROWS = 15;
for (let i = 0; i < ROWS; i++) {
const z = ((i + t * 2.0) % ROWS) / ROWS;
const y = horY + (H - horY) * Math.pow(z, 3);
ctx.moveTo(0, y); ctx.lineTo(W, y);
}
ctx.stroke();
// ---- marching silhouette walkers on the ground line ----
M.spawnAcc -= dt;
if (M.spawnAcc <= 0 && M.walkers.length < 7) {
M.spawnAcc = rand(1.3, 2.6);
const dir = chance(.5) ? 1 : -1;
const cols = ['#ff6b81', '#7dfaff', '#c07dff'];
M.walkers.push({
x: dir > 0 ? -40 : W + 40, dir,
y: horY + rand(16, 44),
r: rand(7, 11),
spd: rand(24, 46),
wob: rand(TAU),
col: pick(cols)
});
}
for (let i = M.walkers.length - 1; i >= 0; i--) {
const wk = M.walkers[i];
wk.x += wk.dir * wk.spd * dt;
if (wk.x < -60 || wk.x > W + 60) { M.walkers.splice(i, 1); continue; }
const bob = Math.abs(Math.sin(t * 6 + wk.wob)) * 3;
ctx.save();
ctx.translate(wk.x, wk.y - bob);
ctx.globalAlpha = 0.92;
// body blob
ctx.fillStyle = '#0b0817';
ctx.beginPath();
ctx.arc(0, 0, wk.r, Math.PI, TAU);
ctx.lineTo(wk.r * 0.9, wk.r * 0.9);
ctx.quadraticCurveTo(0, wk.r * 1.25, -wk.r * 0.9, wk.r * 0.9);
ctx.closePath(); ctx.fill();
// neon rim toward viewer
ctx.strokeStyle = colA(wk.col, .75);
ctx.lineWidth = 1.4;
ctx.stroke();
// eyes
ctx.fillStyle = wk.col;
const eo = wk.dir * wk.r * 0.28;
ctx.beginPath(); ctx.arc(-wk.r * .3 + eo, -wk.r * .15, 1.6, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(wk.r * .3 + eo, -wk.r * .15, 1.6, 0, TAU); ctx.fill();
ctx.restore();
}
ctx.globalAlpha = 1;
// ---- rising dust motes ----
if (!M.motes) {
M.motes = [];
for (let i = 0; i < 26; i++)
M.motes.push({ x: rand(0, 1), y: rand(0, 1), s: rand(1, 2.6), v: rand(8, 26), ph: rand(TAU) });
}
for (const m of M.motes) {
m.y -= m.v * dt / H;
if (m.y < -0.03) { m.y = 1.03; m.x = Math.random(); }
const px = (m.x + Math.sin(t * 0.6 + m.ph) * 0.01) * W;
ctx.globalAlpha = 0.16 + 0.14 * Math.sin(t * 2 + m.ph);
ctx.fillStyle = '#9ad5ff';
ctx.beginPath(); ctx.arc(px, m.y * H, m.s, 0, TAU); ctx.fill();
}
ctx.globalAlpha = 1;
// ---- periodic diagonal light sweep ----
M.sweep += dt;
const period = 8;
const sp = (M.sweep % period) / period;
if (sp < 0.22) {
const k = sp / 0.22;
const cx = -W * 0.3 + k * W * 1.6;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.translate(cx, H * 0.35);
ctx.rotate(-0.35);
const lg = ctx.createLinearGradient(-90, 0, 90, 0);
lg.addColorStop(0, 'rgba(160,140,255,0)');
lg.addColorStop(.5, 'rgba(190,170,255,0.06)');
lg.addColorStop(1, 'rgba(160,140,255,0)');
ctx.fillStyle = lg;
ctx.fillRect(-90, -H, 180, H * 2);
ctx.restore();
}
this._vignette(ctx, W, H);
},
_vignette(ctx, W, H) { if (!this._vig || this._vigW !== W || this._vigH !== H) {
const c = document.createElement('canvas');
c.width = W; c.height = H;
const cc = c.getContext('2d');
const g = cc.createRadialGradient(W / 2, H / 2, Math.min(W, H) * .45, W / 2, H / 2, Math.max(W, H) * .75);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,10,0.42)');
cc.fillStyle = g;
cc.fillRect(0, 0, W, H);
this._vig = c; this._vigW = W; this._vigH = H;
}
ctx.drawImage(this._vig, 0, 0);
},
_roundRect(ctx, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
};