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);
|
||||
Reference in New Issue
Block a user