Files
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

514 lines
18 KiB
JavaScript

/* ============================================================
* Diablo2D — world.js : procedural dungeon & town generation
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
const T = { VOID: 0, FLOOR: 1, WALL: 2 };
const THEMES = {
cathedral: {
gen: 'rooms', floorsPerRoom: 0.9,
floorCols: ['#4a3f38', '#52453c', '#453b34', '#57493e'],
wallTop: '#2c2622', wallFace: '#3a322c',
accent: '#7a5a3a', torch: '#ff9a4a', fog: '#0a0806', ambient: 0.16,
music: 'crypt',
},
catacombs: {
gen: 'rooms',
floorCols: ['#3a4048', '#414750', '#363c44', '#464c55'],
wallTop: '#23282e', wallFace: '#30363d',
accent: '#4a6a8a', torch: '#7ab8ff', fog: '#07090c', ambient: 0.12,
music: 'crypt',
},
caves: {
gen: 'caves',
floorCols: ['#3e4a34', '#46523a', '#38442e', '#4d5940'],
wallTop: '#242c20', wallFace: '#323e2a',
accent: '#5a8a3a', torch: '#b8e86a', fog: '#080a06', ambient: 0.14,
music: 'cave',
},
hell: {
gen: 'caves',
floorCols: ['#48302a', '#50362e', '#422a24', '#583a30'],
wallTop: '#281512', wallFace: '#38201a',
accent: '#c83a1a', torch: '#ff5a2a', fog: '#100604', ambient: 0.18,
music: 'hell',
},
town: {
gen: 'town',
floorCols: ['#55503e', '#5c5644', '#4e4938', '#635d49'],
wallTop: '#33291e', wallFace: '#453829',
accent: '#8a7a4a', torch: '#ffb84a', fog: '#0c0a08', ambient: 0.32,
music: 'town',
},
};
/* ---------------- helpers ---------------- */
function idx(w, x, y) { return y * w + x; }
function inBounds(w, h, x, y) { return x >= 0 && y >= 0 && x < w && y < h; }
/* ---------------- rooms & corridors generator ---------------- */
function genRooms(rng, w, h, roomAttempts) {
const tiles = new Uint8Array(w * h);
const rooms = [];
for (let i = 0; i < roomAttempts && rooms.length < 14; i++) {
const rw = rng.int(6, 13), rh = rng.int(5, 10);
const rx = rng.int(2, w - rw - 3), ry = rng.int(2, h - rh - 3);
const room = { x: rx, y: ry, w: rw, h: rh, cx: (rx + rw / 2) | 0, cy: (ry + rh / 2) | 0 };
if (rooms.some(r => overlap(room, r, 2))) continue;
rooms.push(room);
carveRect(tiles, w, room);
}
/* L corridors between consecutive rooms */
for (let i = 1; i < rooms.length; i++) {
const a = rooms[i - 1], b = rooms[i];
carveL(tiles, w, a.cx, a.cy, b.cx, b.cy);
}
/* a couple of loops */
for (let k = 0; k < 3 && rooms.length > 4; k++) {
const a = rng.pick(rooms), b = rng.pick(rooms);
if (a !== b) carveL(tiles, w, a.cx, a.cy, b.cx, b.cy);
}
return { tiles, rooms };
}
function overlap(a, b, pad) {
return a.x - pad < b.x + b.w && a.x + a.w + pad > b.x &&
a.y - pad < b.y + b.h && a.y + a.h + pad > b.y;
}
function carveRect(tiles, w, r) {
for (let y = r.y; y < r.y + r.h; y++)
for (let x = r.x; x < r.x + r.w; x++)
tiles[idx(w, x, y)] = T.FLOOR;
}
function carveL(tiles, w, x0, y0, x1, y1) {
let x = x0, y = y0;
const horizFirst = Math.random() < 0.5;
const step = () => { tiles[idx(w, x, y)] = T.FLOOR; };
step();
if (horizFirst) {
while (x !== x1) { x += Math.sign(x1 - x); step(); }
while (y !== y1) { y += Math.sign(y1 - y); step(); }
} else {
while (y !== y1) { y += Math.sign(y1 - y); step(); }
while (x !== x1) { x += Math.sign(x1 - x); step(); }
}
}
/* ---------------- cellular cave generator ---------------- */
function genCaves(rng, w, h) {
const tiles = new Uint8Array(w * h);
const wallP = 0.44;
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++)
tiles[idx(w, x, y)] = (x === 0 || y === 0 || x === w - 1 || y === h - 1 || rng.next() < wallP) ? T.WALL : T.FLOOR;
for (let pass = 0; pass < 4; pass++) {
const next = Uint8Array.from(tiles);
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
let walls = 0;
for (let dy = -1; dy <= 1; dy++)
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
if (tiles[idx(w, x + dx, y + dy)] !== T.FLOOR) walls++;
}
next[idx(w, x, y)] = walls >= 5 ? T.WALL : T.FLOOR;
}
}
tiles.set(next);
}
/* keep only the largest connected region */
const seen = new Uint8Array(w * h);
let best = null;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i0 = idx(w, x, y);
if (tiles[i0] !== T.FLOOR || seen[i0]) continue;
const region = [];
const stack = [[x, y]];
seen[i0] = 1;
while (stack.length) {
const [cx, cy] = stack.pop();
region.push([cx, cy]);
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const nx = cx + dx, ny = cy + dy;
if (!inBounds(w, h, nx, ny)) continue;
const ni = idx(w, nx, ny);
if (!seen[ni] && tiles[ni] === T.FLOOR) { seen[ni] = 1; stack.push([nx, ny]); }
}
}
if (!best || region.length > best.length) best = region;
}
}
const keep = new Set(best.map(([x, y]) => idx(w, x, y)));
for (let i = 0; i < tiles.length; i++)
if (tiles[i] === T.FLOOR && !keep.has(i)) tiles[i] = T.VOID;
/* pseudo-rooms = flood-fill pockets for prop/spawn logic */
const rooms = [];
const grid = {};
for (const [x, y] of best) grid[x + ',' + y] = true;
// sample room anchors on a coarse grid where floor exists
for (let gy = 6; gy < h - 6; gy += 9) {
for (let gx = 6; gx < w - 6; gx += 9) {
if (grid[gx + ',' + gy]) rooms.push({ x: gx - 3, y: gy - 3, w: 7, h: 7, cx: gx, cy: gy });
}
}
return { tiles, rooms };
}
/* ---------------- walls around floors ---------------- */
function buildWalls(tiles, w, h) {
const out = Uint8Array.from(tiles);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (tiles[idx(w, x, y)] !== T.VOID) continue;
let touchesFloor = false;
for (let dy = -1; dy <= 1 && !touchesFloor; dy++)
for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx, ny = y + dy;
if (inBounds(w, h, nx, ny) && tiles[idx(w, nx, ny)] === T.FLOOR) { touchesFloor = true; break; }
}
if (touchesFloor) out[idx(w, x, y)] = T.WALL;
}
}
return out;
}
/* ---------------- floor assembly ---------------- */
/**
* opts: { act, floorIdx, seed, bossLair, torment }
* Returns the World object used by simulation & rendering.
*/
function generateFloor(opts) {
const act = opts.act | 0;
const floorIdx = opts.floorIdx | 0;
const themeId = D2.BAL.acts[act].theme;
const theme = THEMES[themeId];
const rng = new D2.util.RNG(opts.seed >>> 0);
const isBoss = !!opts.bossLair;
const w = isBoss ? 52 : 62, h = isBoss ? 38 : 62;
let tiles, rooms;
if (isBoss) {
tiles = new Uint8Array(w * h);
const m = 4;
const room = { x: m, y: m, w: w - m * 2, h: h - m * 2 };
carveRect(tiles, w, room);
rooms = [room];
/* pillars for cover */
for (let py = room.y + 4; py < room.y + room.h - 3; py += 6) {
for (let px = room.x + 5; px < room.x + room.w - 4; px += 8) {
tiles[idx(w, px, py)] = T.WALL;
tiles[idx(w, px + 1, py)] = T.WALL;
}
}
} else if (theme.gen === 'rooms') {
({ tiles, rooms } = genRooms(rng, w, h, 90));
} else {
({ tiles, rooms } = genCaves(rng, w, h));
}
const finalTiles = buildWalls(tiles, w, h);
/* variants for art */
const variant = new Uint8Array(w * h);
for (let i = 0; i < variant.length; i++) variant[i] = (rng.next() * 4) | 0;
const world = {
w, h,
tiles: finalTiles,
variant,
theme, themeId, act, floorIdx,
isBossLair: isBoss,
seed: opts.seed >>> 0,
torment: opts.torment | 0,
rooms,
props: [],
propMap: new Map(), // "x,y" -> blocking prop
spawns: [],
visible: new Uint8Array(w * h),
explored: new Uint8Array(w * h),
torches: [], // light sources {x,y,color,r,flicker}
spawnX: 0, spawnY: 0,
stairsDown: null, stairsUp: null,
mlvl: D2.BAL.monsterLevel(act, floorIdx, opts.torment | 0),
tileAt(x, y) { return inBounds(w, h, x, y) ? finalTiles[idx(w, x, y)] : T.VOID; },
isWalkable(x, y) {
if (this.tileAt(Math.floor(x), Math.floor(y)) !== T.FLOOR) return false;
return !this.propMap.has(Math.floor(x) + ',' + Math.floor(y));
},
transparent(x, y) { return this.tileAt(x, y) === T.FLOOR; },
randomFloorIn(room) {
for (let tries = 0; tries < 30; tries++) {
const x = rng.int(room.x, room.x + room.w - 1);
const y = rng.int(room.y, room.y + room.h - 1);
if (finalTiles[idx(w, x, y)] === T.FLOOR && !this.propMap.has(x + ',' + y)) return { x, y };
}
return null;
},
addProp(p) {
p.uid = D2.util.uid();
this.props.push(p);
if (p.blocking) this.propMap.set(p.x + ',' + p.y, p);
if (p.type === 'torch') this.torches.push({ x: p.x, y: p.y });
return p;
},
removeProp(p) {
const i = this.props.indexOf(p);
if (i >= 0) this.props.splice(i, 1);
this.propMap.delete(p.x + ',' + p.y);
},
};
/* ---- spawn point = first room center ---- */
const startRoom = rooms[0];
world.spawnX = startRoom.cx; world.spawnY = startRoom.cy;
/* ---- stairs ---- */
if (!isBoss) {
const lastRoom = farthestRoom(world, rooms, startRoom);
const sp = world.randomFloorIn(lastRoom) || { x: lastRoom.cx, y: lastRoom.cy };
world.stairsDown = { x: sp.x, y: sp.y };
world.addProp({ type: 'stairs_down', x: sp.x, y: sp.y, blocking: false });
} else {
world.stairsUp = { x: startRoom.cx, y: startRoom.cy };
world.addProp({ type: 'stairs_up', x: startRoom.cx, y: startRoom.cy, blocking: false });
}
populateProps(world, rng, theme);
populateMonsters(world, rng, act, isBoss);
ensureRouteToStairs(world);
return world;
}
/* Remove blocking props along one spawn→stairs route so caves
with 1-wide tunnels can never be sealed by barrels. */
function ensureRouteToStairs(world) {
if (!world.stairsDown || world.isTown) return;
const tilesPath = D2.path.find(
(x, y) => world.transparent(x, y),
world.w, world.h,
world.spawnX, world.spawnY,
world.stairsDown.x, world.stairsDown.y, 30000);
if (!tilesPath) return;
for (const n of tilesPath) {
const pr = world.propMap.get(n.x + ',' + n.y);
if (pr && pr.blocking) world.removeProp(pr);
}
}
function farthestRoom(world, rooms, from) {
let best = rooms[rooms.length - 1], bestD = -1;
for (const r of rooms) {
const d = (r.cx - from.cx) ** 2 + (r.cy - from.cy) ** 2;
if (d > bestD) { bestD = d; best = r; }
}
return best;
}
/* ---------------- props ---------------- */
function populateProps(world, rng, theme) {
const { w, h } = world;
const at = (x, y) => world.tileAt(x, y) === T.FLOOR && !world.propMap.has(x + ',' + y);
/* torches along walls */
const torchSpots = [];
for (let y = 1; y < h - 1; y++)
for (let x = 1; x < w - 1; x++)
if (world.tiles[idx(w, x, y)] === T.WALL &&
world.tileAt(x, y + 1) === T.FLOOR)
torchSpots.push([x, y]);
rng.shuffle(torchSpots);
const torchCount = Math.min(18, torchSpots.length);
for (let i = 0; i < torchCount; i++) {
const [x, y] = torchSpots[i];
world.addProp({ type: 'torch', x, y: y, blocking: false, light: true });
}
if (world.isBossLair) {
/* a chest behind the boss area */
return;
}
for (const room of world.rooms.slice(1)) {
/* barrels & urns */
const clusterN = rng.int(0, 3);
for (let c = 0; c < clusterN; c++) {
const bx = rng.int(room.x + 1, room.x + room.w - 2);
const by = rng.int(room.y + 1, room.y + room.h - 2);
const n = rng.int(1, 3);
for (let i = 0; i < n; i++) {
const x = Math.min(room.x + room.w - 1, bx + i % 2), y = by;
if (at(x, y)) world.addProp({
type: rng.chance(0.5) ? 'barrel' : 'urn',
x, y, blocking: true, hp: 1,
});
}
}
/* chest */
if (rng.chance(0.3)) {
const spot = world.randomFloorIn(room);
if (spot) world.addProp({ type: 'chest', x: spot.x, y: spot.y, blocking: true, opened: false });
}
/* shrine (rare) */
if (rng.chance(0.05)) {
const spot = world.randomFloorIn(room);
if (spot) world.addProp({
type: 'shrine', x: spot.x, y: spot.y, blocking: false, used: false,
buff: rng.pick(['dmg', 'speed', 'armor', 'xp', 'regen']),
});
}
}
}
/* ---------------- monsters ---------------- */
function populateMonsters(world, rng, act, isBoss) {
if (isBoss) {
const bd = D2.Monsters.BOSS_MAP[D2.BAL.acts[act].boss];
world.spawns.push({ boss: bd.id, x: world.w / 2, y: world.h * 0.68 });
/* honor guard */
const guard = D2.Monsters.rollSpeciesForAct(act);
for (let i = 0; i < 4; i++) {
world.spawns.push({ speciesId: guard.id, x: world.w / 2 + (rng.range(-5, 5)), y: world.h * 0.55 + rng.range(-2, 2), elite: false });
}
return;
}
const density = 0.55 + world.act * 0.12 + world.floorIdx * 0.05;
for (const room of world.rooms.slice(1)) {
if (world.stairsDown && room === nearestRoomToStairs(world)) continue;
const packs = Math.max(1, Math.round((room.w * room.h) / 55 * density));
for (let p = 0; p < packs; p++) {
const species = D2.Monsters.rollSpeciesForAct(act);
const packSize = species.ai === 'swarm' ? rng.int(3, 5) : rng.int(2, 4);
const packElite = rng.chance(0.13);
const base = world.randomFloorIn(room);
if (!base) continue;
for (let i = 0; i < packSize; i++) {
const sx = Math.max(1, Math.min(world.w - 2, base.x + ((rng.next() * 5) | 0) - 2));
const sy = Math.max(1, Math.min(world.h - 2, base.y + ((rng.next() * 5) | 0) - 2));
if (world.tileAt(sx, sy) === T.FLOOR)
world.spawns.push({ speciesId: species.id, x: sx, y: sy, elite: packElite });
}
}
}
}
function nearestRoomToStairs(world) {
if (!world.rooms.length || !world.stairsDown) return null;
let best = null, bd = Infinity;
for (const r of world.rooms) {
const d = (r.cx - world.stairsDown.x) ** 2 + (r.cy - world.stairsDown.y) ** 2;
if (d < bd) { bd = d; best = r; }
}
return best;
}
/* ---------------- town ---------------- */
function generateTown(seed) {
const w = 56, h = 40;
const rng = new D2.util.RNG((seed ^ 0x7A6E) >>> 0);
const theme = THEMES.town;
const tiles = new Uint8Array(w * h).fill(T.VOID);
const border = 2;
for (let y = border; y < h - border; y++)
for (let x = border; x < w - border; x++)
tiles[idx(w, x, y)] = T.FLOOR;
const world = {
w, h, tiles, variant: new Uint8Array(w * h),
theme, themeId: 'town', act: -1, floorIdx: -1,
isBossLair: false, isTown: true, seed: seed >>> 0, torment: 0,
rooms: [], props: [], propMap: new Map(),
spawns: [], visible: new Uint8Array(w * h), explored: new Uint8Array(w * h),
torches: [], spawnX: (w / 2) | 0, spawnY: (h * 0.62) | 0,
stairsDown: null, stairsUp: null, mlvl: 1,
tileAt(x, y) { return inBounds(w, h, x, y) ? tiles[idx(w, x, y)] : T.VOID; },
isWalkable(x, y) {
if (this.tileAt(Math.floor(x), Math.floor(y)) !== T.FLOOR) return false;
return !this.propMap.has(Math.floor(x) + ',' + Math.floor(y));
},
transparent(x, y) { return this.tileAt(x, y) === T.FLOOR; },
randomFloorIn(room) { return null; },
addProp(p) {
p.uid = D2.util.uid();
this.props.push(p);
if (p.blocking) this.propMap.set(p.x + ',' + p.y, p);
if (p.type === 'torch') this.torches.push({ x: p.x, y: p.y });
return p;
},
removeProp(p) {
const i = this.props.indexOf(p);
if (i >= 0) this.props.splice(i, 1);
this.propMap.delete(p.x + ',' + p.y);
},
};
/* Rogue-camp buildings along the top (charsi/akara/stash/kashya/cain/gheed) */
const bw = 7, bh = 6;
const buildings = [
{ x: 3, npc: 'charsi' }, // blacksmith
{ x: 11, npc: 'akara' }, // healer & magic
{ x: 19, npc: 'stash' }, // camp storage
{ x: 27, npc: 'kashya' }, // rogue captain — hunt quests
{ x: 35, npc: 'cain' }, // elder lore — main quests
{ x: 43, npc: 'gheed' }, // gambler
].map(b => ({ ...b, w: bw, h: bh, y: 4 }));
for (const b of buildings) {
for (let y = b.y; y < b.y + b.h; y++)
for (let x = b.x; x < b.x + b.w; x++)
tiles[idx(w, x, y)] = T.WALL;
/* doorway at bottom center */
const doorX = b.x + (b.w >> 1);
tiles[idx(w, doorX, b.y + b.h - 1)] = T.FLOOR;
tiles[idx(w, doorX, b.y + b.h - 2)] = T.FLOOR;
world.addProp({ type: 'npc_' + b.npc, x: doorX, y: b.y + b.h - 4, blocking: true });
world.addProp({ type: 'torch', x: b.x - 1, y: b.y + b.h, blocking: false, light: true });
world.addProp({ type: 'torch', x: b.x + b.w, y: b.y + b.h, blocking: false, light: true });
}
/* waypoint stone center */
world.addProp({ type: 'waypoint', x: (w / 2) | 0, y: (h * 0.38) | 0, blocking: true });
/* decorative props */
for (let i = 0; i < 10; i++) {
const x = rng.int(border + 1, w - border - 2), y = rng.int(h * 0.55 | 0, h - border - 2);
if (world.tileAt(x, y) === T.FLOOR && !world.propMap.has(x + ',' + y))
world.addProp({ type: rng.chance(0.5) ? 'barrel' : 'bones', x, y, blocking: rng.chance(0.5), hp: 1 });
}
/* fence posts around perimeter */
for (let x = border; x < w - border; x += 3) {
world.addProp({ type: 'bones', x, y: h - border, blocking: false });
world.addProp({ type: 'bones', x, y: border - 1 >= 0 ? border : border, blocking: false });
}
/* explored everywhere in town */
world.explored.fill(1);
return world;
}
D2.world = { T, THEMES, generateFloor, generateTown };
})(window.D2);