Files
diablo2d/tools/browser_drive.html
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

247 lines
8.9 KiB
HTML

<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Drive</title></head>
<body style="margin:0;background:#000">
<iframe id="g" src="/index.html" style="width:1440px;height:900px;border:0"></iframe>
<pre id="status" style="color:#0f0;font-size:18px;white-space:pre-wrap">booting…</pre>
<script>
'use strict';
/* ============================================================
* Diablo2D E2E driver.
* ?phase=full — everything incl. rendering probes (short)
* ?phase=descend — boot→travel→walk to stairs→KeyF→floor+1
* &norender=1 — pump skips draw calls (long sessions)
* ============================================================ */
const PHASE = new URLSearchParams(location.search).get('phase') || 'full';
const NORENDER = new URLSearchParams(location.search).get('norender') === '1';
const MAXFRAMES = parseInt(new URLSearchParams(location.search).get('maxframes') || '0', 10);
let drawnFrames = 0;
const MUTE = new URLSearchParams(location.search).get('mute') === '1';
const S = [];
function log(m) { S.push(m); document.getElementById('status').textContent = S.join('\n'); }
const frame = document.getElementById('g');
frame.addEventListener('load', () => setTimeout(run, 1500));
const sleep = ms => new Promise(r => setTimeout(r, ms));
let W, D, cv, cvRect;
const TW = 64, THh = 32;
function q(sel) { return frame.contentDocument.querySelector(sel); }
function qa(sel) { return [...frame.contentDocument.querySelectorAll(sel)]; }
function click(el, btn = 0) {
if (!el) return false;
const o = { bubbles: true, button: btn };
el.dispatchEvent(new W.MouseEvent('mousedown', o));
el.dispatchEvent(new W.MouseEvent('mouseup', o));
el.dispatchEvent(new W.MouseEvent('click', o));
return true;
}
function canvasClick(x, y, btn = 0) {
const o = { bubbles: true, button: btn, clientX: x, clientY: y };
cv.dispatchEvent(new W.MouseEvent('mousemove', o));
cv.dispatchEvent(new W.MouseEvent('mousedown', o));
W.dispatchEvent(new W.MouseEvent('mouseup', o)); // iframe window!
}
function toScreen(wx, wy) {
const cam = D.render.cam, z = cam.zoom || 1;
const dx = wx - cam.x, dy = wy - cam.y;
return {
x: cvRect.left + cv.clientWidth / 2 + (dx - dy) * TW / 2 * z,
y: cvRect.top + cv.clientHeight / 2 + (dx + dy) * THh / 2 * z,
};
}
async function ensureLoop() {
let frames = 0;
const orig = D.render.frame.bind(D.render);
D.render.frame = (g, dt) => { frames++; return orig(g, dt); };
await sleep(400);
const rafAlive = frames > 3;
if (!rafAlive) {
setInterval(() => {
try {
const dt = 1 / 30;
D.ui.tickGlobalKeys();
if (D.game.state === 'playing') {
D.game.update(dt);
const allowDraw = !NORENDER && (!MAXFRAMES || drawnFrames < MAXFRAMES);
if (allowDraw) { D.render.frame(D.game, dt); drawnFrames++; }
else if (MAXFRAMES && drawnFrames === MAXFRAMES) { drawnFrames++; log('render capped @' + MAXFRAMES); }
D.ui.tick(dt);
}
D.input.endFrame();
} catch (e) { log('PUMP_ERROR ' + e.message); }
}, 33);
}
return rafAlive;
}
async function bootThroughTown(cls = 'crusader') {
click(q('#t-new'));
await sleep(300);
log('classselect=' + !!q('.class-card'));
click(qa('.class-card')[0]);
await sleep(250);
click(q('#cs-play'));
await sleep(1200);
log('state=' + D.game.state + ' town=' + !!D.game.world.isTown);
}
async function travelAct1Floor1() {
D.input.injectPress('KeyM');
await sleep(350);
const nodes = qa('.wm-node');
log('wmNodes=' + nodes.length);
click(nodes[1]); // cathedral node -> waypoint travel
await sleep(2200);
qa('.fpanel:not(.hidden) .fpanel-close').forEach(b => click(b));
await sleep(250);
log('travel act=' + D.game.world.act + ' f=' + D.game.world.floorIdx +
' mons=' + D.game.monsters.length);
}
async function doDescend() {
const pl = D.game.player;
const sd = D.game.world.stairsDown;
if (!sd || D.game.world.isTown) { log('no stairs here'); return; }
pl.maxHp = 1e7; pl.hp = 1e7;
let pressed = 0;
for (let i = 0; i < 1600; i++) {
if (D.game.world.floorIdx >= 1 || D.game.world.isTown) break;
const d = Math.hypot(pl.x - sd.x - .5, pl.y - sd.y - .5);
if (d < 2.4 && i % 8 === 0) { D.input.injectPress('KeyF'); pressed++; }
if (!pl.moveTarget || i % 30 === 0) pl.moveTarget = { x: sd.x + .5, y: sd.y + .5 };
if (i % 400 === 399) log(' d=' + d.toFixed(2) + ' left=' + D.input.state.left + ' fPresses=' + pressed);
await sleep(33);
}
log('descended f=' + D.game.world.floorIdx + ' presses=' + pressed);
}
async function combatAndLoot() {
/* bind cleave to RMB (learned at char creation) */
const pl0 = D.game.player;
const learned = Object.keys(pl0.skills).filter(k => pl0.skills[k] > 0);
pl0.hotbar[1] = learned[0] || null;
log('rmbSkill=' + pl0.hotbar[1]);
let casts = 0;
const origCast = D.combat.castSkill.bind(D.combat);
D.combat.castSkill = (...a) => { casts++; return origCast(...a); };
let target = null;
for (let i = 0; i < 40; i++) {
if (!target || target.dead) {
target = D.game.nearestMonster(D.game.player.x, D.game.player.y, 24);
if (!target) break;
}
const ang = Math.atan2(D.game.player.y - target.y, D.game.player.x - target.x);
D.game.player.x = target.x + Math.cos(ang) * 1.05;
D.game.player.y = target.y + Math.sin(ang) * 0.63;
D.game.player.moveTarget = null;
await sleep(50);
const mp = toScreen(target.x, target.y);
canvasClick(mp.x, mp.y, 2);
await sleep(300);
}
const p = D.game.player;
log('casts=' + casts + ' kills=' + p.kills + ' xp=' + p.xp +
' inv=' + p.inventory.length + ' gold=' + p.gold);
}
function pixelProof() {
try { D.render.frame(D.game, 1 / 30); } catch (e) { log('renderErr ' + e.message); return; }
/* camera settle check */
const cam = D.render.cam;
const p = D.game.player;
log('camDelta=' + (Math.abs(cam.x - p.x) + Math.abs(cam.y - p.y)).toFixed(2));
/* authoritative: read straight from the game's backbuffer */
const cx = cv.width >> 1, cy = cv.height >> 1;
const pts = [[0, 0], [-120, -80], [120, 80], [-200, 0], [200, 0]].map(([dx, dy]) =>
D.render.debugPixel(cx + dx, cy + dy));
log('backbuffer ' + JSON.stringify(pts));
/* cross-check via canvas copy */
const off = frame.contentDocument.createElement('canvas');
off.width = 80; off.height = 80;
const oc = off.getContext('2d');
oc.drawImage(cv, cv.width / 2 - 200, cv.height / 2 - 150, 400, 300, 0, 0, 80, 80);
const data = oc.getImageData(0, 0, 80, 80).data;
let lit = 0, colors = new Set(), maxB = 0;
for (let i = 0; i < data.length; i += 4) {
const b = data[i] + data[i + 1] + data[i + 2];
if (b > 60) lit++;
if (b > maxB) maxB = b;
colors.add((data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4));
}
log('canvasCopy lit=' + Math.round(lit / (data.length / 4) * 100) + '% colors=' + colors.size + ' maxB=' + maxB);
}
async function run() {
try {
W = frame.contentWindow;
D = W.D2;
frame.contentWindow.onerror = m => log('PAGE_ERR ' + m);
cv = q('#game-canvas');
cvRect = cv.getBoundingClientRect();
if (MUTE) {
try {
D.audio.sfx = () => {};
D.audio.playMusic = () => {};
D.audio.stopMusic = () => {};
D.audio.unlock = () => {};
} catch (e) {}
}
const raf = await ensureLoop();
log('raf=' + raf + ' norender=' + NORENDER + ' mute=' + MUTE);
await bootThroughTown();
if (PHASE === 'full') {
/* movement */
const p0 = { x: D.game.player.x, y: D.game.player.y };
const sp = toScreen(p0.x + 3, p0.y + 1);
canvasClick(sp.x, sp.y);
await sleep(1400);
log('moved=' + Math.hypot(D.game.player.x - p0.x, D.game.player.y - p0.y).toFixed(2));
/* panels */
for (const [code, id] of [['KeyI', '#panel-inventory'], ['KeyC', '#panel-character'], ['KeyT', '#panel-skills'], ['KeyM', '#panel-worldmap']]) {
D.input.injectPress(code);
await sleep(320);
const open = !q(id).classList.contains('hidden');
if (!open) log('PANEL_FAIL ' + code);
click(q(id + ' .fpanel-close'));
await sleep(140);
}
log('panels ok');
}
await travelAct1Floor1();
if (PHASE === 'full') {
await combatAndLoot();
await sleep(900); // camera settle before sampling
pixelProof();
/* save/load */
D.game.saveGame();
const g0 = D.game.player.gold;
D.game.player.gold += 555;
const ok = D.game.loadGame() && D.game.player.gold === g0 && D.game.player.hp === D.game.player.maxHp;
log('save/load=' + ok);
} else {
await doDescend();
}
const fin = D.game.player;
log('DONE f=' + D.game.world.floorIdx + ' lvl=' + fin.level + ' kills=' + fin.kills +
' hp=' + Math.round(fin.hp) + '/' + fin.maxHp + ' errors=none');
} catch (e) {
log('DRIVE_ERROR: ' + (e.stack || e.message));
}
}
</script>
</body>
</html>