Files
deepseek fc1fa2d51e Diablo2D — Shadows of Tristram: complete browser ARPG
- Isometric canvas renderer (depth-sorted, FOV/fog, additive lighting)
- 3 classes x 20 skills, 4 acts x 4 floors + boss lairs, torment I-X
- Diablo-style loot: rarities, affix tiers, 14 legendaries, vendor, stash
- Rogue camp with 6 NPCs: Charsi/Akara/Kashya/Cain/Gheed/storage
- NPC quest chain (accept -> hunt -> turn in) with rewards & gating
- Procedural WebAudio SFX + generative music, EN/VI localization
- Saves, settings, waypoints, hardcore mode, PWA manifest
- 93-assertion headless suite + browser E2E via CDP
2026-08-23 06:59:36 +00:00

1103 lines
41 KiB
JavaScript

/* ============================================================
* 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);