'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(); } };