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:
+284
@@ -0,0 +1,284 @@
|
||||
/* ============================================================
|
||||
* Diablo2D — hud.js : UI core + in-game HUD (orbs, hotbar, minimap)
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
window.D2 = window.D2 || {};
|
||||
(function (D2) {
|
||||
|
||||
const ui = {};
|
||||
D2.ui = ui;
|
||||
|
||||
let el = {};
|
||||
let minimapCtx = null;
|
||||
let _minimapT = 0;
|
||||
let hotbarSlots = []; // {slotEl, cdEl, iconKey}
|
||||
let toastCount = 0;
|
||||
|
||||
/* ---------------- init ---------------- */
|
||||
|
||||
ui.init = function () {
|
||||
el = {
|
||||
hud: document.getElementById('hud'),
|
||||
lifeFill: document.getElementById('life-fill'),
|
||||
manaFill: document.getElementById('mana-fill'),
|
||||
lifeText: document.getElementById('life-text'),
|
||||
manaText: document.getElementById('mana-text'),
|
||||
xpBar: document.getElementById('xp-bar'),
|
||||
xpText: document.getElementById('xp-text'),
|
||||
lvlNum: document.getElementById('lvl-num'),
|
||||
hbSkills: document.getElementById('hb-skills'),
|
||||
hbPotions: document.getElementById('hb-potions'),
|
||||
hbMenus: document.getElementById('hb-menus'),
|
||||
buffBar: document.getElementById('buff-bar'),
|
||||
toasts: document.getElementById('toasts'),
|
||||
minimap: document.getElementById('minimap'),
|
||||
minimapLabel: document.getElementById('minimap-label'),
|
||||
bossWrap: document.getElementById('boss-bar-wrap'),
|
||||
bossName: document.getElementById('boss-name'),
|
||||
bossFill: document.getElementById('boss-fill'),
|
||||
questTracker: document.getElementById('quest-tracker'),
|
||||
};
|
||||
minimapCtx = el.minimap.getContext('2d');
|
||||
buildHotbar();
|
||||
D2.i18n.applyDom(document);
|
||||
};
|
||||
|
||||
ui.showHud = function () { el.hud.classList.remove('hidden'); };
|
||||
ui.hideHud = function () { el.hud.classList.add('hidden'); };
|
||||
|
||||
/* ---------------- hotbar ---------------- */
|
||||
|
||||
function buildHotbar() {
|
||||
el.hbSkills.innerHTML = '';
|
||||
el.hbPotions.innerHTML = '';
|
||||
el.hbMenus.innerHTML = '';
|
||||
hotbarSlots = [];
|
||||
|
||||
/* skill slots: LMB basic, RMB, 1..4 */
|
||||
const defs = [
|
||||
{ slot: 0, label: 'LMB' },
|
||||
{ slot: 1, label: 'RMB' },
|
||||
{ slot: 2, label: '1' },
|
||||
{ slot: 3, label: '2' },
|
||||
{ slot: 4, label: '3' },
|
||||
];
|
||||
for (const d of defs) {
|
||||
const s = document.createElement('div');
|
||||
s.className = 'hb-slot';
|
||||
s.innerHTML = `<span class="keybind">${d.label}</span><canvas width="36" height="36"></canvas><div class="cd-overlay" style="display:none"></div>`;
|
||||
s.addEventListener('mousedown', (e) => {
|
||||
e.stopPropagation();
|
||||
if (d.slot === 0) return;
|
||||
/* clicking a slot casts it toward screen center-ish cursor */
|
||||
D2.game.castHotbarFromUi && D2.game.castHotbarFromUi(d.slot);
|
||||
});
|
||||
el.hbSkills.appendChild(s);
|
||||
hotbarSlots.push({
|
||||
slot: d.slot,
|
||||
root: s,
|
||||
canvas: s.querySelector('canvas'),
|
||||
cd: s.querySelector('.cd-overlay'),
|
||||
iconKey: null,
|
||||
});
|
||||
}
|
||||
|
||||
/* potions */
|
||||
const pots = [
|
||||
{ type: 'hp', label: 'Q', icon: 'potion_hp', tip: 'hb.health_potion' },
|
||||
{ type: 'mp', label: 'E', icon: 'potion_mp', tip: 'hb.mana_potion' },
|
||||
];
|
||||
for (const pd of pots) {
|
||||
const s = document.createElement('div');
|
||||
s.className = 'hb-slot';
|
||||
s.title = D2.i18n.t(pd.tip);
|
||||
s.dataset.potion = pd.type;
|
||||
s.innerHTML = `<span class="keybind">${pd.label}</span><canvas width="36" height="36"></canvas><span class="potion-count">0</span>`;
|
||||
s.querySelector('canvas').getContext('2d').drawImage(
|
||||
D2.sprites.skillIconCanvas(pd.icon, 48), 0, 0, 36, 36);
|
||||
s.addEventListener('mousedown', (e) => {
|
||||
e.stopPropagation();
|
||||
D2.player.usePotion(D2.game, pd.type);
|
||||
});
|
||||
el.hbPotions.appendChild(s);
|
||||
}
|
||||
|
||||
/* menu buttons */
|
||||
const menus = [
|
||||
{ key: 'KeyI', icon: null, glyph: '🎒', tip: 'hb.inventory', act: () => ui.togglePanel('inventory') },
|
||||
{ key: 'KeyC', icon: null, glyph: '💪', tip: 'hb.character', act: () => ui.togglePanel('character') },
|
||||
{ key: 'KeyT', icon: null, glyph: '✨', tip: 'hb.skills', act: () => ui.togglePanel('skills') },
|
||||
{ key: 'KeyJ', icon: null, glyph: '📜', tip: 'hb.quests', act: () => ui.togglePanel('quests') },
|
||||
{ key: 'F1', icon: null, glyph: '?', tip: 'hb.help', act: () => D2.main && D2.main.openHelp() },
|
||||
];
|
||||
for (const md of menus) {
|
||||
const s = document.createElement('div');
|
||||
s.className = 'hb-slot small';
|
||||
s.title = D2.i18n.t(md.tip);
|
||||
s.innerHTML = `<span style="font-size:16px">${md.glyph}</span>`;
|
||||
s.addEventListener('mousedown', (e) => { e.stopPropagation(); md.act(); });
|
||||
el.hbMenus.appendChild(s);
|
||||
}
|
||||
}
|
||||
|
||||
ui.refreshHotbar = function () {
|
||||
const G = D2.game;
|
||||
const p = G.player;
|
||||
if (!p) return;
|
||||
for (const hs of hotbarSlots) {
|
||||
const skillId = p.hotbar[hs.slot];
|
||||
let iconId = null;
|
||||
if (skillId === 'basic') {
|
||||
iconId = 'basic_' + (p.classId === 'crusader' ? 'melee' : p.classId === 'ranger' ? 'ranged' : 'cast');
|
||||
} else if (skillId) {
|
||||
const sk = D2.Skills.findSkill(p.classId, skillId);
|
||||
iconId = sk ? sk.icon : null;
|
||||
}
|
||||
if (iconId && iconId !== hs.iconKey) {
|
||||
const c = hs.canvas.getContext('2d');
|
||||
c.clearRect(0, 0, 36, 36);
|
||||
c.drawImage(D2.sprites.skillIconCanvas(iconId, 48), 0, 0, 36, 36);
|
||||
hs.iconKey = iconId;
|
||||
}
|
||||
hs.root.classList.toggle('empty-slot', !iconId);
|
||||
/* dim if unlearned */
|
||||
if (skillId && skillId !== 'basic') {
|
||||
const rank = D2.player.getSkillRank(p, skillId);
|
||||
hs.root.style.opacity = rank <= 0 ? 0.45 : 1;
|
||||
} else hs.root.style.opacity = 1;
|
||||
}
|
||||
/* potion counts */
|
||||
el.hbPotions.querySelectorAll('[data-potion]').forEach(n => {
|
||||
const t = n.dataset.potion;
|
||||
n.querySelector('.potion-count').textContent = p.potions[t];
|
||||
n.style.opacity = p.potions[t] > 0 ? 1 : 0.4;
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------------- per-frame HUD ---------------- */
|
||||
|
||||
ui.tick = function (dt) {
|
||||
const G = D2.game;
|
||||
if (!G.player || G.state === 'title') return;
|
||||
|
||||
/* cooldown sweeps */
|
||||
for (const hs of hotbarSlots) {
|
||||
const skillId = G.player.hotbar[hs.slot];
|
||||
if (!skillId || skillId === 'basic') { hs.cd.style.display = 'none'; continue; }
|
||||
const left = G.skillCooldownLeft(skillId);
|
||||
const sk = D2.Skills.findSkill(G.player.classId, skillId);
|
||||
const total = sk ? Math.max(0.01, sk.cooldown) : 1;
|
||||
if (left > 0) {
|
||||
const pct = Math.min(1, left / total);
|
||||
const deg = 360 * (1 - pct);
|
||||
hs.cd.style.display = 'block';
|
||||
hs.cd.style.background = `conic-gradient(rgba(0,0,0,.78) ${deg}deg, transparent ${deg}deg)`;
|
||||
hs.cd.textContent = left > 1 ? Math.ceil(left) : '';
|
||||
hs.cd.style.color = '#fff';
|
||||
hs.cd.style.fontSize = '14px';
|
||||
hs.cd.style.display = 'flex';
|
||||
hs.cd.style.alignItems = 'center';
|
||||
hs.cd.style.justifyContent = 'center';
|
||||
} else if (hs.cd.style.display !== 'none') {
|
||||
hs.cd.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/* buffs */
|
||||
if (G.player.buffs.length !== ui._lastBuffCount) {
|
||||
ui._lastBuffCount = G.player.buffs.length;
|
||||
rebuildBuffs();
|
||||
}
|
||||
for (const b of G.player.buffs) {
|
||||
if (b.timerEl) b.timerEl.textContent = Math.ceil(b.t);
|
||||
}
|
||||
|
||||
/* minimap @ ~7fps */
|
||||
_minimapT -= dt;
|
||||
if (_minimapT <= 0) {
|
||||
_minimapT = 0.14;
|
||||
D2.render.minimap(G, el.minimap);
|
||||
el.minimapLabel.textContent = G.world.isTown
|
||||
? D2.i18n.t('town.enter').split('—')[0].trim()
|
||||
: `${G.world.act >= 0 ? D2.i18n.t(D2.BAL.acts[G.world.act].name) : ''} · ${D2.i18n.t('floor.depth', G.world.floorIdx + 1)}`;
|
||||
}
|
||||
};
|
||||
|
||||
function rebuildBuffs() {
|
||||
el.buffBar.innerHTML = '';
|
||||
for (const b of D2.game.player.buffs) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'buff-icon';
|
||||
d.title = b.name ? (b.name[D2.i18n.getLang()] || b.name.en) : b.id;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = 40; cv.height = 40;
|
||||
cv.getContext('2d').drawImage(D2.sprites.skillIconCanvas(b.icon || 'renewal', 48), 2, 2, 36, 36);
|
||||
d.appendChild(cv);
|
||||
const t = document.createElement('div');
|
||||
t.className = 'buff-timer';
|
||||
d.appendChild(t);
|
||||
b.timerEl = t;
|
||||
el.buffBar.appendChild(d);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- periodic refresh ---------------- */
|
||||
|
||||
ui.refreshHud = function () {
|
||||
const G = D2.game;
|
||||
const p = G.player;
|
||||
if (!p) return;
|
||||
el.lifeFill.style.height = Math.max(0, p.hp / p.maxHp * 100) + '%';
|
||||
el.manaFill.style.height = Math.max(0, p.mana / p.maxMana * 100) + '%';
|
||||
el.lifeText.textContent = `${Math.ceil(p.hp)}/${p.maxHp}`;
|
||||
el.manaText.textContent = `${Math.ceil(p.mana)}/${p.maxMana}`;
|
||||
const need = D2.BAL.xpForLevel(p.level);
|
||||
const prev = p.level > 1 ? D2.BAL.xpForLevel(p.level - 1) : 0;
|
||||
const pct = D2.util.clamp((p.xp - prev) / Math.max(1, need - prev), 0, 1);
|
||||
el.xpBar.style.width = (pct * 100) + '%';
|
||||
el.xpText.textContent = `XP ${D2.util.fmtNum(p.xp)} / ${D2.util.fmtNum(need)}${p.statPoints ? ` · +${p.statPoints}${' '}` : ''}`;
|
||||
el.lvlNum.textContent = 'Lv ' + p.level +
|
||||
(G.progress.torment ? ' · ' + D2.i18n.t('torment', G.progress.torment) : '');
|
||||
ui.refreshHotbar();
|
||||
};
|
||||
|
||||
/* ---------------- toasts ---------------- */
|
||||
|
||||
ui.toast = function (text, cls = '') {
|
||||
if (!el.toasts) return;
|
||||
const d = document.createElement('div');
|
||||
d.className = 'toast ' + cls;
|
||||
d.textContent = text;
|
||||
el.toasts.appendChild(d);
|
||||
toastCount++;
|
||||
setTimeout(() => d.classList.add('fadeout'), 2200);
|
||||
setTimeout(() => { d.remove(); toastCount--; }, 2800);
|
||||
while (el.toasts.children.length > 4) el.toasts.firstChild.remove();
|
||||
};
|
||||
|
||||
/* ---------------- boss bar ---------------- */
|
||||
|
||||
ui.showBossBar = function (name) {
|
||||
el.bossName.textContent = name;
|
||||
el.bossFill.style.width = '100%';
|
||||
el.bossWrap.classList.remove('hidden');
|
||||
};
|
||||
ui.updateBossBar = function (pct) {
|
||||
el.bossFill.style.width = Math.max(0, pct * 100) + '%';
|
||||
};
|
||||
ui.hideBossBar = function () {
|
||||
el.bossWrap.classList.add('hidden');
|
||||
};
|
||||
|
||||
/* ---------------- quest tracker ---------------- */
|
||||
|
||||
ui.renderQuestTracker = function () {
|
||||
const G = D2.game;
|
||||
let html = '<div class="quest-header">' + D2.i18n.t('ui.questlog') + '</div>';
|
||||
for (const q of G.quests) {
|
||||
html += `<div class="quest-line ${q.done ? 'quest-done' : ''}">` +
|
||||
`<div>${q.text}</div></div>`;
|
||||
}
|
||||
el.questTracker.innerHTML = html;
|
||||
};
|
||||
|
||||
})(window.D2);
|
||||
+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);
|
||||
@@ -0,0 +1,360 @@
|
||||
/* ============================================================
|
||||
* Diablo2D — screens.js : title, class select, settings, help,
|
||||
* death & victory screens
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
window.D2 = window.D2 || {};
|
||||
(function (D2) {
|
||||
|
||||
const ui = D2.ui;
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
function show(id) {
|
||||
for (const sid of ['screen-loading', 'screen-title', 'screen-classselect', 'screen-settings', 'screen-help', 'screen-death', 'screen-victory']) {
|
||||
$(sid).classList.toggle('hidden', sid !== id);
|
||||
}
|
||||
}
|
||||
ui.hideScreens = () => show(null);
|
||||
|
||||
/* ================= loading ================= */
|
||||
|
||||
const TIP_KEYS = ['loading.tips.0', 'loading.tips.1', 'loading.tips.2', 'loading.tips.3', 'loading.tips.4'];
|
||||
|
||||
ui.showLoading = function () {
|
||||
show('screen-loading');
|
||||
$('loading-tip').textContent = D2.i18n.t(TIP_KEYS[(Math.random() * TIP_KEYS.length) | 0]);
|
||||
};
|
||||
ui.setLoadingProgress = function (p) {
|
||||
$('loading-progress').style.width = Math.round(p * 100) + '%';
|
||||
};
|
||||
|
||||
/* ================= title ================= */
|
||||
|
||||
ui.showTitle = function () {
|
||||
show('screen-title');
|
||||
const scr = $('screen-title');
|
||||
const G = D2.game;
|
||||
const meta = D2.save.readMeta();
|
||||
scr.innerHTML = `
|
||||
<div class="screen-center-col">
|
||||
<h1 class="game-logo">DIABLO<span>2D</span></h1>
|
||||
<div class="loading-sub">${D2.i18n.t('title.tagline')}</div>
|
||||
<div class="title-menu">
|
||||
<button class="btn btn-primary" id="t-new">${D2.i18n.t('title.newgame')}</button>
|
||||
<button class="btn" id="t-continue" ${meta ? '' : 'disabled'}>${D2.i18n.t('title.continue')}</button>
|
||||
<button class="btn" id="t-settings">${D2.i18n.t('title.settings')}</button>
|
||||
<button class="btn" id="t-help">${D2.i18n.t('title.help')}</button>
|
||||
</div>
|
||||
${meta ? `<div class="title-savecard">${D2.i18n.t('title.saved_hero')}: <b>${D2.i18n.t('class.' + meta.classId + '.name')}</b> · ${D2.i18n.t('level_short')} ${meta.level}${meta.torment ? ' · ' + D2.i18n.t('torment', meta.torment) : ''}${meta.hardcore ? ' · ☠ Hardcore' : ''}</div>` : ''}
|
||||
<div class="title-footer">v1.0 — runs fully offline in your browser</div>
|
||||
</div>`;
|
||||
|
||||
scr.querySelector('#t-new').addEventListener('click', () => {
|
||||
if (meta && !confirm(D2.i18n.t('title.confirm_overwrite'))) return;
|
||||
D2.main.openClassSelect();
|
||||
});
|
||||
scr.querySelector('#t-continue').addEventListener('click', () => {
|
||||
D2.main.startContinue();
|
||||
});
|
||||
scr.querySelector('#t-settings').addEventListener('click', () => {
|
||||
ui.showSettings(() => ui.showTitle());
|
||||
});
|
||||
scr.querySelector('#t-help').addEventListener('click', () => {
|
||||
ui.showHelp(() => ui.showTitle());
|
||||
});
|
||||
D2.audio.playMusic('title');
|
||||
};
|
||||
|
||||
/* ================= class select ================= */
|
||||
|
||||
ui.showClassSelect = function () {
|
||||
show('screen-classselect');
|
||||
const scr = $('screen-classselect');
|
||||
scr.innerHTML = `
|
||||
<div class="screen-center-col">
|
||||
<h2 style="letter-spacing:6px;color:var(--gold);text-transform:uppercase;font-weight:normal">${D2.i18n.t('class.select')}</h2>
|
||||
<div class="class-grid"></div>
|
||||
<div style="display:flex;gap:12px">
|
||||
<button class="btn btn-primary" id="cs-play" disabled>${D2.i18n.t('class.play')}</button>
|
||||
<button class="btn" id="cs-back">${D2.i18n.t('back')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const grid = scr.querySelector('.class-grid');
|
||||
let selected = null;
|
||||
|
||||
for (const clsId of ['crusader', 'ranger', 'sorceress']) {
|
||||
const cls = D2.Skills.CLASSES[clsId];
|
||||
const card = document.createElement('div');
|
||||
card.className = 'class-card';
|
||||
card.innerHTML = `
|
||||
<canvas width="150" height="150"></canvas>
|
||||
<h3>${D2.i18n.t('class.' + clsId + '.name')}</h3>
|
||||
<p>${D2.i18n.t(cls.descKey)}</p>
|
||||
<div class="class-stats">
|
||||
<span>⚔ ${cls.baseMods.str} STR</span>
|
||||
<span>🏹 ${cls.baseMods.dex} DEX</span>
|
||||
<span>❤ ${cls.baseMods.vit} VIT</span>
|
||||
<span>✦ ${cls.baseMods.ene} ENE</span>
|
||||
</div>`;
|
||||
drawClassPortrait(card.querySelector('canvas'), cls);
|
||||
card.addEventListener('click', () => {
|
||||
grid.querySelectorAll('.class-card').forEach(c => c.classList.remove('selected'));
|
||||
card.classList.add('selected');
|
||||
selected = clsId;
|
||||
scr.querySelector('#cs-play').disabled = false;
|
||||
D2.audio.sfx('click');
|
||||
});
|
||||
card.addEventListener('dblclick', () => {
|
||||
selected = clsId;
|
||||
D2.main.startNewGame(clsId);
|
||||
});
|
||||
grid.appendChild(card);
|
||||
}
|
||||
|
||||
scr.querySelector('#cs-play').addEventListener('click', () => {
|
||||
if (selected) D2.main.startNewGame(selected);
|
||||
});
|
||||
scr.querySelector('#cs-back').addEventListener('click', () => ui.showTitle());
|
||||
};
|
||||
|
||||
function drawClassPortrait(cv, cls) {
|
||||
const x = cv.getContext('2d');
|
||||
const W = cv.width, H = cv.height;
|
||||
/* bg */
|
||||
const g = x.createRadialGradient(W / 2, H * 0.42, 8, W / 2, H / 2, W * 0.62);
|
||||
g.addColorStop(0, 'rgba(70,55,35,.55)');
|
||||
g.addColorStop(1, 'rgba(8,6,4,.9)');
|
||||
x.fillStyle = g;
|
||||
x.fillRect(0, 0, W, H);
|
||||
/* ring */
|
||||
x.strokeStyle = cls.palette.accent;
|
||||
x.lineWidth = 3;
|
||||
x.beginPath(); x.arc(W / 2, H * 0.46, 52, 0, Math.PI * 2); x.stroke();
|
||||
x.globalAlpha = 0.35;
|
||||
x.beginPath(); x.arc(W / 2, H * 0.46, 60, 0, Math.PI * 2); x.stroke();
|
||||
x.globalAlpha = 1;
|
||||
/* figure: simple hooded silhouette in class colors */
|
||||
x.save();
|
||||
x.translate(W / 2, H * 0.46);
|
||||
const pal = cls.palette;
|
||||
/* cloak */
|
||||
x.fillStyle = pal.cloth;
|
||||
x.beginPath();
|
||||
x.moveTo(0, -44);
|
||||
x.quadraticCurveTo(-38, -8, -30, 44);
|
||||
x.lineTo(30, 44);
|
||||
x.quadraticCurveTo(38, -8, 0, -44);
|
||||
x.fill();
|
||||
/* head */
|
||||
x.fillStyle = '#1a1410';
|
||||
x.beginPath(); x.arc(0, -22, 15, 0, Math.PI * 2); x.fill();
|
||||
/* eyes glow */
|
||||
x.fillStyle = pal.accent;
|
||||
x.fillRect(-8, -26, 5, 3.5);
|
||||
x.fillRect(3, -26, 5, 3.5);
|
||||
/* emblem weapon */
|
||||
x.restore();
|
||||
const wepBaseId = cls.id === 'crusader' ? 'longsword' : cls.id === 'ranger' ? 'huntingbow' : 'runestaff';
|
||||
const fakeItem = { slot: 'weapon', baseId: wepBaseId, kind: cls.weaponKind, rarity: 'legendary' };
|
||||
const icon = D2.sprites.itemIconCanvas(fakeItem, 48);
|
||||
x.drawImage(icon, W / 2 - 24, H * 0.46 - 24, 48, 48);
|
||||
}
|
||||
|
||||
/* ================= settings ================= */
|
||||
|
||||
ui.showSettings = function (onBack) {
|
||||
show('screen-settings');
|
||||
const scr = $('screen-settings');
|
||||
const G = D2.game;
|
||||
const s = G.settings;
|
||||
scr.innerHTML = `
|
||||
<div class="panel-gothic settings-panel">
|
||||
<div class="panel-title">${D2.i18n.t('title.settings')}</div>
|
||||
<div class="settings-body">
|
||||
<div class="setting-row"><label>${D2.i18n.t('set.master')}</label><input type="range" id="s-master" min="0" max="1" step="0.05" value="${s.masterVol}"></div>
|
||||
<div class="setting-row"><label>${D2.i18n.t('set.music')}</label><input type="range" id="s-music" min="0" max="1" step="0.05" value="${s.musicVol}"></div>
|
||||
<div class="setting-row"><label>${D2.i18n.t('set.sfx')}</label><input type="range" id="s-sfx" min="0" max="1" step="0.05" value="${s.sfxVol}"></div>
|
||||
<div class="setting-row"><label>${D2.i18n.t('set.lang')}</label>
|
||||
<select class="gothiselect" id="s-lang">
|
||||
<option value="en" ${s.lang === 'en' ? 'selected' : ''}>English</option>
|
||||
<option value="vi" ${s.lang === 'vi' ? 'selected' : ''}>Tiếng Việt</option>
|
||||
</select></div>
|
||||
<div class="setting-row"><label>${D2.i18n.t('set.shake')}</label><div class="toggle-switch ${s.screenShake ? 'on' : ''}" id="s-shake"></div></div>
|
||||
<div class="setting-row"><label>${D2.i18n.t('set.dmgnum')}</label><div class="toggle-switch ${s.dmgNumbers ? 'on' : ''}" id="s-dmg"></div></div>
|
||||
<div class="setting-row"><label>${D2.i18n.t('set.labels')}</label>
|
||||
<select class="gothiselect" id="s-labels">
|
||||
<option value="alt" ${s.labels === 'alt' ? 'selected' : ''}>Alt</option>
|
||||
<option value="always" ${s.labels === 'always' ? 'selected' : ''}>${D2.i18n.t('yes')}</option>
|
||||
</select></div>
|
||||
<div class="setting-row" style="border-top:1px solid var(--border);padding-top:10px">
|
||||
<label>${D2.i18n.t('set.export')}</label><button class="btn" id="s-export" style="font-size:12px;padding:4px 12px">⧉</button></div>
|
||||
<textarea class="savecode" id="s-code" placeholder="paste save code here…"></textarea>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn" id="s-import" style="font-size:12px">${D2.i18n.t('set.import')}</button>
|
||||
<button class="btn btn-danger" id="s-wipe" style="font-size:12px">${D2.i18n.t('set.wipe')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:center;padding:6px 0 14px">
|
||||
<button class="btn btn-primary" id="s-back">${D2.i18n.t('back')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const bind = (id, key) => {
|
||||
scr.querySelector(id).addEventListener('input', (e) => {
|
||||
s[key] = parseFloat(e.target.value);
|
||||
G.applySettings();
|
||||
D2.save.set(D2.save.KEY.SETTINGS, s);
|
||||
});
|
||||
};
|
||||
bind('#s-master', 'masterVol');
|
||||
bind('#s-music', 'musicVol');
|
||||
bind('#s-sfx', 'sfxVol');
|
||||
|
||||
scr.querySelector('#s-lang').addEventListener('change', (e) => {
|
||||
s.lang = e.target.value;
|
||||
G.applySettings();
|
||||
D2.save.set(D2.save.KEY.SETTINGS, s);
|
||||
ui.showSettings(onBack); // rebuild with new language
|
||||
});
|
||||
const toggle = (id, key) => {
|
||||
scr.querySelector(id).addEventListener('click', (e) => {
|
||||
s[key] = !s[key];
|
||||
e.target.classList.toggle('on', s[key]);
|
||||
G.applySettings();
|
||||
D2.save.set(D2.save.KEY.SETTINGS, s);
|
||||
});
|
||||
};
|
||||
toggle('#s-shake', 'screenShake');
|
||||
toggle('#s-dmg', 'dmgNumbers');
|
||||
scr.querySelector('#s-labels').addEventListener('change', (e) => {
|
||||
s.labels = e.target.value;
|
||||
D2.save.set(D2.save.KEY.SETTINGS, s);
|
||||
});
|
||||
|
||||
scr.querySelector('#s-export').addEventListener('click', () => {
|
||||
const snap = D2.save.serializeSnapshot();
|
||||
const code = btoa(unescape(encodeURIComponent(snap)));
|
||||
const ta = scr.querySelector('#s-code');
|
||||
ta.value = code;
|
||||
ta.select();
|
||||
try {
|
||||
navigator.clipboard.writeText(code);
|
||||
ui.toast(D2.i18n.t('set.copy_ok'));
|
||||
} catch (e) { /* selection is enough */ }
|
||||
});
|
||||
scr.querySelector('#s-import').addEventListener('click', () => {
|
||||
const code = scr.querySelector('#s-code').value.trim();
|
||||
if (!code) return;
|
||||
try {
|
||||
const json = decodeURIComponent(escape(atob(code)));
|
||||
if (D2.save.restoreSnapshot(json)) {
|
||||
ui.toast(D2.i18n.t('set.import_ok'));
|
||||
} else ui.toast(D2.i18n.t('set.import_bad'), 'bad');
|
||||
} catch (e) {
|
||||
ui.toast(D2.i18n.t('set.import_bad'), 'bad');
|
||||
}
|
||||
});
|
||||
scr.querySelector('#s-wipe').addEventListener('click', () => {
|
||||
if (confirm(D2.i18n.t('set.confirm_wipe'))) {
|
||||
D2.save.wipeAll();
|
||||
ui.toast('OK');
|
||||
}
|
||||
});
|
||||
scr.querySelector('#s-back').addEventListener('click', () => onBack());
|
||||
};
|
||||
|
||||
/* ================= help ================= */
|
||||
|
||||
ui.showHelp = function (onBack) {
|
||||
show('screen-help');
|
||||
const scr = $('screen-help');
|
||||
scr.innerHTML = `
|
||||
<div class="panel-gothic help-panel">
|
||||
<div class="panel-title">${D2.i18n.t('ui.help')}</div>
|
||||
<div class="help-body">
|
||||
<h4>${D2.i18n.t('help.keys_title')}</h4>
|
||||
<div class="help-keys">
|
||||
<span><span class="key-cap">LMB</span></span><span>${D2.i18n.t('help.move')}</span>
|
||||
<span><span class="key-cap">RMB</span> <span class="key-cap">1</span>–<span class="key-cap">3</span></span><span>${D2.i18n.t('help.combat')}</span>
|
||||
<span><span class="key-cap">Q</span> <span class="key-cap">E</span></span><span>${D2.i18n.t('hb.health_potion')} / ${D2.i18n.t('hb.mana_potion')}</span>
|
||||
<span><span class="key-cap">F</span>/<span class="key-cap">Space</span></span><span>Interact — stairs, chests, shrines, NPCs</span>
|
||||
<span><span class="key-cap">Alt</span></span><span>${D2.i18n.t('help.items')}</span>
|
||||
<span><span class="key-cap">I</span> <span class="key-cap">C</span> <span class="key-cap">T</span> <span class="key-cap">J</span> <span class="key-cap">M</span></span><span>${D2.i18n.t('help.panels')}</span>
|
||||
</div>
|
||||
<h4>Tips</h4>
|
||||
<div>${D2.i18n.t('help.tip1')}<br>${D2.i18n.t('help.tip2')}<br>${D2.i18n.t('help.tip3')}</div>
|
||||
<h4>Credits</h4>
|
||||
<div style="color:var(--text-dim)">${D2.i18n.t('credits.line1')}<br>${D2.i18n.t('credits.line2')}</div>
|
||||
</div>
|
||||
<div style="text-align:center;padding:6px 0 14px">
|
||||
<button class="btn btn-primary" id="h-back">${D2.i18n.t('back')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
scr.querySelector('#h-back').addEventListener('click', () => onBack());
|
||||
};
|
||||
|
||||
/* ================= death ================= */
|
||||
|
||||
ui.showDeath = function () {
|
||||
show('screen-death');
|
||||
const scr = $('screen-death');
|
||||
scr.innerHTML = `
|
||||
<div class="screen-center-col">
|
||||
<div class="death-title">${D2.i18n.t('death.title')}</div>
|
||||
<div class="screen-subtitle">${D2.i18n.t('death.subtitle')}</div>
|
||||
<div style="display:flex;gap:14px;margin-top:30px">
|
||||
<button class="btn btn-primary" id="d-respawn">${D2.i18n.t('death.respawn')}</button>
|
||||
<button class="btn" id="d-title">${D2.i18n.t('death.title_screen')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
scr.querySelector('#d-respawn').addEventListener('click', () => {
|
||||
ui.hideScreens();
|
||||
D2.game.respawnInTown();
|
||||
});
|
||||
scr.querySelector('#d-title').addEventListener('click', () => {
|
||||
D2.game.saveGame();
|
||||
D2.game.state = 'title';
|
||||
ui.showTitle();
|
||||
});
|
||||
};
|
||||
|
||||
/* ================= victory ================= */
|
||||
|
||||
ui.showVictory = function () {
|
||||
show('screen-victory');
|
||||
const scr = $('screen-victory');
|
||||
const G = D2.game;
|
||||
const p = G.player;
|
||||
scr.innerHTML = `
|
||||
<div class="screen-center-col">
|
||||
<div class="victory-title">${D2.i18n.t('victory.title')}</div>
|
||||
<div class="screen-subtitle">${D2.i18n.t('victory.subtitle')}</div>
|
||||
<div class="victory-stats">
|
||||
${D2.i18n.t('victory.stats')}<br>
|
||||
<b>${D2.i18n.t('level_short')} ${p.level}</b> · ${p.kills} kills · ${p.eliteKills} elites · ${p.bossKills} bosses · ${p.deaths} deaths
|
||||
</div>
|
||||
<div style="display:flex;gap:14px;margin-top:26px">
|
||||
${G.progress.torment < D2.BAL.maxTorment ? `<button class="btn btn-primary" id="v-torment">${D2.i18n.t('victory.continue', G.progress.torment + 1)}</button>` : ''}
|
||||
<button class="btn" id="v-title">${D2.i18n.t('victory.title_screen')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const vt = scr.querySelector('#v-torment');
|
||||
if (vt) vt.addEventListener('click', () => {
|
||||
G.progress.torment++;
|
||||
G.progress.act = 0;
|
||||
G.progress.floorIdx = 0;
|
||||
G.victoryShown = false;
|
||||
G.saveGame();
|
||||
ui.hideScreens();
|
||||
G.enterTown();
|
||||
G.state = 'playing';
|
||||
ui.toast(D2.i18n.t('torment', G.progress.torment), 'gold');
|
||||
});
|
||||
scr.querySelector('#v-title').addEventListener('click', () => {
|
||||
G.saveGame();
|
||||
G.state = 'title';
|
||||
ui.showTitle();
|
||||
});
|
||||
};
|
||||
|
||||
})(window.D2);
|
||||
Reference in New Issue
Block a user