2772 lines
109 KiB
TypeScript
2772 lines
109 KiB
TypeScript
/**
|
||
* Diablo II's random-maze level generator (`Levels.txt.DrlgType == 1`).
|
||
*
|
||
* 70 of the game's 136 levels have no fixed layout: their shape is produced at
|
||
* load time from `LvlMaze.txt` (how many sections, how big each section is, and
|
||
* how eagerly neighbouring sections are joined) plus the level type's
|
||
* `LvlPrest.txt` *piece* rows — each piece a DS1 that knows which of its four
|
||
* sides has a doorway. This module rebuilds that generator and emits a single
|
||
* synthesized {@link Ds1} the existing isometric renderer can draw unmodified.
|
||
*
|
||
* ## Provenance
|
||
*
|
||
* This is an **independent TypeScript port** of the level-generation logic in
|
||
* **ThePhrozenKeep/D2MOO** ("Diablo II Method and Ordinal Overhaul", a C++
|
||
* re-implementation of Diablo II 1.10f). D2MOO ships no licence file, so no code
|
||
* was copied: every algorithm below was read from that project and re-expressed
|
||
* here in this repository's own structure and naming, with the source function
|
||
* cited at each step. The algorithms themselves are Blizzard's, and D2MOO's
|
||
* contribution is having recovered them from the shipped binary.
|
||
*
|
||
* Primary sources, all under `source/D2Common/` in D2MOO:
|
||
*
|
||
* - `src/Drlg/DrlgMaze.cpp` — `DRLGMAZE_GenerateLevel` (the per-level-type
|
||
* dispatch), `DRLGMAZE_AddAdjacentMazeRoom` + `DRLGMAZE_BuildBasicMaze` +
|
||
* `DRLGMAZE_MergeMazeRooms` (placement and the `Merge` roll),
|
||
* `DRLGMAZE_InitBasicMazeLayout` (the ring layout), `DRLGMAZE_PickRoomPreset`
|
||
* (side bitmask → piece), `DRLGMAZE_RollAct_1_2_3_BasicPresets` (the `Theme`
|
||
* pass), `DRLGMAZE_ScanReplaceSpecialPreset` (`Prev`/`Next`/quest replacement),
|
||
* `DRLGMAZE_PlaceArcaneSanctuary`, `DRLGMAZE_PlaceAct5LavaPresets`,
|
||
* `DRLGMAZE_FillBlankMazeSpaces`, `DRLGMAZE_PlaceAct2TombPrev_Act5BaalPrev`.
|
||
* - `src/Drlg/DrlgDrlgRoom.cpp` — `DRLG_ComputeManhattanDistance`,
|
||
* `DRLG_GetRectanglesManhattanDistanceAndCheckNotOverlapping`,
|
||
* `DRLGMAZE_CheckRoomNotOverlaping`, `DRLGROOM_AllocDrlgOrthsForRooms`.
|
||
* - `src/Drlg/DrlgDrlg.cpp` — `DRLG_GetDirectionFromCoordinates`,
|
||
* `DRLG_UpdateRoomExCoordinates`.
|
||
* - `src/Drlg/DrlgPreset.cpp` — `DRLGPRESET_InitPresetRoomGrids` and
|
||
* `DRLGPRESET_AddPresetUnitToDrlgMap`, which fix the stamping geometry and the
|
||
* unit of a DS1 object's coordinates.
|
||
* - `doc/Coordinates.md` — the coordinate systems.
|
||
*
|
||
* ## What D2MOO settles that this repository did not know
|
||
*
|
||
* **`LvlMaze.Merge` is a per-mille probability, not a flag.** Both
|
||
* `DRLGMAZE_AddAdjacentMazeRoom` (inline) and `DRLGMAZE_MergeMazeRooms` contain
|
||
* `if (SEED_RollRandomNumber(&i->pSeed) % 1000 < pLevel->pMaze->dwMerge)` before
|
||
* adding an extra doorway. So while a level grows, every *accidental* adjacency
|
||
* between the room just placed and some other already-placed room (orthogonally
|
||
* touching, not already connected) is joined with probability `Merge / 1000`.
|
||
* `Merge = 0` yields a tree (Act 1's cave treasures), `Merge = 500` joins about
|
||
* half of those contacts, and `Merge = 1000` (Act 2's Harem, Basement and the
|
||
* Arcane Sanctuary) joins all of them. The shipped values are 0, 500, 800 and
|
||
* 1000, which is why a per-mille reading is the only one that fits. The knowledge
|
||
* base's "possibly related to how adjacent ds1s are connected, but what the
|
||
* different values are for is unknown" is answered: it controls how many *loops*
|
||
* the maze has, i.e. how many alternate routes exist between rooms.
|
||
*
|
||
* **Connectivity is structural, not luck.** Growth only ever attaches a new room
|
||
* to an existing one and immediately records a doorway between them
|
||
* (`DRLGROOM_AllocDrlgOrthsForRooms`), so the doorway graph always contains a
|
||
* spanning tree rooted at the first room. `Merge` only ever *adds* doorways.
|
||
* {@link generateMaze} re-derives that with a flood fill and throws if it ever
|
||
* fails, so the guarantee is checked rather than asserted.
|
||
*
|
||
* **A maze piece is exactly one cell larger than its section.** `LvlMaze.SizeX`
|
||
* is the placement stride (24 in Act 1) while the piece DS1 is 25×25 — verified
|
||
* against the shipped archives: `Act1/Caves/CaveW.ds1` is 25×25 with
|
||
* `LvlPrest.SizeX = 24`. `DRLGPRESET_InitPresetRoomGrids` confirms the intent: it
|
||
* fills a room grid of `nTileWidth + 1` by `nTileHeight + 1` cells from the
|
||
* piece, so adjacent pieces overlap by exactly one cell and the later-stamped
|
||
* piece owns the shared border. Both halves of that border are authored to match,
|
||
* so the overwrite is harmless. The repository's assumption was correct.
|
||
*
|
||
* **Objects are in sub-tiles, cells are in cells.**
|
||
* `DRLGPRESET_AddPresetUnitToDrlgMap` converts the room's *tile* origin with
|
||
* `DUNGEON_GameTileToSubtileCoords` (×5) and *then* adds the DS1 object's own
|
||
* coordinates, which are therefore already sub-tiles relative to the piece. A
|
||
* stamp offset of `N` cells shifts an object by `N * 5` sub-tiles, exactly as
|
||
* this repository assumed.
|
||
*/
|
||
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts'
|
||
import { actOfLevel, type D2Table } from './acts.ts'
|
||
import { isWaypointDs1Object } from './object-lookup.ts'
|
||
import { Rng } from './rng.ts'
|
||
import { SUB_TILES_PER_TILE } from './map.ts'
|
||
export { SUB_TILES_PER_TILE }
|
||
import { type SpecialPassName, runMazeSpecialPasses } from './maze-special-passes.ts'
|
||
import {
|
||
populateDungeonObjects,
|
||
type PlacedMazeObject,
|
||
type MazeObjectPopulationOptions,
|
||
type MazeRoomDescriptor,
|
||
} from './maze-objects.ts'
|
||
|
||
export type {
|
||
PlacedMazeObject,
|
||
MazeObjectPopulationOptions,
|
||
MazeObjectCategory,
|
||
ShrineSubType,
|
||
ChestSubType,
|
||
RackSubType,
|
||
ContainerSubType,
|
||
WallAlignment,
|
||
} from './maze-objects.ts'
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Directions
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* Direction indices, in D2MOO's `D2AltDirections` order.
|
||
*
|
||
* `DRLG_GetDirectionFromCoordinates` returns the `D2Directions` *names* but the
|
||
* same numbers, and D2MOO relies on that coincidence: `DIRECTION_SOUTHWEST` is 0
|
||
* and `ALTDIR_WEST` is 0, `DIRECTION_NORTHWEST`/`ALTDIR_NORTH` are 1,
|
||
* `DIRECTION_SOUTHEAST`/`ALTDIR_EAST` are 2 and `DIRECTION_NORTHEAST`/`ALTDIR_SOUTH`
|
||
* are 3. Both are reproduced here as one space, since a maze direction is always
|
||
* an offset on the cell grid.
|
||
*
|
||
* The signs are the ones `doc/Coordinates.md` states: East is +X, West is -X,
|
||
* South is +Y, North is -Y. Note that D2's *names* for the quarter directions do
|
||
* not mean screen directions — `DIRECTION_NORTHEAST` is +Y, i.e. towards the
|
||
* bottom-left of the isometric view. Only the numbers are meaningful.
|
||
*/
|
||
export const DIRECTION_WEST = 0
|
||
/** North is -Y in game coordinates. */
|
||
export const DIRECTION_NORTH = 1
|
||
/** East is +X in game coordinates. */
|
||
export const DIRECTION_EAST = 2
|
||
/** South is +Y in game coordinates. */
|
||
export const DIRECTION_SOUTH = 3
|
||
|
||
/**
|
||
* The side bit `DRLGMAZE_PickRoomPreset` sets for each direction.
|
||
*
|
||
* It ORs 1 for direction 0, 8 for 1, 2 for 2 and 4 for 3, so the resulting 4-bit
|
||
* value is **W=1, E=2, S=4, N=8**. That looks arbitrary until it is compared with
|
||
* `LvlPrest.txt`: side tokens are written with their letters in N,S,E,W order and
|
||
* read as a bitfield most-significant-bit first, so `NS` = 8+4 = 12, `EW` = 2+1 =
|
||
* 3 and `NSEW` = 15. Verified against the real table — `Act 1 - Cave
|
||
* W/E/EW/S/SW/SE/SEW/N/NW/NE/NEW/NS/NSW/NSE/NSEW` are fifteen consecutive rows
|
||
* following the level type's base row, so a piece's row number is `base + mask`.
|
||
*/
|
||
const DIRECTION_SIDE_BIT = [1, 8, 2, 4] as const
|
||
|
||
/**
|
||
* Cell offset for one step in a direction.
|
||
*
|
||
* Directions 4..7 are the diagonals, which only `DRLGMAZE_FillBlankMazeSpaces`
|
||
* uses (it tries all eight). Quarters and diagonals were cross-checked against
|
||
* `DRLGMAZE_LinkMazeRooms`' eight `case`s.
|
||
*
|
||
* @param direction - 0..7.
|
||
* @param width - one section's width in cells.
|
||
* @param height - one section's height in cells.
|
||
* @returns the offset.
|
||
*/
|
||
function step(direction: number, width: number, height: number): { dx: number; dy: number } {
|
||
const dx = (direction === DIRECTION_WEST || direction === 4 || direction === 7 ? -width
|
||
: direction === DIRECTION_EAST || direction === 5 || direction === 6 ? width : 0)
|
||
const dy = (direction === DIRECTION_NORTH || direction === 4 || direction === 5 ? -height
|
||
: direction === DIRECTION_SOUTH || direction === 6 || direction === 7 ? height : 0)
|
||
return { dx, dy }
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Side tokens
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** The four side letters, in the order `LvlPrest.txt` writes them. */
|
||
const SIDE_LETTERS = ['N', 'S', 'E', 'W'] as const
|
||
|
||
/**
|
||
* Decode a side token such as `NSEW`, `EW` or `N` into a bitmask.
|
||
*
|
||
* @param token - the token; characters other than `NSEW` are ignored, so a whole
|
||
* piece name may be passed.
|
||
* @returns the mask, 0..15.
|
||
*/
|
||
export function sideMaskFromToken(token: string): number {
|
||
let mask = 0
|
||
for (const letter of token.toUpperCase()) {
|
||
const index = SIDE_LETTERS.indexOf(letter as (typeof SIDE_LETTERS)[number])
|
||
if (index !== -1) mask |= 1 << (3 - index)
|
||
}
|
||
return mask
|
||
}
|
||
|
||
/**
|
||
* Encode a bitmask the way `LvlPrest.txt` spells it.
|
||
*
|
||
* @param mask - the mask, 0..15.
|
||
* @returns the token, e.g. `NSEW`; an empty string for 0.
|
||
*/
|
||
export function sideTokenFromMask(mask: number): string {
|
||
let token = ''
|
||
SIDE_LETTERS.forEach((letter, index) => {
|
||
if ((mask & (1 << (3 - index))) !== 0) token += letter
|
||
})
|
||
return token
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Public request / response shapes
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** How a maze piece participates in generation. */
|
||
export type MazePieceKind =
|
||
| 'entrance'
|
||
| 'room'
|
||
| 'theme'
|
||
| 'prev'
|
||
| 'next'
|
||
| 'down'
|
||
| 'quest'
|
||
| 'treasure'
|
||
| 'waypoint'
|
||
/**
|
||
* A waypoint room, as reported in `stats.waypoints`.
|
||
*/
|
||
export interface MazeWaypoint {
|
||
/** The room the piece went into, matching `specialsApplied`. */
|
||
readonly room: number
|
||
/** The room's top-left corner in the final map, in cells. */
|
||
readonly x: number
|
||
readonly y: number
|
||
/** The room's extent, in cells. */
|
||
readonly width: number
|
||
readonly height: number
|
||
/** The room's centre, where the player lands or clicks. */
|
||
readonly centreX: number
|
||
readonly centreY: number
|
||
/** Sub-tile coordinates of the waypoint entity within the synthesized map. */
|
||
readonly entityX: number
|
||
readonly entityY: number
|
||
}
|
||
|
||
/**
|
||
* One `LvlPrest.txt` row that can be stamped into a maze level.
|
||
*
|
||
* The caller owns the classification, because it is the only party that knows
|
||
* which DS1 files sit behind each row. {@link classifyMazePieceName} does that
|
||
* classification for callers that want it, since the trailing-token grammar is
|
||
* subtle enough to be worth centralising.
|
||
*/
|
||
export interface MazePiece {
|
||
/** `LvlPrest.txt` `Name`, e.g. `Act 1 - Cave NSEW`. */
|
||
readonly name: string
|
||
/** Which generator pass may place this piece. */
|
||
readonly kind: MazePieceKind
|
||
/**
|
||
* The side token decoded from the name — `NSEW`, `EW`, `N`, or `''` for a piece
|
||
* with no doorways (`Entrance`).
|
||
*/
|
||
readonly sides: string
|
||
/**
|
||
* Decoded variants, in `File1..File6` order.
|
||
*
|
||
* A piece usually has several: `Act 1 - Cave W` ships `CaveW.ds1` and
|
||
* `CaveW2.ds1`. D2MOO walks them round-robin per piece so consecutive rooms of
|
||
* the same shape do not look identical (`DRLGMAZE_RollBasicPresets`), and
|
||
* {@link generateMaze} does the same.
|
||
*/
|
||
readonly levels: readonly Ds1[]
|
||
/**
|
||
* `LvlPrest.txt` `KillEdge`.
|
||
*
|
||
* 1.13c reads this off the maze map's `LvlPrest` record and, when set, trims
|
||
* the one-cell overhang off whichever room sits on the map's outer edge — see
|
||
* {@link stamp} and `DRLGPRESET_InitPresetRoomGrids`
|
||
* (`DrlgPreset.cpp:1105`). The shipped table sets it per *family*, not per
|
||
* row: `Act 1 - Cave`, `Act 2 - Sewer`, `Act 2 - Arcane`, `Act 4 - Mesa`,
|
||
* `Act 5 - Temple` and `Act 5 - Ice` are all `1`, while `Act 1 - Crypt`,
|
||
* `Barracks`, `Jail`, `Catacombs`, `Act 2 - Basement`, `Act 2 - Tomb` and
|
||
* `Act 3 - Sewer` are all `0`.
|
||
*
|
||
* Optional so that callers which do not read the column keep the pre-1.13c
|
||
* behaviour of never trimming.
|
||
*/
|
||
readonly killEdge?: boolean
|
||
/**
|
||
* Animated tile playback speed from `LvlPrest.txt` `Animate` column.
|
||
* In 1.13c, 0 defaults to 80 (128ms per frame at 25Hz).
|
||
*/
|
||
readonly animSpeed?: number
|
||
}
|
||
|
||
/** Everything {@link generateMaze} needs. */
|
||
export interface MazeRequest {
|
||
/** `Levels.txt` `Id`; used in diagnostics and for a few per-level specials. */
|
||
readonly levelId: number
|
||
/** `Levels.txt` `Name`, e.g. `Act 1 - Cave 1`. */
|
||
readonly levelName: string
|
||
/** `LvlMaze.txt` `SizeX`: the section stride in cells. */
|
||
readonly sectionSize: number
|
||
/**
|
||
* `LvlMaze.txt` `SizeY`.
|
||
*
|
||
* Defaults to {@link sectionSize}. It is a separate field because the two
|
||
* genuinely differ for Act 1's Barracks (`SizeX = 10`, `SizeY = 14`), and a
|
||
* single number would silently produce a wrong layout there.
|
||
*/
|
||
readonly sectionHeight?: number
|
||
/** `LvlMaze.txt` `Rooms`: the target number of sections. */
|
||
readonly minRooms: number
|
||
/**
|
||
* `LvlMaze.txt` `Merge`, in per-mille.
|
||
*
|
||
* Values in the shipped table are 0, 500, 800 and 1000.
|
||
*/
|
||
readonly merge: number
|
||
/** Seed for the whole generator; reported back in `stats`. */
|
||
readonly seed: number
|
||
/** The level type's pieces, classified by kind and side token. */
|
||
readonly pieces: readonly MazePiece[]
|
||
/**
|
||
* Overrides the `KillEdge` value derived from {@link pieces}.
|
||
*
|
||
* 1.13c takes the flag from the maze map's own `LvlPrest` record
|
||
* (`pMazeMap->pLvlPrestTxtRecord->dwKillEdge`), which in the shipped table
|
||
* always agrees with the level type's piece rows. When this is omitted the
|
||
* value is derived from the pieces instead, so a caller that already parsed
|
||
* `LvlPrest.txt` needs to do nothing extra.
|
||
*/
|
||
readonly killEdge?: boolean
|
||
|
||
/**
|
||
* `LvlTypes.txt` name, e.g. `Act 1 - Cave`.
|
||
*
|
||
* Optional because it is derivable from {@link pieces} — every piece name
|
||
* starts with it — and the derivation is attempted when it is absent.
|
||
*/
|
||
readonly levelTypeName?: string
|
||
/**
|
||
* `Levels.txt` `Id` of the tomb holding the Horadric Staff, when the caller
|
||
* knows it. In the shipped game this is one of the seven Tal Rasha tombs and
|
||
* that tomb's `LvlMaze.Rooms` is tripled.
|
||
*/
|
||
readonly staffTombLevelId?: number
|
||
/** Boss tomb (`LvlMaze.Rooms` doubled); see {@link staffTombLevelId}. */
|
||
readonly bossTombLevelId?: number
|
||
/**
|
||
* Explicit override for waypoint placement.
|
||
* If true, forces a waypoint room to be placed (using waypoint pieces).
|
||
* If false, skips waypoint room placement even if the level profile includes it.
|
||
* If omitted, defaults to whether the level id has a waypoint in the profile.
|
||
*/
|
||
readonly hasWaypoint?: boolean
|
||
/**
|
||
* Maximum allowed width of the entire maze in cells (`Levels.txt` `SizeX`).
|
||
* When specified, any room placement that would expand the overall maze bounding box
|
||
* beyond this width will be rejected.
|
||
*/
|
||
readonly maxCellsX?: number | undefined
|
||
/**
|
||
* Maximum allowed height of the entire maze in cells (`Levels.txt` `SizeY`).
|
||
* When specified, any room placement that would expand the overall maze bounding box
|
||
* beyond this height will be rejected.
|
||
*/
|
||
readonly maxCellsY?: number | undefined
|
||
/**
|
||
* Maximum allowed sections along X axis.
|
||
* If provided and `maxCellsX` is not specified, `maxCellsX` is computed as `maxSectionsX * sectionSize`.
|
||
*/
|
||
readonly maxSectionsX?: number | undefined
|
||
/**
|
||
* Maximum allowed sections along Y axis.
|
||
* If provided and `maxCellsY` is not specified, `maxCellsY` is computed as `maxSectionsY * (sectionHeight ?? sectionSize)`.
|
||
*/
|
||
readonly maxSectionsY?: number | undefined
|
||
/** Dynamic dungeon object seeding options. */
|
||
readonly objectOptions?: MazeObjectPopulationOptions
|
||
/** Configurable object density multiplier or options. */
|
||
readonly objectDensity?: number | MazeObjectPopulationOptions
|
||
}
|
||
|
||
/** The generated level. */
|
||
export interface MazeResult {
|
||
/** A single synthesized map covering every placed section. */
|
||
readonly level: Ds1
|
||
/** Dynamically seeded interactive objects (shrines, chests, racks, breakables). */
|
||
readonly objects: readonly PlacedMazeObject[]
|
||
/** What the generator did, for reporting and tests. */
|
||
readonly stats: Record<string, unknown>
|
||
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */
|
||
readonly animSpeed?: number
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Classification
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** Roles whose names are spelled `<role> <sides>` after the level type. */
|
||
const KIND_PREFIXES: readonly (readonly [string, MazePieceKind])[] = [
|
||
['Den Of Evil', 'quest'],
|
||
['Coldcrow', 'quest'],
|
||
['Pitspawn', 'quest'],
|
||
['Bonebreak', 'quest'],
|
||
['Chest', 'treasure'],
|
||
['Treasure', 'treasure'],
|
||
['Cath', 'down'],
|
||
['Drain', 'next'],
|
||
['Warp', 'prev'],
|
||
['Down', 'down'],
|
||
['Next', 'next'],
|
||
['Prev', 'prev'],
|
||
['Theme', 'theme'],
|
||
['Waypoint', 'waypoint'],
|
||
['waypoint', 'waypoint'],
|
||
['Talrasha', 'quest'],
|
||
["Radament's Lair", 'quest'],
|
||
['Radament', 'quest'],
|
||
['Tight Spot', 'quest'],
|
||
['Forge', 'quest'],
|
||
['Court Connect', 'next'],
|
||
['Bridge', 'next'],
|
||
['Complex', 'quest'],
|
||
['Final Room', 'quest'],
|
||
['Cube', 'treasure'],
|
||
['Leatherarm', 'quest'],
|
||
['Kaa', 'quest'],
|
||
]
|
||
|
||
/**
|
||
* Relationship between Levels.txt, LvlTypes.txt, and LvlPrest.txt:
|
||
*
|
||
* In Diablo II's data architecture, map pieces and environments are resolved
|
||
* across three interconnected tables:
|
||
*
|
||
* 1. `Levels.txt` contains level declarations. Each row has an `Id` (1..136) and
|
||
* a `LevelType` foreign key pointing to `LvlTypes.txt` (or 0 for preset-only
|
||
* levels like towns).
|
||
* 2. `LvlTypes.txt` defines visual and audio themes (e.g. `Act 1 - Cave`,
|
||
* `Act 1 - Catacombs`, `Act 2 - Harem`, `Act 3 - Kurast`). It specifies up to
|
||
* 32 DT1 tile libraries (`File 1` .. `File 32`) containing the graphic art.
|
||
* 3. `LvlPrest.txt` catalogs the authored DS1 map presets and maze room pieces.
|
||
* Each row contains `Def` (the preset ID), `Name`, `LevelId`, `Dt1Mask`,
|
||
* `SizeX`, `SizeY`, and up to 6 DS1 file paths (`File1` .. `File6`).
|
||
*
|
||
* ### Why Catacombs / Sewers index pieces via `Def` / `LevelId = 0` / first `LevelId`:
|
||
*
|
||
* - Preset levels (like Towns, Tristram, Countess Tower) have fixed layouts and
|
||
* their rows in `LvlPrest.txt` set `LevelId` to their exact `Levels.txt.Id`
|
||
* (e.g., Rogue Encampment has `LevelId = 1`, Lut Gholein has `LevelId = 40`).
|
||
* - Maze levels (Catacombs 1-4 [Levels 32-35], Sewers Act 2 [Levels 41-43],
|
||
* Sewers Act 3 [Levels 92-93], Caves 1-2 [Levels 2-3]), in contrast, are generated
|
||
* dynamically by stitching dozens of modular 25x25 cell DS1 rooms (with 1..4 doors,
|
||
* stairs up/down, waypoints, quest rooms, themed variants).
|
||
* - Blizzard North did not duplicate 30+ room rows for each level layer in
|
||
* `LvlPrest.txt`. Instead:
|
||
* - Room pieces are shared across all levels sharing the theme, stored with
|
||
* `LevelId = 0` or set to the first `LevelId` where that theme appears (e.g. 32
|
||
* for Catacombs).
|
||
* - In D2MOO and the native Blizzard engine (`DRLGMAZE_PickRoomPreset`,
|
||
* `DRLGMAZE_RollBasicPresets`, `DRLGMAZE_ScanReplaceSpecialPreset`), pieces
|
||
* are indexed either by hardcoded `Def` base offsets (`base + door_bitmask`)
|
||
* or discovered by prefix matching against the piece's `Name` column.
|
||
*
|
||
* ### Why composite themes have sub-families:
|
||
*
|
||
* Certain levels share a primary tile theme (`LevelType`) but introduce distinct
|
||
* visual zones, boss chambers, or quest rooms authored under different `Name`
|
||
* prefixes in `LvlPrest.txt`:
|
||
* - `Act 2 - Harem`: Combines normal palace rooms (`Act 2 - Harem ...`) with
|
||
* corrupted harem cellar and basement pieces (`Act 2 - Corrupt Harem ...`).
|
||
* - `Act 3 - Kurast`: Standard Kurast dungeon rooms (`Act 3 - Kurast ...`) are
|
||
* supplemented in Durance of Hate Level 3 with Mephisto's sanctum pieces
|
||
* (`Act 3 - Mephisto ...`).
|
||
* - `Act 3 - Dungeon`: Jungle dungeon rooms (`Act 3 - Dungeon ...`) incorporate
|
||
* temple quest chambers (`Act 3 - Temple ...`).
|
||
* - `Act 4 - Lava`: River of Flame and Chaos Sanctuary lava island pieces
|
||
* (`Act 4 - Lava ...`) are linked by preset bridge pieces (`Act 4 - Bridge ...`).
|
||
* - `Act 5 - Ice Caves`: Contains both full-name (`Act 5 - Ice Caves ...`) and
|
||
* abbreviated (`Act 5 - Ice ...`) piece definitions in `LvlPrest.txt`.
|
||
* - `Act 5 - Baal`: Worldstone Keep pieces (`Act 5 - Baal ...`) terminate in the
|
||
* Throne Room of Destruction (`Act 5 - ThroneRoom ...`).
|
||
*
|
||
* Because `LvlPrest.txt` lacks a `LevelType` column, these sub-families cannot be
|
||
* joined by database foreign key alone; they reflect Blizzard's C++ DRLG engine
|
||
* conventions and are captured canonically in {@link MAZE_PIECE_FAMILIES}.
|
||
*/
|
||
export const MAZE_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||
'Act 1 - Cave': ['Act 1 - Cave'],
|
||
'Act 1 - Crypt': ['Act 1 - Crypt'],
|
||
'Act 1 - Courtyard': ['Act 1 - Courtyard'],
|
||
'Act 1 - Barracks': ['Act 1 - Barracks'],
|
||
'Act 1 - Jail': ['Act 1 - Jail'],
|
||
'Act 1 - Catacombs': ['Act 1 - Catacombs'],
|
||
'Act 2 - Sewer': ['Act 2 - Sewer'],
|
||
'Act 2 - Harem': ['Act 2 - Corrupt Harem', 'Act 2 - Harem'],
|
||
'Act 2 - Basement': ['Act 2 - Basement'],
|
||
'Act 2 - Tomb': ['Act 2 - Tomb'],
|
||
'Act 2 - Lair': ['Act 2 - Lair'],
|
||
'Act 2 - Sanctuary': ['Act 2 - Sanctuary'],
|
||
'Act 3 - Sewer': ['Act 3 - Sewer'],
|
||
'Act 3 - Kurast': ['Act 3 - Mephisto', 'Act 3 - Kurast'],
|
||
'Act 3 - Dungeon': ['Act 3 - Dungeon', 'Act 3 - Temple'],
|
||
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Bridge'],
|
||
'Act 5 - Ice Caves': ['Act 5 - Ice Caves', 'Act 5 - Ice'],
|
||
'Act 5 - Temple': ['Act 5 - Temple'],
|
||
'Act 5 - Baal': ['Act 5 - Baal', 'Act 5 - ThroneRoom'],
|
||
}
|
||
|
||
/**
|
||
* Discover the `LvlPrest` name families belonging to a maze level type.
|
||
*
|
||
* If `lvlPrestTable` is supplied, validates and discovers family prefixes present
|
||
* in the table that correspond to the given `levelTypeName`.
|
||
* If `lvlPrestTable` is omitted or contains no matching family rows, falls back to
|
||
* {@link MAZE_PIECE_FAMILIES}.
|
||
*
|
||
* @param levelTypeName - the owning `LvlTypes.txt` name (e.g. `'Act 2 - Harem'`).
|
||
* @param lvlPrestTable - optional loaded `LvlPrest.txt` table.
|
||
* @returns list of family name prefixes in precedence order (longer/more specific prefixes first).
|
||
*/
|
||
export function discoverMazePieceFamilies(
|
||
levelTypeName: string,
|
||
lvlPrestTable?: D2Table,
|
||
): readonly string[] {
|
||
const staticFamilies = MAZE_PIECE_FAMILIES[levelTypeName] ?? [levelTypeName]
|
||
if (lvlPrestTable === undefined) {
|
||
return staticFamilies
|
||
}
|
||
|
||
const nameCol = lvlPrestTable.header.indexOf('Name')
|
||
if (nameCol === -1) {
|
||
return staticFamilies
|
||
}
|
||
|
||
const discovered = new Set<string>()
|
||
for (const fam of staticFamilies) {
|
||
const lowerFam = fam.toLowerCase()
|
||
for (const row of lvlPrestTable.rows) {
|
||
const rowName = (row[nameCol] ?? '').trim().toLowerCase()
|
||
if (rowName.startsWith(lowerFam)) {
|
||
discovered.add(fam)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if (discovered.size === 0) {
|
||
return staticFamilies
|
||
}
|
||
|
||
const result = staticFamilies.filter(fam => discovered.has(fam))
|
||
return result.length > 0 ? result : staticFamilies
|
||
}
|
||
|
||
/**
|
||
* Classify one `LvlPrest.txt` piece name.
|
||
*
|
||
* The grammar is `<level type name> [<role>] [<sides>]`: `Act 1 - Cave NSEW` is a
|
||
* plain room, `Act 1 - Cave Theme NS` a themed variant, `Act 1 - Cave Prev W` a
|
||
* staircase, `Act 1 - Cave Treasure 3` a numbered treasure room. A name that does
|
||
* not start with the level type's name is not a piece of that type and yields
|
||
* `null`, which is how `Act 1 - DOE Entrance` stays out of the `Act 1 - Cave`
|
||
* group even though D2MOO treats its row as that group's base.
|
||
*
|
||
* @param name - the `LvlPrest.txt` `Name`.
|
||
* @param levelTypeName - the owning `LvlTypes.txt` name.
|
||
* @param lvlPrestTable - optional loaded `LvlPrest.txt` table for dynamic family discovery.
|
||
* @returns the kind and side token, or `null` when the name is not a piece.
|
||
*/
|
||
export function classifyMazePieceName(
|
||
name: string,
|
||
levelTypeName: string,
|
||
lvlPrestTable?: D2Table,
|
||
): { kind: MazePieceKind; sides: string } | null {
|
||
const families = discoverMazePieceFamilies(levelTypeName, lvlPrestTable)
|
||
const matchedFamily = families.find(family => name.toLowerCase().startsWith(family.toLowerCase()))
|
||
if (matchedFamily === undefined) return null
|
||
const rest = name.slice(matchedFamily.length).trim()
|
||
if (rest === '' || rest.toLowerCase() === 'entrance') {
|
||
if (matchedFamily.toLowerCase().endsWith('throneroom')) return { kind: 'quest', sides: '' }
|
||
return { kind: 'entrance', sides: '' }
|
||
}
|
||
for (const [prefix, kind] of KIND_PREFIXES) {
|
||
if (rest.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||
const tail = rest.slice(prefix.length).trim()
|
||
// `Treasure 1`, `Temple 1`, `Bridge 1` are numbered rather than sided; everything else uses sides.
|
||
if ((kind === 'treasure' || kind === 'quest' || kind === 'next') && /^\d+$/.test(tail)) {
|
||
return { kind, sides: '' }
|
||
}
|
||
return { kind, sides: tail }
|
||
}
|
||
}
|
||
if (/^\d+$/.test(rest)) {
|
||
if (matchedFamily.toLowerCase().endsWith('bridge')) return { kind: 'next', sides: '' }
|
||
if (matchedFamily.toLowerCase().endsWith('temple')) return { kind: 'quest', sides: '' }
|
||
if (matchedFamily.toLowerCase().endsWith('treasure')) return { kind: 'treasure', sides: '' }
|
||
}
|
||
const reversedMatch = /^([NSEW]+)\s+(up|down|waypoint)$/i.exec(rest)
|
||
if (reversedMatch !== null) {
|
||
const sides = reversedMatch[1]!.toUpperCase()
|
||
const role = reversedMatch[2]!.toLowerCase()
|
||
return { kind: role === 'up' ? 'prev' : role === 'down' ? 'next' : 'waypoint', sides }
|
||
}
|
||
// Anything left is a bare side token: `W`, `EW`, `NSEW`, or `X` (blank filler)
|
||
if (/^([NSEW]+|X)$/i.test(rest)) return { kind: 'room', sides: rest.toUpperCase() === 'X' ? '' : rest.toUpperCase() }
|
||
if (/^(River|Pool|Tainted Sun)/i.test(rest)) return { kind: 'room', sides: '' }
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Derive a level type's name from its pieces.
|
||
*
|
||
* Used when {@link MazeRequest.levelTypeName} is omitted: the name is the longest
|
||
* prefix shared by every piece name, trimmed back to a word boundary, because
|
||
* `Act 1 - Cave NSEW` and `Act 1 - Cave Theme W` agree on `Act 1 - Cave ` and the
|
||
* trailing space is the boundary.
|
||
*
|
||
* @param pieces - the level's pieces.
|
||
* @returns the inferred name, or `null` when the pieces disagree.
|
||
*/
|
||
export function inferLevelTypeName(pieces: readonly MazePiece[]): string | null {
|
||
const first = pieces[0]
|
||
if (first === undefined) return null
|
||
let prefix = first.name
|
||
for (const piece of pieces) {
|
||
while (prefix.length > 0 && !piece.name.startsWith(prefix)) prefix = prefix.slice(0, -1)
|
||
}
|
||
const trimmed = prefix.replace(/[\s-]+$/, '')
|
||
if (trimmed === '') return null
|
||
if (trimmed === 'Act 5 - Ice') return 'Act 5 - Ice Caves'
|
||
return trimmed
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Level-type profiles
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* One `DRLGMAZE_ScanReplaceSpecialPreset` call site.
|
||
*
|
||
* That function has two modes and both are reproduced: it first looks for the
|
||
* earliest room still holding the *plain* piece for this side pattern and swaps
|
||
* the piece in place; only if no such room exists does it grow a brand-new room
|
||
* off the first available room in `direction` and put the special piece there.
|
||
*
|
||
* `DRLGMAZE_ReplaceRoomPreset`, used by Act 3's Sewer 1, is the in-place-only
|
||
* variant and is modelled with {@link inPlaceOnly}.
|
||
*/
|
||
interface SpecialStep {
|
||
/** Which kind of special piece to place. */
|
||
readonly kind: 'prev' | 'next' | 'down' | 'quest' | 'treasure' | 'waypoint'
|
||
/** Side token of the plain room this step replaces, e.g. `N`. */
|
||
readonly plainSides: string
|
||
/** Side token of the special piece. */
|
||
readonly sides: string
|
||
/** Direction used when a new room must be created (`nDirection`). */
|
||
readonly direction: number
|
||
/** Only replace an existing room; never grow one (D2MOO's `ReplaceRoomPreset`). */
|
||
readonly inPlaceOnly?: boolean
|
||
}
|
||
|
||
/**
|
||
* A group of four candidate steps selected by one rotating counter.
|
||
*
|
||
* D2MOO draws `nRand = SEED_RollRandomNumber(&pLevel->pSeed) & 3` once and then
|
||
* indexes each hardcoded table with it, incrementing the counter after every
|
||
* call that actually happens. So the *n*-th group applied to a level uses entry
|
||
* `(nRand + n) % 4`, not the first entry. Modelling the tables as groups with a
|
||
* shared cursor reproduces that exactly; flattening them into one ordered list
|
||
* would not.
|
||
*/
|
||
interface SpecialGroup {
|
||
readonly kind: SpecialStep['kind']
|
||
/** Four entries, in D2MOO's table order (N, E, S, W). */
|
||
readonly entries: readonly Omit<SpecialStep, 'kind'>[]
|
||
/** Restrict the group to these `Levels.txt` ids; absent means every level. */
|
||
readonly levelIds?: readonly number[]
|
||
/** Apply to every level *except* these ids (D2MOO's `else` arms). */
|
||
readonly exceptLevelIds?: readonly number[]
|
||
/** Only replace; never grow (D2MOO's `ReplaceRoomPreset`). */
|
||
readonly inPlaceOnly?: boolean
|
||
}
|
||
|
||
/**
|
||
* How one maze level type is laid out.
|
||
*
|
||
* D2MOO's `DRLGMAZE_GenerateLevel` `switch (pLevel->nLevelType)` turned into
|
||
* data. Where that function has both a `while (pLevel->nRooms < nRooms)` loop and
|
||
* a `DRLGMAZE_BuildBasicMaze` call, the two are the same growth primitive spelled
|
||
* twice — see {@link growMaze} — so both are represented by `grow: true`.
|
||
*/
|
||
export interface MazeLevelProfile {
|
||
/** `LvlTypes.txt` name this profile applies to. */
|
||
readonly levelTypeName: string
|
||
/**
|
||
* `DRLGMAZE_InitBasicMazeLayout(nRoomsPerDirection)`: pre-place a closed ring of
|
||
* `4n - 4` rooms, then grow from it. Act 3's Sewer 1 overrides this with 5.
|
||
*/
|
||
readonly ring?: number
|
||
/** Grow to `LvlMaze.Rooms` sections with the random-adjacency loop. */
|
||
readonly grow: boolean
|
||
/**
|
||
* `DRLGMAZE_RollAct_1_2_3_BasicPresets`: convert up to
|
||
* `max(rooms / 5 + 1, 2)` plain rooms to their `Theme` variant.
|
||
*/
|
||
readonly theme: boolean
|
||
/** `'catacombs'`: the Act 1 Catacombs opening moves. */
|
||
readonly opening?: 'catacombs'
|
||
/** Chain of three rooms plus a `Prev` piece on the entrance. */
|
||
readonly prevChain?: 'tomb' | 'baal'
|
||
/** `DRLGMAZE_PlaceArcaneSanctuary`: four 15-room spiral branches. */
|
||
readonly arcane?: boolean
|
||
/** `DRLGMAZE_PlaceAct5LavaPresets`: two fixed pieces plus blank filling. */
|
||
readonly lavaPairs?: boolean
|
||
/**
|
||
* Levels that are a single fixed `LvlPrest.txt` row rather than a maze, with
|
||
* the exact names to try, in order. Used for the three Act 5 ice set pieces and
|
||
* Claw Viper Temple level 2.
|
||
*/
|
||
readonly singleRoom?: Readonly<Record<number, readonly string[]>>
|
||
/** `Prev`/`Next`/quest/treasure replacement groups, in call order. */
|
||
readonly specials: readonly SpecialGroup[]
|
||
/** Optional official dungeon special pass from D2MOO. */
|
||
readonly specialPass?: SpecialPassName
|
||
/** Animated tile speed override from LvlPrest.txt (default 80 if 0 or omitted). */
|
||
readonly animSpeed?: number
|
||
}
|
||
|
||
/** The four single-side layouts, in the order the D2MOO tables list them. */
|
||
const NESW = ['N', 'E', 'S', 'W'] as const
|
||
|
||
/** Direction paired with each of {@link NESW} in the D2MOO tables. */
|
||
const NESW_DIRECTIONS = [DIRECTION_SOUTH, DIRECTION_WEST, DIRECTION_NORTH, DIRECTION_EAST] as const
|
||
|
||
/**
|
||
* Build the four side entries of a D2MOO table.
|
||
*
|
||
* @param plainSides - the four plain side tokens, in table order.
|
||
* @returns the entries.
|
||
*/
|
||
function entries(plainSides: readonly string[] = NESW): Omit<SpecialStep, 'kind'>[] {
|
||
return plainSides.map((sides, index) => ({ plainSides: sides, sides, direction: NESW_DIRECTIONS[index]! }))
|
||
}
|
||
|
||
/**
|
||
* The per-level-type layout table, transcribed from `DRLGMAZE_GenerateLevel`.
|
||
*
|
||
* `specials` covers the replacement tables that live inline in that function.
|
||
* The per-act decoration passes that follow it in D2MOO — `DRLGMAZE_PlaceAct2TombStuff`,
|
||
* `DRLGMAZE_PlaceAct3DungeonStuff`, `DRLGMAZE_PlaceAct5IceStuff` and the rest —
|
||
* live in `maze-special-passes.ts` and are named by a row's `specialPass`; each
|
||
* one that runs is listed in `stats.specialPassesApplied`. A row with an empty
|
||
* `specials` table has no transcribed staircase pass at all, and
|
||
* {@link unimplementedPassesFor} reports that gap in `stats.unimplementedPasses`
|
||
* so the omission is visible rather than silent.
|
||
*/
|
||
export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
||
{
|
||
levelTypeName: 'Act 1 - Cave',
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries() },
|
||
{ kind: 'quest', entries: entries(), levelIds: [8] },
|
||
{ kind: 'down', entries: entries(), exceptLevelIds: [8] },
|
||
{ kind: 'quest', entries: entries(), levelIds: [9] },
|
||
{ kind: 'next', entries: entries(), levelIds: [10] },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 1 - Crypt',
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries() },
|
||
{ kind: 'quest', entries: entries(), levelIds: [18] },
|
||
{ kind: 'treasure', entries: entries(), levelIds: [19] },
|
||
{ kind: 'next', entries: entries(), levelIds: [21, 22, 23, 24] },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 1 - Barracks',
|
||
ring: 2,
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
{ kind: 'next', entries: entries() },
|
||
],
|
||
specialPass: 'DRLGMAZE_PlaceAct1Barracks',
|
||
},
|
||
{
|
||
levelTypeName: 'Act 1 - Jail',
|
||
ring: 2,
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries() },
|
||
{ kind: 'waypoint', entries: entries(), levelIds: [29] },
|
||
{ kind: 'quest', entries: entries(), levelIds: [30] },
|
||
// D2MOO's `else` hangs off the `JAILLEV3` test, so levels 1 and 2 get a
|
||
// `Next` staircase and level 3 gets the Cathedral descent instead.
|
||
{ kind: 'next', entries: entries(), levelIds: [29, 30] },
|
||
{ kind: 'down', entries: entries(), levelIds: [31] },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 1 - Catacombs',
|
||
grow: true,
|
||
theme: true,
|
||
opening: 'catacombs',
|
||
specials: [
|
||
{ kind: 'next', entries: entries() },
|
||
{ kind: 'waypoint', entries: entries(), levelIds: [35] },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 2 - Sewer',
|
||
ring: 2,
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries() },
|
||
{ kind: 'next', entries: entries(), levelIds: [47, 48] },
|
||
],
|
||
specialPass: 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets',
|
||
},
|
||
{
|
||
levelTypeName: 'Act 2 - Tomb',
|
||
grow: true,
|
||
theme: true,
|
||
prevChain: 'tomb',
|
||
singleRoom: { 61: ['Act 2 - Tomb Tainted Sun X'] },
|
||
specials: [
|
||
{ kind: 'next', entries: entries(), levelIds: [55, 56, 57, 58] },
|
||
],
|
||
specialPass: 'DRLGMAZE_PlaceAct2TombStuff',
|
||
},
|
||
{
|
||
levelTypeName: 'Act 2 - Lair',
|
||
ring: 2,
|
||
grow: true,
|
||
theme: false,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries() },
|
||
{ kind: 'next', entries: entries(), levelIds: [62, 63] },
|
||
],
|
||
specialPass: 'DRLGMAZE_PlaceAct2LairStuff',
|
||
},
|
||
{
|
||
levelTypeName: 'Act 2 - Arcane',
|
||
grow: false,
|
||
theme: false,
|
||
arcane: true,
|
||
specials: [{ kind: 'quest', entries: entries() }],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 2 - Harem',
|
||
ring: 2,
|
||
grow: false,
|
||
theme: false,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries(), levelIds: [51] },
|
||
{ kind: 'next', entries: entries(), levelIds: [51] },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 2 - Basement',
|
||
ring: 2,
|
||
grow: false,
|
||
theme: false,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries(), levelIds: [52, 53, 54] },
|
||
{ kind: 'waypoint', entries: entries(), levelIds: [52] },
|
||
{ kind: 'next', entries: entries(), levelIds: [52, 53] },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 3 - Spider',
|
||
ring: 2,
|
||
grow: false,
|
||
theme: false,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries(), levelIds: [84, 85] },
|
||
{ kind: 'treasure', entries: entries(), levelIds: [84, 85] },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 3 - Kurast',
|
||
ring: 2,
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries(), levelIds: [100, 101] },
|
||
{ kind: 'next', entries: entries(), levelIds: [100, 101] },
|
||
],
|
||
specialPass: 'DRLGMAZE_PlaceAct3MephistoStuff',
|
||
},
|
||
{
|
||
levelTypeName: 'Act 3 - Dungeon',
|
||
ring: 2,
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries(), levelIds: [86, 87, 88, 89] },
|
||
{ kind: 'next', entries: entries(), levelIds: [86, 87, 88, 89] },
|
||
],
|
||
specialPass: 'DRLGMAZE_PlaceAct3DungeonStuff',
|
||
},
|
||
{
|
||
levelTypeName: 'Act 3 - Sewer',
|
||
ring: 2,
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
// Sewer 1 runs `InitBasicMazeLayout(5)` and places corner staircases for
|
||
// both surface exits (Kurast Bazaar via NW/NE and Upper Kurast via SW/SE),
|
||
// plus the drain descent to Sewer Level 2.
|
||
{
|
||
kind: 'prev',
|
||
entries: [
|
||
{ plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH },
|
||
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
|
||
{ plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH },
|
||
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
|
||
],
|
||
levelIds: [92],
|
||
inPlaceOnly: true,
|
||
},
|
||
{
|
||
kind: 'prev',
|
||
entries: [
|
||
{ plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH },
|
||
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
|
||
{ plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH },
|
||
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
|
||
],
|
||
levelIds: [92],
|
||
inPlaceOnly: true,
|
||
},
|
||
{
|
||
kind: 'next',
|
||
entries: entries(),
|
||
levelIds: [92],
|
||
},
|
||
],
|
||
specialPass: 'DRLGMAZE_PlaceAct3SewerStuff',
|
||
},
|
||
{
|
||
levelTypeName: 'Act 4 - Lava',
|
||
grow: true,
|
||
theme: false,
|
||
animSpeed: 1,
|
||
specials: [],
|
||
specialPass: 'DRLGMAZE_PlaceAct4Lava',
|
||
},
|
||
{
|
||
levelTypeName: 'Act 5 - Ice Caves',
|
||
ring: 2,
|
||
grow: true,
|
||
theme: false,
|
||
singleRoom: {
|
||
114: ['Act 5 - Ice River A', 'Act 5 - Ice River B'],
|
||
116: ['Act 5 - Ice Pool A'],
|
||
119: ['Act 5 - Ice Pool B'],
|
||
},
|
||
specials: [
|
||
{ kind: 'prev', entries: entries(), levelIds: [113, 115, 118] },
|
||
{ kind: 'next', entries: entries(), levelIds: [113, 115, 118] },
|
||
{ kind: 'down', entries: entries(), levelIds: [113, 115, 118] },
|
||
],
|
||
specialPass: 'DRLGMAZE_PlaceAct5IceStuff',
|
||
},
|
||
{
|
||
levelTypeName: 'Act 5 - Temple',
|
||
ring: 2,
|
||
grow: false,
|
||
theme: false,
|
||
specials: [],
|
||
specialPass: 'DRLGMAZE_PlaceAct5TempleStuff',
|
||
},
|
||
{
|
||
levelTypeName: 'Act 5 - Baal',
|
||
grow: true,
|
||
theme: false,
|
||
prevChain: 'baal',
|
||
specials: [
|
||
{ kind: 'next', entries: entries(), levelIds: [128, 129, 130] },
|
||
],
|
||
specialPass: 'DRLGMAZE_PlaceAct5BaalStuff',
|
||
},
|
||
{
|
||
levelTypeName: 'Act 5 - Lava',
|
||
grow: false,
|
||
theme: false,
|
||
lavaPairs: true,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries(), levelIds: [125, 126, 127] },
|
||
{ kind: 'treasure', entries: entries(), levelIds: [125, 126, 127] },
|
||
],
|
||
},
|
||
]
|
||
|
||
/**
|
||
* `LvlPrest.txt` names a level type needs from outside its own name prefix.
|
||
*
|
||
* Only Act 5's Lava levels do this: `DRLGMAZE_PlaceAct5LavaPresets` fills the
|
||
* blank space around the Infernal Pit's two set pieces with the *Act 4* lava
|
||
* filler row, so a caller that groups pieces strictly by name prefix must fetch
|
||
* these extra rows too.
|
||
*/
|
||
export const MAZE_SHARED_PRESET_NAMES: readonly string[] = ['Act 4 - Lava X']
|
||
|
||
/**
|
||
* The gaps one level type still has, derived from its own profile row.
|
||
*
|
||
* This used to be a module constant hardcoded to `[]` with a comment claiming
|
||
* every pass was implemented, which made `stats.unimplementedPasses` report
|
||
* "no gaps" for a level that stamps no staircase at all. The list is now
|
||
* *measured* from {@link MAZE_LEVEL_TYPE_PROFILES}, so it cannot drift away
|
||
* from the table it describes:
|
||
*
|
||
* - **no profile row** — `generateMaze` cannot build the type at all.
|
||
* - **an empty `specials` table** — the `Prev`/`Next`/quest replacement rows
|
||
* that `DRLGMAZE_GenerateLevel` runs inline have not been transcribed, so the
|
||
* level stamps no staircase room and the packer has to invent a fallback warp
|
||
* (see `scripts/pack-act-assets.ts`). A `singleRoom` type is exempt: it is one
|
||
* fixed `LvlPrest.txt` row whose stairs are part of the artwork.
|
||
*
|
||
* The 11 per-act decoration passes named in D2MOO (`DRLGMAZE_PlaceAct2TombStuff`
|
||
* and the rest) *are* reproduced, in `maze-special-passes.ts`; a type that runs
|
||
* one names it in `stats.specialPassesApplied` instead.
|
||
*
|
||
* @param levelTypeName - the `LvlTypes.txt` name.
|
||
* @returns the gaps, empty when the type is fully transcribed.
|
||
*/
|
||
export function unimplementedPassesFor(levelTypeName: string): readonly string[] {
|
||
const profile = MAZE_LEVEL_TYPE_PROFILES.find(candidate => candidate.levelTypeName === levelTypeName)
|
||
if (profile === undefined) return [`DRLGMAZE_GenerateLevel profile (${levelTypeName})`]
|
||
const gaps: string[] = []
|
||
if (profile.specials.length === 0 && profile.singleRoom === undefined && profile.specialPass === undefined) {
|
||
gaps.push(`DRLGMAZE_GenerateLevel specials table (${levelTypeName})`)
|
||
}
|
||
return gaps
|
||
}
|
||
|
||
/**
|
||
* Every gap {@link unimplementedPassesFor} can name, across all level types.
|
||
*
|
||
* Non-empty while any level type is still missing its replacement table. Prefer
|
||
* the per-level-type function: a caller that reports this constant for one level
|
||
* is reporting other levels' gaps as its own.
|
||
*/
|
||
export const UNIMPLEMENTED_PASSES: readonly string[] = MAZE_LEVEL_TYPE_PROFILES
|
||
.flatMap(profile => unimplementedPassesFor(profile.levelTypeName))
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Geometry
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** A rectangle in cells. Mirrors the `nTileXPos`/`nTileYPos`/`nTileWidth`/
|
||
* `nTileHeight` view of `D2DrlgRoomStrc`, which D2MOO also reads as
|
||
* `D2DrlgCoordStrc` through a union. */
|
||
interface Rect {
|
||
x: number
|
||
y: number
|
||
width: number
|
||
height: number
|
||
}
|
||
|
||
/**
|
||
* Signed gap between two cell rectangles, per axis.
|
||
*
|
||
* `DRLG_ComputeManhattanDistance` verbatim, including its one-axis-at-a-time
|
||
* formulation: a negative value means the rectangles overlap on that axis, and
|
||
* two rooms sharing a face give one gap of 0 and one of `-height`, which is the
|
||
* signature every caller tests for.
|
||
*
|
||
* @param a - first rectangle.
|
||
* @param b - second rectangle.
|
||
* @returns the per-axis gaps.
|
||
*/
|
||
function manhattan(a: Rect, b: Rect): { dx: number; dy: number } {
|
||
const dx = a.x >= b.x ? a.x - b.width - b.x : b.x - a.width - a.x
|
||
const dy = a.y >= b.y ? a.y - b.height - b.y : b.y - a.height - a.y
|
||
return { dx, dy }
|
||
}
|
||
|
||
/**
|
||
* Whether two rectangles are clear of each other by at least `margin` cells.
|
||
*
|
||
* `DRLG_GetRectanglesManhattanDistanceAndCheckNotOverlapping`: true when either
|
||
* axis is separated by `margin` or more. With `margin = 0` a pair of face-to-face
|
||
* rooms has `dx = 0` and therefore *passes*, so sharing a face counts as "not
|
||
* overlapping" — which is what lets rooms tile a grid.
|
||
*
|
||
* @param a - first rectangle.
|
||
* @param b - second rectangle.
|
||
* @param margin - required separation on at least one axis.
|
||
* @returns true when they are clear.
|
||
*/
|
||
function notOverlapping(a: Rect, b: Rect, margin: number): boolean {
|
||
const { dx, dy } = manhattan(a, b)
|
||
return dx >= margin || dy >= margin
|
||
}
|
||
|
||
/**
|
||
* The quarter direction from `from` to `to`, or `-1` when they are not
|
||
* face-to-face neighbours.
|
||
*
|
||
* `DRLG_GetDirectionFromCoordinates`. Only the four quarters are produced; the
|
||
* diagonals exist solely as placement offsets in `FillBlankMazeSpaces`.
|
||
*
|
||
* @param from - the origin rectangle.
|
||
* @param to - the candidate neighbour.
|
||
* @returns a direction index 0..3, or -1.
|
||
*/
|
||
function directionFrom(from: Rect, to: Rect): number {
|
||
if (from.x <= to.x) {
|
||
if (to.x === from.x + from.width) return DIRECTION_EAST
|
||
} else if (from.x === to.x + to.width) {
|
||
return DIRECTION_WEST
|
||
}
|
||
if (from.y <= to.y) {
|
||
if (to.y === from.y + from.height) return DIRECTION_SOUTH
|
||
} else if (from.y === to.y + to.height) {
|
||
return DIRECTION_NORTH
|
||
}
|
||
return -1
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Rooms
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** One maze section. */
|
||
export interface Room extends Rect {
|
||
/** Doorways to neighbouring rooms, in insertion order. */
|
||
readonly orths: { room: Room; direction: number }[]
|
||
/** Creation index, so diagnostics can name a room stably. */
|
||
readonly id: number
|
||
/** The chosen piece; `null` until a preset pass runs. */
|
||
piece: MazePiece | null
|
||
/** Which variant of `piece` to stamp. */
|
||
variant: number
|
||
/**
|
||
* `DRLGPRESETROOMFLAG_HAS_MAP_DS1`.
|
||
*
|
||
* Set by the passes that call `DRLGMAZE_SetPickedFileAndPresetId` with
|
||
* `bResetFlag = FALSE`; cleared by every `DRLGMAZE_PickRoomPreset` call, which
|
||
* passes 1. A set flag means "this room's artwork is final", so the merge pass
|
||
* skips it and no later preset pass may overwrite it.
|
||
*/
|
||
hasDs1: boolean
|
||
/** Per-room random stream, seeded from the level's (`DRLGROOM_AllocRoomEx`). */
|
||
readonly rng: Rng
|
||
}
|
||
|
||
/** The level under construction. */
|
||
export interface MazeLevel {
|
||
/** Rooms newest-first, matching `DRLGROOM_AddRoomExToLevel`'s prepend. */
|
||
readonly rooms: Room[]
|
||
/** The first room created; D2MOO keeps this pointer across prepends. */
|
||
readonly first: Room
|
||
readonly width: number
|
||
readonly height: number
|
||
readonly rng: Rng
|
||
readonly mergePerMille: number
|
||
/** Next room id. */
|
||
nextId: number
|
||
/** Doorways added by the merge pass, for reporting. */
|
||
merges: number
|
||
readonly maxCellsX?: number | undefined
|
||
readonly maxCellsY?: number | undefined
|
||
readonly stats?: MazeStats | undefined
|
||
}
|
||
|
||
/**
|
||
* Report collector shared by the passes.
|
||
*
|
||
* Dense on purpose: the generator's job is to be inspectable, and the interesting
|
||
* facts are which piece answered which side pattern and where the exact pattern
|
||
* was missing.
|
||
*/
|
||
export interface MazeStats {
|
||
readonly patterns: Record<string, Record<string, number>>
|
||
readonly fallbacks: { room: number; requested: string; used: string }[]
|
||
readonly specialsApplied: { room: number; kind: string; sides: string }[]
|
||
readonly specialPassesApplied: string[]
|
||
readonly unresolvedRoles: string[]
|
||
readonly notes: string[]
|
||
ringRooms: number
|
||
placementAttempts: number
|
||
boundsRejected?: number
|
||
/** Stair rooms, with the coordinates only `stamp` can work out. */
|
||
warps: MazeWarp[]
|
||
/** Waypoint rooms, with the coordinates and entity positions only `stamp` can work out. */
|
||
waypoints: MazeWaypoint[]
|
||
}
|
||
|
||
/**
|
||
* A staircase, as reported in `stats.warps`.
|
||
*
|
||
* `specialsApplied` already records which room got a stair piece, but a room id
|
||
* is not a place: rooms are positioned in an unbounded coordinate space that
|
||
* `stamp` shifts by `minX`/`minY` to produce the final map. The coordinates
|
||
* below are in that final map's cells, so they can be baked straight into the
|
||
* pack.
|
||
*/
|
||
export interface MazeWarp {
|
||
/** The room the piece went into, matching `specialsApplied`. */
|
||
readonly room: number
|
||
/**
|
||
* Which way it goes:
|
||
* - `up` — `Prev`, back towards the level above.
|
||
* - `down` — `Next` or `Down`, deeper in. `Down` is the Act 1 cave descent,
|
||
* which uses a different piece but means the same thing here.
|
||
*/
|
||
readonly direction: 'up' | 'down'
|
||
/** The piece kind it came from, for tracing back to the artwork. */
|
||
readonly kind: 'prev' | 'next' | 'down'
|
||
/** The room's top-left corner in the final map, in cells. */
|
||
readonly x: number
|
||
readonly y: number
|
||
/** The room's extent, in cells. */
|
||
readonly width: number
|
||
readonly height: number
|
||
/** The room's centre, where the player lands or the warp is clicked. */
|
||
readonly centreX: number
|
||
readonly centreY: number
|
||
}
|
||
|
||
/**
|
||
* Create a room with no position or piece.
|
||
*
|
||
* @param level - the level.
|
||
* @returns the room.
|
||
*/
|
||
function makeRoom(level: MazeLevel): Room {
|
||
const room: Room = {
|
||
x: 0, y: 0, width: level.width, height: level.height,
|
||
orths: [], id: level.nextId, piece: null, variant: 0, hasDs1: false,
|
||
rng: new Rng(level.rng.int(0, 0x7fffffff)),
|
||
}
|
||
level.nextId += 1
|
||
return room
|
||
}
|
||
|
||
/**
|
||
* Add a doorway between two rooms.
|
||
*
|
||
* `DRLGROOM_AllocDrlgOrthsForRooms`: idempotent per room pair, and the reciprocal
|
||
* orth points the opposite way, `(direction - 2) & 3`.
|
||
*
|
||
* @param a - first room.
|
||
* @param b - second room.
|
||
* @param direction - direction from `a` to `b`.
|
||
*/
|
||
function linkRooms(a: Room, b: Room, direction: number): void {
|
||
if (!a.orths.some(orth => orth.room === b)) a.orths.push({ room: b, direction })
|
||
const opposite = (direction - 2) & 3
|
||
if (!b.orths.some(orth => orth.room === a)) b.orths.push({ room: a, direction: opposite })
|
||
}
|
||
|
||
/**
|
||
* The 4-bit side mask a room's doorways produce.
|
||
*
|
||
* `DRLGMAZE_PickRoomPreset` ORs {@link DIRECTION_SIDE_BIT} for every doorway. A
|
||
* room with no doorways gets 0, which in D2MOO selects the level type's base row
|
||
* and here selects the `entrance` piece.
|
||
*
|
||
* @param room - the room.
|
||
* @returns the mask, 0..15.
|
||
*/
|
||
export function roomSideMask(room: Room): number {
|
||
let mask = 0
|
||
for (const orth of room.orths) mask |= DIRECTION_SIDE_BIT[orth.direction]!
|
||
return mask
|
||
}
|
||
|
||
/**
|
||
* Whether placing `room` keeps the overall maze within `maxCellsX` and `maxCellsY`.
|
||
*
|
||
* In Diablo II, maze room growth is constrained by the level's global SizeX and SizeY limits
|
||
* from `Levels.txt`. If growing a candidate room would expand the bounding box of all placed
|
||
* rooms beyond the allowed dimensions, the placement attempt is rejected, forcing the maze
|
||
* to grow in other available directions and cluster compactly.
|
||
*
|
||
* @param level - the level.
|
||
* @param room - the candidate room with proposed coordinates and dimensions.
|
||
* @returns true if within bounds or bounds are unconstrained.
|
||
*/
|
||
function checkRoomWithinBounds(level: MazeLevel, room: Rect): boolean {
|
||
if (level.maxCellsX === undefined && level.maxCellsY === undefined) return true
|
||
if (level.rooms.length === 0) return true
|
||
|
||
if (level.maxCellsX !== undefined && level.maxCellsX > 0) {
|
||
let minX = room.x
|
||
let maxX = room.x + room.width
|
||
for (const other of level.rooms) {
|
||
if (other.x < minX) minX = other.x
|
||
if (other.x + other.width > maxX) maxX = other.x + other.width
|
||
}
|
||
if (maxX - minX > level.maxCellsX) {
|
||
if (level.stats) {
|
||
level.stats.boundsRejected = (level.stats.boundsRejected ?? 0) + 1
|
||
}
|
||
return false
|
||
}
|
||
}
|
||
|
||
if (level.maxCellsY !== undefined && level.maxCellsY > 0) {
|
||
let minY = room.y
|
||
let maxY = room.y + room.height
|
||
for (const other of level.rooms) {
|
||
if (other.y < minY) minY = other.y
|
||
if (other.y + other.height > maxY) maxY = other.y + other.height
|
||
}
|
||
if (maxY - minY > level.maxCellsY) {
|
||
if (level.stats) {
|
||
level.stats.boundsRejected = (level.stats.boundsRejected ?? 0) + 1
|
||
}
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* Whether a room overlaps anything other than itself and one ignored room, and fits within level bounds.
|
||
*
|
||
* `DRLGMAZE_CheckRoomNotOverlaping`. The margin is 0, so face-to-face neighbours
|
||
* are fine and only genuine overlap fails.
|
||
*
|
||
* @param level - the level.
|
||
* @param room - the candidate.
|
||
* @param ignored - a room to skip, usually its parent.
|
||
* @returns true when the room is clear and within global level bounds.
|
||
*/
|
||
function checkRoomNotOverlapping(level: MazeLevel, room: Rect, ignored: Room | null): boolean {
|
||
if (!checkRoomWithinBounds(level, room)) return false
|
||
for (const other of level.rooms) {
|
||
if (other === room || other === ignored) continue
|
||
if (!notOverlapping(room, other, 0)) return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* Position `room` against `anchor` in `direction` and validate the result.
|
||
*
|
||
* `DRLGMAZE_LinkMazeRooms`: the coordinate assignment happens first, so a failed
|
||
* link leaves the caller to discard the room.
|
||
*
|
||
* @param level - the level.
|
||
* @param room - the room to move.
|
||
* @param anchor - the room to sit against.
|
||
* @param direction - which side of the anchor to occupy.
|
||
* @returns true when the position is legal.
|
||
*/
|
||
function linkMazeRooms(level: MazeLevel, room: Room, anchor: Room, direction: number): boolean {
|
||
const { dx, dy } = step(direction, anchor.width, anchor.height)
|
||
room.x = anchor.x + dx
|
||
room.y = anchor.y + dy
|
||
for (const orth of anchor.orths) {
|
||
// Anything already touching the anchor must not be run into.
|
||
if (!notOverlapping(room, orth.room, 0)) return false
|
||
}
|
||
return checkRoomNotOverlapping(level, room, anchor)
|
||
}
|
||
|
||
/**
|
||
* The earliest room that still needs a piece, optionally of a given shape.
|
||
*
|
||
* @param level - the level, newest-first.
|
||
* @param mask - required side mask, or `null` for any.
|
||
* @returns the room, or null.
|
||
*/
|
||
function findUnfinishedRoom(level: MazeLevel, mask: number | null): Room | null {
|
||
for (const room of level.rooms) {
|
||
if (room.hasDs1) continue
|
||
if (mask === null || roomSideMask(room) === mask) return room
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Grow one room off `parent`, or return null when the spot is taken.
|
||
*
|
||
* `DRLGMAZE_AddAdjacentMazeRoom`, with its inline copy of the merge loop factored
|
||
* into {@link mergeMazeRooms}. The fold is safe because in D2MOO the new room has
|
||
* not been added to the roster when either spelling runs, so both iterate exactly
|
||
* the same candidates in the same order; the only difference is that
|
||
* `DRLGMAZE_BuildBasicMaze` calls `MergeMazeRooms` unconditionally while
|
||
* `AddAdjacentMazeRoom` guards it with `bMergeRooms`, and every caller that
|
||
* passes `FALSE` has already established that there is nothing to merge.
|
||
*
|
||
* @param level - the level.
|
||
* @param parent - the room to grow from.
|
||
* @param direction - which side to grow towards, 0..7.
|
||
* @param merge - whether the accidental-adjacency join pass runs.
|
||
* @param repickParent - whether to re-derive the parent's piece afterwards.
|
||
* `DRLGMAZE_FillBlankMazeSpaces` deliberately does not.
|
||
* @returns the new room, or null.
|
||
*/
|
||
export function addAdjacentMazeRoom(
|
||
level: MazeLevel,
|
||
parent: Room,
|
||
direction: number,
|
||
merge: boolean,
|
||
repickParent: boolean,
|
||
index: PieceIndex,
|
||
stats: MazeStats,
|
||
): Room | null {
|
||
const room = makeRoom(level)
|
||
const { dx, dy } = step(direction, parent.width, parent.height)
|
||
room.x = parent.x + dx
|
||
room.y = parent.y + dy
|
||
for (const orth of parent.orths) {
|
||
if (!notOverlapping(room, orth.room, 0)) return null
|
||
}
|
||
if (!checkRoomNotOverlapping(level, room, parent)) return null
|
||
|
||
linkRooms(parent, room, direction)
|
||
if (merge) mergeMazeRooms(level, room, index, stats)
|
||
level.rooms.unshift(room)
|
||
if (repickParent) pickRoomPreset(parent, index, stats)
|
||
pickRoomPreset(room, index, stats)
|
||
return room
|
||
}
|
||
|
||
/**
|
||
* Join a newly placed room to every already-placed room it happens to touch.
|
||
*
|
||
* **This is what `LvlMaze.Merge` does.** `DRLGMAZE_MergeMazeRooms` (and the
|
||
* inline copy inside `DRLGMAZE_AddAdjacentMazeRoom`) walks the whole roster for
|
||
* rooms no more than one cell away where `dx !== dy` — which, given that overlap
|
||
* has already been ruled out, can only mean a face-to-face neighbour — and, when
|
||
* the two are not already connected, rolls
|
||
* `SEED_RollRandomNumber(&i->pSeed) % 1000 < dwMerge`, on the *candidate's* seed
|
||
* rather than the pivot's. A success adds a doorway and re-picks that room's
|
||
* piece, because its side pattern just changed.
|
||
*
|
||
* The consequence is why `Merge` matters: the growth loop attaches a room on one
|
||
* side only, but the section grid is coarse enough that a new room frequently
|
||
* lands flush against an unrelated older room. With `Merge = 0` those contacts
|
||
* stay walls and the level is a bare tree; with `Merge = 1000` every one of them
|
||
* becomes a door and the level acquires loops.
|
||
*
|
||
* @param level - the level.
|
||
* @param pivot - the room just placed.
|
||
* @param index - the piece index, for re-picking.
|
||
* @param stats - report collector.
|
||
*/
|
||
function mergeMazeRooms(level: MazeLevel, pivot: Room, index: PieceIndex, stats: MazeStats): void {
|
||
if (pivot.hasDs1) return
|
||
for (const other of level.rooms) {
|
||
if (other === pivot || other.hasDs1) continue
|
||
const { dx, dy } = manhattan(pivot, other)
|
||
// `!(dx >= 1 || dy >= 1)`, then `dx !== dy`.
|
||
if (dx >= 1 || dy >= 1) continue
|
||
if (dx === dy) continue
|
||
if (pivot.orths.some(orth => orth.room === other)) continue
|
||
if (other.rng.int(0, 999) >= level.mergePerMille) continue
|
||
const direction = directionFrom(other, pivot)
|
||
if (direction === -1) continue
|
||
linkRooms(other, pivot, direction)
|
||
level.merges += 1
|
||
pickRoomPreset(other, index, stats)
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Piece lookup
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** Indexed pieces, so a side mask is one map lookup. */
|
||
export interface PieceIndex {
|
||
/**
|
||
* The piece for a room's doorway mask.
|
||
*
|
||
* @param mask - the room's doorway mask.
|
||
* @param stats - report collector for fallbacks.
|
||
* @param roomId - the room being served, for the report.
|
||
* @returns the piece to stamp.
|
||
*/
|
||
choose(mask: number, stats: MazeStats, roomId: number): MazePiece
|
||
}
|
||
|
||
/**
|
||
* Count set bits.
|
||
*
|
||
* @param value - the value.
|
||
* @returns the population count.
|
||
*/
|
||
function popcount(value: number): number {
|
||
let count = 0
|
||
let rest = value
|
||
while (rest !== 0) { count += rest & 1; rest >>>= 1 }
|
||
return count
|
||
}
|
||
|
||
/**
|
||
* Index a level type's pieces by kind and side mask.
|
||
*
|
||
* `DRLGMAZE_PickRoomPreset` resolves a mask by reading the level type's
|
||
* `LvlPrest` row at `base + mask`, where `base` is a hardcoded per-type preset
|
||
* id. This port has no preset ids, so it resolves by *kind and mask* instead —
|
||
* the same thing expressed in the data the caller supplies.
|
||
*
|
||
* A level type may genuinely lack a piece for a side pattern (Act 1's Catacombs
|
||
* ships no four-sided room). D2MOO would read whatever row happens to sit at
|
||
* `base + mask` — a neighbouring level type's artwork — and produce a map whose
|
||
* doors do not line up. Here the closest piece is substituted and the mismatch is
|
||
* recorded in `stats.fallbacks`, because a map that cannot be walked is worse for
|
||
* this project than a map with one wrong room. This is a deliberate divergence.
|
||
*
|
||
* @param pieces - the level type's pieces.
|
||
* @returns the index.
|
||
*/
|
||
function buildPieceIndex(pieces: readonly MazePiece[]): PieceIndex {
|
||
const rooms = new Map<number, MazePiece>()
|
||
let entrance: MazePiece | undefined
|
||
for (const piece of pieces) {
|
||
if (piece.kind === 'entrance' && entrance === undefined) entrance = piece
|
||
if (piece.kind !== 'room') continue
|
||
const mask = sideMaskFromToken(piece.sides)
|
||
if (!rooms.has(mask)) rooms.set(mask, piece)
|
||
}
|
||
for (const piece of pieces) {
|
||
if (piece.kind !== 'prev') continue
|
||
const mask = sideMaskFromToken(piece.sides)
|
||
if (mask !== 0 && !rooms.has(mask)) rooms.set(mask, piece)
|
||
}
|
||
const entrancePiece = entrance
|
||
|
||
/**
|
||
* Pick the closest available room piece to a mask.
|
||
*
|
||
* @param mask - the wanted mask.
|
||
* @returns the piece and whether it is exact.
|
||
*/
|
||
const closest = (mask: number): { piece: MazePiece; exact: boolean } | null => {
|
||
const exact = rooms.get(mask)
|
||
if (exact !== undefined) return { piece: exact, exact: true }
|
||
let best: MazePiece | null = null
|
||
let bestCost = Number.POSITIVE_INFINITY
|
||
for (const [candidateMask, piece] of rooms) {
|
||
// A missing doorway seals the room off completely; an extra one merely
|
||
// adds a dead end. Weight the two accordingly.
|
||
const cost = popcount(mask & ~candidateMask) * 100 + popcount(candidateMask & ~mask)
|
||
if (cost < bestCost) { bestCost = cost; best = piece }
|
||
}
|
||
return best === null ? null : { piece: best, exact: false }
|
||
}
|
||
|
||
return {
|
||
choose: (mask: number, stats: MazeStats, roomId: number): MazePiece => {
|
||
const result = closest(mask)
|
||
if (result === null) {
|
||
if (entrancePiece !== undefined) return entrancePiece
|
||
const any = pieces[0]
|
||
if (any === undefined) throw new Error('maze piece list is empty')
|
||
return any
|
||
}
|
||
if (!result.exact) {
|
||
stats.fallbacks.push({
|
||
room: roomId,
|
||
requested: sideTokenFromMask(mask),
|
||
used: `${result.piece.name} (${sideTokenFromMask(sideMaskFromToken(result.piece.sides)) || 'no side'})`,
|
||
})
|
||
}
|
||
return result.piece
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Look up a piece by kind and side token.
|
||
*
|
||
* @param pieces - the level's pieces.
|
||
* @param kind - the kind wanted.
|
||
* @param sides - the side token; `''` accepts any piece of that kind.
|
||
* @returns the piece, or undefined.
|
||
*/
|
||
export function findSpecial(pieces: readonly MazePiece[], kind: MazePieceKind, sides: string): MazePiece | undefined {
|
||
return pieces.find(piece => piece.kind === kind
|
||
&& (sides === '' || sideMaskFromToken(piece.sides) === sideMaskFromToken(sides)))
|
||
}
|
||
|
||
/**
|
||
* Give a room the piece its doorways call for.
|
||
*
|
||
* `DRLGMAZE_PickRoomPreset`. Rooms whose artwork is already final (`hasDs1`) are
|
||
* left alone, mirroring the passes that call it with `bResetFlag = FALSE`; every
|
||
* other call clears the flag, which is why the flag cannot be used as a "was
|
||
* visited" marker.
|
||
*
|
||
* @param room - the room to give a piece.
|
||
* @param index - the piece lookup.
|
||
* @param stats - the report collector.
|
||
*/
|
||
function pickRoomPreset(room: Room, index: PieceIndex, stats: MazeStats): void {
|
||
if (room.hasDs1) return
|
||
const mask = roomSideMask(room)
|
||
const chosen = index.choose(mask, stats, room.id)
|
||
room.piece = chosen
|
||
room.variant = 0
|
||
room.hasDs1 = false
|
||
const token = sideTokenFromMask(mask) === '' ? 'no side' : sideTokenFromMask(mask)
|
||
stats.patterns[chosen.kind] ??= {}
|
||
const bucket = stats.patterns[chosen.kind]!
|
||
bucket[token] = (bucket[token] ?? 0) + 1
|
||
}
|
||
|
||
/**
|
||
* Record that a special piece was placed.
|
||
*
|
||
* @param room - the room it went into.
|
||
* @param piece - the piece.
|
||
* @param token - the side token it answers.
|
||
* @param stats - report collector.
|
||
*/
|
||
export function noteSpecial(room: Room, piece: MazePiece, token: string, stats: MazeStats): void {
|
||
stats.specialsApplied.push({ room: room.id, kind: piece.kind, sides: token })
|
||
stats.patterns[piece.kind] ??= {}
|
||
stats.patterns[piece.kind]![token] = (stats.patterns[piece.kind]![token] ?? 0) + 1
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Layout passes
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* Pre-place a closed ring of `4n - 4` rooms.
|
||
*
|
||
* `DRLGMAZE_InitBasicMazeLayout`. Four legs walk north, west, south and east, and
|
||
* the last room is then linked back to the first — so `n = 2` gives a 2×2 square
|
||
* and `n = 5` (Act 3's Sewer 1) a 4×4 ring of 16 rooms. The east leg runs one
|
||
* iteration short because the first room already occupies that corner. Each leg
|
||
* calls `DRLGMAZE_MergeMazeRooms` just like the growth loop does, so the
|
||
* accidental contacts a ring makes are subject to `Merge` too.
|
||
*
|
||
* @param level - the level.
|
||
* @param roomsPerDirection - `n`; `4n - 4` rooms are created.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
*/
|
||
function ringLayout(level: MazeLevel, roomsPerDirection: number, index: PieceIndex, stats: MazeStats): void {
|
||
const legs: readonly number[] = [DIRECTION_NORTH, DIRECTION_WEST, DIRECTION_SOUTH, DIRECTION_EAST]
|
||
let cursor = level.first
|
||
for (const direction of legs) {
|
||
const count = direction === DIRECTION_EAST ? roomsPerDirection - 2 : roomsPerDirection - 1
|
||
for (let remaining = count; remaining > 0; remaining -= 1) {
|
||
const room = addAdjacentMazeRoom(level, cursor, direction, true, true, index, stats)
|
||
if (room !== null) stats.ringRooms += 1
|
||
cursor = room ?? cursor
|
||
}
|
||
}
|
||
linkRooms(cursor, level.first, DIRECTION_EAST)
|
||
pickRoomPreset(cursor, index, stats)
|
||
pickRoomPreset(level.first, index, stats)
|
||
}
|
||
|
||
/**
|
||
* Grow the level to its target room count.
|
||
*
|
||
* The shared body of `DRLGMAZE_BuildBasicMaze` and of the
|
||
* `while (pLevel->nRooms < nRooms)` loop `DRLGMAZE_GenerateLevel` inlines for the
|
||
* Act 1 dungeons: pick a room from the roster at a uniformly random index (the
|
||
* roster is newest-first, so recent rooms are favoured exactly as in the game),
|
||
* roll a direction from *that room's* seed, and try to attach a neighbour.
|
||
* Failure is routine — the target square may already be occupied — so the loop
|
||
* simply tries again. That also means it can only be bounded by a retry cap
|
||
* rather than by construction, and D2MOO would spin forever where this throws.
|
||
*
|
||
* @param level - the level.
|
||
* @param target - rooms wanted.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
* @returns the number of attempts consumed.
|
||
*/
|
||
function growMaze(level: MazeLevel, target: number, index: PieceIndex, stats: MazeStats): number {
|
||
const cap = target * 400 + 20_000
|
||
let attempts = 0
|
||
while (level.rooms.length < target) {
|
||
attempts += 1
|
||
if (attempts > cap) {
|
||
throw new Error(`layout stalled at ${String(level.rooms.length)}/${String(target)} rooms after ${String(attempts)} attempts`)
|
||
}
|
||
const room = level.rooms[level.rng.int(0, level.rooms.length - 1)]!
|
||
const direction = room.rng.int(0, 3)
|
||
if (room.hasDs1) continue
|
||
addAdjacentMazeRoom(level, room, direction, true, true, index, stats)
|
||
}
|
||
return attempts
|
||
}
|
||
|
||
/**
|
||
* The opening moves Act 1's Catacombs get before growth.
|
||
*
|
||
* `DRLGMAZE_GenerateLevel`'s `LVLTYPE_ACT1_CATACOMBS` arm. Level 1 opens in all
|
||
* four directions and pins the entrance to the `NSEW` staircase piece; the rest
|
||
* pick an axis at random and open both ways, pinning `EW` or `NS`. These are the
|
||
* only places the entrance room is finalised with `bResetFlag = FALSE`, i.e. the
|
||
* only rooms `DRLGMAZE_MergeMazeRooms` will later refuse to touch.
|
||
*
|
||
* @param level - the level.
|
||
* @param horizontal - whether the non-first levels open west/east rather than
|
||
* north/south.
|
||
* @param pieces - the level's pieces.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
*/
|
||
function catacombsOpening(level: MazeLevel, horizontal: boolean, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
|
||
const directions = horizontal ? [DIRECTION_WEST, DIRECTION_EAST] : [DIRECTION_NORTH, DIRECTION_SOUTH]
|
||
for (const direction of directions) addAdjacentMazeRoom(level, level.first, direction, true, true, index, stats)
|
||
const token = horizontal ? 'EW' : 'NS'
|
||
const piece = findSpecial(pieces, 'prev', token)
|
||
if (piece === undefined) {
|
||
stats.unresolvedRoles.push(`Act 1 - Catacombs Prev ${token}`)
|
||
return
|
||
}
|
||
level.first.piece = piece
|
||
level.first.hasDs1 = true
|
||
noteSpecial(level.first, piece, token, stats)
|
||
}
|
||
|
||
/**
|
||
* The three-room prologue Act 2's tombs and Act 5's Baal levels share.
|
||
*
|
||
* `DRLGMAZE_PlaceAct2TombPrev_Act5BaalPrev`: three rooms are chained off the
|
||
* entrance, one per direction starting from a random one, each merging as it
|
||
* lands; the entrance then takes the `Prev` piece whose three free sides face the
|
||
* rooms that actually got built. That piece is chosen with the direction counter
|
||
* *after* the loop, which is why it is the successor of the last direction tried
|
||
* rather than the last direction used.
|
||
*
|
||
* @param level - the level.
|
||
* @param kind - which act's `Prev` set to use.
|
||
* @param pieces - the level's pieces.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
*/
|
||
function prevChain(level: MazeLevel, kind: 'tomb' | 'baal', pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
|
||
let direction = level.rng.int(0, 3)
|
||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||
const room = makeRoom(level)
|
||
if (linkMazeRooms(level, room, level.first, direction)) {
|
||
linkRooms(level.first, room, direction)
|
||
mergeMazeRooms(level, room, index, stats)
|
||
level.rooms.unshift(room)
|
||
pickRoomPreset(level.first, index, stats)
|
||
pickRoomPreset(room, index, stats)
|
||
}
|
||
direction = (direction + 1) % 4
|
||
}
|
||
const tokens = ['NSE', 'SEW', 'NSW', 'NEW']
|
||
const token = tokens[direction]!
|
||
const piece = findSpecial(pieces, 'prev', token)
|
||
if (piece === undefined) {
|
||
stats.unresolvedRoles.push(`${kind === 'tomb' ? 'Act 2 - Tomb' : 'Act 5 - Baal'} Prev ${token}`)
|
||
return
|
||
}
|
||
level.first.piece = piece
|
||
level.first.hasDs1 = true
|
||
noteSpecial(level.first, piece, token, stats)
|
||
}
|
||
|
||
/**
|
||
* `DRLGMAZE_PlaceArcaneSanctuary`: four 15-room spiral branches.
|
||
*
|
||
* Each branch starts at the level's first room and walks the pattern D2MOO
|
||
* documents in a comment, encoded in
|
||
* `DRLGMAZE_ArcaneSanctuaryDirectionFromRoomIdx`: one room forward, then a side
|
||
* step, then a long run, then a side step back. Rooms 8 and 12 are placed but
|
||
* deliberately *not* recorded and do not advance the parent, which D2MOO flags
|
||
* with "Is this a mistake?" and then reproduces anyway; so does this port, since
|
||
* the object is fidelity to the shipped maze rather than to the diagram.
|
||
*
|
||
* The four branches then take four different variants of the same pieces,
|
||
* `(nRand + branch) % 4`, which is what gives the Sanctuary its noticeably varied
|
||
* arms; the entry room takes variant 4.
|
||
*
|
||
* @param level - the level.
|
||
* @param pieces - the level's pieces.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
*/
|
||
function arcaneSanctuary(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
|
||
const rand = level.rng.int(0, 3)
|
||
const branches: Room[] = []
|
||
for (let branch = 0; branch < 4; branch += 1) {
|
||
let parent = level.first
|
||
for (let slot = 0; slot < 15; slot += 1) {
|
||
let direction = branch
|
||
if (slot === 2 || slot === 12) direction = branch + 3
|
||
else if (slot === 7 || slot === 9) direction = branch + 1
|
||
else if (slot === 10 || slot === 11 || slot === 13 || slot === 14) direction = branch + 2
|
||
direction %= 4
|
||
const room = addAdjacentMazeRoom(level, parent, direction, true, true, index, stats)
|
||
if (room === null) continue
|
||
if (slot !== 8 && slot !== 12) {
|
||
branches.push(room)
|
||
parent = room
|
||
}
|
||
}
|
||
}
|
||
branches.forEach((room, position) => {
|
||
room.variant = (rand + Math.floor(position / 15)) % 4
|
||
})
|
||
level.first.variant = 4
|
||
void pieces
|
||
}
|
||
|
||
/**
|
||
* `DRLGMAZE_PlaceAct5LavaPresets`: the Infernal Pit's two fixed pieces.
|
||
*
|
||
* The level is not a maze at all. It is an entrance plus exactly two lava pieces,
|
||
* chosen as a pair by a single random draw, after which every still-empty
|
||
* adjacent square is filled with the shared `Act 4 - Lava X` filler — see
|
||
* {@link fillBlankSpaces}.
|
||
*
|
||
* @param level - the level.
|
||
* @param pieces - the level's pieces.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
*/
|
||
function lavaPairs(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
|
||
// `dword_6FDCE850`, transcribed as (piece name, direction, variant).
|
||
const table: readonly (readonly [string, number, number])[] = [
|
||
['Act 5 - Lava S', DIRECTION_SOUTH, 0],
|
||
['Act 5 - Lava N', DIRECTION_NORTH, 1],
|
||
['Act 5 - Lava S', DIRECTION_SOUTH, 1],
|
||
['Act 5 - Lava N', DIRECTION_NORTH, 0],
|
||
['Act 5 - Lava E', DIRECTION_EAST, 1],
|
||
['Act 5 - Lava W', DIRECTION_WEST, 0],
|
||
['Act 5 - Lava E', DIRECTION_EAST, 0],
|
||
['Act 5 - Lava W', DIRECTION_WEST, 1],
|
||
]
|
||
const set = 2 * level.first.rng.int(0, 3)
|
||
for (const [name, direction, variant] of [table[set]!, table[set + 1]!]) {
|
||
const room = makeRoom(level)
|
||
room.variant = variant
|
||
// The table's direction is the direction *from the new room to the entrance*,
|
||
// so the new room sits on the opposite side.
|
||
const { dx, dy } = step(direction, level.first.width, level.first.height)
|
||
room.x = level.first.x + dx
|
||
room.y = level.first.y + dy
|
||
if (!checkRoomNotOverlapping(level, room, level.first)) continue
|
||
linkRooms(level.first, room, direction)
|
||
level.rooms.unshift(room)
|
||
pickRoomPreset(level.first, index, stats)
|
||
const piece = pieces.find(candidate => candidate.name === name)
|
||
if (piece === undefined) {
|
||
stats.unresolvedRoles.push(name)
|
||
pickRoomPreset(room, index, stats)
|
||
continue
|
||
}
|
||
room.piece = piece
|
||
room.hasDs1 = true
|
||
noteSpecial(room, piece, 'no side', stats)
|
||
}
|
||
fillBlankSpaces(level, pieces, index, stats)
|
||
}
|
||
|
||
/**
|
||
* `DRLGMAZE_FillBlankMazeSpaces`: wall a fixed-tile level in with filler rooms.
|
||
*
|
||
* The roster is snapshotted first, then every room in it tries all eight compass
|
||
* directions in order and keeps whatever fits. The filler never takes a role, so
|
||
* this only ever adds area.
|
||
*
|
||
* D2MOO does not re-derive the parent's piece here, which would leave a wall
|
||
* where the filler attaches and make the filler unreachable; this port does
|
||
* re-derive it, because an unreachable room is a defect rather than a detail.
|
||
*
|
||
* @param level - the level.
|
||
* @param pieces - the level's pieces.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
*/
|
||
export function fillBlankSpaces(
|
||
level: MazeLevel,
|
||
pieces: readonly MazePiece[],
|
||
index: PieceIndex,
|
||
stats: MazeStats,
|
||
ignoreRoom?: Room | null,
|
||
): void {
|
||
const filler = pieces.find(piece => piece.name === 'Act 4 - Lava X')
|
||
if (filler === undefined) {
|
||
stats.unresolvedRoles.push('Act 4 - Lava X')
|
||
return
|
||
}
|
||
for (const room of [...level.rooms]) {
|
||
if (ignoreRoom !== null && ignoreRoom !== undefined && room === ignoreRoom) continue
|
||
for (let direction = 0; direction < 8; direction += 1) {
|
||
const created = addAdjacentMazeRoom(level, room, direction, false, true, index, stats)
|
||
if (created === null) continue
|
||
created.piece = filler
|
||
created.hasDs1 = true
|
||
noteSpecial(created, filler, 'filler', stats)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* `DRLGMAZE_RollAct_1_2_3_BasicPresets`: the `Theme` decoration pass.
|
||
*
|
||
* A shuffled list of the fifteen side patterns is walked cyclically and the first
|
||
* plain room matching each pattern is converted to that pattern's `Theme`
|
||
* variant, until `max(rooms / 5 + 1, 2)` rooms have been converted or the attempt
|
||
* budget of `2 × rooms` is spent. D2MOO shuffles the offset list with fifteen
|
||
* Fisher-Yates swaps drawn from the level seed, which is reproduced here.
|
||
*
|
||
* A level type with no `Theme` pieces for a pattern is skipped rather than given
|
||
* a neighbouring type's artwork, and the shortfall is reported.
|
||
*
|
||
* @param level - the level.
|
||
* @param pieces - the level's pieces.
|
||
* @param stats - report collector.
|
||
*/
|
||
function themeRoll(level: MazeLevel, pieces: readonly MazePiece[], stats: MazeStats): void {
|
||
const offsets = Array.from({ length: 15 }, (_, index) => index)
|
||
let cursor = level.rng.int(0, 14)
|
||
for (let swap = 0; swap < 15; swap += 1) {
|
||
const a = level.rng.int(0, 14)
|
||
const b = level.rng.int(0, 14)
|
||
const temporary = offsets[a]!
|
||
offsets[a] = offsets[b]!
|
||
offsets[b] = temporary
|
||
}
|
||
let wanted = Math.max(Math.floor(level.rooms.length / 5) + 1, 2)
|
||
let budget = 2 * level.rooms.length
|
||
while (wanted > 0 && budget > 0) {
|
||
budget -= 1
|
||
const mask = offsets[cursor]! + 1
|
||
cursor = (cursor + 1) % 15
|
||
const token = sideTokenFromMask(mask)
|
||
const room = findUnfinishedRoom(level, mask)
|
||
if (room === null) continue
|
||
const themed = findSpecial(pieces, 'theme', token)
|
||
if (themed === undefined) {
|
||
// D2MOO would write a neighbouring level type's row; here the room keeps
|
||
// its plain piece and the gap is reported.
|
||
if (!stats.unresolvedRoles.includes(`Theme ${token}`)) stats.unresolvedRoles.push(`Theme ${token}`)
|
||
continue
|
||
}
|
||
room.piece = themed
|
||
room.variant = 0
|
||
room.hasDs1 = true
|
||
noteSpecial(room, themed, token, stats)
|
||
wanted -= 1
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Compute BFS topological distance of every room from the level's entrance room
|
||
* (the room holding a 'prev' or 'entrance' piece, or `level.first`).
|
||
*/
|
||
export function bfsDistances(level: MazeLevel): Map<Room, number> {
|
||
const start = level.rooms.find(r => r.piece?.kind === 'prev' || r.piece?.kind === 'entrance') ?? level.first
|
||
const dist = new Map<Room, number>([[start, 0]])
|
||
const queue: Room[] = [start]
|
||
while (queue.length > 0) {
|
||
const current = queue.shift()!
|
||
const d = dist.get(current)!
|
||
for (const orth of current.orths) {
|
||
if (!dist.has(orth.room)) {
|
||
dist.set(orth.room, d + 1)
|
||
queue.push(orth.room)
|
||
}
|
||
}
|
||
}
|
||
return dist
|
||
}
|
||
|
||
/**
|
||
* Place a special room group following standard D2 scanning rules:
|
||
* scans room candidates against valid wall connections, LvlPrest file definitions,
|
||
* and room flags, rather than imposing an artificial global maximum BFS distance.
|
||
*
|
||
* @param level - the level.
|
||
* @param pieces - the level's pieces.
|
||
* @param group - which special group to place.
|
||
* @param startCursor - initial table offset.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
*/
|
||
function placeSpecialGroup(
|
||
level: MazeLevel,
|
||
pieces: readonly MazePiece[],
|
||
group: SpecialGroup,
|
||
startCursor: number,
|
||
index: PieceIndex,
|
||
stats: MazeStats,
|
||
): void {
|
||
// Phase 1: Try to replace an existing unfinished room in place, scanning
|
||
// room candidates against valid wall connections, LvlPrest definitions,
|
||
// and room flags in standard D2 roster order.
|
||
for (let offset = 0; offset < group.entries.length; offset += 1) {
|
||
const entry = group.entries[(startCursor + offset) % group.entries.length]!
|
||
const wanted = sideMaskFromToken(entry.plainSides)
|
||
const token = sideTokenFromMask(wanted)
|
||
const special = findSpecial(pieces, group.kind, entry.sides)
|
||
if (special === undefined) continue
|
||
|
||
const chosen = level.rooms.find(r => {
|
||
if (roomSideMask(r) !== wanted) return false
|
||
if (!r.hasDs1) return true
|
||
if (group.inPlaceOnly === true && (r.piece === special || r.piece?.kind === 'theme')) return true
|
||
return false
|
||
})
|
||
|
||
if (chosen !== undefined) {
|
||
chosen.piece = special
|
||
chosen.variant = 0
|
||
chosen.hasDs1 = true
|
||
noteSpecial(chosen, special, token, stats)
|
||
return
|
||
}
|
||
}
|
||
|
||
if (group.inPlaceOnly === true) {
|
||
const firstEntry = group.entries[startCursor]!
|
||
stats.notes.push(`no ${firstEntry.plainSides} room left for a ${group.kind} ${firstEntry.sides} replacement`)
|
||
return
|
||
}
|
||
|
||
// Phase 2: Grow a new dead-end room off an available parent room in D2
|
||
// roster order (plain rooms without DS1 scanned first, followed by theme rooms).
|
||
const parentCandidates = [
|
||
...level.rooms.filter(r => !r.hasDs1),
|
||
...level.rooms.filter(r => r.hasDs1 && r.piece?.kind === 'theme'),
|
||
]
|
||
|
||
for (const parent of parentCandidates) {
|
||
for (let offset = 0; offset < group.entries.length; offset += 1) {
|
||
const entry = group.entries[(startCursor + offset) % group.entries.length]!
|
||
const wanted = sideMaskFromToken(entry.plainSides)
|
||
const token = sideTokenFromMask(wanted)
|
||
const special = findSpecial(pieces, group.kind, entry.sides)
|
||
if (special === undefined) continue
|
||
|
||
const wasHasDs1 = parent.hasDs1
|
||
parent.hasDs1 = false
|
||
const created = addAdjacentMazeRoom(level, parent, entry.direction, false, true, index, stats)
|
||
if (created !== null) {
|
||
created.piece = special
|
||
created.variant = 0
|
||
created.hasDs1 = true
|
||
noteSpecial(created, special, token, stats)
|
||
return
|
||
}
|
||
parent.hasDs1 = wasHasDs1
|
||
}
|
||
}
|
||
|
||
const fallbackEntry = group.entries[startCursor]!
|
||
stats.notes.push(`could not place ${group.kind} ${fallbackEntry.sides}: no room could host it`)
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Stamping
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** A blank wall cell, matching the decoder's placeholder. */
|
||
function emptyWall(): Ds1Wall {
|
||
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||
}
|
||
|
||
/** A blank floor/shadow cell. */
|
||
function emptyFloor(): Ds1Floor {
|
||
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Edge suppression and doorway thresholds (1.13c `DrlgRoomTile.cpp`)
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** `TILETYPE_WALL_LEFT_DOOR` — a wall cell that a door unit occupies. */
|
||
const TILETYPE_WALL_LEFT_DOOR = 8
|
||
/** `TILETYPE_WALL_RIGHT_DOOR` — the mirrored door wall. */
|
||
const TILETYPE_WALL_RIGHT_DOOR = 9
|
||
/** `TILETYPE_WALL_LEFT_EXIT` — a warp marker, not artwork. */
|
||
const TILETYPE_WALL_LEFT_EXIT = 10
|
||
/** `TILETYPE_WALL_RIGHT_EXIT` — the mirrored warp marker. */
|
||
const TILETYPE_WALL_RIGHT_EXIT = 11
|
||
|
||
/**
|
||
* Highest `style` an exit tile may carry before 1.13c ignores it outright.
|
||
*
|
||
* `DRLGROOMTILE_LoadInitRoomTiles` opens with
|
||
* `if ((nTileType == TILETYPE_WALL_LEFT_EXIT || nTileType == TILETYPE_WALL_RIGHT_EXIT)
|
||
* && nTileStyle >= 8) continue;` — the style is the `Levels.txt` `Warp0..7` slot,
|
||
* so anything at 8 or above is not a warp and the cell is dropped.
|
||
*/
|
||
const MAX_EXIT_WARP_SLOT = 7
|
||
|
||
/**
|
||
* The 2×2 offsets `DRLGROOMTILE_LoadFloorWarpTiles` writes its threshold under.
|
||
*
|
||
* Verbatim `gWarpTileOffsets_6FDD1320`; each is applied to `(nX - 1, nY - 1)`, so
|
||
* the patch covers the doorway cell and the three cells up-left of it.
|
||
*/
|
||
const WARP_TILE_OFFSETS: readonly (readonly [number, number])[] = [
|
||
[0, 0], [1, 0], [0, 1], [1, 1],
|
||
]
|
||
|
||
/**
|
||
* Decide whether a wall cell survives 1.13c's load pass.
|
||
*
|
||
* Three rules from `DRLGROOMTILE_LoadInitRoomTiles`, in the order it applies
|
||
* them:
|
||
*
|
||
* 1. An exit tile whose `style` is above {@link MAX_EXIT_WARP_SLOT} is skipped
|
||
* before anything else looks at it (line 466).
|
||
* 2. A *hidden* door tile spawns a door unit and `continue`s, so its wall
|
||
* artwork is never emitted (line 485).
|
||
* 3. A *hidden* exit tile registers a warp and `continue`s likewise (line 500).
|
||
*
|
||
* Everything else is ordinary artwork and is kept.
|
||
*
|
||
* @param wall - the wall cell as the piece authored it.
|
||
* @returns whether the wall should be drawn.
|
||
*/
|
||
function wallSurvivesLoad(wall: Ds1Wall): boolean {
|
||
const isExit = wall.type === TILETYPE_WALL_LEFT_EXIT || wall.type === TILETYPE_WALL_RIGHT_EXIT
|
||
if (isExit && wall.style > MAX_EXIT_WARP_SLOT) return false
|
||
if (!wall.hidden) return true
|
||
const isDoor = wall.type === TILETYPE_WALL_LEFT_DOOR || wall.type === TILETYPE_WALL_RIGHT_DOOR
|
||
return !(isDoor || isExit)
|
||
}
|
||
|
||
/**
|
||
* Whether a dropped wall is the kind that gets a threshold patch.
|
||
*
|
||
* 1.13c calls `DRLGROOMTILE_LoadFloorWarpTiles` from exactly one place
|
||
* (`DrlgRoomTile.cpp:505`): a *hidden exit* tile whose style is a real
|
||
* `Warp0..7` slot. A hidden *door* takes the branch above it and only spawns a
|
||
* door unit, and an out-of-range exit is skipped before either runs — neither
|
||
* lays down floor, so neither may widen the patch here.
|
||
*
|
||
* @param wall - the wall that `wallSurvivesLoad` rejected.
|
||
* @returns whether its cell needs the 2×2 floor patch.
|
||
*/
|
||
function needsThresholdBackfill(wall: Ds1Wall): boolean {
|
||
const isExit = wall.type === TILETYPE_WALL_LEFT_EXIT || wall.type === TILETYPE_WALL_RIGHT_EXIT
|
||
return isExit && wall.hidden && wall.style <= MAX_EXIT_WARP_SLOT
|
||
}
|
||
|
||
/**
|
||
* Whether a floor cell actually draws something.
|
||
*
|
||
* A slot left at style 0 / sequence 0 is the decoder's placeholder, which is the
|
||
* hole the threshold backfill exists to cover.
|
||
*
|
||
* @param floor - the floor cell.
|
||
* @returns whether it references a tile.
|
||
*/
|
||
function floorIsBlank(floor: Ds1Floor | undefined): boolean {
|
||
return floor === undefined || (floor.style === 0 && floor.sequence === 0 && floor.prop1 === 0)
|
||
}
|
||
|
||
/**
|
||
* Backfill the threshold floor under a doorway, the way 1.13c does.
|
||
*
|
||
* `DRLGROOMTILE_LoadFloorWarpTiles` reserves six floor slots per hidden exit
|
||
* (`DRLGROOMTILE_CountWallWarpTiles` does `pTiles.nFloors += 6`) and fills a 2×2
|
||
* block anchored one cell up-left of the doorway, flagging each tile hidden so
|
||
* it renders under the warp artwork rather than over it. Without it the cells a
|
||
* doorway punches through the wall line keep the piece's blank floor slot and
|
||
* the ground shows a hole at the threshold.
|
||
*
|
||
* The donor tile is the nearest non-blank floor around the doorway, which is the
|
||
* practical stand-in for `DRLGROOMTILE_GetTileCache(TILETYPE_FLOOR, ...)`: the
|
||
* room's own floor style is exactly what the tile cache would have returned.
|
||
*
|
||
* @param cells - the composite grid, mutated in place.
|
||
* @param doorX - the doorway cell's x in the composite grid.
|
||
* @param doorY - the doorway cell's y in the composite grid.
|
||
* @returns how many blank cells were filled.
|
||
*/
|
||
function backfillThresholdFloor(cells: Ds1Cell[][], doorX: number, doorY: number): number {
|
||
const donor = findDonorFloor(cells, doorX, doorY)
|
||
if (donor === null) return 0
|
||
let filled = 0
|
||
for (const [dx, dy] of WARP_TILE_OFFSETS) {
|
||
const x = doorX - 1 + dx
|
||
const y = doorY - 1 + dy
|
||
const row = cells[y]
|
||
if (row === undefined) continue
|
||
const cell = row[x]
|
||
if (cell === undefined) continue
|
||
if (!floorIsBlank(cell.floors[0])) continue
|
||
const floors = [...cell.floors]
|
||
// 1.13c marks these `MAPTILE_HIDDEN` so they sit beneath the warp artwork.
|
||
floors[0] = { ...donor, hidden: true }
|
||
row[x] = { ...cell, floors }
|
||
filled += 1
|
||
}
|
||
return filled
|
||
}
|
||
|
||
/**
|
||
* Find a floor tile to copy into a doorway threshold.
|
||
*
|
||
* Searches the doorway cell first and then rings outwards, so the patch always
|
||
* picks up the floor of the room the doorway belongs to rather than a default.
|
||
*
|
||
* @param cells - the composite grid.
|
||
* @param atX - the doorway cell's x.
|
||
* @param atY - the doorway cell's y.
|
||
* @returns the donor floor, or `null` when nothing nearby draws a floor.
|
||
*/
|
||
function findDonorFloor(cells: readonly (readonly Ds1Cell[])[], atX: number, atY: number): Ds1Floor | null {
|
||
for (let radius = 0; radius <= 2; radius += 1) {
|
||
for (let dy = -radius; dy <= radius; dy += 1) {
|
||
for (let dx = -radius; dx <= radius; dx += 1) {
|
||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
|
||
const floor = cells[atY + dy]?.[atX + dx]?.floors[0]
|
||
if (!floorIsBlank(floor)) return floor!
|
||
}
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Choose each room's variant the way `DRLGMAZE_RollBasicPresets` does.
|
||
*
|
||
* For the plain side pieces D2MOO keeps a per-preset counter initialised to a
|
||
* random file index and then increments it modulo the file count, so two
|
||
* consecutive rooms of the same shape never get the same artwork. Specials and
|
||
* themed pieces keep whatever index the earlier pass assigned — for the Arcane
|
||
* Sanctuary that is the branch number, and for the Infernal Pit an explicit 0/1.
|
||
*
|
||
* @param level - the level.
|
||
* @param levelId - `Levels.txt` ID.
|
||
* @param stats - report collector.
|
||
*/
|
||
function assignVariants(level: MazeLevel, levelId: number, stats: MazeStats): void {
|
||
const counters = new Map<string, number>()
|
||
const usage = new Map<string, number>()
|
||
// D2MOO walks the roster from its head, i.e. newest room first.
|
||
for (const room of level.rooms) {
|
||
const piece = room.piece
|
||
if (piece === null) continue
|
||
usage.set(piece.name, (usage.get(piece.name) ?? 0) + 1)
|
||
if (piece.kind !== 'room' || piece.levels.length <= 1) continue
|
||
// Arcane Sanctuary (Level 74): branches and center room (level.first) have their
|
||
// variants explicitly assigned by arcaneSanctuary (DRLGMAZE_ArcaneSanctuary).
|
||
if (levelId === 74) continue
|
||
// On Palace Cellar Levels 1 & 2 (52, 53), SE and NW variants 0..2 carry the
|
||
// down-staircase warp tiles while variant 3 (CelSE3/CelNW3) is reserved for
|
||
// Level 54 (Palace Cellar Level 3) which has no descent to a fourth cellar.
|
||
const count = piece.name.startsWith('Act 2 - Basement') && levelId !== 54
|
||
? Math.min(piece.levels.length, 3)
|
||
: piece.levels.length
|
||
let counter = counters.get(piece.name)
|
||
if (counter === undefined) counter = level.rng.int(0, count - 1)
|
||
counters.set(piece.name, (counter + 1) % count)
|
||
room.variant = counter
|
||
}
|
||
for (const [name, count] of usage) stats.notes.push(`piece "${name}" used ${String(count)} time(s)`)
|
||
}
|
||
|
||
/**
|
||
* Flatten the room list into one synthesized map.
|
||
*
|
||
* The size is the union of the stamped piece extents — `(span of the room
|
||
* origins) + (piece size)`. Because a piece is one cell larger than the stride,
|
||
* consecutive rooms overlap by exactly one cell and the piece stamped later owns
|
||
* that border, reproducing `DRLGPRESET_InitPresetRoomGrids`, which fills
|
||
* `nTileWidth + 1` cells per room. D2MOO stamps in roster order (newest first),
|
||
* so the level's *first* room is stamped last and wins every overlap it takes
|
||
* part in; the same order is used here.
|
||
*
|
||
* Objects keep their per-piece sub-tile coordinates and gain the room origin
|
||
* converted from cells at 5 sub-tiles per cell.
|
||
*
|
||
* Two 1.13c load-time behaviours are reproduced here rather than at draw time,
|
||
* because this is the only pass that sees both the piece and its place:
|
||
*
|
||
* - **`bKillEdge`** (`DrlgPreset.cpp:1105`, `DrlgRoomTile.cpp:449`). When the
|
||
* level type's `LvlPrest.KillEdge` is set, the room flush against the map's
|
||
* outer edge stamps `nTileWidth` cells instead of `nTileWidth + 1`, so the
|
||
* shared-border overhang that has no neighbour to be overwritten by is
|
||
* dropped instead of duplicating the wall line one cell past the map.
|
||
* - **Hidden door and exit walls** (`DrlgRoomTile.cpp:466`, `479`). A hidden
|
||
* door or exit tile is a marker, not artwork: 1.13c spawns a door unit or
|
||
* registers a warp and `continue`s, then backfills the threshold with the 2×2
|
||
* floor patch `DRLGROOMTILE_LoadFloorWarpTiles` writes. Emitting the wall
|
||
* instead leaves the doorway blocked and the threshold floor blank.
|
||
*
|
||
* @param level - the level.
|
||
* @param stats - report collector.
|
||
* @param killEdge - the level type's `LvlPrest.KillEdge`.
|
||
* @returns the synthesized map.
|
||
*/
|
||
function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean, effectiveAct?: number): Ds1 {
|
||
const minX = Math.min(...level.rooms.map(room => room.x))
|
||
const minY = Math.min(...level.rooms.map(room => room.y))
|
||
const maxX = Math.max(...level.rooms.map(room => room.x))
|
||
const maxY = Math.max(...level.rooms.map(room => room.y))
|
||
|
||
let wallLayers = 1
|
||
let floorLayers = 1
|
||
let substitutionType = 0
|
||
let version = 0
|
||
let act = effectiveAct ?? 1
|
||
let widest = 1
|
||
let highest = 1
|
||
for (const room of level.rooms) {
|
||
const piece = room.piece
|
||
if (piece === null) continue
|
||
const variant = piece.levels[room.variant % piece.levels.length]
|
||
if (variant === undefined) throw new Error(`piece "${piece.name}" has no decoded map to stamp`)
|
||
wallLayers = Math.max(wallLayers, variant.wallLayers)
|
||
floorLayers = Math.max(floorLayers, variant.floorLayers)
|
||
substitutionType = Math.max(substitutionType, variant.substitutionType)
|
||
version = Math.max(version, variant.version)
|
||
if (effectiveAct === undefined) act = variant.act
|
||
widest = Math.max(widest, variant.width)
|
||
highest = Math.max(highest, variant.height)
|
||
}
|
||
|
||
// `KillEdge` shortens the map by the overhang column and row that the
|
||
// right-most and bottom-most rooms would otherwise contribute. A piece is one
|
||
// cell wider than its section so that neighbours share a border; at the outer
|
||
// edge there is no neighbour to share with, so 1.13c drops it rather than
|
||
// letting the piece spill past `pDrlgCoord.nWidth`
|
||
// (`DrlgPreset.cpp:1109`, `DrlgRoomTile.cpp:449`).
|
||
const edgeTrim = killEdge ? 1 : 0
|
||
const width = (maxX - minX) + Math.max(1, widest - edgeTrim)
|
||
const height = (maxY - minY) + Math.max(1, highest - edgeTrim)
|
||
if (width <= 0 || height <= 0 || width > MAX_CELLS_PER_SIDE || height > MAX_CELLS_PER_SIDE
|
||
|| width * height > MAX_CELLS) {
|
||
throw new Error(`synthesized map is ${String(width)}x${String(height)} cells, outside the supported bound`)
|
||
}
|
||
|
||
const substitutionLayers = substitutionType === 1 || substitutionType === 2 ? 1 : 0
|
||
const cells: Ds1Cell[][] = []
|
||
for (let y = 0; y < height; y += 1) {
|
||
const row: Ds1Cell[] = []
|
||
for (let x = 0; x < width; x += 1) {
|
||
row.push({
|
||
walls: Array.from({ length: wallLayers }, emptyWall),
|
||
floors: Array.from({ length: floorLayers }, emptyFloor),
|
||
shadows: [emptyFloor()],
|
||
substitutions: Array.from({ length: substitutionLayers }, () => ({ value: 0 })),
|
||
})
|
||
}
|
||
cells.push(row)
|
||
}
|
||
|
||
const objects: Ds1Object[] = []
|
||
// Doorway cells discovered while blitting; their thresholds are backfilled
|
||
// after every room is down, so a neighbour's floor can serve as the donor.
|
||
const thresholds: { x: number; y: number }[] = []
|
||
let stampedRooms = 0
|
||
let killedEdgeX = 0
|
||
let killedEdgeY = 0
|
||
let hiddenWalls = 0
|
||
for (const room of level.rooms) {
|
||
const piece = room.piece
|
||
if (piece === null) continue
|
||
const variant = piece.levels[room.variant % piece.levels.length]!
|
||
const originX = room.x - minX
|
||
const originY = room.y - minY
|
||
|
||
// `bKillEdgeX = pDrlgRoom->nTileXPos + pDrlgRoom->nTileWidth == pDrlgCoord.nPosX + nWidth`
|
||
// — true only for the room flush against the map's outer edge, which is the
|
||
// one whose overhang has no neighbour to be overwritten by.
|
||
const killEdgeX = killEdge && originX + room.width >= width
|
||
const killEdgeY = killEdge && originY + room.height >= height
|
||
if (killEdgeX) killedEdgeX += 1
|
||
if (killEdgeY) killedEdgeY += 1
|
||
|
||
// `nTileCountX = pDrlgRoom->nTileWidth + (bKillEdgeX == 0)`. The clamp keeps
|
||
// an oddly-sized piece from running off the grid rather than trusting that
|
||
// every piece is exactly one cell wider than its section.
|
||
const spanX = Math.min(variant.width - (killEdgeX ? 1 : 0), width - originX)
|
||
const spanY = Math.min(variant.height - (killEdgeY ? 1 : 0), height - originY)
|
||
if (originX + spanX > width || originY + spanY > height) {
|
||
throw new Error(`piece "${piece.name}" overflows the ${String(width)}x${String(height)} map`)
|
||
}
|
||
for (let y = 0; y < spanY; y += 1) {
|
||
const source = variant.cells[y]
|
||
if (source === undefined) continue
|
||
const target = cells[originY + y]!
|
||
for (let x = 0; x < spanX; x += 1) {
|
||
const cell = source[x]
|
||
if (cell === undefined) continue
|
||
// 1.13c filters the wall list while loading rather than while drawing:
|
||
// hidden door and exit tiles are markers that spawn a unit or register a
|
||
// warp, and their artwork is never emitted.
|
||
const kept = cell.walls.filter(wallSurvivesLoad)
|
||
if (kept.length === cell.walls.length) {
|
||
target[originX + x] = cell
|
||
} else {
|
||
hiddenWalls += cell.walls.length - kept.length
|
||
while (kept.length < cell.walls.length) kept.push(emptyWall())
|
||
target[originX + x] = { ...cell, walls: kept }
|
||
if (cell.walls.some(needsThresholdBackfill)) {
|
||
thresholds.push({ x: originX + x, y: originY + y })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const object of variant.objects) {
|
||
objects.push({
|
||
type: object.type,
|
||
id: object.id,
|
||
x: originX * SUB_TILES_PER_TILE + object.x,
|
||
y: originY * SUB_TILES_PER_TILE + object.y,
|
||
flags: object.flags,
|
||
})
|
||
}
|
||
// This is the only pass that knows both which piece a room got and where
|
||
// that room ends up, so it is also the only place the stairs can be
|
||
// located. `Down` is the Act 1 cave descent and `Next` the ordinary one;
|
||
// they differ in artwork, not in meaning.
|
||
if (piece.kind === 'prev' || piece.kind === 'next' || piece.kind === 'down') {
|
||
stats.warps.push({
|
||
room: room.id,
|
||
direction: piece.kind === 'prev' ? 'up' : 'down',
|
||
kind: piece.kind,
|
||
x: originX,
|
||
y: originY,
|
||
width: variant.width,
|
||
height: variant.height,
|
||
centreX: originX + Math.floor(variant.width / 2),
|
||
centreY: originY + Math.floor(variant.height / 2),
|
||
})
|
||
}
|
||
const wpObj = variant.objects.find(obj => isWaypointDs1Object(act, obj.type, obj.id))
|
||
if (piece.kind === 'waypoint' || wpObj !== undefined) {
|
||
let entityX: number
|
||
let entityY: number
|
||
if (wpObj !== undefined) {
|
||
entityX = originX * SUB_TILES_PER_TILE + wpObj.x
|
||
entityY = originY * SUB_TILES_PER_TILE + wpObj.y
|
||
} else {
|
||
entityX = (originX + Math.floor(variant.width / 2)) * SUB_TILES_PER_TILE + 2
|
||
entityY = (originY + Math.floor(variant.height / 2)) * SUB_TILES_PER_TILE + 2
|
||
const defaultWpId = act === 2 ? 156 : act === 3 ? 237 : act === 4 ? 238 : act === 5 ? 496 : 119
|
||
objects.push({
|
||
type: 2,
|
||
id: defaultWpId,
|
||
x: entityX,
|
||
y: entityY,
|
||
flags: 0,
|
||
})
|
||
}
|
||
stats.waypoints.push({
|
||
room: room.id,
|
||
x: originX,
|
||
y: originY,
|
||
width: variant.width,
|
||
height: variant.height,
|
||
centreX: originX + Math.floor(variant.width / 2),
|
||
centreY: originY + Math.floor(variant.height / 2),
|
||
entityX,
|
||
entityY,
|
||
})
|
||
}
|
||
stampedRooms += 1
|
||
}
|
||
|
||
// Thresholds are filled only after every room is down: a doorway sits on the
|
||
// border between two rooms, so the donor floor may belong to the neighbour
|
||
// that had not been stamped yet when the doorway was found.
|
||
let thresholdCells = 0
|
||
for (const at of thresholds) thresholdCells += backfillThresholdFloor(cells, at.x, at.y)
|
||
|
||
stats.notes.push(`stamped ${String(stampedRooms)} of ${String(level.rooms.length)} rooms`)
|
||
if (killEdge) {
|
||
stats.notes.push(`killEdge trimmed ${String(killedEdgeX)} east and ${String(killedEdgeY)} south room edges`)
|
||
}
|
||
if (hiddenWalls > 0) {
|
||
stats.notes.push(`hid ${String(hiddenWalls)} door/exit walls, backfilled ${String(thresholdCells)} threshold floor cells`)
|
||
}
|
||
|
||
return {
|
||
version: version === 0 ? 18 : version,
|
||
width,
|
||
height,
|
||
act,
|
||
substitutionType,
|
||
wallLayers,
|
||
floorLayers,
|
||
cells,
|
||
objects,
|
||
// The synthesized map is written into an asset pack rather than re-encoded,
|
||
// so there is no NPC-path section to point at.
|
||
npcPathOffset: null,
|
||
}
|
||
}
|
||
|
||
/** Largest synthesized map side, in cells. Well above the widest shipped maze. */
|
||
const MAX_CELLS_PER_SIDE = 4096
|
||
/** Largest synthesized map area, matching the decoder's own sanity limit. */
|
||
const MAX_CELLS = 1 << 22
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Connectivity
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* Flood fill the doorway graph from the first room.
|
||
*
|
||
* The generator's correctness claim is "every placed room is reachable", so it is
|
||
* verified rather than asserted: growth attaches each room to an existing one and
|
||
* `Merge` only adds doorways, so this must always reach everything.
|
||
*
|
||
* @param level - the level.
|
||
* @returns the number of rooms reached.
|
||
*/
|
||
function reachableRooms(level: MazeLevel): number {
|
||
const seen = new Set<Room>([level.first])
|
||
const queue: Room[] = [level.first]
|
||
while (queue.length > 0) {
|
||
const room = queue.pop()!
|
||
for (const orth of room.orths) {
|
||
if (seen.has(orth.room)) continue
|
||
seen.add(orth.room)
|
||
queue.push(orth.room)
|
||
}
|
||
}
|
||
return seen.size
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Entry point
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* Generate one random-maze level.
|
||
*
|
||
* The pipeline is D2MOO's `DRLGMAZE_GenerateLevel`: an opening (ring and/or the
|
||
* Catacombs arms and/or the tomb prologue), optional growth to `LvlMaze.Rooms`
|
||
* sections, a preset for every room from its side pattern, the `Theme` pass, the
|
||
* `Prev`/`Next`/quest replacement passes, and finally one synthesized map.
|
||
*
|
||
* @param request - the real table data for one `DrlgType == 1` level.
|
||
* @returns the synthesized map plus a report of what was done.
|
||
* @throws when the level cannot be built — an unknown level type, a placement
|
||
* stall, or rooms that ended up unreachable. The message always names the level,
|
||
* because an empty map silently baked into an asset pack is worse than a crash.
|
||
*/
|
||
export function generateMaze(request: MazeRequest): MazeResult {
|
||
const width = request.sectionSize
|
||
const height = request.sectionHeight ?? request.sectionSize
|
||
const where = `level ${String(request.levelId)} (${request.levelName})`
|
||
if (!Number.isFinite(width) || width <= 0) throw new Error(`${where}: LvlMaze.SizeX is ${String(width)}`)
|
||
if (!Number.isFinite(height) || height <= 0) throw new Error(`${where}: LvlMaze.SizeY is ${String(height)}`)
|
||
if (request.pieces.length === 0) throw new Error(`${where}: no usable LvlPrest pieces`)
|
||
if (!Number.isFinite(request.minRooms) || request.minRooms < 1) {
|
||
throw new Error(`${where}: LvlMaze.Rooms is ${String(request.minRooms)}`)
|
||
}
|
||
|
||
const levelTypeName = request.levelTypeName ?? inferLevelTypeName(request.pieces)
|
||
if (levelTypeName === null) throw new Error(`${where}: cannot infer the level type from the piece names`)
|
||
const profile = MAZE_LEVEL_TYPE_PROFILES.find(candidate => candidate.levelTypeName === levelTypeName)
|
||
const animSpeed = request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed ?? profile?.animSpeed
|
||
if (profile === undefined) throw new Error(`${where}: no maze profile for level type "${levelTypeName}"`)
|
||
|
||
const stats: MazeStats = {
|
||
patterns: {}, fallbacks: [], specialsApplied: [], specialPassesApplied: [], unresolvedRoles: [], notes: [],
|
||
ringRooms: 0, placementAttempts: 0, warps: [], waypoints: [],
|
||
}
|
||
const rng = new Rng(request.seed)
|
||
const mergePerMille = Math.max(0, Math.min(1000, Math.floor(request.merge)))
|
||
const index = buildPieceIndex(request.pieces)
|
||
const maxCellsX = request.maxCellsX !== undefined
|
||
? request.maxCellsX
|
||
: (request.maxSectionsX !== undefined ? request.maxSectionsX * width : undefined)
|
||
const maxCellsY = request.maxCellsY !== undefined
|
||
? request.maxCellsY
|
||
: (request.maxSectionsY !== undefined ? request.maxSectionsY * height : undefined)
|
||
|
||
const level: MazeLevel = {
|
||
rooms: [], first: null as unknown as Room, width, height, rng, mergePerMille, nextId: 0, merges: 0,
|
||
maxCellsX, maxCellsY, stats,
|
||
}
|
||
const first = makeRoom(level)
|
||
const levelRef: MazeLevel = { ...level, rooms: [first], first }
|
||
// `first` was built against `level`, whose `rooms` array is the live one; copy
|
||
// the identity over rather than duplicating state.
|
||
Object.assign(level, levelRef)
|
||
|
||
// `DRLGMAZE_GetRandomRoomExFromLevel` draws uniformly over the roster and the
|
||
// level seed advances on every call, so the seed must be consumed in the same
|
||
// order as the passes below.
|
||
let target = request.minRooms
|
||
if (request.staffTombLevelId === request.levelId) target *= 3
|
||
else if (request.bossTombLevelId === request.levelId) target *= 2
|
||
|
||
const singleNames = profile.singleRoom?.[request.levelId]
|
||
if (singleNames !== undefined) {
|
||
const piece = singleNames.map(name => request.pieces.find(candidate => candidate.name === name))
|
||
.find(found => found !== undefined)
|
||
if (piece === undefined) {
|
||
for (const name of singleNames) stats.unresolvedRoles.push(name)
|
||
stats.notes.push('single-room level: no named preset resolved, using the first piece')
|
||
const fallback = request.pieces[0]!
|
||
first.piece = fallback
|
||
first.hasDs1 = true
|
||
noteSpecial(first, fallback, 'single room', stats)
|
||
} else {
|
||
first.piece = piece
|
||
first.hasDs1 = true
|
||
noteSpecial(first, piece, 'single room', stats)
|
||
}
|
||
} else {
|
||
if (profile.ring !== undefined) {
|
||
const roomsPerDirection = levelTypeName === 'Act 3 - Sewer' && request.levelId === 92 ? 5 : profile.ring
|
||
ringLayout(level, roomsPerDirection, index, stats)
|
||
}
|
||
if (profile.opening === 'catacombs') {
|
||
// D2MOO branches on the level id before rolling, so level 1 consumes no
|
||
// random number here.
|
||
const horizontal = request.levelId === 34 ? false : rng.int(0, 1) === 1
|
||
catacombsOpening(level, horizontal, request.pieces, index, stats)
|
||
}
|
||
if (profile.prevChain !== undefined) prevChain(level, profile.prevChain, request.pieces, index, stats)
|
||
if (profile.arcane === true) arcaneSanctuary(level, request.pieces, index, stats)
|
||
if (profile.lavaPairs === true) lavaPairs(level, request.pieces, index, stats)
|
||
if (profile.grow) stats.placementAttempts = growMaze(level, target, index, stats)
|
||
|
||
// Every room needs a piece before the replacement passes can look for the
|
||
// plain ones to convert.
|
||
for (const room of level.rooms) {
|
||
if (room.piece === null) pickRoomPreset(room, index, stats)
|
||
}
|
||
|
||
if (profile.theme) themeRoll(level, request.pieces, stats)
|
||
|
||
// One counter shared by every table, advanced once per call that happens.
|
||
let cursor = rng.int(0, 3)
|
||
for (const group of profile.specials) {
|
||
if (group.kind === 'waypoint') {
|
||
if (request.hasWaypoint === false) continue
|
||
if (request.hasWaypoint !== true) {
|
||
if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue
|
||
if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue
|
||
}
|
||
} else {
|
||
if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue
|
||
if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue
|
||
}
|
||
placeSpecialGroup(level, request.pieces, group, cursor, index, stats)
|
||
cursor = (cursor + 1) % 4
|
||
}
|
||
}
|
||
|
||
if (profile.specialPass !== undefined) {
|
||
runMazeSpecialPasses(profile.specialPass, {
|
||
level,
|
||
request,
|
||
pieces: request.pieces,
|
||
index,
|
||
stats,
|
||
rng,
|
||
})
|
||
}
|
||
|
||
const unreachable = level.rooms.length - reachableRooms(level)
|
||
if (unreachable !== 0) {
|
||
throw new Error(`${where}: ${String(unreachable)} of ${String(level.rooms.length)} rooms are unreachable`)
|
||
}
|
||
const orphans = level.rooms.filter(room => room.piece === null).length
|
||
if (orphans !== 0) throw new Error(`${where}: ${String(orphans)} rooms ended up with no piece`)
|
||
|
||
assignVariants(level, request.levelId, stats)
|
||
// 1.13c reads `dwKillEdge` off the maze map's own `LvlPrest` record. The
|
||
// shipped table sets the column uniformly across a level type's piece rows, so
|
||
// deriving it from the pieces gives the same answer without asking callers to
|
||
// look the maze map up separately.
|
||
const killEdge = request.killEdge ?? request.pieces.some(piece => piece.killEdge === true)
|
||
const effectiveAct = actOfLevel(request.levelId, levelTypeName)
|
||
const stamped = stamp(level, stats, killEdge, effectiveAct)
|
||
|
||
const minX = Math.min(...level.rooms.map(room => room.x))
|
||
const minY = Math.min(...level.rooms.map(room => room.y))
|
||
const roomDescriptors: MazeRoomDescriptor[] = level.rooms.map(room => {
|
||
const variant = room.piece ? room.piece.levels[room.variant % room.piece.levels.length] : undefined
|
||
return {
|
||
id: room.id,
|
||
x: room.x,
|
||
y: room.y,
|
||
width: variant?.width ?? width,
|
||
height: variant?.height ?? height,
|
||
orths: room.orths,
|
||
piece: room.piece,
|
||
}
|
||
})
|
||
|
||
const popOptions: MazeObjectPopulationOptions | undefined =
|
||
typeof request.objectDensity === 'number'
|
||
? { density: request.objectDensity, ...request.objectOptions }
|
||
: { ...request.objectDensity, ...request.objectOptions }
|
||
|
||
const objectRng = popOptions?.objectSeed !== undefined
|
||
? new Rng(popOptions.objectSeed)
|
||
: rng.fork('dungeon-objects')
|
||
|
||
const popResult = populateDungeonObjects(
|
||
roomDescriptors,
|
||
minX,
|
||
minY,
|
||
stamped,
|
||
levelTypeName,
|
||
request.levelId,
|
||
popOptions,
|
||
objectRng,
|
||
)
|
||
|
||
const finalLevel: Ds1 = {
|
||
...stamped,
|
||
objects: [...stamped.objects, ...popResult.placedObjects],
|
||
}
|
||
|
||
return {
|
||
level: finalLevel,
|
||
objects: popResult.placedObjects,
|
||
stats: {
|
||
levelId: request.levelId,
|
||
levelName: request.levelName,
|
||
levelTypeName,
|
||
seed: request.seed,
|
||
sectionX: width,
|
||
sectionY: height,
|
||
targetRooms: target,
|
||
roomsPlaced: level.rooms.length,
|
||
ringRooms: stats.ringRooms,
|
||
placementAttempts: stats.placementAttempts,
|
||
doorways: level.rooms.reduce((sum, room) => sum + room.orths.length, 0) / 2,
|
||
mergePerMille,
|
||
mergedDoorways: level.merges,
|
||
gridSize: { width: stamped.width, height: stamped.height },
|
||
gridSizeSubtiles: { width: stamped.width * SUB_TILES_PER_TILE, height: stamped.height * SUB_TILES_PER_TILE },
|
||
objects: finalLevel.objects.length,
|
||
dynamicObjects: popResult.placedObjects,
|
||
dynamicObjectsCount: popResult.stats.dynamicObjectsCount,
|
||
shrinesSpawned: popResult.stats.shrinesSpawned,
|
||
chestsSpawned: popResult.stats.chestsSpawned,
|
||
superChestsSpawned: popResult.stats.superChestsSpawned,
|
||
racksSpawned: popResult.stats.racksSpawned,
|
||
containersSpawned: popResult.stats.containersSpawned,
|
||
cells: stamped.width * stamped.height,
|
||
sidePatterns: stats.patterns,
|
||
fallbacks: stats.fallbacks,
|
||
specialsApplied: stats.specialsApplied,
|
||
specialPassesApplied: stats.specialPassesApplied,
|
||
warps: stats.warps,
|
||
waypoints: stats.waypoints,
|
||
waypoint: stats.waypoints[0] ?? null,
|
||
waypointTile: stats.waypoints[0]
|
||
? {
|
||
x: Math.floor(stats.waypoints[0].entityX / SUB_TILES_PER_TILE),
|
||
y: Math.floor(stats.waypoints[0].entityY / SUB_TILES_PER_TILE),
|
||
}
|
||
: null,
|
||
unresolvedRoles: stats.unresolvedRoles,
|
||
unimplementedPasses: unimplementedPassesFor(levelTypeName),
|
||
maxCellsX,
|
||
maxCellsY,
|
||
boundsRejected: stats.boundsRejected ?? 0,
|
||
notes: stats.notes,
|
||
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||
},
|
||
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||
}
|
||
}
|