/* ============================================================ * 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);