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

285 lines
10 KiB
JavaScript

/* ============================================================
* 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);