// ═══ renderer-world.js ═══ // ============================================================ // RENDERER WORLD — background, arena, fog, portals // ============================================================ // ── OFFSCREEN BACKGROUND CACHE ──────────────────────────────── // Drawn once, re-rendered only on resize — never recomputed each frame let _bgCanvas = null; let _bgW = 0, _bgH = 0; // per-weapon range rings drawn on alt-hold const _mountDropZones = []; const _bagDropZones = []; const _dragRegions = []; function buildBackground(W, H) { _bgCanvas = document.createElement('canvas'); _bgCanvas.width = W; _bgCanvas.height = H; const c = _bgCanvas.getContext('2d'); const grad = c.createRadialGradient(W/2, H/2, 0, W/2, H/2, Math.max(W,H) * 0.7); grad.addColorStop(0, '#060d14'); grad.addColorStop(1, '#020508'); c.fillStyle = grad; c.fillRect(0, 0, W, H); _bgW = W; _bgH = H; } function drawGrid(cx, cy) { const gs = 48; const x0 = Math.floor((cx - ARENA_RADIUS - gs) / gs) * gs; const x1 = Math.ceil((cx + ARENA_RADIUS + gs) / gs) * gs; const y0 = Math.floor((cy - ARENA_RADIUS - gs) / gs) * gs; const y1 = Math.ceil((cy + ARENA_RADIUS + gs) / gs) * gs; ctx.strokeStyle = '#0a1520'; ctx.lineWidth = 1; ctx.beginPath(); for (let x = x0; x <= x1; x += gs) { ctx.moveTo(x, y0); ctx.lineTo(x, y1); } for (let y = y0; y <= y1; y += gs) { ctx.moveTo(x0, y); ctx.lineTo(x1, y); } ctx.stroke(); } // ── CACHED ELEMENT GLOW GRADIENTS ──────────────────────────── // Keyed by "elementId:x:y:radius" — cleared each frame since positions change, // but the gradient itself is reused within a single frame for same-element enemies. // In practice we cache by element type at a unit radius and scale at draw time. const _elemGradCache = {}; function getElemGrad(el, x, y, r) { const grd = ctx.createRadialGradient(x, y, 0, x, y, r); grd.addColorStop(0, el.glow + '55'); grd.addColorStop(1, 'transparent'); return grd; } // ── ARENA OVERLAY HELPERS ───────────────────────────────────── // Per-weapon range rings — visible while Alt is held const _RANGE_COLORS = ['#00d4ff', '#ff6b35', '#a855f7', '#22c55e', '#f59e0b', '#ec4899']; function drawWeaponRanges(cx, cy) { if (!_altHeld) return; const weapons = (G.weapons || []).filter(w => w); if (!weapons.length) return; const zoom = G?.camera?.zoom ?? 1.0; weapons.forEach((w, i) => { const r = w.range ?? 0; if (!r || r >= 9000) return; const color = _RANGE_COLORS[i % _RANGE_COLORS.length]; const label = (getWeaponDef(w)?.name ?? w.defId).toUpperCase(); // Ring drawn in world space — scales naturally with zoom ctx.save(); ctx.strokeStyle = color; ctx.globalAlpha = 0.55; ctx.lineWidth = 1.5; ctx.setLineDash([8, 10]); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); // Label in screen space — constant size regardless of zoom // cx == ARENA_CX so screen x == cx; screen y = ARENA_CY - (r+6)*zoom const sx = cx; const sy = ARENA_CY - (r + 6) * zoom; ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.font = 'bold 11px "Share Tech Mono", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; const tw = ctx.measureText(label).width; ctx.fillStyle = 'rgba(2,6,14,0.78)'; ctx.fillRect(sx - tw / 2 - 4, sy - 13, tw + 8, 14); ctx.fillStyle = color; ctx.globalAlpha = 0.95; ctx.fillText(label, sx, sy); ctx.restore(); }); } // Dark overlay outside the arena circle function drawArenaMask(W, H, cx, cy, zoom = 1) { ctx.fillStyle = '#010407'; ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.arc(cx, cy, ARENA_RADIUS * zoom, 0, Math.PI * 2); ctx.fill('evenodd'); } // Subtle glowing border ring at arena edge function drawArenaRing(cx, cy, zoom = 1) { const r = ARENA_RADIUS * zoom; ctx.save(); ctx.strokeStyle = 'rgba(0,100,180,0.14)'; ctx.lineWidth = 10; ctx.beginPath(); ctx.arc(cx, cy, r - 5, 0, Math.PI * 2); ctx.stroke(); ctx.strokeStyle = 'rgba(0,180,255,0.22)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } // Streak indicator drawn on canvas; reads G.streak directly function drawStreak(cx, cy) { const streak = G.streak; const framesSince = G.frame - streak.lastKillFrame; if (streak.count < 3 || framesSince > 180) return; const alpha = Math.min(1, Math.min(framesSince / 8, (180 - framesSince) / 20)); if (alpha <= 0) return; const high = streak.count >= 10; const color = high ? '#ffd700' : '#00d4ff'; const text = `x${streak.count} STREAK`; ctx.save(); ctx.globalAlpha = alpha; ctx.font = 'bold 18px Orbitron, "Share Tech Mono", monospace'; ctx.textAlign = 'center'; const tw = ctx.measureText(text).width; const pad = 14; const bw = tw + pad * 2; const bh = 30; const bx = cx - bw / 2; const by = cy - 350; ctx.fillStyle = 'rgba(2,6,14,0.9)'; ctx.fillRect(bx, by, bw, bh); ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.strokeRect(bx, by, bw, bh); ctx.fillStyle = color; ctx.shadowColor = color; ctx.shadowBlur = 10; ctx.textBaseline = 'middle'; ctx.fillText(text, cx, by + bh / 2); ctx.shadowBlur = 0; ctx.restore(); } // ── PORTALS ─────────────────────────────────────────────────── function drawPortals() { for (const p of G.portals) { const alpha = Math.min(1, p.life / 30); const fadeOut = Math.min(1, (p.life / p.maxLife) * 3); const a = Math.min(alpha, fadeOut); if (a <= 0) continue; ctx.save(); // Glow blob (no shadow — use radial gradient instead) const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 30); grd.addColorStop(0, p.color + '66'); grd.addColorStop(1, 'transparent'); ctx.globalAlpha = a; ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(p.x, p.y, 30, 0, Math.PI * 2); ctx.fill(); // Outer ring ctx.globalAlpha = a * 0.7; ctx.strokeStyle = p.color; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(p.x, p.y, 22 + Math.sin(G.frame * 0.08) * 3, 0, Math.PI * 2); ctx.stroke(); // Spinning arc ctx.translate(p.x, p.y); ctx.rotate(p.angle); ctx.globalAlpha = a * 0.9; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(0, 0, 14, 0, Math.PI * 1.5); ctx.stroke(); ctx.restore(); } }