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

865 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
* 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);