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:
@@ -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