- 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
1103 lines
41 KiB
JavaScript
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);
|