- 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
361 lines
15 KiB
JavaScript
361 lines
15 KiB
JavaScript
/* ============================================================
|
||
* 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);
|