873 lines
31 KiB
TypeScript
873 lines
31 KiB
TypeScript
/**
|
|
* 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<number, number>
|
|
}
|
|
|
|
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<number, { super: readonly number[]; normal: readonly number[] }> = {
|
|
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<number>()
|
|
|
|
// 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 candidateRoomsForRacks = roomSpots.filter(
|
|
r => r.room.orths.length === 2 || isBarracks || 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,
|
|
},
|
|
}
|
|
}
|