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:
+184
@@ -0,0 +1,184 @@
|
||||
/* ============================================================
|
||||
* Diablo2D — main.js : bootstrap & game loop
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
window.D2 = window.D2 || {};
|
||||
(function (D2) {
|
||||
|
||||
let lastT = 0;
|
||||
let hudRefreshT = 0;
|
||||
const G = D2.game;
|
||||
|
||||
/* ---------------- pause ---------------- */
|
||||
|
||||
function togglePause() {
|
||||
const overlay = document.getElementById('pause-overlay');
|
||||
if (G.state === 'playing') {
|
||||
G.state = 'paused';
|
||||
overlay.classList.remove('hidden');
|
||||
D2.audio.stopMusic(0.3);
|
||||
} else if (G.state === 'paused') {
|
||||
G.state = 'playing';
|
||||
overlay.classList.add('hidden');
|
||||
if (G.world) D2.audio.playMusic(G.world.isTown ? 'town' : (G.world.isBossLair ? 'boss' : G.world.theme.music));
|
||||
}
|
||||
}
|
||||
|
||||
function exitToTitle() {
|
||||
G.saveGame();
|
||||
G.state = 'title';
|
||||
document.getElementById('pause-overlay').classList.add('hidden');
|
||||
D2.ui.closeAllPanels();
|
||||
D2.ui.hideHud();
|
||||
D2.ui.showTitle();
|
||||
}
|
||||
|
||||
function openHelpInGame() {
|
||||
G.state = 'paused';
|
||||
document.getElementById('pause-overlay').classList.add('hidden');
|
||||
D2.ui.showHelp(() => {
|
||||
if (G.player && !G.player.dead) {
|
||||
G.state = 'playing';
|
||||
D2.ui.hideScreens();
|
||||
} else {
|
||||
D2.ui.showTitle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- boot ---------------- */
|
||||
|
||||
function boot() {
|
||||
/* load persisted settings before anything uses them */
|
||||
const savedSettings = D2.save.get(D2.save.KEY.SETTINGS);
|
||||
if (savedSettings) Object.assign(G.settings, savedSettings);
|
||||
D2.i18n.setLang(G.settings.lang);
|
||||
|
||||
const canvas = document.getElementById('game-canvas');
|
||||
D2.render.init(canvas);
|
||||
D2.input.init(canvas);
|
||||
D2.audio.playMusic('title'); // becomes pending until first gesture
|
||||
|
||||
/* staged loading for the progress bar feel */
|
||||
D2.ui.showLoading();
|
||||
const steps = [
|
||||
['Carving tiles…', () => { D2.sprites.init(); }],
|
||||
['Binding sigils…', () => { D2.ui.init(); D2.ui.initPanels(); }],
|
||||
['Raising the dead…', () => { D2.game.updateQuests(); }],
|
||||
['Opening the gates…', () => {}],
|
||||
];
|
||||
let i = 0;
|
||||
const stepFn = () => {
|
||||
if (i < steps.length) {
|
||||
const [label, fn] = steps[i];
|
||||
try { fn(); } catch (e) { console.error('boot step failed:', label, e); }
|
||||
i++;
|
||||
D2.ui.setLoadingProgress(i / steps.length);
|
||||
setTimeout(stepFn, 120);
|
||||
} else {
|
||||
finishBoot();
|
||||
}
|
||||
};
|
||||
setTimeout(stepFn, 200);
|
||||
}
|
||||
|
||||
function finishBoot() {
|
||||
wirePauseMenu();
|
||||
window.D2.main = api; // expose after full init
|
||||
G.applySettings();
|
||||
|
||||
/* click anywhere unlocks audio */
|
||||
const unlock = () => { D2.audio.unlock(); };
|
||||
window.addEventListener('mousedown', unlock, { once: true });
|
||||
window.addEventListener('keydown', unlock, { once: true });
|
||||
|
||||
/* autosave on tab close while playing */
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if ((G.state === 'playing' || G.state === 'paused') && G.player) G.saveGame();
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && G.state === 'playing') togglePause();
|
||||
});
|
||||
|
||||
G.state = 'title';
|
||||
D2.ui.showTitle();
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function wirePauseMenu() {
|
||||
document.getElementById('btn-resume').addEventListener('click', togglePause);
|
||||
document.getElementById('btn-pause-settings').addEventListener('click', () => {
|
||||
D2.ui.showSettings(() => {
|
||||
document.getElementById('screen-settings').classList.add('hidden');
|
||||
if (G.state === 'paused') document.getElementById('pause-overlay').classList.remove('hidden');
|
||||
});
|
||||
document.getElementById('pause-overlay').classList.add('hidden');
|
||||
});
|
||||
document.getElementById('btn-pause-help').addEventListener('click', () => {
|
||||
document.getElementById('pause-overlay').classList.add('hidden');
|
||||
D2.ui.showHelp(() => {
|
||||
document.getElementById('screen-help').classList.add('hidden');
|
||||
document.getElementById('pause-overlay').classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
document.getElementById('btn-save-exit').addEventListener('click', exitToTitle);
|
||||
}
|
||||
|
||||
/* ---------------- main loop ---------------- */
|
||||
|
||||
function loop(t) {
|
||||
requestAnimationFrame(loop);
|
||||
const dt = Math.min(0.05, (t - lastT) / 1000 || 0.016);
|
||||
lastT = t;
|
||||
|
||||
/* consume this frame's inputs first, act on them, clear edges LAST */
|
||||
D2.ui.tickGlobalKeys();
|
||||
|
||||
if (G.state === 'playing') {
|
||||
G.update(dt);
|
||||
D2.render.frame(G, dt);
|
||||
D2.ui.tick(dt);
|
||||
hudRefreshT -= dt;
|
||||
if (hudRefreshT <= 0) { hudRefreshT = 0.12; D2.ui.refreshHud(); }
|
||||
} else if (G.state === 'paused' || G.state === 'dead' || G.state === 'victory') {
|
||||
/* keep rendering the frozen world behind overlays */
|
||||
if (G.world) D2.render.frame(G, 0);
|
||||
}
|
||||
D2.input.endFrame();
|
||||
}
|
||||
|
||||
/* ---------------- public api ---------------- */
|
||||
|
||||
const api = {
|
||||
togglePause,
|
||||
exitToTitle,
|
||||
openHelp: openHelpInGame,
|
||||
openClassSelect() {
|
||||
D2.ui.showClassSelect();
|
||||
},
|
||||
startNewGame(classId) {
|
||||
D2.ui.hideScreens();
|
||||
D2.ui.hideHud ? null : 0;
|
||||
D2.ui.showHud();
|
||||
G.startNewGame(classId);
|
||||
D2.ui.refreshHud();
|
||||
D2.ui.renderQuestTracker();
|
||||
D2.ui.toast(D2.i18n.t('class.' + classId + '.name'), 'gold');
|
||||
setTimeout(() => {
|
||||
D2.ui.toast(D2.i18n.getLang() === 'vi'
|
||||
? '🗣 Hãy nói chuyện với Kashya & Cain trong trại (phím F)'
|
||||
: '🗣 Speak with Kashya & Cain in camp (press F)', 'gold');
|
||||
}, 1200);
|
||||
},
|
||||
startContinue() {
|
||||
D2.ui.hideScreens();
|
||||
D2.ui.showHud();
|
||||
if (G.continueGame()) {
|
||||
D2.ui.refreshHud();
|
||||
D2.ui.renderQuestTracker();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
window.addEventListener('DOMContentLoaded', boot);
|
||||
})(window.D2);
|
||||
Reference in New Issue
Block a user