/* ============================================================ * Diablo2D — render.js : isometric renderer, lighting, particles * ============================================================ */ 'use strict'; window.D2 = window.D2 || {}; (function (D2) { const TW = 64, TH = 32, WALL_H = 46; let canvas = null, ctx = null; let lightCanvas = null, lctx = null; let W = 0, H = 0, dpr = 1; const cam = { x: 0, y: 0, zoom: 1.18, shakeT: 0, shakeMag: 0 }; /* world (tile) -> screen (px), relative to camera */ function worldToScreen(wx, wy) { const dx = wx - cam.x, dy = wy - cam.y; return { x: (dx - dy) * TW / 2, y: (dx + dy) * TH / 2, }; } function screenToWorld(sx, sy) { /* W,H are CSS pixels (see resize()); no extra dpr division here */ const cx = (sx - W / 2) / cam.zoom, cy = (sy - H / 2) / cam.zoom; const a = cx / (TW / 2), b = cy / (TH / 2); return { x: (b + a) / 2 + cam.x, y: (b - a) / 2 + cam.y }; } function init(cv) { canvas = cv; ctx = canvas.getContext('2d'); lightCanvas = document.createElement('canvas'); lctx = lightCanvas.getContext('2d'); resize(); window.addEventListener('resize', resize); } function resize() { dpr = Math.min(window.devicePixelRatio || 1, 2); W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); lightCanvas.width = canvas.width; lightCanvas.height = canvas.height; } function addShake(mag) { if (!D2.game || !D2.game.settings || D2.game.settings.screenShake === false) return; cam.shakeMag = Math.max(cam.shakeMag, mag); cam.shakeT = 0.3; } /* ================= main frame ================= */ function frame(g, dt) { const theme = g.world.theme; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = theme.fog; ctx.fillRect(0, 0, W, H); /* camera follows player */ const target = g.player ? { x: g.player.x, y: g.player.y } : screenToWorld(W / 2, H / 2); if (g.player) { cam.x = D2.util.lerp(cam.x, target.x, Math.min(1, dt * 7)); cam.y = D2.util.lerp(cam.y, target.y, Math.min(1, dt * 7)); } if (cam.shakeT > 0) { cam.shakeT -= dt; const m = cam.shakeMag * (cam.shakeT / 0.3); ctx.translate((Math.random() - .5) * m * 14, (Math.random() - .5) * m * 10); if (cam.shakeT <= 0) cam.shakeMag = 0; } ctx.translate(W / 2, H / 2); ctx.scale(cam.zoom, cam.zoom); /* visible tile bounds via inverse projection of corners */ const corners = [ screenToWorldLocal(0, 0), screenToWorldLocal(W, 0), screenToWorldLocal(0, H), screenToWorldLocal(W, H), ]; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (const c of corners) { minX = Math.min(minX, c.x); maxX = Math.max(maxX, c.x); minY = Math.min(minY, c.y); maxY = Math.max(maxY, c.y); } minX -= 2; minY -= 3; maxX += 2; maxY += 4; drawFloors(g, theme, minX, maxX, minY, maxY); drawGroundFx(g, minX, maxX, minY, maxY); /* depth-sorted pass */ const drawables = []; collectWalls(g, drawables, minX, maxX, minY, maxY); collectProps(g, drawables, minX, maxX, minY, maxY); for (const p of g.pickups) pushDrawable(drawables, p.x, p.y, 0.01, 'pickup', p); for (const e of g.effects) if (e.ring) pushDrawable(drawables, e.x, e.y, 0.02, 'fxring', e); for (const m of g.monsters) if (!m.dead) pushDrawable(drawables, m.x, m.y, 0, 'monster', m); if (g.player && !g.player.dead) pushDrawable(drawables, g.player.x, g.player.y, 0, 'player', g.player); for (const pr of g.projectiles) pushDrawable(drawables, pr.x, pr.y, 0, 'proj', pr); drawables.sort((a, b) => a.depth - b.depth); for (const d of drawables) dispatchDraw(d, g); drawParticles(g); drawSpellGlows(g); drawFloatingTexts(g); drawOverheadUI(g); drawLabelsAndMarkers(g, dt); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); applyLighting(g, theme); drawVignette(theme); } /* local screen->world without re-adding cam offset twice */ function screenToWorldLocal(sx, sy) { const cx = sx - W / 2, cy = sy - H / 2; const a = cx / (TW / 2), b = cy / (TH / 2); return { x: (b + a) / 2 + cam.x, y: (b - a) / 2 + cam.y }; } /* ---------------- floors ---------------- */ function drawFloors(g, theme, minX, maxX, minY, maxY) { const th = D2.sprites.themes[g.world.themeId]; if (!th) return; const w = g.world.w, h = g.world.h; for (let ty = Math.max(0, minY | 0); ty <= Math.min(h - 1, maxY | 0); ty++) { for (let tx = Math.max(0, minX | 0); tx <= Math.min(w - 1, maxX | 0); tx++) { const i = ty * w + tx; if (g.world.tiles[i] !== D2.world.T.FLOOR) continue; if (!g.world.explored[i]) continue; const s = worldToScreen(tx, ty); const vis = g.world.visible[i]; if (!vis) ctx.globalAlpha = 0.42; ctx.drawImage(th.floors[g.world.variant[i]], s.x - TW / 2, s.y - TH / 2); ctx.globalAlpha = 1; } } } /* ---------------- ground effects & decals ---------------- */ function drawGroundFx(g, minX, maxX, minY, maxY) { for (const e of g.groundDecals) { const s = worldToScreen(e.x, e.y); ctx.save(); ctx.globalAlpha = Math.max(0, e.alpha) * (e.life / e.maxLife); ctx.fillStyle = e.color; ellipseWorld(ctx, s.x, s.y, e.r, 0.52); ctx.fill(); ctx.restore(); } for (const e of g.effects) { if (e.type !== 'telegraph') continue; const s = worldToScreen(e.x, e.y); const t = D2.util.clamp((e.t / e.delay), 0, 1); ctx.save(); ctx.strokeStyle = e.color || '#ff8a3a'; ctx.globalAlpha = 0.35 + 0.4 * t; ctx.lineWidth = 2; ellipseWorld(ctx, s.x, s.y, e.radius, 0.52); ctx.stroke(); ctx.fillStyle = e.color || '#ff8a3a'; ctx.globalAlpha *= 0.16; ellipseWorld(ctx, s.x, s.y, e.radius * t, 0.52); ctx.fill(); ctx.restore(); } } function ellipseWorld(c, sx, sy, rTiles, squash) { c.beginPath(); c.ellipse(sx, sy, rTiles * TW / 2, rTiles * TW / 2 * squash, 0, 0, Math.PI * 2); } /* ---------------- drawables ---------------- */ function pushDrawable(arr, x, y, bias, kind, ref) { arr.push({ x, y, kind, ref, depth: x + y + bias }); } function collectWalls(g, arr, minX, maxX, minY, maxY) { const w = g.world.w, h = g.world.h; for (let ty = Math.max(0, minY | 0); ty <= Math.min(h - 1, maxY | 0); ty++) { for (let tx = Math.max(0, minX | 0); tx <= Math.min(w - 1, maxX | 0); tx++) { const i = ty * w + tx; if (g.world.tiles[i] !== D2.world.T.WALL) continue; if (!g.world.explored[i]) continue; pushDrawable(arr, tx, ty, 0.45, 'wall', i); } } } function collectProps(g, arr, minX, maxX, minY, maxY) { for (const p of g.world.props) { if (p.x < minX - 1 || p.x > maxX + 1 || p.y < minY - 1 || p.y > maxY + 1) continue; const i = p.y * g.world.w + p.x; if (!g.world.explored[i]) continue; pushDrawable(arr, p.x, p.y, 0.02, 'prop', p); } } function dispatchDraw(d, g) { switch (d.kind) { case 'wall': drawWall(g, d.ref, d.x, d.y); break; case 'prop': drawProp(g, d.ref, d.x, d.y); break; case 'pickup': drawPickup(g, d.ref, d.x, d.y); break; case 'monster': drawMonster(g, d.ref, d.x, d.y); break; case 'player': drawPlayer(g, d.ref, d.x, d.y); break; case 'proj': drawProjectile(d.ref, d.x, d.y); break; case 'fxring': drawFxRing(d.ref, d.x, d.y); break; } } function drawWall(g, tileIdx, tx, ty) { const th = D2.sprites.themes[g.world.themeId]; const s = worldToScreen(tx, ty); ctx.drawImage(th.wallBlock, s.x - TW / 2 - 2, s.y - WALL_H - TH / 2 - 2); } function drawProp(g, p, tx, ty) { const s = worldToScreen(tx, ty); const P = D2.sprites.props; const px = s.x, py = s.y; switch (p.type) { case 'torch': { ctx.drawImage(P.torch, px - 12, py - 40); /* animated flame */ const fl = Math.sin(g.time * 11 + p.uid * 3) * 0.15 + 1; flame(px - 9.5, py - 46, 6 * fl, '#ffb84a', '#ff6a2a'); break; } case 'barrel': ctx.drawImage(P.barrel, px - 20, py - 34); break; case 'urn': ctx.drawImage(P.urn, px - 18, py - 32); break; case 'chest': ctx.drawImage(p.opened ? P.chest_open : P.chest_closed, px - 24, py - 36); if (!p.opened) sparkle(px, py - 30, '#ffd24a'); break; case 'shrine': { ctx.drawImage(P.shrine, px - 26, py - 50); const col = { dmg: '#e85a3a', speed: '#8ad8ff', armor: '#aab4be', xp: '#ffe86a', regen: '#7ade8a' }[p.buff] || '#fff'; if (!p.used) { glowOrb(px, py - 34, 5 + Math.sin(g.time * 4 + p.uid) * 1.5, col); sparkle(px, py - 34, col); } break; } case 'stairs_down': ctx.drawImage(P.stairs_down, px - TW / 2, py - TH / 2 - 9); glowOrb(px, py, 4 + Math.sin(g.time * 3) * 1.2, '#8ac4ff'); break; case 'stairs_up': ctx.drawImage(P.stairs_up, px - TW / 2, py - TH / 2 - 9); break; case 'bones': ctx.drawImage(P.bones, px - 18, py - 14); break; case 'waypoint': { ctx.drawImage(P.waypoint, px - 24, py - 56); const active = g.waypointsUnlocked && g.waypointsUnlocked.length > 0; glowOrb(px, py - 34, 6 + Math.sin(g.time * (active ? 5 : 2)) * 2, active ? '#8ac4ff' : '#666'); break; } default: if (p.type.startsWith('npc_')) { const spr = P[p.type]; if (spr) ctx.drawImage(spr, px - 26, py - 54); /* quest-ish marker */ glowOrb(px, py - 58 + Math.sin(g.time * 3) * 2, 3, '#ffd24a'); } } } function flame(x, y, r, c1, c2) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; const g = ctx.createRadialGradient(x, y, 1, x, y, r * 2.2); g.addColorStop(0, '#fff8e0'); g.addColorStop(0.3, c1); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(x, y - r * 0.4, r * 0.8, r * 1.6, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } function glowOrb(x, y, r, col) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; const g = ctx.createRadialGradient(x, y, 0.5, x, y, r * 2.4); g.addColorStop(0, '#ffffff'); g.addColorStop(0.25, col); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r * 2.4, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } function sparkle(x, y, col) { ctx.save(); ctx.globalAlpha = 0.65; ctx.strokeStyle = col; ctx.lineWidth = 1.5; const t = performance.now() / 500 + x; for (let i = 0; i < 2; i++) { const a = t % (Math.PI * 2); const r = 3 + ((t + i * 2) % 4); ctx.beginPath(); ctx.moveTo(x - r, y); ctx.lineTo(x + r, y); ctx.moveTo(x, y - r); ctx.lineTo(x, y + r); ctx.stroke(); } ctx.restore(); } /* ---------------- pickups ---------------- */ function drawPickup(g, p, tx, ty) { const s = worldToScreen(tx, ty); if (p.kind === 'gold') { const bob = Math.sin(g.time * 4 + p.uid) * 1.5; ctx.fillStyle = '#ffd24a'; ctx.beginPath(); ctx.ellipse(s.x - 3, s.y - 4 + bob, 4, 3, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#c8a35a'; ctx.beginPath(); ctx.ellipse(s.x + 3, s.y - 3 + bob, 4, 3, 0, 0, 7); ctx.fill(); ctx.strokeStyle = '#8a6c1c'; ctx.lineWidth = 1; ctx.beginPath(); ctx.ellipse(s.x + 3, s.y - 3 + bob, 4, 3, 0, 0, 7); ctx.stroke(); } else if (p.kind === 'potion') { const col = p.potionType === 'hp' ? '#e84a3a' : '#4a7ae8'; const bob = Math.sin(g.time * 4 + p.uid) * 1.5; ctx.fillStyle = col; ctx.beginPath(); ctx.arc(s.x, s.y - 5 + bob, 4.5, 0, 7); ctx.fill(); ctx.fillStyle = '#caa'; ctx.fillRect(s.x - 1.5, s.y - 12 + bob, 3, 4); glowOrb(s.x, s.y - 5 + bob, 2.5, col); } else if (p.kind === 'item') { const rarCol = D2.Items.RARITIES[p.item.rarity].color; const bob = Math.sin(g.time * 3 + p.uid) * 2; /* beam of light */ ctx.save(); ctx.globalCompositeOperation = 'lighter'; const grad = ctx.createLinearGradient(s.x, s.y - 40, s.x, s.y); grad.addColorStop(0, 'rgba(0,0,0,0)'); grad.addColorStop(1, D2.util.rgba(rarCol, 0.35)); ctx.fillStyle = grad; ctx.fillRect(s.x - 5, s.y - 40, 10, 40); ctx.restore(); /* mini item silhouette */ ctx.fillStyle = rarCol; ctx.save(); ctx.translate(s.x, s.y - 6 + bob); ctx.rotate(Math.sin(g.time * 2 + p.uid) * 0.2); ctx.fillRect(-2, -7, 4, 14); ctx.beginPath(); ctx.moveTo(-5, 7); ctx.lineTo(5, 7); ctx.lineTo(0, 12); ctx.closePath(); ctx.fill(); ctx.restore(); } } /* ---------------- entities ---------------- */ function shadowEllipse(sx, sy, r) { ctx.fillStyle = 'rgba(0,0,0,.4)'; ctx.beginPath(); ctx.ellipse(sx, sy, r * TW / 2, r * TW / 2 * 0.5, 0, 0, 7); ctx.fill(); } function drawMonster(g, m, tx, ty) { const s = worldToScreen(tx, ty); shadowEllipse(s.x, s.y + 2, m.radius * 1.05); const pal = m.palette; const walkB = m.moving ? Math.sin(g.time * 10 * (m.speedFactor || 1)) * 2 : 0; const hurt = m.hurtFlash > 0; ctx.save(); ctx.translate(s.x, s.y - walkB); if (m.isBoss) { /* boss aura */ ctx.save(); ctx.globalCompositeOperation = 'lighter'; const gr = ctx.createRadialGradient(0, -m.radius * 22, 2, 0, -m.radius * 22, m.radius * 60); gr.addColorStop(0, 'rgba(255,80,30,.25)'); gr.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = gr; ctx.fillRect(-80, -140, 160, 160); ctx.restore(); } if (m.isElite) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; const gr = ctx.createRadialGradient(0, -10, 2, 0, -10, m.radius * 44); gr.addColorStop(0, D2.util.rgba(m.eliteColor || '#c8f', 0.28)); gr.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = gr; ctx.fillRect(-60, -100, 120, 120); ctx.restore(); } const bodyCol = hurt ? '#ffffff' : pal.body; const R = m.radius * 30; // px scale drawMonsterShape(m.shape, bodyCol, pal.accent, R, m, g); /* frozen overlay */ if (m.frozen > 0) { ctx.globalAlpha = 0.55; ctx.fillStyle = '#9adfff'; ctx.beginPath(); ctx.ellipse(0, -R * 0.55, R * 0.75, R * 1.0, 0, 0, 7); ctx.fill(); ctx.globalAlpha = 1; } ctx.restore(); /* hp bar */ if (m.hp < m.maxHp && !m.isBoss) { const wpx = Math.max(26, R * 1.4); const hpx = 3.5; const bx = s.x - wpx / 2, by = s.y - R * 1.75 - 10; ctx.fillStyle = 'rgba(0,0,0,.65)'; ctx.fillRect(bx - 1, by - 1, wpx + 2, hpx + 2); ctx.fillStyle = m.isElite ? '#d86aff' : '#d84b37'; ctx.fillRect(bx, by, wpx * (m.hp / m.maxHp), hpx); } if (m.isElite && !m.isBoss) { ctx.fillStyle = m.eliteColor || '#d86aff'; ctx.font = 'bold 9px Georgia'; ctx.textAlign = 'center'; ctx.fillText('◆', s.x, s.y - R * 1.75 - 13); } } function drawMonsterShape(shape, body, accent, R, m, g) { const swing = m.attackAnim > 0 ? Math.sin((1 - m.attackAnim) * Math.PI) : 0; switch (shape) { case 'skeleton': case 'skeleton_bow': case 'boneking': { ctx.strokeStyle = body; ctx.lineWidth = Math.max(2, R * 0.16); ctx.lineCap = 'round'; /* spine */ ctx.beginPath(); ctx.moveTo(0, -R * 1.15); ctx.lineTo(0, -R * 0.35); ctx.stroke(); /* ribs */ for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.moveTo(-R * 0.42, -R * (0.95 - i * 0.22)); ctx.lineTo(R * 0.42, -R * (0.95 - i * 0.22)); ctx.stroke(); } /* skull */ ctx.fillStyle = body; ctx.beginPath(); ctx.arc(0, -R * 1.38, R * 0.34, 0, 7); ctx.fill(); ctx.fillStyle = accent; ctx.fillRect(-R * 0.18, -R * 1.48, R * 0.13, R * 0.13); ctx.fillRect(R * 0.06, -R * 1.48, R * 0.13, R * 0.13); /* legs */ ctx.beginPath(); ctx.moveTo(0, -R * 0.35); ctx.lineTo(-R * 0.28, 0); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, -R * 0.35); ctx.lineTo(R * 0.28, 0); ctx.stroke(); /* arms + weapon */ ctx.beginPath(); ctx.moveTo(0, -R * 1.05); ctx.lineTo(-R * 0.5, -R * 0.7); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, -R * 1.05); ctx.lineTo(R * (0.5 + swing * 0.5), -R * (0.7 - swing * 0.5)); ctx.stroke(); if (shape === 'skeleton_bow') { ctx.strokeStyle = '#6e5b34'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(R * 0.62, -R * 0.72, R * 0.4, -1.2, 1.2); ctx.stroke(); } else { ctx.strokeStyle = accent; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(R * (0.5 + swing * 0.5), -R * (0.7 - swing * 0.5)); ctx.lineTo(R * (0.95 + swing * 0.7), -R * (1.1 - swing)); ctx.stroke(); } if (shape === 'boneking') crown(accent, R); break; } case 'zombie': case 'troll': case 'butcher': { const big = shape !== 'zombie' ? 1.25 : 1; ctx.fillStyle = body; roundRectE(-R * 0.55 * big, -R * 1.25 * big, R * 1.1 * big, R * 1.0 * big, R * 0.3); ctx.fill(); ctx.fillStyle = body; ctx.beginPath(); ctx.arc(0, -R * 1.45 * big, R * 0.38 * big, 0, 7); ctx.fill(); /* arms forward zombie style */ ctx.strokeStyle = body; ctx.lineWidth = Math.max(3, R * 0.22); ctx.beginPath(); ctx.moveTo(-R * 0.4 * big, -R * 1.0 * big); ctx.lineTo(-R * (0.85 + swing * 0.3), -R * 0.78); ctx.stroke(); ctx.beginPath(); ctx.moveTo(R * 0.4 * big, -R * 1.0 * big); ctx.lineTo(R * (0.85 + swing * 0.3), -R * 0.78); ctx.stroke(); /* eyes */ ctx.fillStyle = accent; ctx.fillRect(-R * 0.2 * big, -R * 1.52 * big, R * 0.14, R * 0.14); ctx.fillRect(R * 0.08 * big, -R * 1.52 * big, R * 0.14, R * 0.14); if (shape === 'butcher') { /* cleaver in right hand */ ctx.fillStyle = '#c8ccd4'; ctx.save(); ctx.translate(R * (0.9 + swing * 0.3), -R * 0.78); ctx.rotate(-0.4 - swing * 0.9); ctx.fillRect(0, -3, R * 0.8, R * 0.55); ctx.restore(); } break; } case 'imp': { ctx.fillStyle = body; ctx.beginPath(); ctx.arc(0, -R * 0.9, R * 0.5, 0, 7); ctx.fill(); /* horns */ ctx.strokeStyle = accent; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(-R * 0.3, -R * 1.25); ctx.quadraticCurveTo(-R * 0.5, -R * 1.6, -R * 0.2, -R * 1.55); ctx.stroke(); ctx.beginPath(); ctx.moveTo(R * 0.3, -R * 1.25); ctx.quadraticCurveTo(R * 0.5, -R * 1.6, R * 0.2, -R * 1.55); ctx.stroke(); /* eyes bright */ ctx.fillStyle = accent; ctx.beginPath(); ctx.arc(-R * 0.17, -R * 1.0, R * 0.09, 0, 7); ctx.fill(); ctx.beginPath(); ctx.arc(R * 0.17, -R * 1.0, R * 0.09, 0, 7); ctx.fill(); /* little wings */ ctx.fillStyle = 'rgba(0,0,0,.35)'; ctx.beginPath(); ctx.ellipse(-R * 0.55, -R * 0.95, R * 0.3, R * 0.16, 0.5, 0, 7); ctx.fill(); ctx.beginPath(); ctx.ellipse(R * 0.55, -R * 0.95, R * 0.3, R * 0.16, -0.5, 0, 7); ctx.fill(); break; } case 'bat': { const flap = Math.sin(g.time * 18 + (m.uid || 0)) * 0.5; ctx.fillStyle = body; ctx.beginPath(); ctx.arc(0, -R * 1.0, R * 0.34, 0, 7); ctx.fill(); ctx.beginPath(); ctx.moveTo(-R * 0.2, -R); ctx.quadraticCurveTo(-R * (0.9 + flap), -R * (1.3 + flap * 0.4), -R * 1.1, -R * 0.8); ctx.quadraticCurveTo(-R * 0.6, -R * 0.85, -R * 0.2, -R); ctx.fill(); ctx.beginPath(); ctx.moveTo(R * 0.2, -R); ctx.quadraticCurveTo(R * (0.9 + flap), -R * (1.3 + flap * 0.4), R * 1.1, -R * 0.8); ctx.quadraticCurveTo(R * 0.6, -R * 0.85, R * 0.2, -R); ctx.fill(); ctx.fillStyle = accent; ctx.fillRect(-R * 0.12, -R * 1.08, R * 0.09, R * 0.09); ctx.fillRect(R * 0.04, -R * 1.08, R * 0.09, R * 0.09); break; } case 'cultist': case 'wraith': { const float = Math.sin(g.time * 3 + (m.uid || 0)) * 2; ctx.translate(0, float); /* robe */ ctx.fillStyle = body; ctx.beginPath(); ctx.moveTo(0, -R * 1.5); ctx.quadraticCurveTo(-R * 0.75, -R * 0.9, -R * 0.62, 0); ctx.lineTo(R * 0.62, 0); ctx.quadraticCurveTo(R * 0.75, -R * 0.9, 0, -R * 1.5); ctx.fill(); /* hood shade + eyes */ ctx.fillStyle = 'rgba(0,0,0,.55)'; ctx.beginPath(); ctx.arc(0, -R * 1.22, R * 0.3, 0, 7); ctx.fill(); ctx.fillStyle = accent; ctx.beginPath(); ctx.arc(-R * 0.12, -R * 1.24, R * 0.07, 0, 7); ctx.fill(); ctx.beginPath(); ctx.arc(R * 0.12, -R * 1.24, R * 0.07, 0, 7); ctx.fill(); if (shape === 'wraith') { ctx.globalAlpha = 0.5; ctx.fillStyle = accent; ctx.beginPath(); ctx.moveTo(-R * 0.6, 0); ctx.quadraticCurveTo(-R * 0.3, R * 0.3, 0, R * 0.15); ctx.quadraticCurveTo(R * 0.3, R * 0.35, R * 0.6, 0); ctx.fill(); ctx.globalAlpha = 1; } else { /* staff with orb */ ctx.strokeStyle = '#4a3a2a'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(R * 0.55, -R * 1.1); ctx.lineTo(R * 0.62, 0); ctx.stroke(); glowOrb(R * 0.55, -R * 1.2, R * 0.14, accent); } break; } case 'spider': case 'queenspider': { const big = shape === 'queenspider' ? 1.5 : 1; ctx.fillStyle = body; ctx.beginPath(); ctx.ellipse(0, -R * 0.6 * big, R * 0.62 * big, R * 0.42 * big, 0, 0, 7); ctx.fill(); /* legs */ ctx.strokeStyle = accent; ctx.lineWidth = Math.max(1.5, R * 0.09); for (let side = -1; side <= 1; side += 2) { for (let i = 0; i < 3; i++) { const lx = side * R * 0.5 * big; const ly = -R * (0.75 - i * 0.18) * big; const wig = Math.sin(g.time * 12 + i * 2 + side) * R * 0.08; ctx.beginPath(); ctx.moveTo(lx * 0.4, ly); ctx.quadraticCurveTo(lx * 1.8, ly - R * 0.3 * big + wig, side * R * 1.05 * big, ly + R * 0.28 * big); ctx.stroke(); } } /* eyes */ ctx.fillStyle = accent; for (let i = -1; i <= 1; i += 2) { ctx.beginPath(); ctx.arc(i * R * 0.16 * big, -R * 0.68 * big, R * 0.07 * big, 0, 7); ctx.fill(); } if (shape === 'queenspider') crown('#a8e84a', R * big); break; } case 'shroom': case 'shroom_big': { const big = shape === 'shroom_big' ? 1.4 : 1; ctx.fillStyle = '#d8d0bc'; roundRectE(-R * 0.2 * big, -R * 0.8 * big, R * 0.4 * big, R * 0.8 * big, R * 0.1); ctx.fill(); ctx.fillStyle = body; ctx.beginPath(); ctx.ellipse(0, -R * 0.85 * big, R * 0.6 * big, R * 0.38 * big, 0, Math.PI, 0); ctx.fill(); ctx.fillStyle = accent; for (const [ox, oy] of [[-0.25, -0.95], [0.15, -1.05], [0.3, -0.85]]) { ctx.beginPath(); ctx.arc(R * ox * big, R * oy * big, R * 0.09 * big, 0, 7); ctx.fill(); } break; } case 'hound': { ctx.fillStyle = body; ctx.beginPath(); ctx.ellipse(0, -R * 0.62, R * 0.68, R * 0.4, 0, 0, 7); ctx.fill(); ctx.beginPath(); ctx.arc(R * 0.55, -R * 0.85, R * 0.28, 0, 7); ctx.fill(); /* ears */ ctx.beginPath(); ctx.moveTo(R * 0.4, -R * 1.05); ctx.lineTo(R * 0.45, -R * 1.3); ctx.lineTo(R * 0.58, -R * 1.05); ctx.fill(); /* fire mane */ flame(0, -R * 0.9, R * 0.35, accent, '#ff5a1a'); /* legs gallop */ ctx.strokeStyle = body; ctx.lineWidth = 3; const run = Math.sin(g.time * 14) * R * 0.2; ctx.beginPath(); ctx.moveTo(-R * 0.4, -R * 0.35); ctx.lineTo(-R * 0.45 + run, 0); ctx.stroke(); ctx.beginPath(); ctx.moveTo(R * 0.4, -R * 0.35); ctx.lineTo(R * 0.45 - run, 0); ctx.stroke(); ctx.fillStyle = accent; ctx.beginPath(); ctx.arc(R * 0.68, -R * 0.92, R * 0.07, 0, 7); ctx.fill(); break; } case 'knight': { ctx.fillStyle = body; roundRectE(-R * 0.5, -R * 1.35, R * 1.0, R * 1.15, R * 0.25); ctx.fill(); ctx.beginPath(); ctx.arc(0, -R * 1.55, R * 0.32, 0, 7); ctx.fill(); ctx.fillStyle = '#000'; ctx.fillRect(-R * 0.22, -R * 1.6, R * 0.44, R * 0.1); ctx.fillStyle = accent; ctx.beginPath(); ctx.moveTo(0, -R * 1.95); ctx.lineTo(R * 0.12, -R * 1.75); ctx.lineTo(-R * 0.12, -R * 1.75); ctx.closePath(); ctx.fill(); /* greatsword */ ctx.save(); ctx.translate(R * 0.62, -R * 0.9); ctx.rotate(-0.3 + swing * -1.6); ctx.fillStyle = '#c8ccd4'; ctx.fillRect(-2, -R * 1.3, 4, R * 1.3); ctx.fillStyle = accent; ctx.fillRect(-5, 0, 10, 3); ctx.restore(); break; } case 'terrorlord': { /* huge demon */ ctx.fillStyle = body; ctx.beginPath(); ctx.moveTo(-R * 0.8, 0); ctx.quadraticCurveTo(-R * 1.0, -R * 1.6, -R * 0.4, -R * 1.8); ctx.lineTo(R * 0.4, -R * 1.8); ctx.quadraticCurveTo(R * 1.0, -R * 1.6, R * 0.8, 0); ctx.closePath(); ctx.fill(); /* horns */ ctx.strokeStyle = '#d8cba8'; ctx.lineWidth = R * 0.12; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(-R * 0.3, -R * 1.75); ctx.quadraticCurveTo(-R * 0.7, -R * 2.3, -R * 0.25, -R * 2.4); ctx.stroke(); ctx.beginPath(); ctx.moveTo(R * 0.3, -R * 1.75); ctx.quadraticCurveTo(R * 0.7, -R * 2.3, R * 0.25, -R * 2.4); ctx.stroke(); /* burning eyes */ flame(-R * 0.18, -R * 1.6, R * 0.12, '#ffd24a', '#ff4a1a'); flame(R * 0.18, -R * 1.6, R * 0.12, '#ffd24a', '#ff4a1a'); /* arms with flames */ ctx.strokeStyle = body; ctx.lineWidth = R * 0.22; ctx.beginPath(); ctx.moveTo(-R * 0.7, -R * 1.3); ctx.lineTo(-R * (1.0 + swing * 0.2), -R * 0.7); ctx.stroke(); ctx.beginPath(); ctx.moveTo(R * 0.7, -R * 1.3); ctx.lineTo(R * (1.0 + swing * 0.2), -R * 0.7); ctx.stroke(); flame(-R * (1.0 + swing * 0.2), -R * 0.65, R * 0.25, accent, '#ff4a1a'); flame(R * (1.0 + swing * 0.2), -R * 0.65, R * 0.25, accent, '#ff4a1a'); /* chest rune */ ctx.strokeStyle = accent; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, -R * 1.1, R * 0.25, 0, 7); ctx.stroke(); break; } default: { /* fallback blob */ ctx.fillStyle = body; ctx.beginPath(); ctx.arc(0, -R * 0.7, R * 0.55, 0, 7); ctx.fill(); ctx.fillStyle = accent; ctx.beginPath(); ctx.arc(0, -R * 0.85, R * 0.18, 0, 7); ctx.fill(); } } ctx.lineCap = 'butt'; function crown(col, rr) { ctx.fillStyle = col; ctx.beginPath(); ctx.moveTo(-rr * 0.3, -rr * 1.7); ctx.lineTo(-rr * 0.15, -rr * 1.95); ctx.lineTo(0, -rr * 1.72); ctx.lineTo(rr * 0.15, -rr * 1.95); ctx.lineTo(rr * 0.3, -rr * 1.7); ctx.closePath(); ctx.fill(); } function roundRectE(rx, ry, rw, rh, r) { ctx.beginPath(); ctx.moveTo(rx + r, ry); ctx.arcTo(rx + rw, ry, rx + rw, ry + rh, r); ctx.arcTo(rx + rw, ry + rh, rx, ry + rh, r); ctx.arcTo(rx, ry + rh, rx, ry, r); ctx.arcTo(rx, ry, rx + rw, ry, r); ctx.closePath(); } } function drawPlayer(g, pl, tx, ty) { const s = worldToScreen(tx, ty); shadowEllipse(s.x, s.y + 2, 0.34); const cls = D2.Skills.CLASSES[pl.classId]; const pal = cls.palette; const walkB = pl.moving ? Math.abs(Math.sin(pl.walkPhase)) * 2.5 : 0; const hurt = pl.hurtFlash > 0; const R = 30; ctx.save(); ctx.translate(s.x, s.y - walkB); /* buff aura */ if (pl.buffs.some(b => b.icon === 'warcry' || b.id === 'consecrate_buff')) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; const gr = ctx.createRadialGradient(0, -14, 3, 0, -14, 40); gr.addColorStop(0, 'rgba(255,210,74,.22)'); gr.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = gr; ctx.fillRect(-40, -70, 80, 90); ctx.restore(); } if (pl.smokeveilT > 0) ctx.globalAlpha = 0.55; const bodyCol = hurt ? '#fff' : pal.body; /* legs */ ctx.fillStyle = '#2a2018'; const legSw = pl.moving ? Math.sin(pl.walkPhase) * 4 : 0; ctx.fillRect(-7, -12 + Math.max(0, legSw), 6, 12); ctx.fillRect(1, -12 + Math.max(0, -legSw), 6, 12); /* torso */ ctx.fillStyle = bodyCol; ctx.beginPath(); ctx.moveTo(-9, -12); ctx.lineTo(9, -12); ctx.lineTo(7, -30); ctx.lineTo(-7, -30); ctx.closePath(); ctx.fill(); /* cloth accent */ ctx.fillStyle = pal.cloth; ctx.fillRect(-7, -20, 14, 5); /* head */ ctx.fillStyle = hurt ? '#fff' : '#d8b89a'; ctx.beginPath(); ctx.arc(0, -36, 6.5, 0, 7); ctx.fill(); /* helm per class */ if (pl.classId === 'crusader') { ctx.fillStyle = pal.accent; ctx.beginPath(); ctx.arc(0, -37, 7, Math.PI, 0); ctx.fill(); ctx.fillRect(-7, -37, 14, 3); } else if (pl.classId === 'ranger') { ctx.fillStyle = pal.cloth; ctx.beginPath(); ctx.arc(0, -38, 7, Math.PI * 1.05, Math.PI * 1.95); ctx.fill(); ctx.beginPath(); ctx.moveTo(0, -46); ctx.lineTo(9, -30); ctx.lineTo(4, -29); ctx.closePath(); ctx.fill(); } else { ctx.fillStyle = pal.cloth; ctx.beginPath(); ctx.arc(0, -38, 7.4, Math.PI, 0); ctx.fill(); glowOrb(0, -44, 2, pal.accent); } /* weapon by class with attack animation */ const sw = pl.attackAnim > 0 ? Math.sin((1 - pl.attackAnim / 0.28) * Math.PI) : 0; if (pl.classId === 'crusader') { ctx.save(); ctx.translate(11, -22); ctx.rotate(-0.5 + sw * 2.4); const wep = pl.equip.weapon; const len = wep && wep.baseId === 'greatsword' ? 30 : 22; ctx.fillStyle = '#c8ccd4'; ctx.beginPath(); ctx.moveTo(-2.5, 0); ctx.lineTo(-2.5, -len); ctx.lineTo(0, -len - 5); ctx.lineTo(2.5, -len); ctx.lineTo(2.5, 0); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#c8a35a'; ctx.fillRect(-6, 0, 12, 3.5); if (sw > 0.1) arcSweepWorld(sw); ctx.restore(); } else if (pl.classId === 'ranger') { ctx.save(); ctx.translate(11, -24); ctx.rotate(sw * -0.5); ctx.strokeStyle = '#8a6a42'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(0, 0, 12, -1.25, 1.25); ctx.stroke(); ctx.strokeStyle = '#d8cba8'; ctx.lineWidth = 1; const pull = sw > 0.3 ? 6 : 0; ctx.beginPath(); ctx.moveTo(Math.cos(-1.25) * 12, Math.sin(-1.25) * 12); ctx.lineTo(-pull, 0); ctx.lineTo(Math.cos(1.25) * 12, Math.sin(1.25) * 12); ctx.stroke(); ctx.restore(); } else { ctx.save(); ctx.translate(11, -24); ctx.rotate(-0.2 + sw * 1.2); ctx.strokeStyle = '#6a4a2a'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(0, 6); ctx.lineTo(0, -18); ctx.stroke(); glowOrb(0, -21, 3.5, pal.accent); ctx.restore(); } /* shield on left arm if equipped */ if (pl.equip.offhand && pl.equip.offhand.kind === 'shield') { ctx.fillStyle = '#7a808a'; ctx.beginPath(); ctx.moveTo(-12, -28); ctx.quadraticCurveTo(-19, -26, -18, -18); ctx.quadraticCurveTo(-17, -11, -12, -9); ctx.quadraticCurveTo(-7, -11, -6, -18); ctx.quadraticCurveTo(-5, -26, -12, -28); ctx.fill(); } /* guaranteed crit glint */ if (pl.guaranteedCritT > 0) { glowOrb(0, -22, 4, '#ffd24a'); } ctx.restore(); /* selection ring under cursor hover handled elsewhere */ if (g.hoverEntity === pl || g.clickMarker) { /* noop */ } ctx.globalAlpha = 1; } function arcSweepWorld(intensity) { ctx.save(); ctx.globalAlpha = intensity * 0.5; ctx.strokeStyle = '#fff'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(0, 0, 24, -1.9, -0.2); ctx.stroke(); ctx.restore(); } /* ---------------- projectiles ---------------- */ function drawProjectile(pr, tx, ty) { const s = worldToScreen(tx, ty); ctx.save(); switch (pr.visual) { case 'arrow': ctx.strokeStyle = '#d8cba8'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(s.x, s.y - 6); ctx.lineTo(s.x + pr.vx * 2.2, s.y - 6 + pr.vy * 1.1); ctx.stroke(); break; case 'bolt': { const g2 = ctx.createRadialGradient(s.x, s.y - 8, 1, s.x, s.y - 8, 9); g2.addColorStop(0, '#fff'); g2.addColorStop(0.4, pr.color); g2.addColorStop(1, 'rgba(0,0,0,0)'); ctx.globalCompositeOperation = 'lighter'; ctx.fillStyle = g2; ctx.beginPath(); ctx.arc(s.x, s.y - 8, 9, 0, 7); ctx.fill(); break; } case 'orb': { ctx.globalCompositeOperation = 'lighter'; const g2 = ctx.createRadialGradient(s.x, s.y - 8, 1, s.x, s.y - 8, 12); g2.addColorStop(0, '#fff'); g2.addColorStop(0.35, pr.color); g2.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g2; ctx.beginPath(); ctx.arc(s.x, s.y - 8, 12, 0, 7); ctx.fill(); break; } case 'knife': ctx.fillStyle = '#c8d8e8'; ctx.save(); ctx.translate(s.x, s.y - 8); ctx.rotate(Math.atan2(pr.vy * 0.5, pr.vx) ); ctx.beginPath(); ctx.moveTo(6, 0); ctx.lineTo(-4, -2.5); ctx.lineTo(-4, 2.5); ctx.closePath(); ctx.fill(); ctx.restore(); break; } ctx.restore(); } function drawFxRing(e, tx, ty) { const s = worldToScreen(tx, ty); const t = e.t / e.dur; ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.strokeStyle = D2.util.rgba(e.color, 1 - t); ctx.lineWidth = 3 * (1 - t) + 1; ellipseWorld(ctx, s.x, s.y, e.radius * (0.3 + 0.7 * t), 0.52); ctx.stroke(); ctx.restore(); } /* ---------------- particles & texts ---------------- */ function drawParticles(g) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (const pt of g.particles) { const s = worldToScreen(pt.x, pt.y); const lifeF = pt.life / pt.maxLife; ctx.globalAlpha = lifeF * (pt.alpha ?? 1); ctx.fillStyle = pt.color; if (pt.size > 2.2) { ctx.beginPath(); ctx.arc(s.x, s.y - pt.z, pt.size * lifeF, 0, 7); ctx.fill(); } else { ctx.fillRect(s.x - pt.size, s.y - pt.z - pt.size, pt.size * 2, pt.size * 2); } } ctx.restore(); ctx.globalAlpha = 1; } function drawSpellGlows(g) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (const l of g.lights) { const s = worldToScreen(l.x, l.y); const r = l.radius * TW * 0.5 * cam.zoom; const g2 = ctx.createRadialGradient(s.x, s.y, 1, s.x, s.y, r); g2.addColorStop(0, D2.util.rgba(l.color, (l.intensity ?? 0.5))); g2.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g2; ctx.fillRect(s.x - r, s.y - r, r * 2, r * 2); } ctx.restore(); } function drawFloatingTexts(g) { ctx.textAlign = 'center'; for (const ft of g.floatTexts) { const s = worldToScreen(ft.x, ft.y); const t = 1 - ft.life / ft.maxLife; const rise = t * 26; ctx.globalAlpha = Math.min(1, ft.life * 2.5); ctx.font = `bold ${ft.size || 13}px Georgia`; ctx.strokeStyle = 'rgba(0,0,0,.85)'; ctx.lineWidth = 3; const txt = ft.text; ctx.strokeText(txt, s.x, s.y - 20 - rise); ctx.fillStyle = ft.color; ctx.fillText(txt, s.x, s.y - 20 - rise); } ctx.globalAlpha = 1; ctx.textAlign = 'left'; } /* overhead bars: cast bar? skip. enemy elite names drawn here */ function drawOverheadUI(g) { /* reserved */ } /* ---------------- labels & markers ---------------- */ function drawLabelsAndMarkers(g, dt) { /* move marker */ if (g.moveMarker && g.moveMarker.t > 0) { const s = worldToScreen(g.moveMarker.x, g.moveMarker.y); g.moveMarker.t -= dt; const k = g.moveMarker.t / 0.5; ctx.save(); ctx.strokeStyle = `rgba(232,214,170,${k})`; ctx.lineWidth = 2; ellipseWorld(ctx, s.x, s.y, 0.34 * (1.6 - k), 0.52); ctx.stroke(); ctx.restore(); } /* hover enemy ring */ if (g.hoverEntity && !g.hoverEntity.dead) { const e = g.hoverEntity; const s = worldToScreen(e.x, e.y); ctx.save(); ctx.strokeStyle = 'rgba(220,70,50,.85)'; ctx.lineWidth = 2; ellipseWorld(ctx, s.x, s.y, e.radius + 0.12, 0.52); ctx.stroke(); ctx.restore(); } /* ground item labels */ const showLabels = g.settings.labels === 'always' || (g.settings.labels === 'alt' && D2.input.isDown('AltLeft')) || (g.settings.labels === 'alt' && D2.input.isDown('AltRight')); if (showLabels) { ctx.textAlign = 'center'; ctx.font = '11px Georgia'; for (const p of g.pickups) { if (p.kind !== 'item') continue; const s = worldToScreen(p.x, p.y); const label = p.item.name; const wPx = ctx.measureText(label).width + 14; const rarCol = D2.Items.RARITIES[p.item.rarity].color; ctx.fillStyle = 'rgba(8,6,4,.88)'; ctx.fillRect(s.x - wPx / 2, s.y - 52, wPx, 16); ctx.strokeStyle = D2.util.rgba(rarCol, 0.8); ctx.lineWidth = 1; ctx.strokeRect(s.x - wPx / 2, s.y - 52, wPx, 16); ctx.fillStyle = rarCol; ctx.fillText(label, s.x, s.y - 40); } ctx.textAlign = 'left'; } } /* ---------------- lighting ---------------- */ function applyLighting(g, theme) { const lw = lightCanvas.width, lh = lightCanvas.height; lctx.setTransform(1, 0, 0, 1, 0, 0); lctx.clearRect(0, 0, lw, lh); lctx.fillStyle = D2.util.rgba(theme.fog, 1 - theme.ambient); lctx.fillRect(0, 0, lw, lh); lctx.globalCompositeOperation = 'destination-out'; punchLight(g.player.x, g.player.y, 6.8, 1.0); for (const t of g.world.torches) { const i = t.y * g.world.w + t.x; if (!g.world.explored[i]) continue; const flick = 0.82 + Math.sin(g.time * 9 + t.x * 3.1 + t.y * 1.7) * 0.12 + Math.sin(g.time * 23 + t.x) * 0.06; punchLight(t.x + 0.5, t.y + 0.5, 3.6 * flick, 0.85); } for (const l of g.lights) punchLight(l.x, l.y, l.radius, l.intensity ?? 0.8); for (const e of g.effects) { if (e.lightPunch) punchLight(e.x, e.y, e.lightPunch, 0.9); } lctx.globalCompositeOperation = 'source-over'; ctx.drawImage(lightCanvas, 0, 0, W, H); } function punchLight(wx, wy, radiusTiles, strength) { const s = worldToScreen(wx, wy); const px = (s.x) * cam.zoom + W / 2; const py = (s.y) * cam.zoom + H / 2; const r = radiusTiles * TW * cam.zoom; const g2 = lctx.createRadialGradient(px, py, r * 0.12, px, py, r); g2.addColorStop(0, `rgba(0,0,0,${strength})`); g2.addColorStop(0.65, `rgba(0,0,0,${strength * 0.55})`); g2.addColorStop(1, 'rgba(0,0,0,0)'); lctx.fillStyle = g2; lctx.fillRect(px - r, py - r, r * 2, r * 2); } function drawVignette(theme) { const grd = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.36, W / 2, H / 2, Math.max(W, H) * 0.72); grd.addColorStop(0, 'rgba(0,0,0,0)'); grd.addColorStop(1, D2.util.rgba(theme.fog, 0.8)); ctx.fillStyle = grd; ctx.fillRect(0, 0, W, H); /* low HP pulse */ if (D2.game && D2.game.player && !D2.game.player.dead) { const hpPct = D2.game.player.hp / D2.game.player.maxHp; if (hpPct < 0.34) { const pulse = 0.16 + Math.sin(performance.now() / 300) * 0.08; ctx.fillStyle = `rgba(140,20,10,${pulse * (1 - hpPct / 0.34)})`; ctx.fillRect(0, 0, W, H); } } } /* ---------------- minimap ---------------- */ function minimap(g, mmCanvas) { const mctx = mmCanvas.getContext('2d'); const mw = mmCanvas.width, mh = mmCanvas.height; mctx.clearRect(0, 0, mw, mh); const w = g.world.w, h = g.world.h; const scale = Math.min(mw / w, mh / h); const ox = (mw - w * scale) / 2, oy = (mh - h * scale) / 2; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = y * w + x; if (!g.world.explored[i]) continue; const t = g.world.tiles[i]; if (t === D2.world.T.VOID) continue; mctx.fillStyle = t === D2.world.T.WALL ? '#3a3228' : (g.world.visible[i] ? '#8a7a5a' : '#544a38'); mctx.fillRect(ox + x * scale, oy + y * scale, scale, scale); } } /* stairs */ const st = g.world.stairsDown; if (st) { mctx.fillStyle = '#8ac4ff'; mctx.fillRect(ox + st.x * scale - 1.5, oy + st.y * scale - 1.5, 4, 4); } /* pickups (items only, visible ones) */ for (const p of g.pickups) { if (p.kind !== 'item') continue; const i = (p.y | 0) * w + (p.x | 0); if (!g.world.explored[i]) continue; mctx.fillStyle = D2.Items.RARITIES[p.item.rarity].color; mctx.fillRect(ox + p.x * scale - 1, oy + p.y * scale - 1, 3, 3); } /* monsters visible */ for (const m of g.monsters) { if (m.dead) continue; const i = (m.y | 0) * w + (m.x | 0); if (!g.world.visible[i]) continue; mctx.fillStyle = m.isBoss ? '#ff3a1a' : m.isElite ? '#d86aff' : '#c83a2a'; const sz = m.isBoss ? 5 : 3; mctx.fillRect(ox + m.x * scale - sz / 2, oy + m.y * scale - sz / 2, sz, sz); } /* player */ if (g.player) { mctx.fillStyle = '#ffe8b0'; mctx.beginPath(); mctx.arc(ox + g.player.x * scale, oy + g.player.y * scale, 2.5, 0, 7); mctx.fill(); } } D2.render = { init, frame, resize, minimap, worldToScreen, screenToWorld, addShake, get cam() { return cam; }, get size() { return { W, H }; }, /* debug: read a pixel straight from the game backbuffer */ debugPixel(sx, sy) { const d = ctx.getImageData(Math.max(0, Math.min(W - 1, sx | 0)), Math.max(0, Math.min(H - 1, sy | 0)), 1, 1).data; return [d[0], d[1], d[2]]; }, TW, TH, WALL_H, }; })(window.D2);