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:
+864
@@ -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, '<'); }
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user