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
+77
View File
@@ -0,0 +1,77 @@
/* Measures click→destination accuracy in the live game.
* usage: node tools/cdp_move_accuracy.mjs [port] */
const port = process.argv[2] || '9370';
const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
const page = targets.find(t => t.type === 'page');
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
const send = (m, p = {}) => new Promise((res, rej) => {
const i = ++id; pending.set(i, { res, rej });
ws.send(JSON.stringify({ id: i, method: m, params: p }));
});
ws.onmessage = ev => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const { res, rej } = pending.get(msg.id); pending.delete(msg.id);
msg.error ? rej(new Error(msg.error.message)) : res(msg.result);
}
};
await new Promise(r => { ws.onopen = r; });
const ev = async expr => (await send('Runtime.evaluate', { expression: expr, returnByValue: true })).result.value;
for (let i = 0; i < 90; i++) {
const s = await ev(`(document.getElementById('status')||{textContent:''}).textContent`);
if (/DONE|DRIVE_ERROR/.test(s)) break;
await new Promise(r => setTimeout(r, 1000));
}
/* make sure the camera is live (RAF or pump renders) */
console.log('env:', await ev(`(() => {
const D = document.getElementById('g').contentWindow.D2;
return JSON.stringify({ cam: [+D.render.cam.x.toFixed(1), +D.render.cam.y.toFixed(1)],
player: [+D.game.player.x.toFixed(1), +D.game.player.y.toFixed(1)] });
})()`));
const runCase = await send('Runtime.evaluate', {
awaitPromise: true, returnByValue: true,
expression: `(async () => {
const w = document.getElementById('g').contentWindow;
const D = w.D2, cv = w.document.getElementById('game-canvas');
const r = cv.getBoundingClientRect();
const TW = 64, TH = 32;
const toScreen = (wx, wy) => {
const cam = D.render.cam, z = cam.zoom || 1;
const dx = wx - cam.x, dy = wy - cam.y;
return { x: r.left + cv.clientWidth / 2 + (dx - dy) * TW / 2 * z,
y: r.top + cv.clientHeight / 2 + (dx + dy) * TH / 2 * z };
};
const sleep = ms => new Promise(r2 => setTimeout(r2, ms));
const results = [];
const cases = [[2, 1], [-3, 2], [4, -2], [-2, -3]];
for (const [ox, oy] of cases) {
const p = D.game.player;
const want = { x: Math.floor(p.x + ox) + .5, y: Math.floor(p.y + oy) + .5 };
if (!D.world || !D.game.world.isWalkable(Math.floor(want.x), Math.floor(want.y))) {
results.push({ ox, oy, skipped: 'not walkable' });
continue;
}
const sp = toScreen(want.x, want.y);
/* settle camera right before measuring */
D.render.frame(D.game, 1 / 60);
const o = { bubbles: true, button: 0, clientX: sp.x, clientY: sp.y };
cv.dispatchEvent(new w.MouseEvent('mousemove', o));
cv.dispatchEvent(new w.MouseEvent('mousedown', o));
w.dispatchEvent(new w.MouseEvent('mouseup', { bubbles: true, button: 0 }));
await sleep(3500);
const q = D.game.player;
results.push({ ox, oy, want: [+want.x.toFixed(2), +want.y.toFixed(2)],
got: [+q.x.toFixed(2), +q.y.toFixed(2)],
err: +Math.hypot(q.x - want.x, q.y - want.y).toFixed(2),
hoverTaken: !!(D.game.hoverEntity) });
}
return JSON.stringify(results);
})()`,
});
console.log('accuracy:', runCase.result ? runCase.result.value : JSON.stringify(runCase));
ws.close();