/** * Dynamic Dungeon Object Spawning Pipeline (Issue #50). * * In Diablo II, maze dungeon generation doesn't rely solely on static objects authored in DS1 presets. * A dedicated population pass dynamically scans the synthesized maze layout to seed: * * 1. Shrines (神殿: Armor, Combat, Resist, Skill, Mana, Health, Experience, Gem, Fire, Poison): * - Typically seeded in dead-end rooms (`degree === 1`) or quiet room corners/alcoves. * 2. Super/Gold Chests (金宝箱 / 特殊宝箱): * - Placed in dead-end rooms (often as reward rooms) or special treasure clusters. * 3. Weapon & Armor Racks (武器架、防具架): * - Placed adjacent to perimeter walls in corridors (`degree === 2`), barracks, or dungeon chambers, * with strict wall alignment. * 4. Breakable Containers (可破坏物件: barrels, urns, coffins, caskets, crates, tombs): * - Scattered in room corners, dead ends, and along walls in clusters of 2..5 items. * * All objects use authentic Diablo II DS1 object records `(type: 2, id)` matching the level's act. */ import type { Ds1, Ds1Cell, Ds1Object } from '../formats/ds1.ts' import { Rng } from './rng.ts' import { SUB_TILES_PER_TILE } from './map.ts' import { lookupObject } from './object-lookup.ts' import type { ObjectsTable } from './objects.ts' import type { D2Table } from './acts.ts' /** Subtiles per DS1 cell. */ export { SUB_TILES_PER_TILE } /** Dynamic dungeon object categories. */ export type MazeObjectCategory = 'shrine' | 'chest' | 'rack' | 'container' /** Canonical shrine sub-types in Diablo II. */ export type ShrineSubType = | 'armor' | 'combat' | 'resist' | 'skill' | 'mana' | 'health' | 'experience' | 'refilling' | 'gem' | 'fire' | 'poison' /** Chest sub-types. */ export type ChestSubType = 'super_chest' | 'chest' /** Weapon and armor rack sub-types. */ export type RackSubType = 'armor_rack' | 'weapon_rack' /** Breakable container sub-types. */ export type ContainerSubType = | 'barrel' | 'exploding_barrel' | 'urn' | 'casket' | 'coffin' | 'crate' | 'tomb' /** Wall alignment directions for wall-mounted/wall-adjacent objects like racks. */ export type WallAlignment = 'north' | 'south' | 'east' | 'west' /** A dynamically placed dungeon object with rich semantic metadata. */ export interface PlacedMazeObject extends Ds1Object { /** High-level category. */ readonly category: MazeObjectCategory /** Specific sub-type (e.g. 'armor', 'super_chest', 'weapon_rack', 'barrel'). */ readonly subType: ShrineSubType | ChestSubType | RackSubType | ContainerSubType | string /** Index of the owning room in the level rooms array. */ readonly roomIndex?: number /** Room ID assigned during maze generation. */ readonly roomId?: number /** Whether the object was spawned in a dead-end room (`degree === 1`). */ readonly isDeadEnd?: boolean /** Whether the object was placed in a room corner. */ readonly isCorner?: boolean /** For racks and wall decorations, which wall it is aligned against. */ readonly alignedWall?: WallAlignment | null } /** Options to configure the dungeon object population pipeline. */ export interface MazeObjectPopulationOptions { /** Overall object density multiplier (default: 1.0). */ readonly density?: number /** Breakable container density multiplier (default: 1.0). When 0, containers are omitted. */ readonly containerDensity?: number /** Shrine density multiplier (default: 1.0). When 0, shrines are omitted. */ readonly shrineDensity?: number /** Chest density multiplier (default: 1.0). When 0, chests are omitted. */ readonly chestDensity?: number /** Rack density multiplier (default: 1.0). When 0, racks are omitted. */ readonly rackDensity?: number /** Deterministic RNG seed override for object seeding. Defaults to the maze seed. */ readonly objectSeed?: number /** If true, skips dynamic object generation entirely. */ readonly disableDynamicObjects?: boolean } /** Minimal room descriptor interface needed by the object populator. */ export interface MazeRoomDescriptor { readonly id: number readonly x: number readonly y: number readonly width: number readonly height: number readonly orths: readonly { readonly room: unknown; readonly direction: number }[] readonly piece?: { readonly name: string; readonly kind?: string } | null } /** Candidate tile spot inside a room. */ export interface CandidateSpot { readonly cellX: number readonly cellY: number readonly roomIndex: number readonly roomId: number readonly isDeadEnd: boolean readonly isCorner: boolean readonly alignedWall: WallAlignment | null } /* ------------------------------------------------------------------------- * * Object Definitions & Act Mapping * ------------------------------------------------------------------------- */ /** * Shrine definition: subtype to act-specific DS1 Type 2 local ID mapping. * * Each entry maps a logical shrine subtype to the DS1 object ID used in each Act: * - Act 1: IDs 6..11 map to Cairn Stone / shrine tokens S1..S6 (Objects.txt 17..22); * 29 maps to Shrine token SF (Objects.txt 2); 82/83 map to Shrines SH/BC (Objects.txt 77/85); * 84 maps to Dummy SG (Objects.txt 86). * - Acts 2..5: IDs 167..172 map to Act 2+ shrine tokens MH, MJ, CZ, JH, SX, MK (Objects.txt 166..172); * 227 maps to Dummy QX (Objects.txt 227, Experience/Gem); 152 maps to Fire Orifice HA (Objects.txt 152); * 55/236 map to Poison corpses/shrines (Objects.txt 55, 236). */ interface ShrineDef { readonly subType: ShrineSubType readonly idsByAct: Record } export const CANONICAL_SHRINES: readonly ShrineDef[] = [ { subType: 'armor', idsByAct: { 1: 8, 2: 169, 3: 169, 4: 169, 5: 169 } }, { subType: 'combat', idsByAct: { 1: 9, 2: 170, 3: 170, 4: 170, 5: 170 } }, { subType: 'resist', idsByAct: { 1: 10, 2: 171, 3: 171, 4: 171, 5: 171 } }, { subType: 'skill', idsByAct: { 1: 11, 2: 172, 3: 172, 4: 172, 5: 172 } }, { subType: 'mana', idsByAct: { 1: 7, 2: 168, 3: 168, 4: 168, 5: 168 } }, { subType: 'health', idsByAct: { 1: 6, 2: 167, 3: 167, 4: 167, 5: 167 } }, { subType: 'experience', idsByAct: { 1: 82, 2: 227, 3: 227, 4: 227, 5: 227 } }, { subType: 'refilling', idsByAct: { 1: 6, 2: 167, 3: 167, 4: 167, 5: 167 } }, { subType: 'gem', idsByAct: { 1: 82, 2: 227, 3: 227, 4: 227, 5: 227 } }, { subType: 'fire', idsByAct: { 1: 29, 2: 152, 3: 152, 4: 152, 5: 152 } }, { subType: 'poison', idsByAct: { 1: 84, 2: 55, 3: 236, 4: 236, 5: 236 } }, ] /** * Chest ID mapping per act. * * DS1 Type 2 IDs correspond to: * - Super Chests: * - Act 1: 5 (chest L1, Objects.txt 5), 63 (chest L2, Objects.txt 6), 155/156 (hidden stashes C7, Objects.txt 155) * - Act 2: 32 (CL, Objects.txt 32), 33 (A1, Objects.txt 33), 155/156 * - Act 3: 65 (X1, Objects.txt 65), 66 (X2, Objects.txt 66), 155/156 * - Acts 4-5: 155, 156 * - Normal Chests: * - Act 1: 89..94 (Caskets C1..C6, Objects.txt 50, 51, 79, 53, 1, 3); * 200..203 (Shrines/Stashes MY, MX, MW, MV, Objects.txt 200..203); 229 (door/chest SQ, Objects.txt 229) * - Acts 2..5: 200..203, 229 */ export const CHEST_IDS_BY_ACT: Record = { 1: { super: [5, 63, 155, 156], normal: [89, 90, 91, 92, 200, 201, 229, 203] }, 2: { super: [32, 33, 155, 156], normal: [200, 201, 229, 203] }, 3: { super: [65, 66, 155, 156], normal: [200, 201, 229, 203] }, 4: { super: [155, 156], normal: [200, 201, 229, 203] }, 5: { super: [155, 156], normal: [200, 201, 229, 203] }, } /** * Weapon and Armor rack ID mapping per act and orientation. * * DS1 Type 2 IDs correspond to: * - Armor Racks: * - Act 1: 19/20 (A1/A2, Objects.txt 33/34), 46/47 (Crate/Door CT/AD, Objects.txt 46/47), * 183/184 (Chest/Shrine JZ/JY, Objects.txt 183/184), 254/255 (Dummy 12/SS, Objects.txt 254/255) * - Act 2: 74/75 (TrappDoor/Door TD/DD, Objects.txt 74/75), 183/184, 254/255 * - Act 3: 9/10 (A3/A4 ArmorStand, Objects.txt 104/105), 183/184, 254/255 * - Acts 4-5: 183/184, 254/255, 10/11 * - Weapon Racks: * - Act 1: 48/49 (Dummy T1/T2, Objects.txt 48/49), 256/257 (Dummy S9/WG, Objects.txt 256/257) * - Act 2: 76/77 (Dummy/Shrine SZ/SH, Objects.txt 76/77), 256/257 * - Act 3: 11/12 (3R/4R WeaponStand, Objects.txt 106/107), 256/257 * - Acts 4-5: 256, 257 */ export const RACK_IDS_BY_ACT: Record< number, { armor: { west: readonly number[]; north: readonly number[]; any: readonly number[] } weapon: { west: readonly number[]; north: readonly number[]; any: readonly number[] } } > = { 1: { armor: { west: [19, 46, 183, 254], north: [20, 47, 184, 255], any: [19, 20, 46, 47, 183, 184, 254, 255] }, weapon: { west: [48, 256], north: [49, 257], any: [48, 49, 256, 257] }, }, 2: { armor: { west: [74, 183, 254], north: [75, 184, 255], any: [74, 75, 183, 184, 254, 255] }, weapon: { west: [76, 256], north: [77, 257], any: [76, 77, 256, 257] }, }, 3: { armor: { west: [9, 183, 254], north: [10, 184, 255], any: [9, 10, 183, 184, 254, 255] }, weapon: { west: [11, 256], north: [12, 257], any: [11, 12, 256, 257] }, }, 4: { armor: { west: [183, 254], north: [184, 255], any: [183, 184, 254, 255] }, weapon: { west: [256], north: [257], any: [256, 257] }, }, 5: { armor: { west: [10, 183, 254], north: [11, 184, 255], any: [10, 11, 183, 184, 254, 255] }, weapon: { west: [256], north: [257], any: [256, 257] }, }, } /** * Container pool mapping per act. * * DS1 Type 2 IDs correspond to: * - Barrels: 95 (LargeUrn U5, Objects.txt 95), 28 (B2, Objects.txt 82), 50/51 (Caskets C1/C2, Objects.txt 50/51), * 102 (Dummy FB, Objects.txt 102), 157 (Waypoint WN, Objects.txt 157), 232 (Shrine HQ, Objects.txt 232), * 251 (altar GA, Objects.txt 251), 329/330 (Chests XB/XC, Objects.txt 329/330), 417 (Flag YM, Objects.txt 417) * - Urns: 154 (Corpse GC, Objects.txt 154), 159 (Hidden stash CQ, Objects.txt 159), 202 (Shrine MW, Objects.txt 202), * 244 (Ratnest RN, Objects.txt 244), 245 (Body BY, Objects.txt 245), 25..29 (Act 2 Urns U1..U5, Objects.txt 4, 9, 52, 94, 95) * - Caskets: 93/94 (Caskets C5/C6, Objects.txt 1/3), 151 (Shrine ZE, Objects.txt 151), 153 (Door DX, Objects.txt 153) * - Crates: 161 (Fire FY, Objects.txt 161) */ export const CONTAINER_IDS_BY_ACT: Record< number, { barrels: readonly number[] urns: readonly number[] caskets: readonly number[] crates: readonly number[] } > = { 1: { barrels: [95, 28, 50, 51, 102, 157, 232, 251, 329, 330, 417], urns: [154, 159, 202, 244, 245], caskets: [93, 94, 151, 153], crates: [161], }, 2: { barrels: [6, 21, 157, 232, 251, 329, 330, 417], urns: [25, 26, 27, 28, 29, 154, 159, 202], caskets: [151, 153], crates: [161], }, 3: { barrels: [34, 157, 232, 251, 329, 330, 417], urns: [154, 159, 202, 244, 245], caskets: [151, 153], crates: [161], }, 4: { barrels: [2, 157, 232, 251, 329, 330, 417], urns: [154, 159, 202], caskets: [151, 153], crates: [161], }, 5: { barrels: [4, 44, 157, 232, 251, 329, 330, 417], urns: [154, 159, 202], caskets: [151, 153], crates: [161], }, } /** * Resolves the underlying Diablo II Objects.txt row ID for a given Act and DS1 Type 2 object ID. * Returns null if unmapped in object-lookup tables or absent in the optional objects table. */ export function resolveObjectsTxtId( act: number, ds1ObjectId: number, objectsTable?: ObjectsTable | D2Table, ): number | null { const entry = lookupObject(act, 2, ds1ObjectId) if (entry === null || entry.objectsTxtId < 0) return null if (objectsTable) { if ('byId' in objectsTable) { if (!objectsTable.byId.has(entry.objectsTxtId)) return null } else if ('header' in objectsTable && 'rows' in objectsTable) { const idCol = objectsTable.header.indexOf('Id') const found = idCol !== -1 && objectsTable.rows.some(row => Number(row[idCol]) === entry.objectsTxtId) if (!found) return null } } return entry.objectsTxtId } /** * Validates dungeon object pools against object-lookup metadata or an optional loaded Objects.txt table. * Ensures data-driven correctness for all hardcoded object pools. */ export function validateDungeonObjectPools(objectsTable?: ObjectsTable | D2Table): { valid: boolean totalChecked: number unmapped: number[] errors: string[] } { const unmapped: number[] = [] let totalChecked = 0 const check = (act: number, id: number) => { totalChecked += 1 const entry = lookupObject(act, 2, id) if (!entry) { unmapped.push(id) } else if (objectsTable) { if ('byId' in objectsTable) { if (entry.objectsTxtId >= 0 && !objectsTable.byId.has(entry.objectsTxtId)) { unmapped.push(id) } } else if ('header' in objectsTable && 'rows' in objectsTable) { const idCol = objectsTable.header.indexOf('Id') const found = idCol !== -1 && objectsTable.rows.some(row => Number(row[idCol]) === entry.objectsTxtId) if (entry.objectsTxtId >= 0 && !found) { unmapped.push(id) } } } } for (let act = 1; act <= 5; act += 1) { const chests = CHEST_IDS_BY_ACT[act] if (chests) { for (const id of chests.super) check(act, id) for (const id of chests.normal) check(act, id) } const racks = RACK_IDS_BY_ACT[act] if (racks) { for (const id of racks.armor.any) check(act, id) for (const id of racks.weapon.any) check(act, id) } const containers = CONTAINER_IDS_BY_ACT[act] if (containers) { for (const id of containers.barrels) check(act, id) for (const id of containers.urns) check(act, id) for (const id of containers.caskets) check(act, id) for (const id of containers.crates) check(act, id) } for (const shrine of CANONICAL_SHRINES) { const id = shrine.idsByAct[act] if (id !== undefined) check(act, id) } } const errors = unmapped.map(id => `DS1 Type 2 object ID ${id} is unmapped or missing in Objects.txt`) return { valid: unmapped.length === 0, totalChecked, unmapped, errors } } /* ------------------------------------------------------------------------- * * Spatial Analysis * ------------------------------------------------------------------------- */ /** Check if a cell has a floor and no solid wall. */ export function isOpenFloor(cells: readonly (readonly Ds1Cell[])[], cx: number, cy: number): boolean { if (cy < 0 || cy >= cells.length) return false const row = cells[cy] if (!row || cx < 0 || cx >= row.length) return false const cell = row[cx] if (!cell) return false // Must contain floor *art*. A `Ds1Floor` whose `prop1`, `style` and `sequence` // are all zero is an empty slot, not a tile: DS1 stores one entry per floor // layer for every cell, so a one-layer map has a zero-filled entry everywhere // the level does not paint. The `|| true` that used to close this condition // made the whole content test vacuous, so objects were placed on void cells. const hasFloor = cell.floors.length > 0 && cell.floors.some(f => !f.hidden && (f.prop1 !== 0 || f.style !== 0 || f.sequence !== 0)) if (!hasFloor) return false // Must not have a solid, visible wall const hasWall = cell.walls.some(w => !w.hidden && (w.prop1 !== 0 || w.style !== 0 || w.type !== 0)) return !hasWall } /** Check if a cell contains a wall or is off-map. */ export function isWallCell(cells: readonly (readonly Ds1Cell[])[], cx: number, cy: number): boolean { if (cy < 0 || cy >= cells.length) return true const row = cells[cy] if (!row || cx < 0 || cx >= row.length) return true const cell = row[cx] if (!cell) return true return cell.walls.some(w => !w.hidden && (w.prop1 !== 0 || w.style !== 0 || w.type !== 0)) } /** Determine which adjacent cardinal wall a floor cell is next to (if any). */ export function getAdjacentWall( cells: readonly (readonly Ds1Cell[])[], cx: number, cy: number, ): WallAlignment | null { if (isWallCell(cells, cx, cy - 1)) return 'north' if (isWallCell(cells, cx - 1, cy)) return 'west' if (isWallCell(cells, cx, cy + 1)) return 'south' if (isWallCell(cells, cx + 1, cy)) return 'east' return null } /** Check if an open floor cell is in an inner corner (walls on at least 2 orthogonal sides). */ export function isInnerCorner( cells: readonly (readonly Ds1Cell[])[], cx: number, cy: number, ): boolean { const n = isWallCell(cells, cx, cy - 1) const s = isWallCell(cells, cx, cy + 1) const w = isWallCell(cells, cx - 1, cy) const e = isWallCell(cells, cx + 1, cy) return (n && w) || (n && e) || (s && w) || (s && e) } /** * Scan a single room and harvest candidate placement spots categorized into: * - dead ends (`degree === 1`) * - room corners * - perimeter wall offsets (wall alignment) */ export function analyzeRoomSpots( room: MazeRoomDescriptor, roomIndex: number, originX: number, originY: number, cells: readonly (readonly Ds1Cell[])[], ): CandidateSpot[] { const spots: CandidateSpot[] = [] const isDeadEnd = room.orths.length === 1 const width = room.width const height = room.height for (let dy = 0; dy < height; dy += 1) { for (let dx = 0; dx < width; dx += 1) { const cx = originX + dx const cy = originY + dy if (!isOpenFloor(cells, cx, cy)) continue const alignedWall = getAdjacentWall(cells, cx, cy) const isCorner = isInnerCorner(cells, cx, cy) || (dx <= 2 && dy <= 2) || (dx >= width - 3 && dy <= 2) || (dx <= 2 && dy >= height - 3) || (dx >= width - 3 && dy >= height - 3) spots.push({ cellX: cx, cellY: cy, roomIndex, roomId: room.id, isDeadEnd, isCorner, alignedWall, }) } } return spots } /* ------------------------------------------------------------------------- * * Main Object Population Pipeline * ------------------------------------------------------------------------- */ export interface PopulationResult { readonly placedObjects: readonly PlacedMazeObject[] readonly stats: { readonly dynamicObjectsCount: number readonly shrinesSpawned: number readonly chestsSpawned: number readonly superChestsSpawned: number readonly racksSpawned: number readonly containersSpawned: number } } /** * Executes the dynamic dungeon object spawning pipeline. * * @param rooms - The list of rooms placed in the maze. * @param minX - Minimum room x in cells. * @param minY - Minimum room y in cells. * @param level - The synthesized Ds1 level containing cells and static objects. * @param levelTypeName - LvlTypes name (e.g. 'Act 1 - Cave', 'Act 2 - Tomb'). * @param levelId - Levels.txt ID. * @param options - Configurable density and seeding options. * @param rng - Pseudo-random number generator. * @returns Placed dynamic objects and spawn statistics. */ export function populateDungeonObjects( rooms: readonly MazeRoomDescriptor[], minX: number, minY: number, level: Ds1, levelTypeName: string, levelId: number, options?: MazeObjectPopulationOptions, rng?: Rng, ): PopulationResult { if (options?.disableDynamicObjects === true) { return { placedObjects: [], stats: { dynamicObjectsCount: 0, shrinesSpawned: 0, chestsSpawned: 0, superChestsSpawned: 0, racksSpawned: 0, containersSpawned: 0, }, } } const act = levelTypeName.startsWith('Act 2') ? 2 : levelTypeName.startsWith('Act 3') ? 3 : levelTypeName.startsWith('Act 4') ? 4 : levelTypeName.startsWith('Act 5') ? 5 : 1 const seed = options?.objectSeed ?? 0x5050_0000 + levelId const popRng = rng ?? new Rng(seed).fork('dungeon-objects') const density = Math.max(0, options?.density ?? 1.0) if (density <= 0) { return { placedObjects: [], stats: { dynamicObjectsCount: 0, shrinesSpawned: 0, chestsSpawned: 0, superChestsSpawned: 0, racksSpawned: 0, containersSpawned: 0, }, } } const shrineDensity = options?.shrineDensity ?? density const chestDensity = options?.chestDensity ?? density const rackDensity = options?.rackDensity ?? density const containerDensity = options?.containerDensity ?? density const chestPool = CHEST_IDS_BY_ACT[act] ?? CHEST_IDS_BY_ACT[1]! const rackPool = RACK_IDS_BY_ACT[act] ?? RACK_IDS_BY_ACT[1]! const containerPool = CONTAINER_IDS_BY_ACT[act] ?? CONTAINER_IDS_BY_ACT[1]! // Track existing objects to avoid collisions const occupiedSubtiles: { x: number; y: number }[] = level.objects.map(o => ({ x: o.x, y: o.y })) const placedObjects: PlacedMazeObject[] = [] const isSubtileClear = (subX: number, subY: number, minDistance = SUB_TILES_PER_TILE): boolean => { for (const occ of occupiedSubtiles) { if (Math.hypot(occ.x - subX, occ.y - subY) < minDistance) return false } return true } const placeObject = ( cellX: number, cellY: number, id: number, category: MazeObjectCategory, subType: string, metadata: { roomIndex?: number roomId?: number isDeadEnd?: boolean isCorner?: boolean alignedWall?: WallAlignment | null }, subOffset = 2, ): boolean => { const subX = cellX * SUB_TILES_PER_TILE + subOffset const subY = cellY * SUB_TILES_PER_TILE + subOffset if (!isSubtileClear(subX, subY, SUB_TILES_PER_TILE * 0.9)) return false const placed: PlacedMazeObject = { type: 2, id, x: subX, y: subY, flags: 0, category, subType, ...metadata, } placedObjects.push(placed) occupiedSubtiles.push({ x: subX, y: subY }) return true } // 1. Gather candidate spots per room const roomSpots: { room: MazeRoomDescriptor; roomIndex: number; spots: CandidateSpot[] }[] = [] for (let i = 0; i < rooms.length; i += 1) { const room = rooms[i]! const originX = room.x - minX const originY = room.y - minY const spots = analyzeRoomSpots(room, i, originX, originY, level.cells) roomSpots.push({ room, roomIndex: i, spots }) } let shrinesSpawned = 0 let chestsSpawned = 0 let superChestsSpawned = 0 let racksSpawned = 0 let containersSpawned = 0 // 2. Dead-end rooms population: Shrines and Super/Gold Chests const deadEndRooms = roomSpots.filter(r => r.room.orths.length === 1) // Decide shrine quota: 1..3 depending on dungeon room count and shrineDensity const baseShrineTarget = Math.max(1, Math.min(3, Math.round((rooms.length / 6) * shrineDensity))) const shrineTarget = shrineDensity > 0 ? baseShrineTarget : 0 const usedRoomsForMajorObject = new Set() // A. Place Shrines in dead-end rooms (or corner spots if dead-ends are scarce) if (shrineTarget > 0) { const shuffledDeadEnds = [...deadEndRooms].sort(() => popRng.next() - 0.5) for (const r of shuffledDeadEnds) { if (shrinesSpawned >= shrineTarget) break // Pick a center or deep spot in the dead end const candidate = r.spots.find(s => !s.alignedWall) ?? r.spots[Math.floor(r.spots.length / 2)] if (!candidate) continue const shrineDef = CANONICAL_SHRINES[popRng.int(0, CANONICAL_SHRINES.length - 1)]! const shrineId = shrineDef.idsByAct[act] ?? shrineDef.idsByAct[1]! if ( placeObject(candidate.cellX, candidate.cellY, shrineId, 'shrine', shrineDef.subType, { roomIndex: r.roomIndex, roomId: r.room.id, isDeadEnd: true, isCorner: candidate.isCorner, alignedWall: candidate.alignedWall, }) ) { shrinesSpawned += 1 usedRoomsForMajorObject.add(r.room.id) } } // If more shrines are needed, place them in regular room corners if (shrinesSpawned < shrineTarget) { for (const r of roomSpots) { if (shrinesSpawned >= shrineTarget) break if (usedRoomsForMajorObject.has(r.room.id)) continue const cornerSpot = r.spots.find(s => s.isCorner && isSubtileClear(s.cellX * SUB_TILES_PER_TILE + 2, s.cellY * SUB_TILES_PER_TILE + 2, SUB_TILES_PER_TILE * 2)) if (!cornerSpot) continue const shrineDef = CANONICAL_SHRINES[popRng.int(0, CANONICAL_SHRINES.length - 1)]! const shrineId = shrineDef.idsByAct[act] ?? shrineDef.idsByAct[1]! if ( placeObject(cornerSpot.cellX, cornerSpot.cellY, shrineId, 'shrine', shrineDef.subType, { roomIndex: r.roomIndex, roomId: r.room.id, isDeadEnd: r.room.orths.length === 1, isCorner: true, alignedWall: cornerSpot.alignedWall, }) ) { shrinesSpawned += 1 usedRoomsForMajorObject.add(r.room.id) } } } } // B. Place Super/Gold Chests in remaining dead-end rooms and special treasure rooms if (chestDensity > 0) { for (const r of roomSpots) { const isDeadEnd = r.room.orths.length === 1 const isTreasureRoom = r.room.piece?.kind === 'treasure' || /Treasure|Chest/i.test(r.room.piece?.name ?? '') if (!isDeadEnd && !isTreasureRoom) continue if (usedRoomsForMajorObject.has(r.room.id) && !isTreasureRoom) continue // In dead-ends, place a Super Chest const candidate = r.spots.find(s => !s.alignedWall) ?? r.spots[Math.floor(r.spots.length / 2)] if (!candidate) continue const superChestId = chestPool.super[popRng.int(0, chestPool.super.length - 1)]! if ( placeObject(candidate.cellX, candidate.cellY, superChestId, 'chest', 'super_chest', { roomIndex: r.roomIndex, roomId: r.room.id, isDeadEnd, isCorner: candidate.isCorner, alignedWall: candidate.alignedWall, }) ) { chestsSpawned += 1 superChestsSpawned += 1 usedRoomsForMajorObject.add(r.room.id) // For special clusters / treasure rooms, add 1..2 surrounding regular chests const clusterCount = isTreasureRoom ? popRng.int(2, 3) : popRng.chance(0.5) ? 1 : 0 const secondarySpots = r.spots.filter( s => (s.cellX !== candidate.cellX || s.cellY !== candidate.cellY) && Math.hypot(s.cellX - candidate.cellX, s.cellY - candidate.cellY) <= 3, ) for (let c = 0; c < clusterCount && c < secondarySpots.length; c += 1) { const sec = secondarySpots[c]! const regChestId = chestPool.normal[popRng.int(0, chestPool.normal.length - 1)]! if ( placeObject(sec.cellX, sec.cellY, regChestId, 'chest', 'chest', { roomIndex: r.roomIndex, roomId: r.room.id, isDeadEnd, isCorner: sec.isCorner, alignedWall: sec.alignedWall, }) ) { chestsSpawned += 1 } } } } } // 3. Weapon & Armor Racks: placed against walls in corridors (`degree === 2`), barracks, or chambers if (rackDensity > 0) { const isBarracks = /Barracks|Jail/i.test(levelTypeName) const baseRackTarget = Math.max(1, Math.min(4, Math.round((rooms.length / 5) * rackDensity * (isBarracks ? 1.5 : 1.0)))) const rackTarget = baseRackTarget // Prefer corridor rooms (`degree === 2`) or barracks const corridors = roomSpots.filter(r => r.room.orths.length === 2 || isBarracks) const candidateRoomsForRacks = corridors.length > 0 ? corridors : roomSpots.filter(r => r.room.orths.length <= 2) for (const r of candidateRoomsForRacks) { if (racksSpawned >= rackTarget) break // Find spots directly adjacent to a wall const wallSpots = r.spots.filter(s => s.alignedWall !== null) if (wallSpots.length === 0) continue const chosenSpot = wallSpots[popRng.int(0, wallSpots.length - 1)]! const isArmor = popRng.chance(0.5) const subType: RackSubType = isArmor ? 'armor_rack' : 'weapon_rack' let rackId: number const wall = chosenSpot.alignedWall if (isArmor) { if (wall === 'west') rackId = rackPool.armor.west[popRng.int(0, rackPool.armor.west.length - 1)]! else if (wall === 'north') rackId = rackPool.armor.north[popRng.int(0, rackPool.armor.north.length - 1)]! else rackId = rackPool.armor.any[popRng.int(0, rackPool.armor.any.length - 1)]! } else { if (wall === 'west') rackId = rackPool.weapon.west[popRng.int(0, rackPool.weapon.west.length - 1)]! else if (wall === 'north') rackId = rackPool.weapon.north[popRng.int(0, rackPool.weapon.north.length - 1)]! else rackId = rackPool.weapon.any[popRng.int(0, rackPool.weapon.any.length - 1)]! } if ( placeObject(chosenSpot.cellX, chosenSpot.cellY, rackId, 'rack', subType, { roomIndex: r.roomIndex, roomId: r.room.id, isDeadEnd: r.room.orths.length === 1, isCorner: chosenSpot.isCorner, alignedWall: chosenSpot.alignedWall, }) ) { racksSpawned += 1 } } } // 4. Breakable Containers (barrels, urns, coffins, caskets, crates, tombs) if (containerDensity > 0) { const isCryptOrTomb = /Crypt|Catacombs|Tomb/i.test(levelTypeName) const isSewerOrJail = /Sewer|Jail|Barracks/i.test(levelTypeName) // Average 1..2 clusters per room according to containerDensity const clusterTarget = Math.max(1, Math.round(rooms.length * 1.2 * containerDensity)) for (let cl = 0; cl < clusterTarget; cl += 1) { const r = roomSpots[popRng.int(0, roomSpots.length - 1)]! // Prefer corner spots or wall-aligned spots for clusters const preferredSpots = r.spots.filter(s => s.isCorner || s.alignedWall !== null) const anchorSpot = preferredSpots.length > 0 ? preferredSpots[popRng.int(0, preferredSpots.length - 1)]! : r.spots[popRng.int(0, r.spots.length - 1)] if (!anchorSpot) continue // Determine container type for this cluster let chosenType: ContainerSubType = 'barrel' let idPool = containerPool.barrels if (isCryptOrTomb) { const roll = popRng.next() if (roll < 0.4) { chosenType = 'casket' idPool = containerPool.caskets } else if (roll < 0.8) { chosenType = 'urn' idPool = containerPool.urns } else { chosenType = 'barrel' idPool = containerPool.barrels } } else if (isSewerOrJail) { const roll = popRng.next() if (roll < 0.3) { chosenType = 'crate' idPool = containerPool.crates } else if (roll < 0.7) { chosenType = 'barrel' idPool = containerPool.barrels } else { chosenType = 'urn' idPool = containerPool.urns } } // Cluster size: 2 to 4 items const clusterSize = popRng.int(2, 4) const nearbySpots = r.spots.filter( s => Math.hypot(s.cellX - anchorSpot.cellX, s.cellY - anchorSpot.cellY) <= 2, ) for (let ci = 0; ci < clusterSize && ci < nearbySpots.length; ci += 1) { const spot = nearbySpots[ci]! const objId = idPool[popRng.int(0, idPool.length - 1)]! if ( placeObject(spot.cellX, spot.cellY, objId, 'container', chosenType, { roomIndex: r.roomIndex, roomId: r.room.id, isDeadEnd: r.room.orths.length === 1, isCorner: spot.isCorner, alignedWall: spot.alignedWall, }) ) { containersSpawned += 1 } } } } return { placedObjects, stats: { dynamicObjectsCount: placedObjects.length, shrinesSpawned, chestsSpawned, superChestsSpawned, racksSpawned, containersSpawned, }, } }