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
This commit is contained in:
2026-08-23 06:59:36 +00:00
commit fc1fa2d51e
42 changed files with 11784 additions and 0 deletions
+1102
View File
File diff suppressed because it is too large Load Diff
+769
View File
@@ -0,0 +1,769 @@
/* ============================================================
* Diablo2D — sprites.js : procedural baked art (tiles, props, icons)
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
const TW = 64, TH = 32; // iso tile diamond size
const WALL_H = 46;
const cache = {
themes: {}, // themeId -> {floors[], wallBlock}
props: {}, // key -> canvas
skills: {}, // iconId -> canvas
};
function mkCanvas(w, h) {
const c = document.createElement('canvas');
c.width = w; c.height = h;
return c;
}
/* ---------------- iso helpers ---------------- */
function diamondPath(ctx, cx, cy, hw, hh) {
ctx.beginPath();
ctx.moveTo(cx, cy - hh);
ctx.lineTo(cx + hw, cy);
ctx.lineTo(cx, cy + hh);
ctx.lineTo(cx - hw, cy);
ctx.closePath();
}
/* ---------------- floor tiles ---------------- */
function bakeFloorTile(theme, colA, variant) {
const c = mkCanvas(TW, TH);
const ctx = c.getContext('2d');
const shade = [1, 0.92, 1.06, 0.85][variant];
const base = D2.util.shade(colA, shade);
diamondPath(ctx, TW / 2, TH / 2, TW / 2, TH / 2);
ctx.fillStyle = base;
ctx.fill();
/* subtle inner texture */
ctx.save();
diamondPath(ctx, TW / 2, TH / 2, TW / 2, TH / 2);
ctx.clip();
const rnd = new D2.util.RNG(variant * 7919 + colA.length * 131);
ctx.globalAlpha = 0.14;
for (let i = 0; i < 10; i++) {
const x = rnd.range(6, TW - 6), y = rnd.range(4, TH - 4);
ctx.fillStyle = i % 2 ? '#000' : '#fff';
ctx.fillRect(x, y, rnd.range(3, 10), 1);
}
/* grout lines */
ctx.globalAlpha = 0.25;
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(TW / 2, 0); ctx.lineTo(TW, TH / 2);
ctx.moveTo(TW / 2, TH); ctx.lineTo(0, TH / 2);
ctx.stroke();
/* cracks on some variants */
if (variant === 2 || variant === 3) {
ctx.globalAlpha = 0.3;
ctx.beginPath();
let x = TW * 0.3, y = TH * 0.5;
ctx.moveTo(x, y);
for (let i = 0; i < 4; i++) { x += rnd.range(-8, 12); y += rnd.range(-6, 6); ctx.lineTo(x, y); }
ctx.stroke();
}
ctx.restore();
return c;
}
function bakeWallBlock(theme) {
const c = mkCanvas(TW + 4, WALL_H + TH + 4);
const ctx = c.getContext('2d');
const ox = 2, oy = WALL_H + 2;
/* front-left face (SW) */
ctx.fillStyle = theme.wallFace;
ctx.beginPath();
ctx.moveTo(ox, oy - TH / 2);
ctx.lineTo(ox + TW / 2, oy);
ctx.lineTo(ox + TW / 2, oy - WALL_H);
ctx.lineTo(ox, oy - WALL_H - TH / 2);
ctx.closePath();
ctx.fill();
/* front-right face (SE) */
ctx.fillStyle = D2.util.shade(theme.wallFace, 0.72);
ctx.beginPath();
ctx.moveTo(ox + TW / 2, oy);
ctx.lineTo(ox + TW, oy - TH / 2);
ctx.lineTo(ox + TW, oy - TH / 2 - WALL_H);
ctx.lineTo(ox + TW / 2, oy - WALL_H);
ctx.closePath();
ctx.fill();
/* top */
diamondPath(ctx, ox + TW / 2, oy - WALL_H - TH / 2 + TH / 2, TW / 2, TH / 2);
ctx.fillStyle = theme.wallTop;
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.05)';
ctx.stroke();
/* brick lines on faces */
ctx.strokeStyle = 'rgba(0,0,0,.28)';
ctx.lineWidth = 1;
for (let row = 1; row < 4; row++) {
const yy = oy - (row / 4) * WALL_H;
ctx.beginPath();
ctx.moveTo(ox, yy - TH / 2 * (1 - row / 4));
ctx.lineTo(ox + TW / 2, yy);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(ox + TW / 2, yy);
ctx.lineTo(ox + TW, yy - TH / 2 * (1 - row / 4));
ctx.stroke();
}
return c;
}
/* ---------------- props ---------------- */
function bakeProps() {
const P = cache.props;
/* barrel */
let c = mkCanvas(40, 46); let x = c.getContext('2d');
barrelShape(x, 20, 30, '#6a4a2a', '#4a3018');
P.barrel = c;
/* urn */
c = mkCanvas(36, 42); x = c.getContext('2d');
x.fillStyle = '#8a7a5e';
x.beginPath(); x.ellipse(18, 26, 11, 13, 0, 0, Math.PI * 2); x.fill();
x.fillStyle = '#a89a78';
x.beginPath(); x.ellipse(18, 16, 7, 5, 0, 0, Math.PI * 2); x.fill();
x.fillStyle = '#3a3020'; x.fillRect(14, 8, 8, 5);
highlight(x, 13, 20, 5);
P.urn = c;
/* chest closed / open */
c = mkCanvas(48, 42); x = c.getContext('2d');
x.fillStyle = '#5e3f22'; roundRect(x, 6, 18, 36, 20, 3); x.fill();
x.fillStyle = '#7a5530'; roundRect(x, 6, 8, 36, 14, 6); x.fill();
x.strokeStyle = '#c8a35a'; x.lineWidth = 2;
x.strokeRect(6, 18, 36, 1);
x.fillStyle = '#c8a35a'; x.fillRect(21, 16, 6, 8);
metalEdges(x, 6, 8, 36, 30);
P.chest_closed = c;
c = mkCanvas(48, 42); x = c.getContext('2d');
x.fillStyle = '#5e3f22'; roundRect(x, 6, 18, 36, 20, 3); x.fill();
x.fillStyle = '#241505'; x.fillRect(8, 14, 32, 8);
x.fillStyle = '#7a5530'; roundRect(x, 4, 4, 40, 10, 5); x.fill();
metalEdges(x, 6, 8, 36, 30);
P.chest_open = c;
/* shrine (rune glow added at runtime) */
c = mkCanvas(52, 60); x = c.getContext('2d');
x.fillStyle = '#5a564e';
x.beginPath(); x.moveTo(26, 6); x.lineTo(42, 20); x.lineTo(38, 54); x.lineTo(14, 54); x.lineTo(10, 20); x.closePath(); x.fill();
x.fillStyle = '#6c685e';
x.beginPath(); x.moveTo(26, 6); x.lineTo(34, 14); x.lineTo(22, 22); x.closePath(); x.fill();
P.shrine = c;
/* torch bracket (flame drawn dynamically) */
c = mkCanvas(24, 44); x = c.getContext('2d');
x.fillStyle = '#3a2c1c'; x.fillRect(10, 18, 5, 22);
x.fillStyle = '#55432c'; x.fillRect(7, 14, 11, 8);
P.torch = c;
/* stairs down (dark pit with steps) */
c = mkCanvas(TW, TH + 18); x = c.getContext('2d');
x.save();
x.translate(TW / 2, 9);
diamondPath(x, 0, 0, TW / 2 - 2, TH / 2 - 1); x.fillStyle = '#0c0906'; x.fill();
x.clip();
for (let i = 0; i < 5; i++) {
diamondPath(x, 0, -i * 3.5, (TW / 2 - 3) * (1 - i * 0.09), (TH / 2 - 1) * (1 - i * 0.09));
x.fillStyle = ['#181310', '#221b15', '#2c231a', '#372c1f', '#43362a'][i];
x.fill();
}
x.restore();
P.stairs_down = c;
/* stairs up */
c = mkCanvas(TW, TH + 18); x = c.getContext('2d');
x.save();
x.translate(TW / 2, 9);
diamondPath(x, 0, 0, TW / 2 - 2, TH / 2 - 1); x.fillStyle = '#d8cba8'; x.fill();
x.clip();
for (let i = 0; i < 5; i++) {
diamondPath(x, 0, i * 3.5, (TW / 2 - 3) * (1 - i * 0.09), (TH / 2 - 1) * (1 - i * 0.09));
x.fillStyle = ['#cfc2a0', '#bfb294', '#afa286', '#9f9278', '#8f826a'][i];
x.fill();
}
x.restore();
P.stairs_up = c;
/* bones decor */
c = mkCanvas(36, 20); x = c.getContext('2d');
x.strokeStyle = '#cfc7ae'; x.lineWidth = 3; x.lineCap = 'round';
x.beginPath(); x.moveTo(6, 14); x.lineTo(28, 8); x.stroke();
x.beginPath(); x.moveTo(10, 6); x.lineTo(24, 15); x.stroke();
x.fillStyle = '#cfc7ae';
x.beginPath(); x.arc(29, 7, 4, 0, Math.PI * 2); x.fill();
x.fillStyle = '#222'; x.fillRect(28, 5, 2, 2); x.fillRect(31, 6, 2, 2);
P.bones = c;
/* waypoint stone */
c = mkCanvas(48, 62); x = c.getContext('2d');
x.fillStyle = '#4e5a66';
x.beginPath(); x.moveTo(24, 4); x.lineTo(38, 16); x.lineTo(34, 56); x.lineTo(14, 56); x.lineTo(10, 16); x.closePath(); x.fill();
x.fillStyle = '#61707e';
x.beginPath(); x.moveTo(24, 4); x.lineTo(31, 12); x.lineTo(17, 20); x.closePath(); x.fill();
x.strokeStyle = '#8ac4ff'; x.lineWidth = 2;
x.beginPath(); x.arc(24, 30, 8, 0, Math.PI * 2); x.stroke();
x.beginPath(); x.moveTo(24, 22); x.lineTo(24, 38); x.moveTo(17, 30); x.lineTo(31, 30); x.stroke();
P.waypoint = c;
/* NPCs */
c = mkCanvas(52, 58); x = c.getContext('2d'); // blacksmith
humanoid(x, 26, 34, { body: '#5a4a3a', accent: '#8a6a3a', cloth: '#3a2c1c' }, 1.15);
x.fillStyle = '#333'; x.fillRect(38, 40, 14, 8); // anvil block
x.fillStyle = '#555'; x.fillRect(36, 36, 18, 5);
P.npc_smith = c;
c = mkCanvas(52, 58); x = c.getContext('2d'); // healer
humanoid(x, 26, 34, { body: '#d8d4c8', accent: '#e8c87b', cloth: '#b8b4a8' }, 1.1);
x.strokeStyle = '#e86a5a'; x.lineWidth = 3;
x.beginPath(); x.moveTo(26, 22); x.lineTo(26, 40); x.moveTo(18, 30); x.lineTo(34, 30); x.stroke();
P.npc_healer = c;
c = mkCanvas(52, 58); x = c.getContext('2d'); // stash keeper chest
x.fillStyle = '#4a3a26'; roundRect(x, 10, 22, 32, 26, 4); x.fill();
x.fillStyle = '#5e4a30'; roundRect(x, 10, 14, 32, 12, 5); x.fill();
x.fillStyle = '#c8a35a'; x.fillRect(23, 20, 6, 10);
metalEdges(x, 10, 14, 32, 34);
P.npc_stash = c;
c = mkCanvas(52, 58); x = c.getContext('2d'); // Charsi the smith (renamed key)
humanoid(x, 26, 34, { body: '#8a6a52', accent: '#c89a5a', cloth: '#4a3626' }, 1.15);
x.fillStyle = '#333'; x.fillRect(38, 40, 14, 8);
x.fillStyle = '#555'; x.fillRect(36, 36, 18, 5);
P.npc_charsi = P.npc_smith;
c = mkCanvas(52, 58); x = c.getContext('2d'); // Akara — robed priestess
humanoid(x, 26, 34, { body: '#c8b090', accent: '#e86a5a', cloth: '#7a2c3a' }, 1.05);
x.fillStyle = '#e8d8a8';
x.beginPath(); x.moveTo(26, 14); x.lineTo(34, 22); x.lineTo(18, 22); x.closePath(); x.fill();
x.strokeStyle = '#f0e0b0'; x.lineWidth = 2;
x.beginPath(); x.moveTo(26, 24); x.lineTo(26, 44); x.stroke();
P.npc_akara = c;
c = mkCanvas(52, 58); x = c.getContext('2d'); // Kashya — rogue captain
humanoid(x, 26, 34, { body: '#7a6a5a', accent: '#a83a3a', cloth: '#3a3236' }, 1.1);
x.strokeStyle = '#9a8a6a'; x.lineWidth = 3;
x.beginPath(); x.moveTo(40, 10); x.quadraticCurveTo(46, 26, 42, 44); x.stroke(); // bow
x.fillStyle = '#8a2c2c';
x.beginPath(); x.moveTo(20, 16); x.lineTo(32, 16); x.lineTo(26, 10); x.closePath(); x.fill();
P.npc_kashya = c;
c = mkCanvas(52, 58); x = c.getContext('2d'); // Deckard Cain — elder scholar
humanoid(x, 26, 34, { body: '#c0b09a', accent: '#d8cba8', cloth: '#584a38' }, 0.92);
x.fillStyle = '#e8e0d0';
x.beginPath(); x.arc(26, 18, 9, Math.PI * 0.15, Math.PI * 0.85); x.fill(); // white beard
x.fillStyle = '#6a5030'; x.fillRect(14, 30, 12, 9);
x.fillStyle = '#e8dcc0'; x.fillRect(15, 31, 10, 7); // open book
P.npc_cain = c;
c = mkCanvas(52, 58); x = c.getContext('2d'); // Gheed — rotund merchant
humanoid(x, 26, 36, { body: '#b08a62', accent: '#d8b04a', cloth: '#6a4a2c' }, 1.28);
x.fillStyle = '#e8c84a';
x.beginPath(); x.arc(26, 30, 6, 0, Math.PI * 2); x.fill(); // coin pouch glint
x.strokeStyle = '#caa232'; x.lineWidth = 2;
x.beginPath(); x.moveTo(33, 34); x.lineTo(39, 40); x.stroke();
P.npc_gheed = c;
}
function barrelShape(x, cx, baseY, main, dark) {
x.fillStyle = dark;
x.beginPath(); x.ellipse(cx, baseY - 4, 13, 5, 0, 0, Math.PI * 2); x.fill();
x.fillStyle = main;
x.beginPath();
x.moveTo(cx - 12, baseY - 4);
x.bezierCurveTo(cx - 16, baseY - 16, cx - 16, baseY - 26, cx - 11, baseY - 34);
x.lineTo(cx + 11, baseY - 34);
x.bezierCurveTo(cx + 16, baseY - 26, cx + 16, baseY - 16, cx + 12, baseY - 4);
x.closePath(); x.fill();
x.strokeStyle = '#3a2812'; x.lineWidth = 2;
x.beginPath(); x.moveTo(cx - 13, baseY - 12); x.quadraticCurveTo(cx, baseY - 8, cx + 13, baseY - 12); x.stroke();
x.beginPath(); x.moveTo(cx - 13, baseY - 26); x.quadraticCurveTo(cx, baseY - 22, cx + 13, baseY - 26); x.stroke();
highlight(x, cx - 6, baseY - 22, 4);
}
function humanoid(x, cx, baseY, pal, s = 1) {
const S = v => v * s;
x.fillStyle = pal.cloth;
roundRect(x, cx - S(8), baseY - S(26), S(16), S(20), S(4)); x.fill(); // torso
x.fillStyle = pal.body;
x.beginPath(); x.arc(cx, baseY - S(32), S(6.5), 0, Math.PI * 2); x.fill(); // head
roundRect(x, cx - S(11), baseY - S(8), S(6), S(9), 2); x.fill(); // legs
roundRect(x, cx + S(5), baseY - S(8), S(6), S(9), 2); x.fill();
x.fillStyle = pal.accent;
roundRect(x, cx - S(10), baseY - S(22), S(20), S(4), 2); x.fill(); // belt
}
function metalEdges(x, rx, ry, rw, rh) {
x.strokeStyle = 'rgba(200,163,90,.5)';
x.lineWidth = 1.5;
x.strokeRect(rx, ry, rw, rh);
}
function roundRect(x, rx, ry, rw, rh, r) {
x.beginPath();
x.moveTo(rx + r, ry);
x.arcTo(rx + rw, ry, rx + rw, ry + rh, r);
x.arcTo(rx + rw, ry + rh, rx, ry + rh, r);
x.arcTo(rx, ry + rh, rx, ry, r);
x.arcTo(rx, ry, rx + rw, ry, r);
x.closePath();
}
function highlight(x, hx, hy, hr) {
x.fillStyle = 'rgba(255,255,255,.18)';
x.beginPath(); x.ellipse(hx, hy, hr, hr * 1.8, -0.4, 0, Math.PI * 2); x.fill();
}
/* ---------------- item icons ---------------- */
const MAT_COLORS = {
melee: ['#9aa0aa', '#7a808a'], ranged: ['#8a6a42', '#6e5432'], cast: ['#7a5a9a', '#5e4478'],
shield: ['#7a808a', '#5a606a'], armor: ['#8a8578', '#6a665a'], jewelry: ['#c8a35a', '#a8853a'],
};
/** returns a fresh canvas with the item's icon */
function itemIconCanvas(item, size = 48) {
const c = mkCanvas(size, size);
const x = c.getContext('2d');
x.save();
x.scale(size / 48, size / 48);
const rarCol = D2.Items.RARITIES[item.rarity].color;
const kind = item.kind || 'armor';
switch (item.slot) {
case 'weapon': drawWeaponIcon(x, item, kind, rarCol); break;
case 'offhand': kind === 'shield' ? drawShieldIcon(x, rarCol) : drawFocusIcon(x, kind, rarCol); break;
case 'head': drawHelmIcon(x, rarCol); break;
case 'chest': drawChestIcon(x, rarCol); break;
case 'hands': drawGlovesIcon(x, rarCol); break;
case 'feet': drawBootsIcon(x, rarCol); break;
case 'amulet': drawAmuletIcon(x, rarCol); break;
case 'ring': drawRingIcon(x, rarCol); break;
}
x.restore();
return c;
}
function drawWeaponIcon(x, item, kind, rarCol) {
if (kind === 'melee') {
const heavy = ['battleaxe', 'warhammer', 'reaver'].includes(item.baseId);
const blunt = ['club', 'mace', 'warhammer'].includes(item.baseId);
x.translate(24, 24); x.rotate(-Math.PI / 4);
if (heavy && !blunt) { // axe
x.fillStyle = '#6a4a2a'; x.fillRect(-2.5, -4, 5, 26);
x.fillStyle = MAT_COLORS.melee[0];
x.beginPath(); x.moveTo(2, -4); x.quadraticCurveTo(20, -2, 14, 10); x.lineTo(2, 8); x.closePath(); x.fill();
x.beginPath(); x.moveTo(-2, -4); x.quadraticCurveTo(-20, -2, -14, 10); x.lineTo(-2, 8); x.closePath(); x.fill();
} else if (blunt) { // mace
x.fillStyle = '#6a4a2a'; x.fillRect(-2.5, 0, 5, 22);
x.fillStyle = MAT_COLORS.melee[0];
x.beginPath(); x.arc(0, -5, 9, 0, Math.PI * 2); x.fill();
x.fillStyle = '#555'; x.fillRect(-9, -7, 18, 4);
} else { // sword / dagger
const len = item.baseId === 'greatsword' ? 30 : item.baseId === 'dagger' ? 16 : 24;
x.fillStyle = '#6a4a2a'; x.fillRect(-2, len - 4, 4, 10);
x.fillStyle = MAT_COLORS.melee[0];
x.beginPath(); x.moveTo(-3, len - 6); x.lineTo(-3, -len + 6); x.lineTo(0, -len); x.lineTo(3, -len + 6); x.lineTo(3, len - 6); x.closePath(); x.fill();
x.fillStyle = '#c8a35a'; x.fillRect(-8, len - 6, 16, 4);
}
} else if (kind === 'ranged') { // bow
x.strokeStyle = MAT_COLORS.ranged[0]; x.lineWidth = 4;
x.beginPath(); x.arc(20, 24, 16, Math.PI * 0.6, -Math.PI * 0.6); x.stroke();
x.strokeStyle = '#d8cba8'; x.lineWidth = 1.5;
x.beginPath(); x.moveTo(20 + 16 * Math.cos(Math.PI * 0.6), 24 + 16 * Math.sin(Math.PI * 0.6));
x.lineTo(20 + 16 * Math.cos(-Math.PI * 0.6), 24 + 16 * Math.sin(-Math.PI * 0.6)); x.stroke();
x.strokeStyle = rarCol; x.lineWidth = 3;
x.beginPath(); x.moveTo(8, 24); x.lineTo(34, 24); x.stroke();
x.beginPath(); x.moveTo(34, 24); x.lineTo(28, 21); x.moveTo(34, 24); x.lineTo(28, 27); x.stroke();
} else { // staff
x.translate(24, 24); x.rotate(Math.PI / 5);
x.fillStyle = '#6a4a2a'; roundRect(x, -3, -8, 6, 32, 3); x.fill();
const g = x.createRadialGradient(0, -12, 1, 0, -12, 10);
g.addColorStop(0, '#fff'); g.addColorStop(0.4, rarCol); g.addColorStop(1, 'rgba(0,0,0,0)');
x.fillStyle = g;
x.beginPath(); x.arc(0, -12, 10, 0, Math.PI * 2); x.fill();
}
}
function drawShieldIcon(x, rarCol) {
x.fillStyle = MAT_COLORS.shield[0];
x.beginPath();
x.moveTo(24, 6); x.quadraticCurveTo(40, 8, 39, 22); x.quadraticCurveTo(38, 38, 24, 44);
x.quadraticCurveTo(10, 38, 9, 22); x.quadraticCurveTo(8, 8, 24, 6);
x.closePath(); x.fill();
x.strokeStyle = '#444'; x.lineWidth = 2; x.stroke();
x.strokeStyle = rarCol; x.lineWidth = 2.5;
x.beginPath(); x.moveTo(24, 14); x.lineTo(24, 34); x.moveTo(15, 24); x.lineTo(33, 24); x.stroke();
}
function drawFocusIcon(x, kind, rarCol) {
if (kind === 'focus') {
const g = x.createRadialGradient(24, 24, 2, 24, 24, 16);
g.addColorStop(0, '#dff'); g.addColorStop(0.5, '#6ab8ff'); g.addColorStop(1, '#245');
x.fillStyle = g;
x.beginPath(); x.arc(24, 24, 14, 0, Math.PI * 2); x.fill();
x.strokeStyle = '#c8a35a'; x.lineWidth = 2.5;
x.beginPath(); x.arc(24, 24, 16, 0, Math.PI * 2); x.stroke();
} else { // quiver
x.fillStyle = '#6a4a2a'; roundRect(x, 16, 12, 16, 26, 4); x.fill();
x.strokeStyle = '#c8a35a'; x.lineWidth = 2;
for (let i = 0; i < 3; i++) {
x.beginPath(); x.moveTo(19 + i * 5, 14); x.lineTo(19 + i * 5, 4); x.stroke();
}
}
}
function drawHelmIcon(x, rarCol) {
x.fillStyle = MAT_COLORS.armor[0];
x.beginPath(); x.arc(24, 26, 15, Math.PI, 0); x.lineTo(39, 34); x.lineTo(9, 34); x.closePath(); x.fill();
x.fillStyle = '#222'; x.fillRect(15, 24, 18, 5);
x.fillStyle = rarCol; x.fillRect(22, 10, 4, 8);
}
function drawChestIcon(x, rarCol) {
x.fillStyle = MAT_COLORS.armor[0];
x.beginPath();
x.moveTo(14, 10); x.lineTo(34, 10); x.lineTo(40, 18); x.lineTo(36, 42); x.lineTo(12, 42); x.lineTo(8, 18);
x.closePath(); x.fill();
x.fillStyle = 'rgba(0,0,0,.25)';
x.fillRect(21, 10, 6, 32);
x.strokeStyle = rarCol; x.lineWidth = 2;
x.beginPath(); x.moveTo(14, 20); x.lineTo(34, 20); x.stroke();
}
function drawGlovesIcon(x, rarCol) {
x.fillStyle = MAT_COLORS.armor[0];
roundRect(x, 8, 16, 13, 20, 4); x.fill();
roundRect(x, 27, 16, 13, 20, 4); x.fill();
roundRect(x, 8, 34, 13, 8, 3); x.fill();
roundRect(x, 27, 34, 13, 8, 3); x.fill();
x.strokeStyle = rarCol; x.lineWidth = 2;
x.beginPath(); x.moveTo(14, 16); x.lineTo(14, 30); x.moveTo(33, 16); x.lineTo(33, 30); x.stroke();
}
function drawBootsIcon(x, rarCol) {
x.fillStyle = MAT_COLORS.armor[0];
roundRect(x, 12, 10, 10, 26, 3); x.fill();
roundRect(x, 12, 32, 22, 8, 3); x.fill();
roundRect(x, 26, 10, 10, 26, 3); x.fill();
roundRect(x, 26, 32, 14, 8, 3); x.fill();
x.fillStyle = rarCol;
x.fillRect(12, 28, 10, 3); x.fillRect(26, 28, 10, 3);
}
function drawAmuletIcon(x, rarCol) {
x.strokeStyle = '#c8a35a'; x.lineWidth = 2.5;
x.beginPath(); x.arc(24, 22, 12, Math.PI * 0.15, Math.PI * 0.85, true); x.stroke();
const g = x.createRadialGradient(24, 34, 1, 24, 34, 8);
g.addColorStop(0, '#fff'); g.addColorStop(0.5, rarCol); g.addColorStop(1, '#222');
x.fillStyle = g;
x.beginPath(); x.moveTo(24, 26); x.lineTo(31, 33); x.lineTo(24, 43); x.lineTo(17, 33); x.closePath(); x.fill();
x.strokeStyle = '#c8a35a'; x.lineWidth = 1.5; x.stroke();
}
function drawRingIcon(x, rarCol) {
x.strokeStyle = MAT_COLORS.jewelry[0]; x.lineWidth = 5;
x.beginPath(); x.arc(24, 27, 11, 0, Math.PI * 2); x.stroke();
x.strokeStyle = 'rgba(255,255,255,.4)'; x.lineWidth = 1.5;
x.beginPath(); x.arc(24, 27, 13, Math.PI * 1.1, Math.PI * 1.6); x.stroke();
const g = x.createRadialGradient(24, 12, 1, 24, 12, 7);
g.addColorStop(0, '#fff'); g.addColorStop(0.6, rarCol); g.addColorStop(1, '#222');
x.fillStyle = g;
x.beginPath(); x.arc(24, 12, 6, 0, Math.PI * 2); x.fill();
}
/* ---------------- skill icons ---------------- */
const SKILL_ICON_STYLE = {
cleave: { col: '#e8d8b0', draw: (x) => arcSweep(x, '#e8d8b0') },
whirlwind: { col: '#c8e8ff', draw: (x) => spiral(x, '#c8e8ff') },
consecrate: { col: '#ffe86a', draw: (x) => pillar(x, '#ffe86a') },
charge: { col: '#ffb04a', draw: (x) => arrowFat(x, '#ffb04a') },
warcry: { col: '#ff6a4a', draw: (x) => shoutRings(x, '#ff6a4a') },
seismic: { col: '#d8a84a', draw: (x) => crackBurst(x, '#d8a84a') },
ironskin: { col: '#aab4be', draw: (x) => shieldGlyph(x, '#aab4be') },
renewal: { col: '#7ade8a', draw: (x) => crossPulse(x, '#7ade8a') },
laststand: { col: '#e85a4a', draw: (x) => heartGuard(x, '#e85a4a') },
multishot: { col: '#d8cba8', draw: (x) => fanArrows(x, '#d8cba8', 3) },
piercingbolt: { col: '#ffe8b0', draw: (x) => fanArrows(x, '#ffe8b0', 1) },
arrowstorm: { col: '#ffd24a', draw: (x) => rainDown(x, '#ffd24a') },
shadowstep: { col: '#8a6aff', draw: (x) => blinkGlyph(x, '#8a6aff') },
fanofknives: { col: '#c8d8e8', draw: (x) => radialKnives(x, '#c8d8e8') },
smokeveil: { col: '#9aa8b8', draw: (x) => puffCloud(x, '#9aa8b8') },
predator: { col: '#ff8a4a', draw: (x) => eyeGlyph(x, '#ff8a4a') },
bleedingedge: { col: '#e83a5a', draw: (x) => dropletBlade(x, '#e83a5a') },
swiftvantage: { col: '#8ad8ff', draw: (x) => windLines(x, '#8ad8ff') },
firebolt: { col: '#ff7a2a', draw: (x) => fireOrb(x, '#ff7a2a') },
meteor: { col: '#ff5a1a', draw: (x) => fallingRock(x, '#ff5a1a') },
flamewave: { col: '#ffa02a', draw: (x) => novaRing(x, '#ffa02a') },
iceshards: { col: '#8ad8ff', draw: (x) => radialKnives(x, '#8ad8ff') },
frostnova: { col: '#b8ecff', draw: (x) => snowflake(x, '#b8ecff') },
blizzard: { col: '#c8f0ff', draw: (x) => rainDown(x, '#c8f0ff') },
chainlightning: { col: '#ffee6a', draw: (x) => boltZigzag(x, '#ffee6a') },
teleport: { col: '#c86aff', draw: (x) => blinkGlyph(x, '#c86aff') },
arcaneecho: { col: '#b08aff', draw: (x) => spiral(x, '#b08aff') },
basic_melee: { col: '#e8d8b0', draw: (x) => swordGlyph(x, '#e8d8b0') },
basic_ranged: { col: '#d8cba8', draw: (x) => fanArrows(x, '#d8cba8', 1) },
basic_cast: { col: '#c86aff', draw: (x) => fireOrb(x, '#c86aff') },
potion_hp: { col: '#e84a3a', draw: (x) => potionGlyph(x, '#e84a3a') },
potion_mp: { col: '#4a7ae8', draw: (x) => potionGlyph(x, '#4a7ae8') },
};
function skillIconCanvas(iconId, size = 48) {
const key = iconId + '_' + size;
if (!cache.skills[key]) {
const c = mkCanvas(size, size);
const x = c.getContext('2d');
x.save();
x.scale(size / 48, size / 48);
const st = SKILL_ICON_STYLE[iconId] || { col: '#ccc', draw: xx => orb(xx, '#ccc') };
/* backdrop */
const g = x.createRadialGradient(24, 24, 4, 24, 24, 24);
g.addColorStop(0, 'rgba(60,50,40,.9)');
g.addColorStop(1, 'rgba(15,10,8,.95)');
x.fillStyle = g;
x.fillRect(0, 0, 48, 48);
x.strokeStyle = 'rgba(200,163,90,.35)';
x.strokeRect(1, 1, 46, 46);
st.draw(x);
x.restore();
cache.skills[key] = c;
}
return cache.skills[key];
}
function cloneCanvas(src) {
const c = mkCanvas(src.width, src.height);
c.getContext('2d').drawImage(src, 0, 0);
return c;
}
/* little glyph helpers */
function orb(x, col) {
const g = x.createRadialGradient(24, 22, 2, 24, 22, 13);
g.addColorStop(0, '#fff'); g.addColorStop(0.45, col); g.addColorStop(1, 'rgba(0,0,0,.1)');
x.fillStyle = g; x.beginPath(); x.arc(24, 22, 13, 0, 7); x.fill();
}
function fireOrb(x, col) { orb(x, col);
x.strokeStyle = col; x.lineWidth = 2;
x.beginPath(); x.moveTo(24, 36); x.quadraticCurveTo(20, 30, 24, 26); x.stroke();
}
function arcSweep(x, col) {
x.strokeStyle = col; x.lineWidth = 5; x.lineCap = 'round';
x.beginPath(); x.arc(24, 30, 15, Math.PI * 1.15, Math.PI * 1.85); x.stroke();
x.lineWidth = 2;
x.beginPath(); x.arc(24, 30, 9, Math.PI * 1.2, Math.PI * 1.8); x.stroke();
}
function spiral(x, col) {
x.strokeStyle = col; x.lineWidth = 3.5; x.lineCap = 'round';
x.beginPath();
for (let a = 0; a < Math.PI * 4; a += 0.2) {
const r = 3 + a * 1.7;
const px = 24 + Math.cos(a) * r, py = 24 + Math.sin(a) * r * 0.8;
a === 0 ? x.moveTo(px, py) : x.lineTo(px, py);
}
x.stroke();
}
function pillar(x, col) {
const g = x.createLinearGradient(0, 8, 0, 40);
g.addColorStop(0, 'rgba(255,255,255,.9)'); g.addColorStop(1, 'rgba(0,0,0,0)');
x.fillStyle = g;
x.fillRect(18, 8, 12, 32);
x.strokeStyle = col; x.lineWidth = 2;
x.beginPath(); x.arc(24, 36, 12, Math.PI, 0); x.stroke();
}
function arrowFat(x, col) {
x.fillStyle = col;
x.beginPath(); x.moveTo(40, 24); x.lineTo(26, 16); x.lineTo(30, 24); x.lineTo(26, 32); x.closePath(); x.fill();
x.fillRect(8, 21, 20, 6);
}
function shoutRings(x, col) {
x.strokeStyle = col;
x.lineWidth = 2.5;
for (const r of [6, 12, 18]) { x.globalAlpha = 1 - r / 22; x.beginPath(); x.arc(24, 26, r, 0, 7); x.stroke(); }
x.globalAlpha = 1;
}
function crackBurst(x, col) {
x.strokeStyle = col; x.lineWidth = 3; x.lineCap = 'round';
for (let i = 0; i < 6; i++) {
const a = i / 6 * Math.PI * 2;
x.beginPath(); x.moveTo(24, 26);
x.lineTo(24 + Math.cos(a) * 16, 26 + Math.sin(a) * 13);
x.stroke();
}
}
function shieldGlyph(x, col) {
x.fillStyle = col;
x.beginPath(); x.moveTo(24, 8); x.quadraticCurveTo(38, 12, 37, 24);
x.quadraticCurveTo(35, 36, 24, 42); x.quadraticCurveTo(13, 36, 11, 24);
x.quadraticCurveTo(10, 12, 24, 8); x.closePath(); x.fill();
x.fillStyle = 'rgba(0,0,0,.3)';
x.beginPath(); x.moveTo(24, 8); x.lineTo(24, 42); x.lineTo(13, 36); x.lineTo(11, 24); x.quadraticCurveTo(10, 12, 24, 8); x.closePath(); x.fill();
}
function crossPulse(x, col) {
x.fillStyle = col;
x.fillRect(20, 10, 8, 28); x.fillRect(10, 20, 28, 8);
x.strokeStyle = col; x.globalAlpha = .5; x.lineWidth = 2;
x.beginPath(); x.arc(24, 24, 19, 0, 7); x.stroke(); x.globalAlpha = 1;
}
function heartGuard(x, col) {
x.fillStyle = col;
x.beginPath();
x.moveTo(24, 38); x.bezierCurveTo(6, 24, 14, 8, 24, 18); x.bezierCurveTo(34, 8, 42, 24, 24, 38);
x.fill();
x.strokeStyle = '#fff'; x.lineWidth = 2; x.globalAlpha = .6;
x.beginPath(); x.moveTo(24, 18); x.lineTo(24, 34); x.stroke(); x.globalAlpha = 1;
}
function fanArrows(x, col, n) {
x.strokeStyle = col; x.lineWidth = 3; x.lineCap = 'round';
const angles = n === 1 ? [0] : [-0.5, 0, 0.5];
for (const a of angles) {
x.save(); x.translate(24, 34); x.rotate(a);
x.beginPath(); x.moveTo(0, 0); x.lineTo(0, -22); x.stroke();
x.beginPath(); x.moveTo(-3, -18); x.lineTo(0, -24); x.lineTo(3, -18); x.stroke();
x.restore();
}
}
function rainDown(x, col) {
x.strokeStyle = col; x.lineWidth = 2.5; x.lineCap = 'round';
for (let i = 0; i < 5; i++) {
const px = 8 + i * 8, py = 8 + (i % 3) * 8;
x.beginPath(); x.moveTo(px, py); x.lineTo(px - 2, py + 10); x.stroke();
}
x.beginPath(); x.arc(24, 36, 9, Math.PI, 0); x.stroke();
}
function blinkGlyph(x, col) {
x.fillStyle = col; x.globalAlpha = .35;
x.beginPath(); x.arc(14, 30, 8, 0, 7); x.fill();
x.globalAlpha = .8;
x.beginPath(); x.arc(33, 18, 8, 0, 7); x.fill();
x.globalAlpha = 1;
x.strokeStyle = col; x.lineWidth = 2; x.setLineDash([4, 3]);
x.beginPath(); x.moveTo(17, 26); x.lineTo(29, 21); x.stroke(); x.setLineDash([]);
}
function radialKnives(x, col) {
x.strokeStyle = col; x.lineWidth = 2.5; x.lineCap = 'round';
for (let i = 0; i < 8; i++) {
const a = i / 8 * Math.PI * 2;
x.beginPath();
x.moveTo(24 + Math.cos(a) * 7, 24 + Math.sin(a) * 7);
x.lineTo(24 + Math.cos(a) * 17, 24 + Math.sin(a) * 17);
x.stroke();
}
}
function puffCloud(x, col) {
x.fillStyle = col; x.globalAlpha = .75;
for (const [ox, oy, r] of [[18, 28, 9], [28, 24, 11], [22, 18, 8], [32, 30, 7]]) {
x.beginPath(); x.arc(ox, oy, r, 0, 7); x.fill();
}
x.globalAlpha = 1;
}
function eyeGlyph(x, col) {
x.strokeStyle = col; x.lineWidth = 2.5;
x.beginPath(); x.moveTo(8, 24); x.quadraticCurveTo(24, 10, 40, 24); x.quadraticCurveTo(24, 38, 8, 24); x.stroke();
x.fillStyle = col;
x.beginPath(); x.arc(24, 24, 6, 0, 7); x.fill();
x.fillStyle = '#000'; x.beginPath(); x.arc(24, 24, 2.5, 0, 7); x.fill();
}
function dropletBlade(x, col) {
x.strokeStyle = '#c8d8e8'; x.lineWidth = 4; x.lineCap = 'round';
x.beginPath(); x.moveTo(14, 36); x.lineTo(34, 12); x.stroke();
x.fillStyle = col;
x.beginPath(); x.moveTo(30, 30); x.quadraticCurveTo(38, 36, 32, 41); x.quadraticCurveTo(25, 37, 30, 30); x.fill();
}
function windLines(x, col) {
x.strokeStyle = col; x.lineWidth = 2.5; x.lineCap = 'round';
for (const [y0, len] of [[16, 22], [24, 30], [32, 20]]) {
x.beginPath(); x.moveTo(40 - len, y0);
x.quadraticCurveTo(40 - len * .5, y0 - 4, 40, y0);
x.stroke();
}
}
function fallingRock(x, col) {
x.fillStyle = '#7a5a3a';
x.beginPath(); x.moveTo(28, 8); x.lineTo(36, 14); x.lineTo(33, 24); x.lineTo(24, 22); x.closePath(); x.fill();
x.strokeStyle = col; x.lineWidth = 3;
x.beginPath(); x.moveTo(20, 14); x.lineTo(14, 22); x.stroke();
x.beginPath(); x.moveTo(24, 10); x.lineTo(20, 16); x.stroke();
const g = x.createRadialGradient(24, 36, 2, 24, 36, 12);
g.addColorStop(0, col); g.addColorStop(1, 'rgba(0,0,0,0)');
x.fillStyle = g; x.beginPath(); x.arc(24, 36, 12, 0, 7); x.fill();
}
function novaRing(x, col) {
x.strokeStyle = col; x.lineWidth = 5;
x.globalAlpha = .9; x.beginPath(); x.arc(24, 24, 14, 0, 7); x.stroke();
x.globalAlpha = .4; x.lineWidth = 3; x.beginPath(); x.arc(24, 24, 20, 0, 7); x.stroke();
x.globalAlpha = 1;
}
function snowflake(x, col) {
x.strokeStyle = col; x.lineWidth = 2.5; x.lineCap = 'round';
for (let i = 0; i < 6; i++) {
const a = i / 6 * Math.PI * 2;
x.beginPath(); x.moveTo(24, 24);
x.lineTo(24 + Math.cos(a) * 16, 24 + Math.sin(a) * 16);
x.stroke();
x.beginPath(); x.arc(24 + Math.cos(a) * 16, 24 + Math.sin(a) * 16, 2.5, 0, 7);
x.fillStyle = col; x.fill();
}
}
function boltZigzag(x, col) {
x.strokeStyle = col; x.lineWidth = 3.5; x.lineJoin = 'round';
x.beginPath();
x.moveTo(28, 6); x.lineTo(18, 22); x.lineTo(26, 24); x.lineTo(16, 42);
x.stroke();
x.globalAlpha = .5; x.lineWidth = 2;
x.beginPath(); x.moveTo(36, 12); x.lineTo(30, 24); x.lineTo(36, 26); x.lineTo(30, 38); x.stroke();
x.globalAlpha = 1;
}
function swordGlyph(x, col) {
x.save(); x.translate(24, 24); x.rotate(-Math.PI / 4);
x.fillStyle = col;
x.beginPath(); x.moveTo(-3, 12); x.lineTo(-3, -14); x.lineTo(0, -19); x.lineTo(3, -14); x.lineTo(3, 12); x.closePath(); x.fill();
x.fillStyle = '#c8a35a'; x.fillRect(-8, 12, 16, 4); x.fillRect(-2, 16, 4, 6);
x.restore();
}
function potionGlyph(x, col) {
x.fillStyle = 'rgba(220,220,220,.25)';
x.beginPath(); x.arc(24, 30, 12, 0, 7); x.fill();
x.fillStyle = col;
x.beginPath(); x.arc(24, 30, 12, 0, 7); x.fill();
x.fillStyle = '#caa';
x.fillRect(20, 8, 8, 8);
x.fillStyle = 'rgba(255,255,255,.35)';
x.beginPath(); x.ellipse(19, 25, 4, 6, -.4, 0, 7); x.fill();
}
/* ---------------- init ---------------- */
function init() {
for (const [id, th] of Object.entries(D2.world.THEMES)) {
cache.themes[id] = {
floors: th.floorCols.map((col, v) => bakeFloorTile(th, col, v)),
wallBlock: bakeWallBlock(th),
};
}
bakeProps();
}
D2.sprites = {
init, TW, TH, WALL_H,
itemIconCanvas, skillIconCanvas,
get themes() { return cache.themes; },
get props() { return cache.props; },
};
})(window.D2);