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
+864
View File
@@ -0,0 +1,864 @@
/* ============================================================
* Diablo2D — panels.js : floating panels (inventory, char, skills,
* vendor, stash, dialogs, world map) + tooltips + item actions
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
const ui = D2.ui;
const panelsEl = () => document.getElementById('panels');
const tooltipEl = () => document.getElementById('tooltip');
const registry = {};
/* ================= tooltip ================= */
ui.showTooltip = function (html, x, y) {
const tt = tooltipEl();
tt.innerHTML = html;
tt.classList.remove('hidden');
const r = tt.getBoundingClientRect();
let px = x + 16, py = y + 12;
if (px + r.width > window.innerWidth - 8) px = x - r.width - 14;
if (py + r.height > window.innerHeight - 8) py = y - r.height - 10;
tt.style.left = Math.max(4, px) + 'px';
tt.style.top = Math.max(4, py) + 'px';
};
ui.hideTooltip = function () {
tooltipEl().classList.add('hidden');
hideItemActions();
};
function esc(s) { return String(s).replace(/</g, '&lt;'); }
ui.statLabel = function (k) {
const keys = {
str: 'stat.strength', dex: 'stat.dexterity', vit: 'stat.vitality', ene: 'stat.energy',
dmgPct: 'stat.damage', critChance: 'stat.crit', critDmgPct: 'stat.critdmg',
attackSpeedPct: '+Attack Speed %', moveSpeedPct: 'stat.speed',
armor: 'stat.armor', hpFlat: '+Life', manaFlat: '+Mana',
hpRegen: '+HP Regen/s', manaRegen: '+Mana Regen/s',
lifeOnKill: 'stat.lifelench', magicFind: 'stat.magicfind', goldFind: '% Gold Find',
fireDmg: '+Fire Dmg', coldDmg: '+Cold Dmg', litDmg: '+Lightning Dmg', poisDmg: '+Poison Dmg',
resFire: 'stat.fire', resCold: 'stat.cold', resLit: 'stat.lightning', resPois: 'stat.poison',
cdr: '-Cooldowns %', thorns: 'Thorns', blockChance: '% Block', flatDmg: '+Flat Dmg',
};
return keys[k] ? D2.i18n.t(keys[k]) : k;
};
ui.itemTooltipHtml = function (item, opts = {}) {
const rarCol = D2.Items.RARITIES[item.rarity].color;
let h = `<div class="tt-title" style="color:${rarCol}">${esc(item.name)}</div>`;
h += `<div class="tt-type">${esc(D2.i18n.t('ui.equip.' + item.slot))} · ${esc(item.rarity)} · ilvl ${item.ilvl}</div>`;
if (item.dmgMax) {
h += `<div class="tt-value">${item.dmgMin}${item.dmgMax} damage · ${item.aps.toFixed(2)}/s</div>`;
}
if (item.armor) h += `<div class="tt-value">${item.armor} armor</div>`;
const lines = [];
for (const [k, v] of Object.entries(item.stats || {})) {
const val = typeof v === 'number' ? (Math.round(v * 10) / 10) : v;
lines.push('+' + val + ' ' + ui.statLabel(k));
}
if (item.flatDmg) lines.push('+' + item.flatDmg + ' Flat Damage');
if (lines.length) h += lines.map(l => `<div class="tt-affix">${l}</div>`).join('');
if (item.special) {
const sp = SPECIAL_TEXT[item.special];
if (sp) h += `<div class="tt-affix" style="color:${rarCol}">★ ${sp[D2.i18n.getLang()] || sp.en}</div>`;
}
if (item.reqLevel > 1) h += `<div class="tt-type">Requires level ${item.reqLevel}</div>`;
if (item.flavor) h += `<div class="tt-flavor">“${esc(item.flavor)}”</div>`;
/* compare vs equipped */
if (!opts.noCompare && D2.game.player) {
const p = D2.game.player;
let cur = null;
if (item.slot === 'ring') cur = p.equip.ring1 || p.equip.ring2;
else cur = p.equip[item.slot];
if (cur && cur.uid !== item.uid) {
let score = 0, curScore = 0;
score = itemScore(item);
curScore = itemScore(cur);
const diff = score - curScore;
h += `<div class="tt-compare">Equipped: ${esc(cur.name)} ` +
`<span class="${diff >= 0 ? 'tt-up' : 'tt-down'}">(${diff >= 0 ? '+' : ''}${Math.round(diff)})</span></div>`;
}
}
h += `<div class="tt-value" style="color:#c8a35a">${item.value} gold</div>`;
return h;
};
const SPECIAL_TEXT = {
cleaveEcho: { en: 'Cleave strikes twice', vi: 'Chém Rộng đánh hai lần' },
doubleShot: { en: 'Basic attacks loose an extra arrow', vi: 'Đánh thường bắn thêm một mũi tên' },
fireNovaOnKill: { en: 'Kills erupt in a fire nova', vi: 'Hạ gục gây nova lửa' },
chillAttacker: { en: 'Melee attackers are chilled', vi: 'Kẻ cận chiến bị đóng băng chậm' },
allSkills: { en: '+1 to all active skills', vi: '+1 tất cả kỹ năng chủ động' },
execHeal: { en: 'Elite kills restore 15% life', vi: 'Hạ tinh anh hồi 15% máu' },
stormDash: { en: 'Charge cooldown 30%', vi: 'Charge hồi nhanh hơn 30%' },
lifesteal: { en: '6% life steal', vi: 'Hút 6% máu' },
eliteSlayer: { en: '+25% damage to elites', vi: '+25% sát thương lên tinh anh' },
chainOnHit: { en: '15% chance to zap chains on hit', vi: '15% cơ hội xả sét lan khi đánh' },
corpseBoom: { en: '20% chance corpses explode', vi: '20% xác chết nổ' },
venomStrike: { en: 'Attacks poison the target', vi: 'Đòn đánh gây độc mục tiêu' },
voidstep: { en: 'Teleport cooldown 30%', vi: 'Teleport hồi nhanh hơn 30%' },
};
function itemScore(it) {
let s = 0;
if (it.dmgMax) s += (it.dmgMin + it.dmgMax) / 2 * it.aps * 3;
if (it.armor) s += it.armor * 0.8;
for (const v of Object.values(it.stats || {})) s += typeof v === 'number' ? Math.abs(v) * 1.5 : 0;
if (it.special) s += 12;
return s;
}
/* ================= item actions popup ================= */
let actionsEl = null;
function showItemActions(item, source, sourceIdx, x, y) {
hideItemActions();
const G = D2.game;
actionsEl = document.createElement('div');
actionsEl.className = 'panel-gothic';
actionsEl.style.cssText =
`position:fixed;z-index:95;padding:8px;display:flex;flex-direction:column;gap:6px;left:${x}px;top:${y}px;min-width:130px`;
const mkBtn = (label, fn, cls = '') => {
const b = document.createElement('button');
b.className = 'btn ' + cls;
b.style.fontSize = '13px';
b.style.padding = '5px 12px';
b.textContent = label;
b.addEventListener('click', (e) => { e.stopPropagation(); fn(); hideItemActions(); ui.hideTooltip(); });
actionsEl.appendChild(b);
};
if (source === 'inv') {
if (item.slot) mkBtn(D2.i18n.t('ui.equip.' + item.slot).split(' ')[0] === 'Ring' ? 'Equip' : D2.i18n.t('ui.buy') === 'Buy' ? 'Equip' : 'Equip', () => {
D2.player.equipItem(G, item, sourceIdx);
}, 'btn-primary');
if (G.stashOpen) mkBtn('→ ' + D2.i18n.t('ui.stash'), () => {
if (G.stash.length < 35) { G.player.inventory.splice(sourceIdx, 1); G.stash.push(item); G.saveGame(); refreshAllOpen(); }
});
if (vendorOpen()) {
mkBtn(`${D2.i18n.t('ui.sell')} (+${Math.round(item.value * D2.BAL.sellRatio)})`, () => {
D2.player.sellItem(G, sourceIdx);
});
}
mkBtn('✕ ' + D2.i18n.t('cancel'), () => {});
} else if (source === 'equip') {
mkBtn('Unequip', () => {
D2.player.unequipSlot(G, sourceIdx);
});
} else if (source === 'stash') {
mkBtn('Withdraw', () => {
const i = G.stash.indexOf(item);
if (i >= 0 && G.player.inventory.length < 40) {
G.stash.splice(i, 1);
G.player.inventory.push(item);
G.saveGame();
refreshAllOpen();
} else ui.toast(D2.i18n.t('msg.inventory_full'), 'bad');
}, 'btn-primary');
if (vendorOpen()) {
mkBtn(`${D2.i18n.t('ui.sell')} (+${Math.round(item.value * D2.BAL.sellRatio)})`, () => {
const i = G.stash.indexOf(item);
if (i >= 0) {
G.stash.splice(i, 1);
G.player.gold += Math.round(item.value * D2.BAL.sellRatio);
G.sfx('sell');
G.saveGame();
refreshAllOpen();
}
});
}
} else if (source === 'vendor') {
mkBtn(`${D2.i18n.t('ui.buy')} (${item.value})`, () => {
const i = G.vendorStock.indexOf(item);
if (D2.player.buyItem(G, item)) {
G.vendorStock.splice(i, 1);
ui.refreshVendor();
ui.refreshHud();
}
}, 'btn-primary');
}
document.body.appendChild(actionsEl);
}
function hideItemActions() {
if (actionsEl) { actionsEl.remove(); actionsEl = null; }
}
ui.hideItemActions = hideItemActions;
function vendorOpen() { return registry.vendor && registry.vendor.isOpen; }
/* ================= slot builders ================= */
function makeItemSlot(item, source, sourceIdx, opts = {}) {
const d = document.createElement('div');
d.className = 'item-slot';
if (opts.size) {
d.style.width = opts.size + 'px';
d.style.height = opts.size + 'px';
}
if (item) {
if (item.rarity !== 'common') d.classList.add('rarity-glow-' + item.rarity);
const cv = document.createElement('canvas');
cv.width = 44; cv.height = 44;
cv.getContext('2d').drawImage(D2.sprites.itemIconCanvas(item, 48), 0, 0, 44, 44);
d.appendChild(cv);
d.addEventListener('mouseenter', (e) => {
ui.showTooltip(ui.itemTooltipHtml(item, opts), e.clientX, e.clientY);
});
d.addEventListener('mousemove', (e) => {
if (!tooltipEl().classList.contains('hidden')) ui.showTooltip(ui.itemTooltipHtml(item, opts), e.clientX, e.clientY);
});
d.addEventListener('mouseleave', () => ui.hideTooltip());
d.addEventListener('contextmenu', (e) => {
e.preventDefault();
if (source === 'inv') D2.player.equipItem(D2.game, item, sourceIdx);
else if (source === 'equip') D2.player.unequipSlot(D2.game, sourceIdx);
else if (source === 'stash') showItemActions(item, source, sourceIdx, e.clientX, e.clientY);
});
d.addEventListener('click', (e) => {
e.stopPropagation();
showItemActions(item, source, sourceIdx, e.clientX, e.clientY);
});
}
return d;
}
/* ================= panel framework ================= */
function createPanel(id, titleKey, widthPx) {
const root = document.createElement('div');
root.className = 'fpanel panel-gothic hidden';
root.id = 'panel-' + id;
if (widthPx) root.style.width = widthPx + 'px';
root.innerHTML = `
<div class="fpanel-head">
<div class="panel-title">${D2.i18n.t(titleKey)}</div>
<div class="fpanel-close">✕</div>
</div>
<div class="fpanel-body"></div>`;
root.querySelector('.fpanel-close').addEventListener('click', () => ui.closePanel(id));
panelsEl().appendChild(root);
const reg = {
id, root,
body: root.querySelector('.fpanel-body'),
isOpen: false,
refresh: () => {},
};
registry[id] = reg;
return reg;
}
ui.openPanel = function (id) {
const reg = registry[id];
if (!reg) return;
reg.root.classList.remove('hidden');
reg.isOpen = true;
reg.refresh();
D2.audio.sfx('click');
};
ui.closePanel = function (id) {
const reg = registry[id];
if (!reg) return;
reg.root.classList.add('hidden');
reg.isOpen = false;
if (id === 'stash') D2.game.stashOpen = false;
ui.hideTooltip();
};
ui.togglePanel = function (id) {
const reg = registry[id];
if (!reg) return;
reg.isOpen ? ui.closePanel(id) : ui.openPanel(id);
};
ui.closeAllPanels = function () {
for (const id of Object.keys(registry)) ui.closePanel(id);
};
ui.anyPanelOpen = function () {
return Object.values(registry).some(r => r.isOpen);
};
function refreshAllOpen() {
for (const reg of Object.values(registry)) if (reg.isOpen) reg.refresh();
ui.refreshHud();
}
ui.refreshInventory = refreshAllOpen;
ui.refreshCharacter = refreshAllOpen;
ui.refreshSkills = refreshAllOpen;
ui.refreshVendor = refreshAllOpen;
/* ================= inventory ================= */
function buildInventory() {
const reg = createPanel('inventory', 'ui.inventory');
reg.refresh = () => {
const G = D2.game;
const p = G.player;
if (!p) return;
const b = reg.body;
b.innerHTML = '';
/* paperdoll */
const eq = document.createElement('div');
eq.className = 'equip-grid';
const layout = [
['weapon', 'head', 'offhand'],
['ring1', 'chest', 'ring2'],
['hands', 'feet', 'amulet'],
];
const abbrev = { weapon: 'WPN', head: 'HELM', offhand: 'OFF', ring1: 'RING', chest: 'BODY', ring2: 'RING', hands: 'HAND', feet: 'BOOT', amulet: 'AMLT' };
for (const row of layout) {
for (const slotKey of row) {
const cell = document.createElement('div');
cell.className = 'equip-slot';
cell.style.position = 'relative';
const it = p.equip[slotKey];
if (it) {
const slotEl = makeItemSlot(it, 'equip', slotKey);
slotEl.style.cssText += 'position:absolute;inset:0;width:54px;height:54px;background:transparent;';
cell.appendChild(slotEl);
} else {
const lbl = document.createElement('span');
lbl.className = 'slot-label';
lbl.textContent = abbrev[slotKey];
cell.appendChild(lbl);
}
eq.appendChild(cell);
}
}
b.appendChild(eq);
/* gold */
const goldRow = document.createElement('div');
goldRow.style.cssText = 'text-align:center;color:var(--gold-hi);font-size:13px;margin-bottom:8px;';
goldRow.textContent = '🜚 ' + D2.util.fmtNum(p.gold) + ' ' + D2.i18n.t('ui.gold');
b.appendChild(goldRow);
/* bag grid */
const grid = document.createElement('div');
grid.className = 'inv-grid';
for (let i = 0; i < 40; i++) {
const it = p.inventory[i];
grid.appendChild(it ? makeItemSlot(it, 'inv', i) : emptySlot());
}
b.appendChild(grid);
if (p.skillPoints > 0 || p.statPoints > 0) {
const hint = document.createElement('div');
hint.style.cssText = 'text-align:center;color:var(--gold);font-size:11px;margin-top:8px;';
hint.textContent = `${p.statPoints ? D2.i18n.t('stat.points') + ': ' + p.statPoints + ' ' : ''}${p.skillPoints ? D2.i18n.t('stat.skillpoints') + ': ' + p.skillPoints : ''}`;
b.appendChild(hint);
}
};
}
function emptySlot() {
const d = document.createElement('div');
d.className = 'item-slot empty-slot';
return d;
}
/* ================= character ================= */
function buildCharacter() {
const reg = createPanel('character', 'ui.character');
reg.refresh = () => {
const G = D2.game;
const p = G.player;
if (!p) return;
const s = p.stats;
const b = reg.body;
b.innerHTML = '';
const attrs = [
['str', 'stat.strength'], ['dex', 'stat.dexterity'],
['vit', 'stat.vitality'], ['ene', 'stat.energy'],
];
const list = document.createElement('div');
list.className = 'stat-list';
for (const [key, label] of attrs) {
const row = document.createElement('div');
row.className = 'stat-row';
const canAlloc = p.statPoints > 0;
row.innerHTML = `
<span class="stat-name">${D2.i18n.t(label)} ${canAlloc ? '' : ''}</span>
<span><span class="stat-val">${s[key]}</span>${canAlloc ? '<span class="plus-btn" data-k="' + key + '">+</span>' : ''}</span>`;
list.appendChild(row);
}
const pts = document.createElement('div');
pts.className = 'stat-row';
pts.innerHTML = `<span style="color:var(--gold)">${D2.i18n.t('stat.points')}</span><span class="stat-val">${p.statPoints}</span>`;
list.appendChild(pts);
b.appendChild(list);
list.querySelectorAll('.plus-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (D2.player.allocateStat(p, btn.dataset.k)) {
G.sfx('click');
reg.refresh();
ui.refreshHud();
}
});
});
const hr = document.createElement('div');
hr.style.cssText = 'border-top:1px solid var(--border);margin:10px 0;';
b.appendChild(hr);
const wdr = D2.combat.weaponDamageRange(p);
const rows = [
['stat.damage', `${wdr.min}${wdr.max}`],
['stat.armor', s.armor],
['stat.crit', Math.round((D2.BAL.baseCrit + (s.critChance || 0) / 100) * 100) + '%'],
['stat.critdmg', Math.round((D2.BAL.baseCritDmg + (s.critDmgPct || 0) / 100) * 100) + '%'],
['stat.speed', p.moveSpeed.toFixed(1)],
['stat.magicfind', '+' + (s.magicFind || 0) + '%'],
['stat.lifelench', s.lifeOnKill || 0],
];
const list2 = document.createElement('div');
list2.className = 'stat-list';
for (const [k, v] of rows) {
const r = document.createElement('div');
r.className = 'stat-row';
r.innerHTML = `<span>${D2.i18n.t(k)}</span><span class="stat-val">${v}</span>`;
list2.appendChild(r);
}
b.appendChild(list2);
/* resist strip */
const rs = document.createElement('div');
rs.className = 'resist-strip';
rs.innerHTML = `
<span>🔥<b>${Math.min(72, s.resFire || 0)}%</b></span>
<span>❄️<b>${Math.min(72, s.resCold || 0)}%</b></span>
<span>⚡<b>${Math.min(72, s.resLit || 0)}%</b></span>
<span>☠️<b>${Math.min(72, s.resPois || 0)}%</b></span>`;
b.appendChild(rs);
};
}
/* ================= skills ================= */
let selectedSkill = null;
function buildSkills() {
const reg = createPanel('skills', 'ui.skills', 560);
reg.refresh = () => {
const G = D2.game;
const p = G.player;
if (!p) return;
const cls = D2.Skills.CLASSES[p.classId];
const skills = D2.Skills.skillsFor(p.classId);
const b = reg.body;
b.innerHTML = '';
const header = document.createElement('div');
header.style.cssText = 'display:flex;justify-content:space-between;font-size:13px;color:var(--gold);margin-bottom:8px;';
header.innerHTML = `<span>${cls.name[D2.i18n.getLang()] || cls.name.en}</span>
<span>${D2.i18n.t('stat.skillpoints')}: <b>${p.skillPoints}</b></span>`;
b.appendChild(header);
const tree = document.createElement('div');
tree.className = 'skill-tree';
for (const br of cls.branches) {
const branchEl = document.createElement('div');
branchEl.innerHTML = `<div class="skill-branch-name">${br.name[D2.i18n.getLang()] || br.name.en}</div>`;
const tierSkills = skills.filter(s => s.branch === br.id);
for (const sk of tierSkills) {
const tierRow = document.createElement('div');
tierRow.className = 'skill-tier';
const rank = p.skills[sk.id] || 0;
const effRank = D2.player.getSkillRank(p, sk.id);
const unlocked = p.level >= sk.unlockLevel;
const maxed = rank >= sk.maxRank;
const node = document.createElement('div');
node.className = 'skill-node' + (unlocked ? '' : ' locked') + (maxed ? ' maxed' : '');
const btn = document.createElement('div');
btn.className = 'skill-btn';
const cv = document.createElement('canvas');
cv.width = 56; cv.height = 56;
cv.getContext('2d').drawImage(D2.sprites.skillIconCanvas(sk.icon, 48), 4, 4, 48, 48);
btn.appendChild(cv);
const rankBadge = document.createElement('span');
rankBadge.className = 'skill-rank';
rankBadge.textContent = rank > 0 ? (effRank > rank ? `${rank}+${effRank - rank}` : rank) : '';
btn.appendChild(rankBadge);
const canLearn = D2.player.canLearnSkill(p, sk) === true;
if (canLearn) {
const plus = document.createElement('div');
plus.className = 'skill-plus';
plus.textContent = '+';
plus.title = '+1 ' + (sk.name[D2.i18n.getLang()] || sk.name.en);
plus.addEventListener('click', (e) => {
e.stopPropagation();
if (D2.player.learnSkill(G, sk)) {
selectedSkill = sk;
reg.refresh();
ui.refreshHotbar();
}
});
node.appendChild(plus);
}
btn.addEventListener('mouseenter', (e) => {
selectedSkill = sk;
showSkillDesc(b, sk, p);
ui.showTooltip(skillTooltipHtml(sk, effRank, unlocked), e.clientX, e.clientY);
});
btn.addEventListener('mouseleave', () => ui.hideTooltip());
node.appendChild(btn);
/* hotkey binding */
if (sk.type === 'active' && rank > 0) {
const hk = document.createElement('div');
hk.className = 'skill-hotkey-select';
const binds = [['RMB', 1], ['1', 2], ['2', 3], ['3', 4]];
for (const [label, slot] of binds) {
const bb = document.createElement('button');
bb.textContent = label;
if (p.hotbar[slot] === sk.id) bb.className = 'active';
bb.addEventListener('click', () => {
/* clear other binding of same skill */
p.hotbar.forEach((v, i) => { if (v === sk.id) p.hotbar[i] = null; });
p.hotbar[slot] = sk.id;
ui.refreshHotbar();
reg.refresh();
});
hk.appendChild(bb);
}
tierRow.appendChild(node);
tierRow.appendChild(hk);
const descBox = document.createElement('div');
tierRow.appendChild(descBox);
branchEl.appendChild(tierRow);
continue;
}
tierRow.appendChild(node);
branchEl.appendChild(tierRow);
}
tree.appendChild(branchEl);
}
b.appendChild(tree);
if (selectedSkill) showSkillDesc(b, selectedSkill, p);
};
}
function skillTooltipHtml(sk, rank, unlocked) {
const d = sk.desc(Math.max(1, rank));
let h = `<div class="tt-title" style="color:var(--gold-hi)">${sk.name[D2.i18n.getLang()] || sk.name.en}</div>`;
h += `<div class="tt-type">${sk.type} · ${D2.i18n.t('level_short')} ${sk.unlockLevel}+</div>`;
if (rank <= 0) {
h += `<div class="tt-simple" style="color:#93876f">${d[D2.i18n.getLang()] || d.en}</div>`;
} else {
h += `<div class="tt-simple">${d[D2.i18n.getLang()] || d.en}</div>`;
}
if (sk.type === 'active') {
h += `<div class="tt-compare">Mana: ${sk.manaCost(Math.max(1, rank))} · CD: ${sk.cooldown}s</div>`;
}
if (!unlocked) h += `<div class="tt-compare" style="color:#e07a6a">Locked — requires level ${sk.unlockLevel}</div>`;
return h;
}
function showSkillDesc(container, sk, p) {
let box = container.querySelector('.skill-desc-box');
if (!box) {
box = document.createElement('div');
box.className = 'skill-desc-box';
box.style.cssText = 'margin-top:10px;padding:10px;border:1px solid var(--border);border-radius:5px;font-size:12.5px;line-height:1.55;color:var(--text-dim)';
container.appendChild(box);
}
const rank = p.skills[sk.id] || 0;
const d = sk.desc(Math.max(1, rank));
box.innerHTML = `<b style="color:var(--gold-hi)">${sk.name[D2.i18n.getLang()] || sk.name.en}</b> ` +
`<span style="color:var(--gold)">(${rank}/${sk.maxRank})</span><br>` +
(d[D2.i18n.getLang()] || d.en);
}
/* ================= quests panel ================= */
function buildQuests() {
const reg = createPanel('quests', 'ui.questlog');
reg.refresh = () => {
const G = D2.game;
const b = reg.body;
b.innerHTML = '';
const p = G.player;
const stats = [
['Kills', p.kills], ['Elites slain', p.eliteKills],
['Bosses slain', p.bossKills], ['Deaths', p.deaths],
['Gold', p.gold], ['Torment', G.progress.torment],
];
for (const q of G.quests) {
const d = document.createElement('div');
d.style.marginBottom = '10px';
d.innerHTML = `<div style="color:var(--gold);font-size:12px;letter-spacing:1px;text-transform:uppercase">${q.header}</div>
<div class="${q.done ? 'quest-done' : ''}" style="font-size:14px;margin-top:3px">${q.text} ${q.done ? '✔' : ''}</div>`;
b.appendChild(d);
}
const hr = document.createElement('div');
hr.style.cssText = 'border-top:1px solid var(--border);margin:10px 0';
b.appendChild(hr);
for (const [k, v] of stats) {
const r = document.createElement('div');
r.className = 'stat-row';
r.innerHTML = `<span>${k}</span><span class="stat-val">${v}</span>`;
b.appendChild(r);
}
};
}
/* ================= stash ================= */
function buildStash() {
const reg = createPanel('stash', 'ui.stash', 380);
reg.refresh = () => {
const G = D2.game;
const b = reg.body;
b.innerHTML = '';
G.stashOpen = true;
const grid = document.createElement('div');
grid.className = 'inv-grid';
for (let i = 0; i < 35; i++) {
const it = G.stash[i];
grid.appendChild(it ? makeItemSlot(it, 'stash', i) : emptySlot());
}
b.appendChild(grid);
const note = document.createElement('div');
note.style.cssText = 'text-align:center;color:var(--text-dim);font-size:11px;margin-top:8px;';
note.textContent = 'Click items to withdraw · right-click inventory items to store via panel actions';
b.appendChild(note);
};
const origClose = ui.closePanel.bind(ui);
}
/* ================= vendor ================= */
let vendorTab = 'buy';
function buildVendor() {
const reg = createPanel('vendor', 'ui.vendor', 640);
reg.refresh = () => {
const G = D2.game;
const p = G.player;
if (!p) return;
const b = reg.body;
b.innerHTML = '';
const tabs = document.createElement('div');
tabs.className = 'vendor-tabs';
const tb = document.createElement('div');
tb.className = 'vendor-tab' + (vendorTab === 'buy' ? ' active' : '');
tb.textContent = D2.i18n.t('ui.buy');
tb.addEventListener('click', () => { vendorTab = 'buy'; reg.refresh(); });
const ts = document.createElement('div');
ts.className = 'vendor-tab' + (vendorTab === 'sell' ? ' active' : '');
ts.textContent = D2.i18n.t('ui.sell');
ts.addEventListener('click', () => { vendorTab = 'sell'; reg.refresh(); });
tabs.appendChild(tb); tabs.appendChild(ts);
const gold = document.createElement('div');
gold.style.cssText = 'margin-left:auto;color:var(--gold-hi);align-self:center';
gold.textContent = '🜚 ' + D2.util.fmtNum(p.gold);
tabs.appendChild(gold);
b.appendChild(tabs);
const listWrap = document.createElement('div');
listWrap.className = 'vendor-layout';
const list = document.createElement('div');
list.className = 'vendor-list';
list.style.flex = '1';
if (vendorTab === 'buy') {
for (const it of G.vendorStock) {
list.appendChild(vendorRow(it, 'vendor'));
}
} else {
for (let i = 0; i < p.inventory.length; i++) {
const it = p.inventory[i];
const row = vendorRow(it, 'sell', i);
list.appendChild(row);
}
}
listWrap.appendChild(list);
b.appendChild(listWrap);
};
}
function vendorRow(item, mode, idx) {
const G = D2.game;
const row = document.createElement('div');
row.className = 'vendor-item-row';
const iconCv = document.createElement('canvas');
iconCv.width = 40; iconCv.height = 40;
iconCv.getContext('2d').drawImage(D2.sprites.itemIconCanvas(item, 48), 0, 0, 40, 40);
row.appendChild(iconCv);
const nameSpan = document.createElement('span');
nameSpan.style.color = D2.Items.RARITIES[item.rarity].color;
nameSpan.style.fontSize = '13px';
nameSpan.textContent = item.name;
row.appendChild(nameSpan);
const price = document.createElement('span');
price.className = 'vendor-price';
price.textContent = mode === 'sell'
? '+' + Math.round(item.value * D2.BAL.sellRatio)
: item.value;
row.appendChild(price);
row.addEventListener('mouseenter', (e) => ui.showTooltip(ui.itemTooltipHtml(item, { noCompare: mode === 'sell' }), e.clientX, e.clientY));
row.addEventListener('mousemove', (e) => ui.showTooltip(ui.itemTooltipHtml(item, { noCompare: mode === 'sell' }), e.clientX, e.clientY));
row.addEventListener('mouseleave', () => ui.hideTooltip());
row.addEventListener('click', () => {
if (mode === 'vendor') {
if (D2.player.buyItem(G, item)) {
const i = G.vendorStock.indexOf(item);
if (i >= 0) G.vendorStock.splice(i, 1);
refreshAllOpen();
}
} else {
D2.player.sellItem(G, idx);
refreshAllOpen();
}
});
return row;
}
/* ================= NPC dialog ================= */
function buildDialog() {
const reg = createPanel('dialog', '', 520);
reg.root.querySelector('.panel-title').textContent = '';
reg.refresh = () => {};
}
ui.openNpcDialog = function (npcType) {
const reg = registry.dialog;
const G = D2.game;
if (!G.player) return;
reg.root.classList.remove('hidden');
reg.isOpen = true;
reg.root.querySelector('.panel-title').textContent =
D2.i18n.t('npc.' + npcType + '.name') || npcType;
const greetKeys = {
charsi: 'npc.charsi.greet', akara: 'npc.akara.greet',
kashya: 'npc.kashya.greet', cain: 'npc.cain.greet',
gheed: 'npc.gheed.greet', stash: 'npc.stash.greet',
healer: 'npc.healer.greet', smith: 'npc.smith.greet',
};
const b = reg.body;
b.innerHTML = `<div class="dialog-text">“${esc(D2.i18n.t(greetKeys[npcType] || 'npc.stash.greet'))}”</div>`;
const options = (G.npcOptions && G.npcOptions(npcType)) || [];
const opts = document.createElement('div');
opts.className = 'dialog-options';
for (const o of options) {
if (!o.label || !o.fn) continue;
const bt = document.createElement('button');
bt.className = 'btn' + (o.highlight ? ' btn-primary' : '');
bt.textContent = o.label;
bt.addEventListener('click', () => { D2.audio.sfx('click'); o.fn(); });
opts.appendChild(bt);
}
const close = document.createElement('button');
close.className = 'btn';
close.textContent = D2.i18n.t('close');
close.addEventListener('click', () => ui.closePanel('dialog'));
opts.appendChild(close);
b.appendChild(opts);
};
/* ================= world map / waypoints ================= */
function buildWorldMap() {
const reg = createPanel('worldmap', 'ui.map', 700);
reg.refresh = () => {
const G = D2.game;
const b = reg.body;
b.innerHTML = '';
const nodes = document.createElement('div');
nodes.className = 'worldmap-nodes';
const inTown = G.world.isTown;
const mkNode = (icon, label, sub, state, fn) => {
const n = document.createElement('div');
n.className = 'wm-node' + (state === 'current' ? ' current' : '') + (state === 'locked' ? ' locked' : '');
n.innerHTML = `<div class="wm-icon">${icon}</div><div>${label}</div><div class="wm-depth">${sub}</div>`;
if (state !== 'locked' && fn) n.addEventListener('click', fn);
nodes.appendChild(n);
};
const arrow = () => {
const a = document.createElement('div');
a.className = 'wm-arrow';
a.textContent = '➤';
nodes.appendChild(a);
};
mkNode('🏕️', 'Tristram', inTown ? '•' : '', inTown ? 'current' : 'ok',
() => { if (!inTown) { ui.closePanel('worldmap'); G.useWaypoint({ town: true }); } });
arrow();
D2.BAL.acts.forEach((act, i) => {
const unlocked = G.waypointsUnlocked[i];
const isCurrent = !inTown && G.world.act === i;
mkNode(['⛪', '⚰️', '🍄', '🔥'][i],
D2.i18n.t(act.name),
unlocked ? (isCurrent ? '• ' + D2.i18n.t('floor.depth', G.world.floorIdx + 1) : D2.i18n.t('floor.depth', 1) + '' + D2.BAL.floorsPerAct) : '🔒',
isCurrent ? 'current' : unlocked ? 'ok' : 'locked',
() => {
if (!unlocked || isCurrent) return;
ui.closePanel('worldmap');
G.useWaypoint({ act: i, floor: 0 });
});
if (i < D2.BAL.acts.length - 1) arrow();
});
b.appendChild(nodes);
if (!inTown) {
const back = document.createElement('button');
back.className = 'btn';
back.style.cssText = 'display:block;margin:10px auto 0;';
back.textContent = '⌂ ' + D2.i18n.t('town.enter').split('—')[0].trim();
back.addEventListener('click', () => {
ui.closePanel('worldmap');
G.enterTown();
});
b.appendChild(back);
}
};
}
/* ================= init & global keys ================= */
ui.initPanels = function () {
buildInventory();
buildCharacter();
buildSkills();
buildQuests();
buildStash();
buildVendor();
buildDialog();
buildWorldMap();
};
ui.tickGlobalKeys = function () {
const inp = D2.input;
const G = D2.game;
if (G.state === 'playing' || G.state === 'paused') {
if (inp.wasPressed('KeyI')) ui.togglePanel('inventory');
if (inp.wasPressed('KeyC')) ui.togglePanel('character');
if (inp.wasPressed('KeyT')) ui.togglePanel('skills');
if (inp.wasPressed('KeyJ')) ui.togglePanel('quests');
if (inp.wasPressed('KeyM')) ui.togglePanel('worldmap');
if (inp.wasPressed('F1')) { if (D2.main) D2.main.openHelp(); }
if (inp.wasPressed('Escape')) {
if (ui.anyPanelOpen()) ui.closeAllPanels();
else if (D2.main) D2.main.togglePause();
}
}
};
})(window.D2);