- 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
247 lines
8.9 KiB
HTML
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>
|