Files
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

361 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
* 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);