/* ============================================================ * 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(/${esc(item.name)}`; h += `
${esc(D2.i18n.t('ui.equip.' + item.slot))} · ${esc(item.rarity)} · ilvl ${item.ilvl}
`; if (item.dmgMax) { h += `
${item.dmgMin}–${item.dmgMax} damage · ${item.aps.toFixed(2)}/s
`; } if (item.armor) h += `
${item.armor} armor
`; 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 => `
${l}
`).join(''); if (item.special) { const sp = SPECIAL_TEXT[item.special]; if (sp) h += `
★ ${sp[D2.i18n.getLang()] || sp.en}
`; } if (item.reqLevel > 1) h += `
Requires level ${item.reqLevel}
`; if (item.flavor) h += `
“${esc(item.flavor)}”
`; /* 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 += `
Equipped: ${esc(cur.name)} ` + `(${diff >= 0 ? '+' : ''}${Math.round(diff)})
`; } } h += `
${item.value} gold
`; 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 = `
${D2.i18n.t(titleKey)}
`; 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 = ` ${D2.i18n.t(label)} ${canAlloc ? '' : ''} ${s[key]}${canAlloc ? '+' : ''}`; list.appendChild(row); } const pts = document.createElement('div'); pts.className = 'stat-row'; pts.innerHTML = `${D2.i18n.t('stat.points')}${p.statPoints}`; 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 = `${D2.i18n.t(k)}${v}`; list2.appendChild(r); } b.appendChild(list2); /* resist strip */ const rs = document.createElement('div'); rs.className = 'resist-strip'; rs.innerHTML = ` 🔥${Math.min(72, s.resFire || 0)}% ❄️${Math.min(72, s.resCold || 0)}% ${Math.min(72, s.resLit || 0)}% ☠️${Math.min(72, s.resPois || 0)}%`; 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 = `${cls.name[D2.i18n.getLang()] || cls.name.en} ${D2.i18n.t('stat.skillpoints')}: ${p.skillPoints}`; 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 = `
${br.name[D2.i18n.getLang()] || br.name.en}
`; 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 = `
${sk.name[D2.i18n.getLang()] || sk.name.en}
`; h += `
${sk.type} · ${D2.i18n.t('level_short')} ${sk.unlockLevel}+
`; if (rank <= 0) { h += `
${d[D2.i18n.getLang()] || d.en}
`; } else { h += `
${d[D2.i18n.getLang()] || d.en}
`; } if (sk.type === 'active') { h += `
Mana: ${sk.manaCost(Math.max(1, rank))} · CD: ${sk.cooldown}s
`; } if (!unlocked) h += `
Locked — requires level ${sk.unlockLevel}
`; 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 = `${sk.name[D2.i18n.getLang()] || sk.name.en} ` + `(${rank}/${sk.maxRank})
` + (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 = `
${q.header}
${q.text} ${q.done ? '✔' : ''}
`; 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 = `${k}${v}`; 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 = `
“${esc(D2.i18n.t(greetKeys[npcType] || 'npc.stash.greet'))}”
`; 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 = `
${icon}
${label}
${sub}
`; 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);