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:
2026-08-23 06:59:36 +00:00
commit fc1fa2d51e
42 changed files with 11784 additions and 0 deletions
+360
View File
@@ -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);