diablo2-web/src/game/wilderness.ts

3768 lines
136 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Diablo II's wilderness level generator (`Levels.txt.DrlgType == 3`).
*
* 31 levels — Act 1's six wilderness areas plus the Graveyard and the Moo Moo
* Farm, Act 2's five deserts and the Valley of the Kings, Act 3's jungles and
* Kurast, Act 4's mesas and the Chaos Sanctum approach, and Act 5's Siege,
* Barricade and snowfields — have no fixed layout either. Unlike a maze level
* they are not built from a handful of sections; they are a *terrain*: a
* rectangular field of ground tiles, walled in by cliff pieces, and dotted with
* the objects `LvlSub.txt` describes.
*
* ## Provenance
*
* An **independent TypeScript port** of the outdoor generator 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. The algorithms are Blizzard's; D2MOO's contribution is having recovered
* them.
*
* Sources, all under `source/D2Common/` in D2MOO:
*
* - `src/Drlg/DrlgOutdoors.cpp` — `DRLGOUTDOORS_GenerateLevel` (the entry point),
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` (how a preset DS1 is stamped into
* the block grid), `DRLGOUTDOORS_AddAct124SecondaryBorder`,
* `DRLGOUTDOORS_PlaceAct1245OutdoorBorders` (in `DrlgOutPlace.cpp`),
* `DRLGOUTWILD_InitAct1OutdoorLevel`.
* - `src/Drlg/DrlgOutPlace.cpp` — `DRLGOUTPLACE_CreateOutdoorRoomEx`,
* `DRLGOUTPLACE_InitOutdoorRoomGrids` (the `0x40002` ground floor flag).
* - `src/Drlg/DrlgTileSub.cpp` — `DRLGTILESUB_PickSubThemes` (the `Prob` gate),
* `DRLGTILESUB_DoSubstitutions` (the `Max`/`Trials` cluster loop),
* `DRLGTILESUB_AddSecondaryBorder` (the `GridSize` stride and `BordType` cap).
* - `include/DataTbls/LevelsTbls.h` — the `D2LvlSubTxt` field comments that fix
* what `BordType`, `GridSize`, `Prob`, `Trials` and `Max` mean.
* - `doc/Coordinates.md` — the coordinate systems.
*
* ## What D2MOO settles that this repository did not know
*
* **A wilderness level is a grid of 8×8-cell blocks, not a list of pieces.**
* `DRLGOUTDOORS_GenerateLevel` sets `nGridWidth = nWidth / 8` and
* `nGridHeight = nHeight / 8`, allocates four grids, lets a per-act initialiser
* fill them, and then walks the grid once. Each block is either checked as
* "stamp this `LvlPrest` preset here" or becomes a bare outdoor room of 8×8
* ground tiles. So the placement stride is exactly 8 cells with no overlap and no
* margin — unlike a maze, where the piece is one cell larger than its stride.
*
* **`LvlSub.GridSize` does not size the level.** `Levels.txt.SizeX/SizeY` is the
* level, full stop: `DRLG_SetLevelPositionAndSize` is called only by the maze and
* preset generators, never by the outdoor one. `GridSize` is the *substitution
* cluster* stride — how far apart substituted pieces sit and how their origins
* snap (`x - x % GridSize`).
*
* **`BordType` does not choose the border DS1.** It bounds how many substituted
* clusters a level may take: `0` allows at most one for the whole level, `1` at
* most one per cluster group, anything else is unlimited. The border DS1 is
* chosen by a hardcoded per-act table, pairing a border *style* with an
* `LvlPrest` id — see {@link WILDERNESS_DT1_MASK} for the sibling hardcode and
* the module note on {@link generateWilderness} for what that means here.
*
* **`ProbN`/`TrialsN`/`MaxN` are indexed by `Levels.txt.SubTheme`, not by
* difficulty.** The knowledge base lists them as five opaque groups. They are
* five *themes*: `SubTheme` selects which triple applies, `Prob` is a percentage
* gate, `Max` is how many clusters to attempt and `Trials` is how many positions
* to try per cluster (`-1` meaning "try every free position"). The table bears
* this out — `SubTheme` runs 0..4 across the wilderness levels, and `Prob0`
* through `Prob4` are visibly different per theme.
*
* **The ground tile is not in any table.** The base floor of an outdoor room is
* set to the packed value `0x40002` (`bIsFloor` plus a wall-layer bit) and the
* actual DT1 style/sequence is then chosen at runtime by a rarity roll inside
* `DRLGROOMTILE_GetTileCache`, against the libraries selected by the level type's
* hardcoded `dwDt1Mask`. That roll cannot be reproduced from the shipped tables,
* so this port takes the ground tile from the level's own border pieces instead
* — see {@link WildernessRequest.groundTile}.
*/
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall, Ds1Substitution } from '../formats/ds1.ts'
import { Rng } from './rng.ts'
import { SUB_TILES_PER_TILE } from './map.ts'
import type { Side } from './world-graph.ts'
/** One generator block, in cells. `DRLGOUTDOORS_GenerateLevel` divides by 8. */
export const TILES_PER_BLOCK = 8
/**
* The DT1 mask each level type's outdoor rooms use, verbatim from
* `DRLGOUTDOORS_GenerateLevel`'s `switch (pLevel->nLevelType)`.
*
* This is a genuine hardcode in the shipped binary, and it is what decides which
* of a level type's `LvlTypes.txt` `File 1..32` libraries supply the outdoor
* ground. Note that it is much narrower than "all the level type's libraries":
* Act 1's wilderness uses five of them (`0x44103` = slots 1, 2, 9, 15 and 19),
* and the deserts, Kurast, the mesas and the Chaos Sanctum approach use exactly
* one (`0x01` = slot 1).
*/
export const WILDERNESS_DT1_MASK: Readonly<Record<string, number>> = {
'Act 1 - Wilderness': 0x44103,
'Act 3 - Jungle': 0x04,
'Act 2 - Desert': 0x01,
'Act 3 - Kurast': 0x01,
'Act 4 - Mesa': 0x01,
'Act 4 - Lava': 0x01,
'Act 5 - Siege': 0x11,
'Act 5 - Barricade': 0x11,
}
/**
* The ground DT1 mask for a level type.
*
* @param levelTypeName - `LvlTypes.txt` name.
* @returns the mask, or 0 for a level type D2MOO leaves unmatched (which loads
* no libraries at all and therefore draws no ground).
*/
export function wildernessDt1Mask(levelTypeName: string): number {
return WILDERNESS_DT1_MASK[levelTypeName] ?? 0
}
/* ------------------------------------------------------------------------- *
* Public request / response shapes
* ------------------------------------------------------------------------- */
/**
* One `LvlPrest.txt` row that can be stamped into a wilderness level.
*
* These are the border and fill pieces — `Act 1 - Wild Border 1..12`,
* `Act 1 - Wild Cliff Border 2..10`, `Act 2 - Desert Border 1..12`,
* `Act 2 - Desert Fill *`, `Act 3 - Jungle W/E/...` and so on. Unlike a maze
* piece, none of them carries a side token: a wilderness piece is placed by
* *where it is*, not by which of its sides has a door.
*/
export interface WildernessPiece {
/** `LvlPrest.txt` `Name`. */
readonly name: string
/** Decoded variants, in `File1..File6` order. */
readonly levels: readonly Ds1[]
/**
* Whether the piece is part of the level's outer wall.
*
* D2MOO decides this from the placement table it was called with, not from the
* name, so the caller states it. Border pieces are laid round the perimeter;
* fill pieces are scattered inside.
*/
readonly border: boolean
}
/**
* One `LvlSub.txt` row: a set of objects or terrain patches the level may scatter.
*
* `Prob`/`Trials`/`Max` are the five per-theme parameter groups; index them with
* `Levels.txt.SubTheme`.
*/
export interface WildernessSubstitution {
/** `LvlSub.txt` `Name`, e.g. `Trees`. */
readonly name: string
/** `LvlSub.txt` `Type`, which `Levels.txt.SubType` (or `SubShrine`) selects. */
readonly type: number
/** `LvlSub.txt` `GridSize`: cluster stride and origin snap, in blocks. */
readonly gridSize: number
/** `LvlSub.txt` `BordType`: how many clusters the level may take. */
readonly bordType: number
/** `LvlSub.txt` `Dt1Mask`, OR-ed into the room's mask when the theme is picked. */
readonly dt1Mask: number
/** `LvlSub.txt` `Prob0..4`, in per-cent. */
readonly prob: readonly number[]
/** `LvlSub.txt` `Trials0..4`; `-1` means "try every free position". */
readonly trials: readonly number[]
/** `LvlSub.txt` `Max0..4`: clusters to attempt. */
readonly max: readonly number[]
/** The decoded `LvlSub.txt` `File`; index 0 is enough for every shipped row. */
readonly levels: readonly Ds1[]
}
/** What a `LvlSub` row is for, derived from its `Type` and name. */
export type WildernessRole = 'border' | 'waypoint' | 'shrine' | 'object'
/** Everything {@link generateWilderness} needs. */
export interface WildernessRequest {
/** `Levels.txt` `Id`. */
readonly levelId: number
/** `Levels.txt` `Name`. */
readonly levelName: string
/** `LvlTypes.txt` name, e.g. `Act 1 - Wilderness`. */
readonly levelTypeName: string
/** `Levels.txt` `SizeX`, in cells. */
readonly sizeX: number
/** `Levels.txt` `SizeY`, in cells. */
readonly sizeY: number
/** `Levels.txt` `SubType`, selecting the `LvlSub` rows to scatter. */
readonly subType: number
/** `Levels.txt` `SubTheme`, indexing `Prob`/`Trials`/`Max`. */
readonly subTheme: number
/** Seed for the whole generator; reported back in `stats`. */
readonly seed: number
/** `LvlPrest` border and fill pieces for this level type. */
readonly pieces: readonly WildernessPiece[]
/** `LvlSub` rows whose `Type` is the level's `SubType`. */
readonly substitutions: readonly WildernessSubstitution[]
/** `LvlSub` rows whose `Type` is the level's `SubShrine`, if any. */
readonly shrineSubstitutions?: readonly WildernessSubstitution[]
/**
* Special preset names to spawn in the level's interior.
*
* When omitted, defaults to {@link SPECIAL_PRESETS_BY_LEVEL} for the level's ID.
*/
readonly specialPresets?: readonly string[]
/**
* The ground tile to lay under everything, as a DT1 `style`/`sequence` pair.
*
* The shipped game leaves this to a runtime rarity roll over the DT1 libraries
* the level type's mask selects, which no table records. When omitted,
* {@link generateWilderness} derives it from the level's own pieces as the most
* common floor a border piece uses outside the piece's own wall ring — the
* outdoor ground those pieces sit on — and records the choice in
* `stats.groundTile`.
*/
readonly groundTile?: { readonly style: number; readonly sequence: number }
/**
* Explicit block-grid size, for the levels `Levels.txt` leaves unset.
*
* Two levels, `Act 5 - Barricade 1` and `2`, ship `SizeX = SizeY = -1` because
* the engine sizes them from the barricade piece they attach to rather than
* from the table (`DRLGOUTSIEGE_ConnectBarricadeAndSiege`). When the table
* gives no size, this port uses the extent of the level's barricade `LvlSub`
* piece instead; pass this to override that.
*/
readonly sizeOverride?: { readonly x: number; readonly y: number }
/**
* The cross-level openings this level must have, and which edge each is on.
*
* Supplied by the world graph rather than decided here, for two reasons.
*
* The first is that the sides are not a property of the level: Diablo II's
* outdoor placer re-picks them for every seed, subject to the constraint that
* two openings of one level may not share an edge. Hard-coding "the Cold
* Plains exit is on the north edge" states one legal roll as if it were a
* law.
*
* The second is that a seam has two ends. If this level puts its exit on the
* east edge, its neighbour must put the matching entrance on its west edge,
* and neither level can work that out alone. `assignGateSides` in
* `world-graph.ts` solves the whole act at once and hands each level the
* answer.
*
* When omitted the generator falls back to its own Act 1 table, which is what
* the tests and `verify-generators` use.
*/
readonly gates?: readonly PlannedGate[]
}
/** One cross-level opening, as decided by the act layout. */
export interface PlannedGate {
/** Which edge of this level the opening sits on. */
readonly side: Side
/** The level on the other side. */
readonly toLevelId: number
/** A human-readable name, used for the road anchor. */
readonly label: string
/**
* Which border-piece variant to open, if the caller has a preference.
*
* Three is the ordinary open gate; four is the variant the Burial Grounds and
* the Cold Plains use for the graveyard seam.
*/
readonly variant?: number
}
/** The generated level. */
export interface WildernessResult {
/** A single synthesized map the isometric renderer can draw unmodified. */
readonly level: Ds1
/** What the generator did, for reporting and tests. */
readonly stats: Record<string, unknown>
}
/** Canonical default open ground floor tiles per wilderness level type. */
export const CANONICAL_GROUND_TILES: Readonly<Record<string, { readonly style: number; readonly sequence: number }>> = {
'Act 1 - Wilderness': { style: 0, sequence: 0 },
'Act 2 - Desert': { style: 0, sequence: 1 },
'Act 3 - Jungle': { style: 0, sequence: 0 },
'Act 3 - Kurast': { style: 0, sequence: 0 },
'Act 4 - Mesa': { style: 10, sequence: 0 },
'Act 4 - Lava': { style: 20, sequence: 3 },
'Act 5 - Siege': { style: 0, sequence: 0 },
'Act 5 - Barricade': { style: 0, sequence: 0 },
}
/* ------------------------------------------------------------------------- *
* Classification
* ------------------------------------------------------------------------- */
/**
* Classify a `LvlSub.txt` row.
*
* `LvlSub.Type` is the join key against `Levels.txt.SubType` and `SubShrine`, and
* the shipped rows fall into fixed bands: `0..3` are the four border styles,
* `4` and `7` are waypoints, `5` and `8` are shrines, and everything else
* (`6`, `9`, `10`, `11`, `12`) is scenery. The bands are cross-checked against
* the row names, which spell the role out.
*
* @param name - `LvlSub.txt` `Name`.
* @param type - `LvlSub.txt` `Type`.
* @returns the role.
*/
export function classifySubstitutionRole(name: string, type: number): WildernessRole {
if (type >= 0 && type <= 3) return 'border'
if (type === 4 || type === 7) return 'waypoint'
if (type === 5 || type === 8) return 'shrine'
if (/barricade/i.test(name)) return 'border'
return 'object'
}
/* ------------------------------------------------------------------------- *
* The canvas
* ------------------------------------------------------------------------- */
/** 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 or shadow cell. */
function emptyFloor(): Ds1Floor {
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/** The mutable map under construction. */
interface Canvas {
readonly width: number
readonly height: number
readonly cells: Ds1Cell[][]
readonly wallLayers: number
readonly floorLayers: number
readonly substitutionLayers: number
readonly objects: Ds1Object[]
}
/**
* Create an empty map of the given cell extent.
*
* @param width - cells.
* @param height - cells.
* @param wallLayers - wall layers every cell carries.
* @param floorLayers - floor layers every cell carries.
* @param substitutionLayers - substitution layers every cell carries.
* @returns the canvas.
*/
function createCanvas(width: number, height: number, wallLayers: number, floorLayers: number, substitutionLayers: number): Canvas {
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)
}
return { width, height, cells, wallLayers, floorLayers, substitutionLayers, objects: [] }
}
/**
* Copy a decoded map's cells into the canvas at a cell offset.
*
* Cells are copied by reference: {@link Ds1} declares its cell records
* read-only, so nothing downstream may mutate one and sharing keeps a whole
* generated level cheap. Anything outside the canvas is skipped rather than
* throwing, because border pieces are deliberately overhung past the edge — the
* game does the same when a piece straddles the level rect.
*
* Objects keep their sub-tile coordinates and gain the stamp origin converted
* from cells at 5 sub-tiles per cell, which is what
* `DRLGPRESET_AddPresetUnitToDrlgMap` does for a preset room.
*
* @param canvas - the canvas.
* @param source - the piece to stamp.
* @param originX - left edge in cells.
* @param originY - top edge in cells.
* @returns the number of cells actually written.
*/
function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number): number {
let written = 0
for (let y = 0; y < source.height; y += 1) {
const targetY = originY + y
if (targetY < 0 || targetY >= canvas.height) continue
const sourceRow = source.cells[y]
if (sourceRow === undefined) continue
const targetRow = canvas.cells[targetY]!
for (let x = 0; x < source.width; x += 1) {
const targetX = originX + x
if (targetX < 0 || targetX >= canvas.width) continue
const cell = sourceRow[x]
if (cell === undefined) continue
const targetCell = targetRow[targetX]!
// Floors: copy any non-empty floor layer from source. If source cell has no
// non-empty floors, target's ground floor is preserved intact.
for (let i = 0; i < cell.floors.length; i += 1) {
const floor = cell.floors[i]
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
const targetFloors = targetCell.floors as Ds1Floor[]
if (i < targetFloors.length) {
targetFloors[i] = floor
} else {
targetFloors.push(floor)
}
}
}
// Walls: copy any non-empty wall layer from source.
for (let i = 0; i < cell.walls.length; i += 1) {
const wall = cell.walls[i]
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
const targetWalls = targetCell.walls as Ds1Wall[]
if (i < targetWalls.length) {
targetWalls[i] = wall
} else {
targetWalls.push(wall)
}
}
}
// Shadows: copy any non-empty shadow layer from source.
for (let i = 0; i < cell.shadows.length; i += 1) {
const shadow = cell.shadows[i]
if (shadow !== undefined && !shadow.hidden && shadow.prop1 !== 0) {
const targetShadows = targetCell.shadows as Ds1Floor[]
if (i < targetShadows.length) {
targetShadows[i] = shadow
} else {
targetShadows.push(shadow)
}
}
}
// Substitutions: copy non-zero substitution layer values.
for (let i = 0; i < cell.substitutions.length; i += 1) {
const sub = cell.substitutions[i]
if (sub !== undefined && sub.value !== 0) {
const targetSubs = targetCell.substitutions as Ds1Substitution[]
if (i < targetSubs.length) {
targetSubs[i] = sub
} else {
targetSubs.push(sub)
}
}
}
written += 1
}
}
for (const object of source.objects) {
canvas.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,
})
}
return written
}
/**
* Write one floor tile into every cell of a rectangle.
*
* Used for the ground. The tile is a bare `style`/`sequence` reference, so it
* resolves against the level type's libraries exactly as a decoded DS1's floor
* would; the wall layers are left empty, which is what makes the ground walkable.
*
* @param canvas - the canvas.
* @param tile - the floor reference.
* @param x - left edge in cells.
* @param y - top edge in cells.
* @param width - cells.
* @param height - cells.
* @returns the number of cells written.
*/
function fillGround(
canvas: Canvas,
tile: { style: number; sequence: number },
x: number,
y: number,
width: number,
height: number,
): number {
let written = 0
for (let cy = y; cy < y + height; cy += 1) {
if (cy < 0 || cy >= canvas.height) continue
const row = canvas.cells[cy]!
for (let cx = x; cx < x + width; cx += 1) {
if (cx < 0 || cx >= canvas.width) continue
const cell = row[cx]!
const floor = cell.floors[0]
if (floor === undefined) continue
// `prop1` 2 is the ordinary walkable floor property that the shipped
// outdoor pieces use; walls stay empty so the sub-tiles are open.
Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false })
written += 1
}
}
return written
}
/* ------------------------------------------------------------------------- *
* Ground selection
* ------------------------------------------------------------------------- */
/**
* Pick the tile the level's own pieces stand on.
*
* The shipped game picks the ground with a rarity roll over the DT1 libraries
* that the level type's hardcoded mask selects (`DRLGROOMTILE_GetTileCache`),
* and no table records which tile that lands on. The observable proxy is the
* pieces themselves: a border or fill DS1 is authored as a chunk of scenery
* sitting *on* the outdoor ground, so the floor it uses away from its own outer
* ring is that ground.
*
* The outer ring is excluded because that is where the cliff face and its
* transition tiles live. Cells with `prop1 == 0` are excluded because they are
* unused slots, not tiles. The most frequent survivor wins, ties going to the
* first seen so the choice is deterministic.
*
* @param pieces - the level's border and fill pieces.
* @returns the tile, or null when no piece carries any floor.
*/
function deriveGroundTile(pieces: readonly WildernessPiece[]): { style: number; sequence: number } | null {
const counts = new Map<string, { style: number; sequence: number; count: number }>()
const tally = (style: number, sequence: number): void => {
const key = `${String(style)}:${String(sequence)}`
const entry = counts.get(key)
if (entry === undefined) counts.set(key, { style, sequence, count: 1 })
else entry.count += 1
}
for (const piece of pieces) {
for (const level of piece.levels) {
for (let y = 0; y < level.height; y += 1) {
const row = level.cells[y]
if (row === undefined) continue
for (let x = 0; x < level.width; x += 1) {
if (x === 0 || y === 0 || x === level.width - 1 || y === level.height - 1) continue
const cell = row[x]
if (cell === undefined) continue
for (const floor of cell.floors) {
if (floor.hidden || floor.prop1 === 0) continue
tally(floor.style, floor.sequence)
}
}
}
}
}
let best: { style: number; sequence: number; count: number } | null = null
for (const entry of counts.values()) {
if (best === null || entry.count > best.count) best = entry
}
return best === null ? null : { style: best.style, sequence: best.sequence }
}
/* ------------------------------------------------------------------------- *
* Piece geometry
* ------------------------------------------------------------------------- */
/**
* A piece's extent in blocks.
*
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` computes exactly this, with integer
* division, to decide how many grid cells a stamped preset clears.
*
* @param piece - the piece.
* @param variant - which decoded variant to measure.
* @returns the extent in blocks, at least 1×1.
*/
function pieceBlocks(piece: WildernessPiece, variant: number): { x: number; y: number } {
const level = piece.levels[variant % piece.levels.length]
if (level === undefined) return { x: 1, y: 1 }
return {
x: Math.max(Math.floor(level.width / TILES_PER_BLOCK), 1),
y: Math.max(Math.floor(level.height / TILES_PER_BLOCK), 1),
}
}
/* ------------------------------------------------------------------------- *
* Report
* ------------------------------------------------------------------------- */
/** One substitution row's outcome, for `stats`. */
interface SubstitutionReport {
readonly name: string
readonly role: WildernessRole
readonly enabled: boolean
readonly clusters: number
}
/** A fixed SuperUnique boss or guard NPC anchored to a wilderness landmark or border gate. */
export interface LandmarkEntity {
readonly role: 'superunique' | 'npc_guard'
readonly id: string
readonly monsterId: string
readonly name: string
readonly landmark: string
readonly tileX: number
readonly tileY: number
readonly modifiers: readonly string[]
readonly minionCount: readonly [number, number]
readonly minionMonsterId: string
}
/** The report collector. */
interface WildernessStats {
readonly substitutions: SubstitutionReport[]
readonly borderPieces: Record<string, number>
readonly unresolved: string[]
readonly notes: string[]
borderStamped: number
groundCells: number
groundTile: { style: number; sequence: number } | null
sizeSource: string
roadCells: number
roadSegments: number
anchors: number
specialPresets: string[]
/** Every way into or out of the level, in cells. See `WildernessEntrance`. */
entrances: WildernessEntrance[]
waypointsSpawned?: number
waypointTile?: { readonly x: number; readonly y: number }
shrinesSpawned?: number
interactivesSpawned?: number
landmarkEntities?: LandmarkEntity[]
}
/**
* Exact 256-entry 8-neighbor bitmask lookup table for Act 1 dirt path tiles
* (`byte_6FDCF958` in `DrlgOutdoors.cpp` / `DRLG_OUTDOORS_GenerateDirtPath`).
*
* Given a cell `(x, y)` marked as part of a dirt path, sample its 8 neighbors:
* - bit 7 (`0x80`): `(x + 1, y - 1)`
* - bit 6 (`0x40`): `(x + 1, y )`
* - bit 5 (`0x20`): `(x + 1, y + 1)`
* - bit 4 (`0x10`): `(x , y - 1)`
* - bit 3 (`0x08`): `(x , y + 1)`
* - bit 2 (`0x04`): `(x - 1, y - 1)`
* - bit 1 (`0x02`): `(x - 1, y )`
* - bit 0 (`0x01`): `(x - 1, y + 1)`
*
* Non-zero entries give the DT1 `sequence` (with `style = 0`, `prop1 = 2`) for
* the resulting straight, corner, or transition dirt road tile.
*/
export const DIRT_PATH_TILE_LUT: Readonly<Uint8Array> = new Uint8Array([
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10, 0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15, 0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14,
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10, 0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15, 0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14,
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07, 0x04, 0x04, 0x0B, 0x25, 0x04, 0x04, 0x0B, 0x2B,
0x03, 0x03, 0x0C, 0x0C, 0x03, 0x03, 0x27, 0x27, 0x09, 0x09, 0x02, 0x2B, 0x09, 0x09, 0x2C, 0x1A,
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07, 0x17, 0x17, 0x29, 0x11, 0x17, 0x17, 0x29, 0x11,
0x03, 0x03, 0x0C, 0x0C, 0x03, 0x03, 0x27, 0x27, 0x2A, 0x2A, 0x2E, 0x2A, 0x2A, 0x2A, 0x21, 0x1F,
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10, 0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15, 0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x23, 0x14,
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10, 0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15, 0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14,
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07, 0x04, 0x04, 0x0B, 0x25, 0x04, 0x04, 0x0B, 0x25,
0x12, 0x12, 0x23, 0x23, 0x12, 0x12, 0x16, 0x16, 0x24, 0x24, 0x2D, 0x22, 0x24, 0x24, 0x1C, 0x1D,
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07, 0x17, 0x17, 0x29, 0x11, 0x17, 0x17, 0x29, 0x11,
0x12, 0x12, 0x23, 0x23, 0x12, 0x12, 0x16, 0x16, 0x18, 0x18, 0x19, 0x20, 0x18, 0x18, 0x1E, 0x01,
])
/**
* The hardcoded passes D2MOO runs for an outdoor level that this port does not
* reproduce. Reported verbatim in every result's `stats.unimplementedPasses` so
* the gap is visible rather than implied.
*/
export const UNIMPLEMENTED_PASSES: readonly string[] = [
'DRLGVER_CreateVertices',
'DRLGOUTPLACE_CreateLevelConnections',
'DRLGOUTDESR_InitAct2OutdoorLevel',
'DRLGOUTPLACE_InitAct3OutdoorLevel',
'DRLGOUTDOORS_InitAct4OutdoorLevel',
'DRLGOUTSIEGE_InitAct5OutdoorLevel',
'DRLG_GenerateJungles',
'DRLGOUTDOORS_SpawnAct3Mephisto',
]
/* ------------------------------------------------------------------------- *
* Border
* ------------------------------------------------------------------------- */
type BorderRole = 'tl' | 'tr' | 'br' | 'bl' | 'top' | 'right' | 'bottom' | 'left'
function borderRoleAt(x: number, y: number, gridWidth: number, gridHeight: number): BorderRole {
if (x === 0 && y === 0) return 'tl'
if (x === gridWidth - 1 && y === 0) return 'tr'
if (x === gridWidth - 1 && y === gridHeight - 1) return 'br'
if (x === 0 && y === gridHeight - 1) return 'bl'
if (y === 0) return 'top'
if (x === gridWidth - 1) return 'right'
if (y === gridHeight - 1) return 'bottom'
return 'left'
}
/**
* Find the directional border piece matching `DRLGOUTPLACE_PlaceAct1245OutdoorBorders`.
*
* In Diablo II:
* - Numeric `Border 1..8` map to: 1=Bottom, 2=Left, 3=Top, 4=Right,
* 5=Bottom-Left, 6=Top-Left, 7=Top-Right, 8=Bottom-Right.
* - Compass `Border N/S/E/W/NE/NW/SE/SW` (Act 3 Kurast) map directly by cardinal direction.
*/
function findDirectionalBorderPiece(pieces: readonly WildernessPiece[], role: BorderRole): WildernessPiece | null {
const numericPatterns: Record<BorderRole, RegExp> = {
bottom: /Border\s+1\b/i,
left: /Border\s+2\b/i,
top: /Border\s+3\b/i,
right: /Border\s+4\b/i,
bl: /Border\s+5\b/i,
tl: /Border\s+6\b/i,
tr: /Border\s+7\b/i,
br: /Border\s+8\b/i,
}
const compassPatterns: Record<BorderRole, RegExp> = {
top: /Border\s+N\b/i,
bottom: /Border\s+S\b/i,
right: /Border\s+E\b/i,
left: /Border\s+W\b/i,
tr: /Border\s+NE\b/i,
tl: /Border\s+NW\b/i,
br: /Border\s+SE\b/i,
bl: /Border\s+SW\b/i,
}
const hasExtendedNumericBorders = pieces.some(p => /Border\s+(?:9|1\d)\b/i.test(p.name))
if (!hasExtendedNumericBorders) {
const numPat = numericPatterns[role]
const matchingNum = pieces.find(p => numPat.test(p.name) && !/Cliff Border/i.test(p.name))
?? pieces.find(p => numPat.test(p.name))
if (matchingNum !== undefined) return matchingNum
}
const compPat = compassPatterns[role]
const matchingComp = pieces.find(p => compPat.test(p.name))
if (matchingComp !== undefined) return matchingComp
return null
}
/**
* Walk the block grid's outer ring, clockwise from the top-left.
*
* This is the path `DRLGOUTPLACE_PlaceAct1245OutdoorBorders` walks: it follows
* the level's outline vertex ring and stamps one preset per grid step along each
* edge with the edge's own preset id.
*
* @param gridWidth - blocks across.
* @param gridHeight - blocks down.
* @returns the ring's block coordinates, clockwise, each exactly once.
*/
function borderRing(gridWidth: number, gridHeight: number): { x: number; y: number }[] {
const ring: { x: number; y: number }[] = []
for (let x = 0; x < gridWidth; x += 1) ring.push({ x, y: 0 })
for (let y = 1; y < gridHeight; y += 1) ring.push({ x: gridWidth - 1, y })
for (let x = gridWidth - 2; x >= 0; x -= 1) ring.push({ x, y: gridHeight - 1 })
for (let y = gridHeight - 2; y >= 1; y -= 1) ring.push({ x: 0, y })
return ring
}
/** Orientation of a border piece along the outer edge of a level. */
export type BorderOrientation =
| 'north'
| 'south'
| 'east'
| 'west'
| 'corner-nw'
| 'corner-ne'
| 'corner-se'
| 'corner-sw'
export interface ClassifiedBorderPiece {
readonly piece: WildernessPiece
readonly orientation: BorderOrientation | null
readonly isCliff: boolean
}
/**
* Classify a border piece by its orientation and whether it is a stone cliff.
*
* Blizzard North uses a universal numbering convention across outdoor border presets:
* - 1 = South edge (horizontal straight)
* - 2 = West edge (vertical straight)
* - 3 = North edge (horizontal straight)
* - 4 = East edge (vertical straight)
* - 5 = SW corner
* - 6 = NW corner
* - 7 = NE corner
* - 8 = SE corner
* - 9..12 = corner / transition variations
*
* Act 3 outdoor tiles use cardinal suffixes ("Border N", "Border S", "Border NE", etc.).
* Cave entrances ("clfcave", "clfcave2") are dedicated structures, never generic border pieces.
*/
export function classifyBorderPiece(name: string): { orientation: BorderOrientation | null; isCliff: boolean } {
if (/cave/i.test(name)) return { orientation: null, isCliff: false }
const isCliff = /cliff|stnclf/i.test(name)
// Act 3 style: "Border N", "Border S", "Border NE", etc.
const cardinal = name.match(/Border\s+([NESW]{1,2})\b/i)
if (cardinal) {
const dir = cardinal[1]!.toUpperCase()
if (dir === 'N') return { orientation: 'north', isCliff }
if (dir === 'S') return { orientation: 'south', isCliff }
if (dir === 'E') return { orientation: 'east', isCliff }
if (dir === 'W') return { orientation: 'west', isCliff }
if (dir === 'NW') return { orientation: 'corner-nw', isCliff }
if (dir === 'NE') return { orientation: 'corner-ne', isCliff }
if (dir === 'SE') return { orientation: 'corner-se', isCliff }
if (dir === 'SW') return { orientation: 'corner-sw', isCliff }
}
// Act 1, 2, 4, 5 style: 1=S, 2=W, 3=N, 4=E, 5=SW, 6=NW, 7=NE, 8=SE
const numMatch = name.match(/(?:border|edge|brdr_?snow|brdr)\s*0?(\d+)/i)
if (numMatch) {
const num = Number(numMatch[1])
if (num === 1) return { orientation: 'south', isCliff }
if (num === 2) return { orientation: 'west', isCliff }
if (num === 3) return { orientation: 'north', isCliff }
if (num === 4) return { orientation: 'east', isCliff }
if (num === 5) return { orientation: 'corner-sw', isCliff }
if (num === 6) return { orientation: 'corner-nw', isCliff }
if (num === 7) return { orientation: 'corner-ne', isCliff }
if (num === 8) return { orientation: 'corner-se', isCliff }
}
return { orientation: null, isCliff }
}
/**
* Determine the perimeter orientation for a block cell on a `gridWidth x gridHeight` grid.
*/
function getCellOrientation(x: number, y: number, gridWidth: number, gridHeight: number): BorderOrientation {
if (x === 0 && y === 0) return 'corner-nw'
if (x === gridWidth - 1 && y === 0) return 'corner-ne'
if (x === gridWidth - 1 && y === gridHeight - 1) return 'corner-se'
if (x === 0 && y === gridHeight - 1) return 'corner-sw'
if (y === 0) return 'north'
if (x === gridWidth - 1) return 'east'
if (y === gridHeight - 1) return 'south'
return 'west'
}
/**
* Select a matching piece for the given orientation.
*/
function selectBorderPiece(
orientation: BorderOrientation,
classified: readonly ClassifiedBorderPiece[],
rng: Rng,
preferCliff: boolean,
): WildernessPiece | null {
const matches = classified.filter(c => c.orientation === orientation)
if (matches.length === 0) return null
if (preferCliff) {
const cliffMatches = matches.filter(c => c.isCliff)
if (cliffMatches.length > 0) {
return cliffMatches[rng.int(0, cliffMatches.length - 1)]!.piece
}
} else {
const nonCliffMatches = matches.filter(c => !c.isCliff)
if (nonCliffMatches.length > 0) {
return nonCliffMatches[rng.int(0, nonCliffMatches.length - 1)]!.piece
}
}
return matches[rng.int(0, matches.length - 1)]!.piece
}
/**
* Check if a border DS1 variant has a continuous wall along the border orientation.
*/
export function isClosedBorder(level: Ds1, orientation: BorderOrientation): boolean {
if (orientation === 'north' || orientation === 'south') {
for (let y = 0; y < level.height; y += 1) {
let continuous = true
for (let x = 0; x < level.width; x += 1) {
if (!level.cells[y]?.[x]?.walls.some(w => !w.hidden && w.prop1 !== 0)) {
continuous = false
break
}
}
if (continuous) return true
}
return false
}
if (orientation === 'west' || orientation === 'east') {
for (let x = 0; x < level.width; x += 1) {
let continuous = true
for (let y = 0; y < level.height; y += 1) {
if (!level.cells[y]?.[x]?.walls.some(w => !w.hidden && w.prop1 !== 0)) {
continuous = false
break
}
}
if (continuous) return true
}
return false
}
return true
}
/**
* Select a specific border variant based on whether this perimeter position is an opening (exit/entrance).
*/
export function selectBorderVariant(
piece: WildernessPiece,
orientation: BorderOrientation,
isOpen: boolean,
rng: Rng,
): Ds1 {
// In Blizzard LvlPrest.txt tables with >= 4 variants (Act 1 Bord1..4 and Act 4 Border1..4),
// slots 0..2 are closed borders (a/b/c) and slots 3+ are open exit/gate borders (o/oe).
let closedLevels: readonly Ds1[]
let openLevels: readonly Ds1[]
if (piece.levels.length >= 4) {
closedLevels = piece.levels.slice(0, 3)
openLevels = piece.levels.slice(3)
} else {
closedLevels = piece.levels.filter(l => isClosedBorder(l, orientation))
openLevels = piece.levels.filter(l => !isClosedBorder(l, orientation))
}
if (isOpen && openLevels.length > 0) {
return openLevels[rng.int(0, openLevels.length - 1)]!
}
if (!isOpen && closedLevels.length > 0) {
return closedLevels[rng.int(0, closedLevels.length - 1)]!
}
return piece.levels[rng.int(0, piece.levels.length - 1)]!
}
/**
* Determine perimeter opening (exit/entrance) coordinates for a wilderness level.
*/
export function getPerimeterOpenings(
levelId: number,
gridWidth: number,
gridHeight: number,
): Set<string> {
const openings = new Set<string>()
const key = (x: number, y: number) => `${String(x)},${String(y)}`
if (levelId === 39) {
// Moo Moo Farm (Cow Level): 0 exits!
return openings
}
// Act 1 wilderness levels (2..7) & Act 4 Mesa wilderness levels (104..106):
// entrance on South edge, exit on East edge
if ((levelId >= 2 && levelId <= 7) || (levelId >= 104 && levelId <= 106)) {
const inX = Math.floor(gridWidth / 2)
openings.add(key(inX, gridHeight - 1))
const outY = Math.floor(gridHeight / 2)
openings.add(key(gridWidth - 1, outY))
return openings
}
return openings
}
/**
* Lay the level's border pieces around the block grid's outer ring.
*
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` is the model: straight edge pieces are
* placed according to edge orientation (North, South, East, West), followed by corner
* join pieces (NW, NE, SE, SW). In Act 1 wilderness levels, North and West boundaries
* prefer solid rock cliffs (`StnClf`), while South and East boundaries use fence borders,
* matching original Diablo II DRLG rules.
*
* Perimeter openings (exits/entrances) select open variants (e.g. `Bord1o`/`Bord4o`),
* while all other perimeter pieces use solid/closed wall variants (`isClosedBorder`).
* Supports forced gate variants (`openGates`) and claimed border blocks (`claimedBorderBlocks`).
*
* @param canvas - the canvas.
* @param pieces - the level's border pieces, already filtered to `border`.
* @param gridWidth - blocks across.
* @param gridHeight - blocks down.
* @param rng - the level's random stream, for the variant rotation.
* @param stats - report collector.
* @param levelId - optional level id for perimeter opening rules.
* @param openGates - optional map from `${x},${y}` block key to forced variant index.
* @param claimedBorderBlocks - optional set of `${x},${y}` keys to skip (e.g. Town Transitions).
* @returns the number of pieces stamped.
*/
function layBorder(
canvas: Canvas,
pieces: readonly WildernessPiece[],
gridWidth: number,
gridHeight: number,
rng: Rng,
stats: WildernessStats,
levelId?: number,
openGates?: ReadonlyMap<string, number>,
claimedBorderBlocks?: ReadonlySet<string>,
): number {
if (pieces.length === 0) {
stats.unresolved.push('no border piece for this level type')
return 0
}
// Filter out cave entrances from generic border placement
const validPieces = pieces.filter(p => !/cave/i.test(p.name))
const candidatePieces = validPieces.length > 0 ? validPieces : pieces
const classified: ClassifiedBorderPiece[] = candidatePieces
.map(piece => {
const { orientation, isCliff } = classifyBorderPiece(piece.name)
return { piece, orientation, isCliff }
})
.filter((item): item is ClassifiedBorderPiece & { orientation: BorderOrientation } => item.orientation !== null)
const ordered = [...candidatePieces].sort((a, b) => {
const areaA = pieceBlocks(a, 0).x * pieceBlocks(a, 0).y
const areaB = pieceBlocks(b, 0).x * pieceBlocks(b, 0).y
return areaA - areaB || a.name.localeCompare(b.name)
})
// Stamp straight edges first, then corners last, matching D2MOO DRLGOUTPLACE_PlaceAct1245OutdoorBorders
// where corner presets overwrite edge presets to form seamless joins.
const straightCells: { x: number; y: number }[] = []
// North edge
for (let x = 1; x < gridWidth - 1; x += 1) straightCells.push({ x, y: 0 })
// East edge
for (let y = 1; y < gridHeight - 1; y += 1) straightCells.push({ x: gridWidth - 1, y })
// South edge
for (let x = gridWidth - 2; x >= 1; x -= 1) straightCells.push({ x, y: gridHeight - 1 })
// West edge
for (let y = gridHeight - 2; y >= 1; y -= 1) straightCells.push({ x: 0, y })
const cornerCells: { x: number; y: number }[] = [
{ x: 0, y: 0 }, // NW
{ x: gridWidth - 1, y: 0 }, // NE
{ x: gridWidth - 1, y: gridHeight - 1 }, // SE
{ x: 0, y: gridHeight - 1 }, // SW
]
const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight) : new Set<string>()
let cursor = rng.int(0, ordered.length - 1)
let stamped = 0
const stampCell = (cell: { x: number; y: number }, isCorner: boolean) => {
const key = `${String(cell.x)},${String(cell.y)}`
if (claimedBorderBlocks?.has(key) === true) {
return
}
const orientation = getCellOrientation(cell.x, cell.y, gridWidth, gridHeight)
// North, West, NW, NE, SW prefer cliff pieces if available (e.g. Act 1 wilderness mountain boundaries)
const preferCliff =
orientation === 'north' ||
orientation === 'west' ||
orientation === 'corner-nw' ||
orientation === 'corner-ne' ||
orientation === 'corner-sw'
let piece = selectBorderPiece(orientation, classified, rng, preferCliff)
if (!piece) {
piece = ordered[cursor]!
cursor = (cursor + 1) % ordered.length
}
if (!piece || piece.levels.length === 0) return
let level: Ds1 | undefined
const forcedVariant = openGates?.get(key)
if (forcedVariant !== undefined) {
level = piece.levels[Math.min(forcedVariant, piece.levels.length - 1)]
} else {
const isOpen = !isCorner && openings.has(key)
level = selectBorderVariant(piece, orientation, isOpen, rng)
}
if (level === undefined) return
const maxOriginX = Math.max(canvas.width - level.width, 0)
const maxOriginY = Math.max(canvas.height - level.height, 0)
const originX = level.width <= TILES_PER_BLOCK + 1
? cell.x * TILES_PER_BLOCK
: Math.min(cell.x * TILES_PER_BLOCK, maxOriginX)
const originY = level.height <= TILES_PER_BLOCK + 1
? cell.y * TILES_PER_BLOCK
: Math.min(cell.y * TILES_PER_BLOCK, maxOriginY)
stampDs1(canvas, level, originX, originY)
stats.borderPieces[piece.name] = (stats.borderPieces[piece.name] ?? 0) + 1
stats.borderStamped += 1
stamped += 1
}
for (const cell of straightCells) stampCell(cell, false)
for (const cell of cornerCells) stampCell(cell, true)
return stamped
}
/* ------------------------------------------------------------------------- *
* Act 1 Outdoor Anchors, Presets & Dirt Road Generation
* (`DRLGOUTWILD_InitAct1OutdoorLevel`, `DRLGOUTDOORS_SpawnAct1DirtPaths`,
* `DRLG_OUTDOORS_GenerateDirtPath`)
* ------------------------------------------------------------------------- */
/**
* Where a road ends.
*
* Until now these were purely internal: the road network needs endpoints to
* draw between and nothing more. They are also, though, the exact world
* coordinates of every way into and out of the level, which is what the world
* graph needs and what was previously being computed and thrown away — only
* `stats.anchors`, a count, escaped the generator.
*
* Coordinates are in cells, with `(0, 0)` at the final map's origin. Unlike the
* maze generator there is no `minX` shift to undo.
*/
interface RoadAnchor {
readonly label: string
/** The opening itself: the cell the player crosses. */
readonly anchorCell: { readonly x: number; readonly y: number }
/** One step inside the level from `anchorCell`, where the road starts. */
readonly interiorCell: { readonly x: number; readonly y: number }
/** The level on the other side, when this anchor is a way out. */
readonly toLevelId?: number | undefined
/** Which edge of the map this anchor sits on, for border gates. */
readonly side?: Side | undefined
/** Whether this is a border seam or the mouth of an interior preset. */
readonly kind?: 'gate' | 'preset' | undefined
}
/** A level entrance or exit, as reported in `stats.entrances`. */
export interface WildernessEntrance {
/** The anchor's name, e.g. `Cold Plains Exit`. */
readonly label: string
/** The crossing point, in cells. */
readonly x: number
readonly y: number
/** Where the player stands after crossing, in cells. */
readonly interiorX: number
readonly interiorY: number
/** The level on the other side, or -1 when the generator does not know. */
readonly toLevelId: number
/** The map edge, for border seams. */
readonly side: Side | null
/** Border seam or interior preset mouth. */
readonly kind: 'gate' | 'preset'
}
interface SpecialPresetPlacement {
readonly name: string
readonly ds1: Ds1
readonly bx: number
readonly by: number
readonly blocksX: number
readonly blocksY: number
}
interface Act1OutdoorPlan {
readonly openGates: Map<string, number>
readonly claimedBorderBlocks: Set<string>
readonly claimedBlocks: boolean[][]
readonly roadObstacleBlocks: boolean[][]
readonly specialPresets: SpecialPresetPlacement[]
readonly anchors: RoadAnchor[]
}
/** Map a compass side onto the generator's own edge names. */
const SIDE_TO_EDGE: Readonly<Record<Side, 'top' | 'bottom' | 'left' | 'right'>> = {
north: 'top',
south: 'bottom',
east: 'right',
west: 'left',
}
/**
* Turn the plan's anchors into the entrance list the packer bakes.
*
* @param anchors - the plan's anchors.
* @returns one entrance per anchor.
*/
function toEntrances(anchors: readonly RoadAnchor[]): WildernessEntrance[] {
return anchors.map(anchor => ({
label: anchor.label,
x: anchor.anchorCell.x,
y: anchor.anchorCell.y,
interiorX: anchor.interiorCell.x,
interiorY: anchor.interiorCell.y,
toLevelId: anchor.toLevelId ?? -1,
side: anchor.side ?? null,
kind: anchor.kind ?? 'preset',
}))
}
/**
* Plan Act 1 wilderness special presets, border road gates, and road anchors.
*/
function planAct1OutdoorLevel(
request: WildernessRequest,
gridWidth: number,
gridHeight: number,
rng: Rng,
): Act1OutdoorPlan {
const openGates = new Map<string, number>()
const claimedBorderBlocks = new Set<string>()
const claimedBlocks: boolean[][] = Array.from({ length: gridHeight }, () =>
Array.from({ length: gridWidth }, () => false),
)
const roadObstacleBlocks: boolean[][] = Array.from({ length: gridHeight }, () =>
Array.from({ length: gridWidth }, () => false),
)
const specialPresets: SpecialPresetPlacement[] = []
const anchors: RoadAnchor[] = []
const claimBox = (bx: number, by: number, w: number, h: number, isObstacle = true): void => {
for (let dy = 0; dy < h; dy += 1) {
for (let dx = 0; dx < w; dx += 1) {
const y = by + dy
const x = bx + dx
if (y >= 0 && y < gridHeight && x >= 0 && x < gridWidth) {
claimedBlocks[y]![x] = true
if (isObstacle) roadObstacleBlocks[y]![x] = true
}
}
}
}
const findPiece = (prefix: string): WildernessPiece | undefined =>
request.pieces.find(p => p.name.toLowerCase().startsWith(prefix.toLowerCase()))
const addBorderGate = (
edge: 'top' | 'bottom' | 'left' | 'right',
variant: number,
label: string,
toLevelId?: number,
): void => {
if (edge === 'left') {
let by = rng.int(2, Math.max(2, gridHeight - 3))
for (let tries = 0; tries < gridHeight; tries += 1) {
if (!claimedBorderBlocks.has(`0,${String(by)}`) && !openGates.has(`0,${String(by)}`)) break
by = 2 + ((by - 1) % Math.max(1, gridHeight - 4))
}
openGates.set(`0,${String(by)}`, variant)
claimedBlocks[by]![0] = true
anchors.push({
label,
anchorCell: { x: 3, y: 8 * by + 3 },
interiorCell: { x: 11, y: 8 * by + 3 },
toLevelId,
side: 'west',
kind: 'gate',
})
} else if (edge === 'right') {
let by = rng.int(2, Math.max(2, gridHeight - 3))
for (let tries = 0; tries < gridHeight; tries += 1) {
if (!claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`) && !openGates.has(`${String(gridWidth - 1)},${String(by)}`)) break
by = 2 + ((by - 1) % Math.max(1, gridHeight - 4))
}
openGates.set(`${String(gridWidth - 1)},${String(by)}`, variant)
claimedBlocks[by]![gridWidth - 1] = true
anchors.push({
label,
anchorCell: { x: 8 * (gridWidth - 1) + 3, y: 8 * by + 3 },
interiorCell: { x: 8 * (gridWidth - 2) + 3, y: 8 * by + 3 },
toLevelId,
side: 'east',
kind: 'gate',
})
} else if (edge === 'top') {
let bx = rng.int(2, Math.max(2, gridWidth - 3))
for (let tries = 0; tries < gridWidth; tries += 1) {
if (!claimedBorderBlocks.has(`${String(bx)},0`) && !openGates.has(`${String(bx)},0`)) break
bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4))
}
openGates.set(`${String(bx)},0`, variant)
claimedBlocks[0]![bx] = true
anchors.push({
label,
anchorCell: { x: 8 * bx + 3, y: 3 },
interiorCell: { x: 8 * bx + 3, y: 11 },
toLevelId,
side: 'north',
kind: 'gate',
})
} else {
let bx = rng.int(2, Math.max(2, gridWidth - 3))
for (let tries = 0; tries < gridWidth; tries += 1) {
if (!claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`) && !openGates.has(`${String(bx)},${String(gridHeight - 1)}`)) break
bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4))
}
openGates.set(`${String(bx)},${String(gridHeight - 1)}`, variant)
claimedBlocks[gridHeight - 1]![bx] = true
anchors.push({
label,
anchorCell: { x: 8 * bx + 3, y: 8 * (gridHeight - 1) + 3 },
interiorCell: { x: 8 * bx + 3, y: 8 * (gridHeight - 2) + 3 },
toLevelId,
side: 'south',
kind: 'gate',
})
}
}
const pickFreeInteriorBox = (w: number, h: number, minMargin = 2): { bx: number; by: number } | null => {
const minX = Math.min(minMargin, Math.max(1, gridWidth - w - 1))
const minY = Math.min(minMargin, Math.max(1, gridHeight - h - 1))
const maxX = Math.max(minX, gridWidth - minMargin - w)
const maxY = Math.max(minY, gridHeight - minMargin - h)
const candidates: { bx: number; by: number }[] = []
for (let by = minY; by <= maxY; by += 1) {
for (let bx = minX; bx <= maxX; bx += 1) {
let free = true
for (let dy = 0; dy < h && free; dy += 1) {
for (let dx = 0; dx < w && free; dx += 1) {
if (claimedBlocks[by + dy]?.[bx + dx] === true) free = false
}
}
if (free) candidates.push({ bx, by })
}
}
if (candidates.length === 0) return null
return candidates[rng.int(0, candidates.length - 1)]!
}
const addInteriorPresetWithRoad = (
prefix: string,
label: string,
forceExitSide?: 'east' | 'south',
toLevelId?: number,
): boolean => {
const piece = findPiece(prefix)
if (piece === undefined || piece.levels.length === 0) return false
const variant = rng.int(0, piece.levels.length - 1)
const ds1 = piece.levels[variant]!
const blocksX = Math.max(1, Math.floor(ds1.width / TILES_PER_BLOCK))
const blocksY = Math.max(1, Math.floor(ds1.height / TILES_PER_BLOCK))
const pos = pickFreeInteriorBox(blocksX, blocksY, 2)
if (pos === null) return false
const { bx, by } = pos
claimBox(bx, by, blocksX, blocksY, true)
specialPresets.push({ name: piece.name, ds1, bx, by, blocksX, blocksY })
const exitSide = forceExitSide ?? (variant === 0 ? 'east' : 'south')
if (exitSide === 'east' && bx + blocksX < gridWidth - 1) {
roadObstacleBlocks[by]![bx + blocksX - 1] = false
anchors.push({
label,
anchorCell: { x: 8 * (bx + blocksX - 1) + 7, y: 8 * by + 3 },
interiorCell: { x: 8 * (bx + blocksX) + 3, y: 8 * by + 3 },
toLevelId,
kind: 'preset',
})
} else if (by + blocksY < gridHeight - 1) {
roadObstacleBlocks[by + blocksY - 1]![bx] = false
anchors.push({
label,
anchorCell: { x: 8 * bx + 3, y: 8 * (by + blocksY - 1) + 7 },
interiorCell: { x: 8 * bx + 3, y: 8 * (by + blocksY) + 3 },
toLevelId,
kind: 'preset',
})
}
return true
}
/**
* Stamp the Rogue Encampment's gate preset onto one edge.
*
* There are two authored pieces, one for the camp lying west of the Blood
* Moor and one for it lying north; the rest of the compass has no art, so a
* side we have no piece for falls back to an ordinary border gate.
*
* @param side - which edge of this level the camp is on.
* @param toLevelId - the town's level id.
* @returns whether a preset was placed.
*/
const placeTownTransition = (side: Side, toLevelId?: number): boolean => {
if (side === 'west') {
const townE = findPiece('Act 1 - Town 1 Transition E')
if (townE?.levels[0] === undefined) return false
const ds1 = townE.levels[0]
const by = Math.max(1, Math.floor((gridHeight - 5) / 2))
for (let dy = 0; dy < 5; dy += 1) {
claimedBorderBlocks.add(`0,${String(by + dy)}`)
claimedBlocks[by + dy]![0] = true
}
specialPresets.push({ name: townE.name, ds1, bx: 0, by, blocksX: 1, blocksY: 5 })
anchors.push({
label: 'Town E Transition',
anchorCell: { x: 7, y: 8 * by + 19 },
interiorCell: { x: 11, y: 8 * by + 19 },
toLevelId,
side: 'west',
kind: 'gate',
})
return true
}
if (side === 'north') {
const townS = findPiece('Act 1 - Town 1 Transition S')
if (townS?.levels[0] === undefined) return false
const ds1 = townS.levels[0]
const bx = Math.max(1, Math.floor((gridWidth - 7) / 2))
for (let dx = 0; dx < 7; dx += 1) {
claimedBorderBlocks.add(`${String(bx + dx)},0`)
claimedBlocks[0]![bx + dx] = true
}
specialPresets.push({ name: townS.name, ds1, bx, by: 0, blocksX: 7, blocksY: 1 })
anchors.push({
label: 'Town S Transition',
anchorCell: { x: 8 * bx + 29, y: 7 },
interiorCell: { x: 8 * bx + 29, y: 11 },
toLevelId,
side: 'north',
kind: 'gate',
})
return true
}
return false
}
/**
* Stamp the walled graveyard onto the Burial Grounds.
*
* @param withGate - whether to cut the seam back to the Cold Plains here too.
* False when the caller is placing that seam itself from the world graph.
* @returns whether the preset fitted.
*/
const placeGraveyardPreset = (withGate: boolean): boolean => {
const gyPiece = findPiece('Act 1 - Graveyard')
if (gyPiece?.levels[0] === undefined || gridWidth < 5 || gridHeight < 6) return false
const ds1 = gyPiece.levels[0]
claimBox(1, 1, 3, 4, true)
specialPresets.push({ name: gyPiece.name, ds1, bx: 1, by: 1, blocksX: 3, blocksY: 4 })
roadObstacleBlocks[4]![1] = false
roadObstacleBlocks[4]![2] = false
if (withGate) {
openGates.set(`2,${String(gridHeight - 1)}`, 4)
claimedBlocks[gridHeight - 1]![2] = true
anchors.push({
label: 'Burial Grounds Gate',
anchorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 1) + 3 },
interiorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 2) + 3 },
toLevelId: 3,
side: 'south',
kind: 'gate',
})
}
anchors.push({
label: 'Graveyard Entrance',
anchorCell: { x: 1 * 8 + 6, y: 1 * 8 + 31 },
interiorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 2) + 3 },
toLevelId: 19,
kind: 'preset',
})
return true
}
/**
* Scatter the level's landmarks and the caves that open off it.
*
* These are separate from the seams because they do not care which edge
* anything is on: the Forgotten Tower, the Cairn Stones and the Tree of
* Inifuss all stand beside one of the level's roads, wherever the roads end
* up running, and `addInteriorPresetWithRoad` is what joins them to the
* network.
*
* @param levelId - the level being planned.
*/
const placeInteriorPresets = (levelId: number): void => {
if (levelId === 2) {
addInteriorPresetWithRoad('Act 1 - DOE Entrance', 'Den of Evil Entrance', undefined, 8)
const bivouac = findPiece('Act 1 - Bivouac') ?? findPiece('Act 1 - Cottages')
if (bivouac?.levels[0] !== undefined) {
const ds1 = bivouac.levels[0]
const w = Math.max(1, Math.floor(ds1.width / TILES_PER_BLOCK))
const h = Math.max(1, Math.floor(ds1.height / TILES_PER_BLOCK))
const pos = pickFreeInteriorBox(w, h, 2)
if (pos !== null) {
claimBox(pos.bx, pos.by, w, h, true)
specialPresets.push({ name: bivouac.name, ds1, bx: pos.bx, by: pos.by, blocksX: w, blocksY: h })
}
}
} else if (levelId === 3) {
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Cave Level 1 Entrance', undefined, 9)
addInteriorPresetWithRoad('Act 1 - Bivouac', 'Bivouac Camp', 'south')
} else if (levelId === 4) {
addInteriorPresetWithRoad('Act 1 - Cairn Stones', 'Cairn Stones', 'south', 38)
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Entrance', undefined, 10)
} else if (levelId === 5) {
addInteriorPresetWithRoad('Act 1 - Inifus', 'Tree of Inifuss', 'south')
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Exit', undefined, 10)
} else if (levelId === 6) {
// The Forgotten Tower is always beside one of the Black Marsh's roads,
// never dropped on open ground, which is why it goes through the
// road-joining path rather than the plain scatter.
addInteriorPresetWithRoad('Act 1 - Tower 1', 'Forgotten Tower', 'south', 20)
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Hole Level 1 Entrance', undefined, 11)
} else if (levelId === 7) {
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Pit Level 1 Entrance', undefined, 12)
} else if (levelId === 39) {
addInteriorPresetWithRoad('Act 1 - Corral Fill', 'Cow Corral', 'south')
addInteriorPresetWithRoad('Act 1 - Bivouac', 'Cow Bivouac', 'east')
}
}
const id = request.levelId
const plannedGates = request.gates
if (plannedGates !== undefined) {
// Seams come from the act layout solve. The table below is not consulted:
// it records one legal roll of the outdoor placer as if it were fixed, and
// it only ever covered Act 1.
const townSeam = id === 2 ? plannedGates.find(gate => gate.toLevelId === 1) : undefined
const placedTown = townSeam !== undefined && placeTownTransition(townSeam.side, townSeam.toLevelId)
for (const gate of plannedGates) {
if (placedTown && gate === townSeam) continue
addBorderGate(SIDE_TO_EDGE[gate.side], gate.variant ?? 3, gate.label, gate.toLevelId)
}
if (id === 17) placeGraveyardPreset(false)
placeInteriorPresets(id)
} else if (id === 2) {
// Blood Moor: Town Transition + Exit Gate to Cold Plains + Den of Evil Entrance
const townE = findPiece('Act 1 - Town 1 Transition E')
const useWest = rng.int(0, 1) === 0 && townE !== undefined
if (useWest && placeTownTransition('west', 1)) {
addBorderGate(rng.int(0, 1) === 0 ? 'right' : 'bottom', 3, 'Cold Plains Exit', 3)
} else if (placeTownTransition('north', 1)) {
addBorderGate(rng.int(0, 1) === 0 ? 'bottom' : 'right', 3, 'Cold Plains Exit', 3)
} else {
addBorderGate('left', 3, 'Town Entry', 1)
addBorderGate('right', 3, 'Cold Plains Exit', 3)
}
placeInteriorPresets(2)
} else if (id === 3) {
// Cold Plains: Blood Moor Entry + Stony Field Exit + Burial Grounds Exit (variant 4) + Cave Level 1 Entrance
addBorderGate('left', 3, 'Blood Moor Entry', 2)
addBorderGate('top', 3, 'Stony Field Exit', 4)
addBorderGate('right', 4, 'Burial Grounds Exit', 17)
placeInteriorPresets(3)
} else if (id === 4) {
// Stony Field: Cold Plains Entry + Exit Gate + Cairn Stones + Underground Passage Entrance
addBorderGate('bottom', 3, 'Cold Plains Entry', 3)
addBorderGate('top', 3, 'Wilderness Exit', 5)
placeInteriorPresets(4)
} else if (id === 5) {
// Dark Wood: Entry Gate + Black Marsh Exit + Tree of Inifuss + Underground Passage Exit
addBorderGate('left', 3, 'Wilderness Entry', 4)
addBorderGate('right', 3, 'Black Marsh Exit', 6)
placeInteriorPresets(5)
} else if (id === 6) {
// Black Marsh: Dark Wood Entry + Tamoe Highland Exit + Forgotten Tower + Hole Level 1 Entrance
addBorderGate('bottom', 3, 'Dark Wood Entry', 5)
addBorderGate('top', 3, 'Tamoe Highland Exit', 7)
placeInteriorPresets(6)
} else if (id === 7) {
// Tamoe Highland: Black Marsh Entry + Monastery Gate Exit + Pit Level 1 Entrance
addBorderGate('left', 3, 'Black Marsh Entry', 6)
addBorderGate('right', 3, 'Monastery Exit', 26)
placeInteriorPresets(7)
} else if (id === 17) {
// Burial Grounds (5x6 blocks): Graveyard preset (3x4 blocks at bx=1, by=1) + South Entry Gate
if (!placeGraveyardPreset(true)) addBorderGate('bottom', 4, 'Burial Grounds Gate', 3)
} else if (id === 39) {
// Moo Moo Farm: Closed border, internal Corral & Bivouac camps connected by paths
placeInteriorPresets(39)
} else {
addBorderGate('left', 3, 'Entry Gate')
addBorderGate('right', 3, 'Exit Gate')
}
return {
openGates,
claimedBorderBlocks,
claimedBlocks,
roadObstacleBlocks,
specialPresets,
anchors,
}
}
/**
* Build stepped block-grid road polylines connecting all anchors to a central hub
* (`DRLGOUTDOORS_SpawnAct1DirtPaths` / `sub_6FD7F5B0`).
*/
function buildRoadNetworkPolylines(
plan: Act1OutdoorPlan,
gridWidth: number,
gridHeight: number,
rng: Rng,
): { x: number; y: number }[][] {
if (plan.anchors.length < 2) return []
let sumBx = 0
let sumBy = 0
for (const anchor of plan.anchors) {
sumBx += Math.floor(anchor.interiorCell.x / TILES_PER_BLOCK)
sumBy += Math.floor(anchor.interiorCell.y / TILES_PER_BLOCK)
}
let hubBx = Math.max(2, Math.min(gridWidth - 3, Math.round(sumBx / plan.anchors.length) + rng.int(-1, 1)))
let hubBy = Math.max(2, Math.min(gridHeight - 3, Math.round(sumBy / plan.anchors.length) + rng.int(-1, 1)))
if (plan.roadObstacleBlocks[hubBy]?.[hubBx] === true) {
let found = false
for (let r = 1; r < Math.max(gridWidth, gridHeight) && !found; r += 1) {
for (let dy = -r; dy <= r && !found; dy += 1) {
for (let dx = -r; dx <= r && !found; dx += 1) {
const nx = hubBx + dx
const ny = hubBy + dy
if (nx >= 1 && nx < gridWidth - 1 && ny >= 1 && ny < gridHeight - 1) {
if (plan.roadObstacleBlocks[ny]?.[nx] !== true) {
hubBx = nx
hubBy = ny
found = true
}
}
}
}
}
}
const hubCell = { x: hubBx * TILES_PER_BLOCK + 3, y: hubBy * TILES_PER_BLOCK + 3 }
const polylines: { x: number; y: number }[][] = []
for (const anchor of plan.anchors) {
const line: { x: number; y: number }[] = [
anchor.anchorCell,
anchor.interiorCell,
]
let currBx = Math.floor(anchor.interiorCell.x / TILES_PER_BLOCK)
let currBy = Math.floor(anchor.interiorCell.y / TILES_PER_BLOCK)
const maxSteps = gridWidth + gridHeight + 8
for (let step = 0; step < maxSteps && (currBx !== hubBx || currBy !== hubBy); step += 1) {
const dx = hubBx - currBx
const dy = hubBy - currBy
const stepX = Math.sign(dx)
const stepY = Math.sign(dy)
const candidates: { bx: number; by: number }[] = []
if (Math.abs(dx) >= 2 && Math.abs(dy) >= 2) {
const mode = rng.int(0, 2)
if (mode === 0) {
candidates.push({ bx: currBx + stepX, by: currBy }, { bx: currBx, by: currBy + stepY }, { bx: currBx + stepX, by: currBy + stepY })
} else if (mode === 1) {
candidates.push({ bx: currBx, by: currBy + stepY }, { bx: currBx + stepX, by: currBy }, { bx: currBx + stepX, by: currBy + stepY })
} else {
candidates.push({ bx: currBx + stepX, by: currBy + stepY }, { bx: currBx + stepX, by: currBy }, { bx: currBx, by: currBy + stepY })
}
} else if (Math.abs(dx) > Math.abs(dy)) {
candidates.push({ bx: currBx + stepX, by: currBy })
if (stepY !== 0) candidates.push({ bx: currBx, by: currBy + stepY })
} else {
if (stepY !== 0) candidates.push({ bx: currBx, by: currBy + stepY })
if (stepX !== 0) candidates.push({ bx: currBx + stepX, by: currBy })
}
// Detour candidates if primary directions hit an obstacle preset
if (stepX !== 0) {
candidates.push({ bx: currBx + stepX, by: currBy - 1 }, { bx: currBx + stepX, by: currBy + 1 })
}
if (stepY !== 0) {
candidates.push({ bx: currBx - 1, by: currBy + stepY }, { bx: currBx + 1, by: currBy + stepY })
}
let chosen = candidates[0]!
for (const cand of candidates) {
if (cand.bx >= 1 && cand.bx < gridWidth - 1 && cand.by >= 1 && cand.by < gridHeight - 1) {
if (plan.roadObstacleBlocks[cand.by]?.[cand.bx] !== true) {
chosen = cand
break
}
}
}
currBx = chosen.bx
currBy = chosen.by
line.push({ x: currBx * TILES_PER_BLOCK + 3, y: currBy * TILES_PER_BLOCK + 3 })
}
line.push(hubCell)
polylines.push(line)
}
return polylines
}
/**
* Rasterize a 2-cell-wide line segment onto the dirt path grid (`sub_6FD75F60`).
*/
function rasterizeDirtPathSegment(
grid: Uint8Array,
width: number,
height: number,
x1: number,
y1: number,
x2: number,
y2: number,
): void {
const mark2x2 = (x: number, y: number): void => {
for (let dy = 0; dy < 2; dy += 1) {
for (let dx = 0; dx < 2; dx += 1) {
const px = x + dx
const py = y + dy
if (px >= 0 && px < width && py >= 0 && py < height) {
grid[py * width + px] = 1
}
}
}
}
let x = x1
let y = y1
const dx = Math.abs(x2 - x1)
const dy = Math.abs(y2 - y1)
const sx = x2 >= x1 ? 1 : -1
const sy = y2 >= y1 ? 1 : -1
mark2x2(x, y)
if (dx >= dy) {
let err = 0
for (let i = 0; i < dx; i += 1) {
err += dy
if (err > dx) {
y += sy
mark2x2(x, y)
err -= dx
}
x += sx
mark2x2(x, y)
}
} else {
let err = 0
for (let i = 0; i < dy; i += 1) {
err += dx
if (err > dy) {
x += sx
mark2x2(x, y)
err -= dy
}
y += sy
mark2x2(x, y)
}
}
}
/**
* Rasterize all road polylines into a 2D bitmask grid and apply
* `DIRT_PATH_TILE_LUT` (`DRLG_OUTDOORS_GenerateDirtPath`).
*/
function generateDirtPaths(
canvas: Canvas,
polylines: readonly (readonly { x: number; y: number }[])[],
): { dirtPathGrid: Uint8Array; roadCells: number; roadSegments: number } {
const width = canvas.width
const height = canvas.height
const dirtPathGrid = new Uint8Array(width * height)
let roadSegments = 0
for (const line of polylines) {
for (let i = 0; i + 1 < line.length; i += 1) {
const p0 = line[i]!
const p1 = line[i + 1]!
rasterizeDirtPathSegment(dirtPathGrid, width, height, p0.x, p0.y, p1.x, p1.y)
roadSegments += 1
}
}
const isRoad = (nx: number, ny: number): boolean => {
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return true
return dirtPathGrid[ny * width + nx] === 1
}
let roadCells = 0
for (let y = 0; y < height; y += 1) {
const row = canvas.cells[y]
if (row === undefined) continue
for (let x = 0; x < width; x += 1) {
if (dirtPathGrid[y * width + x] === 0) continue
let mask = 0
if (isRoad(x + 1, y - 1)) mask |= 0x80
if (isRoad(x + 1, y )) mask |= 0x40
if (isRoad(x + 1, y + 1)) mask |= 0x20
if (isRoad(x , y - 1)) mask |= 0x10
if (isRoad(x , y + 1)) mask |= 0x08
if (isRoad(x - 1, y - 1)) mask |= 0x04
if (isRoad(x - 1, y )) mask |= 0x02
if (isRoad(x - 1, y + 1)) mask |= 0x01
const seq = DIRT_PATH_TILE_LUT[mask] ?? 0
if (seq !== 0) {
const cell = row[x]
const floor = cell?.floors[0]
if (floor !== undefined) {
Object.assign(floor, { prop1: 2, style: 0, sequence: seq, hidden: false })
roadCells += 1
}
}
}
}
return { dirtPathGrid, roadCells, roadSegments }
}
/* ------------------------------------------------------------------------- *
* Substitutions
* ------------------------------------------------------------------------- */
/**
* Check whether placing a substitution cluster at `(bx, by)` of size
* `(blocksX, blocksY)` would collide with claimed special presets or dirt roads.
*/
function canPlaceSubstitutionCluster(
bx: number,
by: number,
blocksX: number,
blocksY: number,
claimedBlocks: boolean[][] | undefined,
dirtPathGrid: Uint8Array | undefined,
canvasWidth: number,
): boolean {
if (claimedBlocks !== undefined) {
for (let dy = 0; dy < blocksY; dy += 1) {
for (let dx = 0; dx < blocksX; dx += 1) {
if (claimedBlocks[by + dy]?.[bx + dx] === true) return false
}
}
}
if (dirtPathGrid !== undefined) {
const startX = bx * TILES_PER_BLOCK
const startY = by * TILES_PER_BLOCK
const endX = (bx + blocksX) * TILES_PER_BLOCK
const endY = (by + blocksY) * TILES_PER_BLOCK
for (let cy = startY; cy < endY; cy += 1) {
for (let cx = startX; cx < endX; cx += 1) {
if (dirtPathGrid[cy * canvasWidth + cx] === 1) return false
}
}
}
return true
}
/**
* Scatter one `LvlSub` row's pieces across the level's interior.
*
* Avoids overwriting blocks claimed by special presets (`claimedBlocks`) or
* cells traversed by generated dirt roads (`dirtPathGrid`).
*
* @param canvas - the canvas.
* @param row - the `LvlSub` row.
* @param themeIndex - the clamped `Levels.txt.SubTheme`.
* @param gridWidth - blocks across.
* @param gridHeight - blocks down.
* @param rng - the level's random stream.
* @param claimedBlocks - optional 2D block claim grid.
* @param dirtPathGrid - optional cell-level dirt road bitmask grid.
* @returns how many clusters were stamped.
*/
function applySubstitution(
canvas: Canvas,
row: WildernessSubstitution,
themeIndex: number,
gridWidth: number,
gridHeight: number,
rng: Rng,
claimedBlocks?: boolean[][],
dirtPathGrid?: Uint8Array,
): number {
const level = row.levels[0]
if (level === undefined) return 0
const max = Math.max(0, Math.floor(row.max[themeIndex] ?? 0))
if (max === 0) return 0
const trials = Math.floor(row.trials[themeIndex] ?? 0)
const gridSize = Math.max(1, Math.floor(row.gridSize))
const blocksX = Math.max(Math.floor(level.width / TILES_PER_BLOCK), 1)
const blocksY = Math.max(Math.floor(level.height / TILES_PER_BLOCK), 1)
// Keep scenery off the wall ring, so it can never open or seal the level.
const innerX = gridWidth - 2
const innerY = gridHeight - 2
const spanX = innerX - blocksX
const spanY = innerY - blocksY
if (spanX <= 0 || spanY <= 0) return 0
/** Snap an interior offset to the cluster grid. */
const snap = (value: number, span: number): number => {
const limit = Math.max(Math.floor((span - 1) / gridSize), 0)
return Math.min(Math.floor(value / gridSize), limit) * gridSize
}
const recordClaim = (bx: number, by: number): void => {
if (claimedBlocks === undefined) return
for (let dy = 0; dy < blocksY; dy += 1) {
for (let dx = 0; dx < blocksX; dx += 1) {
if (claimedBlocks[by + dy] !== undefined) {
claimedBlocks[by + dy]![bx + dx] = true
}
}
}
}
let stamped = 0
for (let cluster = 0; cluster < max; cluster += 1) {
let placed = false
if (trials === -1) {
// "Try every free position", in a stable order.
for (let oy = 0; oy < spanY && !placed; oy += gridSize) {
for (let ox = 0; ox < spanX && !placed; ox += gridSize) {
const snappedX = snap(ox, spanX)
const snappedY = snap(oy, spanY)
const bx = 1 + snappedX
const by = 1 + snappedY
if (!canPlaceSubstitutionCluster(bx, by, blocksX, blocksY, claimedBlocks, dirtPathGrid, canvas.width)) {
continue
}
stampDs1(canvas, level, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK)
recordClaim(bx, by)
placed = true
}
}
} else {
for (let attempt = 0; attempt < trials && !placed; attempt += 1) {
const offsetX = snap(rng.int(0, Math.max(spanX - 1, 0)), spanX)
const offsetY = snap(rng.int(0, Math.max(spanY - 1, 0)), spanY)
const bx = 1 + offsetX
const by = 1 + offsetY
if (!canPlaceSubstitutionCluster(bx, by, blocksX, blocksY, claimedBlocks, dirtPathGrid, canvas.width)) {
continue
}
stampDs1(canvas, level, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK)
recordClaim(bx, by)
placed = true
}
}
if (placed) stamped += 1
}
return stamped
}
/**
* Run the `Prob` gate and then the cluster loop for every substitution row.
*
* @param canvas - the canvas.
* @param rows - the rows to run, in table order.
* @param themeIndex - the clamped `SubTheme`.
* @param gridWidth - blocks across.
* @param gridHeight - blocks down.
* @param rng - the level's random stream.
* @param stats - report collector.
* @param claimedBlocks - optional 2D block claim grid.
* @param dirtPathGrid - optional cell-level dirt road bitmask grid.
* @returns the number of clusters stamped.
*/
function applySubstitutions(
canvas: Canvas,
rows: readonly WildernessSubstitution[],
themeIndex: number,
gridWidth: number,
gridHeight: number,
rng: Rng,
stats: WildernessStats,
claimedBlocks?: boolean[][],
dirtPathGrid?: Uint8Array,
): number {
let total = 0
let unlimitedBudget = Number.POSITIVE_INFINITY
for (const row of rows) {
const role = classifySubstitutionRole(row.name, row.type)
const chance = row.prob[themeIndex] ?? 0
const enabled = chance > 0 && rng.int(0, 99) < chance
let clusters = 0
if (enabled) {
// `BordType` 0 = one cluster for the whole level, 1 = one per row.
const allowance = row.bordType === 0 ? Math.min(1, unlimitedBudget) : row.bordType === 1 ? 1 : Number.POSITIVE_INFINITY
const rowMax = Math.max(0, Math.floor(row.max[themeIndex] ?? 0))
const wanted = Math.min(rowMax, allowance)
if (wanted > 0) {
const single = applySubstitution(canvas, row, themeIndex, gridWidth, gridHeight, rng, claimedBlocks, dirtPathGrid)
clusters = Math.min(wanted, single)
if (row.bordType !== 0 && row.bordType !== 1) clusters = single
if (row.bordType === 0) unlimitedBudget = Math.max(0, unlimitedBudget - clusters)
total += clusters
}
}
stats.substitutions.push({ name: row.name, role, enabled, clusters })
}
return total
}
/* ------------------------------------------------------------------------- *
* Special presets
* ------------------------------------------------------------------------- */
/**
* Special presets that D2MOO's `DRLGOUTWILD_SpawnSpecialPresets` spawns for
* specific outdoor levels.
*
* For example, Level 39 (Moo Moo Farm) has `SubType = -1` in `Levels.txt` so it
* gets no `LvlSub` substitutions; instead the engine stamps its iconic interior
* landmarks: the fallen bivouac camp, the pond, the cow corral, and swamp/stone
* fills.
*/
export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]>> = {
2: [
'Act 1 - DOE Entrance',
'Act 1 - River Upper',
'Act 1 - River Lower',
'Act 1 - Bridge',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
3: [
'Act 1 - Cave Entrance',
'Act 1 - Fallen Camp Bishibosh',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 2',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 2',
'Act 1 - Cottages 3',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 6',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
4: [
'Act 1 - Cairn Stones',
'Act 1 - Cave Entrance',
'Act 1 - Tower Tome',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Camp',
'Act 1 - Cottages 1',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
],
5: [
'Act 1 - Inifus',
'Act 1 - Cave Entrance',
'Act 1 - Ruin',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 2',
'Act 1 - Fence Fill 3',
'Act 1 - Tree Fill',
'Act 1 - Cottages 2',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp 2',
'Act 1 - Fence Fill 5',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
6: [
'Act 1 - Tower 1',
'Act 1 - Cave Entrance',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Swamp Fill 1',
'Act 1 - Swamp Fill 2',
'Act 1 - Cottages 1',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp 1',
'Act 1 - Fence Fill 6',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
7: [
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 2',
'Act 1 - Fallen Camp 2',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
],
17: [
'Act 1 - Graveyard',
],
39: [
'Act 1 - Bivouac',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Corral Fill',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
'Act 1 - Pond',
'Act 1 - Swamp Fill 1',
'Act 1 - Swamp Fill 2',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
41: [
'Act 2 - Desert Tomb 1',
'Act 2 - Desert Fill Wagon 1',
'Act 2 - Desert Fill Bone 1',
'Act 2 - Desert Fill Bone 2',
'Act 2 - Desert Fill Berms 1',
'Act 2 - Desert Fill Berms 2',
],
42: [
'Act 2 - Desert Tomb 1',
'Act 2 - Desert Ruins 16X16',
'Act 2 - Desert Fill Mesa 1',
'Act 2 - Desert Fill Head 1',
'Act 2 - Desert Fill Head 2',
],
43: [
'Act 2 - Desert Oasis 2',
'Act 2 - Desert Oasis 3',
'Act 2 - Desert Oasis 1',
'Act 2 - Desert Lair 1',
'Act 2 - Desert Fill Bone 1',
'Act 2 - Desert Fill Berms 3',
],
44: [
'Act 2 - Desert Ruins Sewer',
'Act 2 - Desert Ruins Elder',
'Act 2 - Desert Ruins 16X16',
'Act 2 - Desert Ruins 16X08',
'Act 2 - Desert Ruins 08X16',
],
45: [
'Act 2 - Desert Tomb 2',
'Act 2 - Desert Valley Ruin 1',
'Act 2 - Desert Valley Ruin 2',
],
46: [
'Act 2 - Desert Valley Warp',
'Act 2 - Desert Cliff Top King Tomb',
'Act 2 - Desert Cliff Left King Tomb',
'Act 2 - Desert Cliff Right King Tomb',
'Act 2 - Desert Fill Mesa 1',
],
79: [
'Act 3 - Slums 16x16',
'Act 3 - Slums 16x16',
'Act 3 - Slums 16x08',
'Act 3 - Slums 08x16',
'Act 3 - Slums 08x08',
],
80: [
'Act 3 - Burbs Temple',
'Act 3 - Burbs Temple',
'Act 3 - Burbs Sewer',
'Act 3 - Burbs Waypoint',
'Act 3 - Burbs 16x16',
'Act 3 - Burbs 16x08',
],
81: [
'Act 3 - MetroTemple',
'Act 3 - MetroTemple',
'Act 3 - Metro Sewer',
'Act 3 - Metro 16x16',
'Act 3 - Metro 16x08',
],
111: [
'Act 5 - Barricade Waypoint Dirt',
'Act 5 - Barricade Hell Portal N',
'Act 5 - Barricade Prison 1',
'Act 5 - Barricade Prison 2',
'Act 5 - Barricade Prison 3',
'Act 5 - Barricade 1',
'Act 5 - Barricade 2',
'Act 5 - Barricade 3',
'Act 5 - Barricade 4',
'Act 5 - Barricade Building',
'Act 5 - Barricade Filler Treasure',
'Act 5 - Barricade Ruins N',
'Act 5 - Barricade Ruins W',
],
112: [
'Act 5 - Barricade Waypoint Snow',
'Act 5 - Barricade Hell Portal N',
'Act 5 - Barricade Snow Unique',
'Act 5 - Barricade 1 Snow',
'Act 5 - Barricade 2 Snow',
'Act 5 - Barricade 3 Snow',
'Act 5 - Barricade 4 Snow',
'Act 5 - Barricade Snow Lake 1',
'Act 5 - Barricade Snow Lake 2',
'Act 5 - Barricade Ruins N Treasure',
'Act 5 - Barricade Ruins W Treasure',
],
117: [
'Act 5 - Barricade To Cave 32x16 Snow',
'Act 5 - Barricade Hell Portal N',
'Act 5 - Barricade Waypoint Snow',
'Act 5 - Barricade 1 Snow',
'Act 5 - Barricade 2 Snow',
'Act 5 - Barricade 3 Snow',
'Act 5 - Barricade Snow Lake 1',
],
}
/**
* Scatter special presets across the level's interior.
*
* Follows D2MOO's `DRLGOUTWILD_SpawnSpecialPresets` and
* `DRLGOUTDOORS_SpawnOutdoorLevelPreset`: candidate interior grid coordinates
* (excluding the outer 1-block border) are shuffled deterministically with the
* level's RNG, and each preset in sequence scans for the first free block
* rectangle where it can fit without overlapping already-occupied blocks or the
* perimeter.
*
* @param canvas - the mutable canvas.
* @param pieces - available fill pieces.
* @param presetNames - preset name prefixes to spawn in priority order.
* @param gridWidth - blocks across.
* @param gridHeight - blocks down.
* @param rng - seeded RNG.
* @param stats - report collector.
* @returns set of piece names that were placed.
*/
/** Bounding rectangle of a placed special preset in canvas tile coordinates. */
export interface PlacedPresetRect {
readonly name: string
readonly x: number
readonly y: number
readonly w: number
readonly h: number
}
/**
* Stamp a continuous 2-block-wide (16-tile) North-South river stream across the level with
* a 2-block wooden bridge crossing at `by = outY` where the main East-West road passes.
*/
function stampAct1RiverAndBridge(
canvas: Canvas,
pieces: readonly WildernessPiece[],
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
occupied: boolean[][],
placedPieces: Set<string>,
placedRects: PlacedPresetRect[],
stats: WildernessStats,
): boolean {
const upperPiece = pieces.find(p => p.name === 'Act 1 - River Upper' || p.name.startsWith('Act 1 - River Upper'))
const lowerPiece = pieces.find(p => p.name === 'Act 1 - River Lower' || p.name.startsWith('Act 1 - River Lower'))
const bridgePiece = pieces.find(p => p.name === 'Act 1 - Bridge' || p.name.startsWith('Act 1 - Bridge'))
if (!upperPiece?.levels[0] || !lowerPiece?.levels[0] || !bridgePiece?.levels[0]) return false
// Place the 2-column vertical river stream just East of the central crossroads (e.g. blocks 6 & 7 on 10x10 grid)
const riverBlockX = Math.min(gridWidth - 3, inX + 1)
if (riverBlockX < 1 || riverBlockX + 1 >= gridWidth - 1) return false
for (let by = 1; by <= gridHeight - 2; by += 1) {
const tileX0 = riverBlockX * TILES_PER_BLOCK
const tileX1 = (riverBlockX + 1) * TILES_PER_BLOCK
const tileY = by * TILES_PER_BLOCK
if (by === outY) {
// Main East-West road crossing: stamp BridgeU (west half) + BridgeL (east half)
const bridgeU = bridgePiece.levels[0]!
const bridgeL = bridgePiece.levels[1] ?? bridgePiece.levels[0]!
stampDs1(canvas, bridgeU, tileX0, tileY)
stampDs1(canvas, bridgeL, tileX1, tileY)
} else if (by === 1) {
// North river source/mouth (URiverN + LRiverN)
const uNorth = upperPiece.levels[1] ?? upperPiece.levels[0]!
const lNorth = lowerPiece.levels[1] ?? lowerPiece.levels[0]!
stampDs1(canvas, uNorth, tileX0, tileY)
stampDs1(canvas, lNorth, tileX1, tileY)
} else if (by === gridHeight - 2) {
// South river mouth (URiverS + LRiverS)
const uSouth = upperPiece.levels[2] ?? upperPiece.levels[0]!
const lSouth = lowerPiece.levels[2] ?? lowerPiece.levels[0]!
stampDs1(canvas, uSouth, tileX0, tileY)
stampDs1(canvas, lSouth, tileX1, tileY)
} else {
// Center continuous river channel (URiverC + LRiverC)
stampDs1(canvas, upperPiece.levels[0]!, tileX0, tileY)
stampDs1(canvas, lowerPiece.levels[0]!, tileX1, tileY)
}
occupied[by]![riverBlockX] = true
occupied[by]![riverBlockX + 1] = true
}
for (const name of [upperPiece.name, lowerPiece.name, bridgePiece.name]) {
placedPieces.add(name)
stats.substitutions.push({ name, role: 'object', enabled: true, clusters: 1 })
}
placedRects.push({
name: 'Act 1 - River',
x: riverBlockX * TILES_PER_BLOCK,
y: TILES_PER_BLOCK,
w: 2 * TILES_PER_BLOCK,
h: (gridHeight - 2) * TILES_PER_BLOCK,
})
return true
}
/**
* Scatter special presets across the level's interior.
*
* Follows D2MOO's `DRLGOUTWILD_SpawnSpecialPresets` and
* `DRLGOUTDOORS_SpawnOutdoorLevelPreset`: candidate interior grid coordinates
* (excluding the outer 1-block border and any reserved main road corridors) are
* shuffled deterministically with the level's RNG, and each preset in sequence
* scans for the first free block rectangle where it can fit without overlapping
* already-occupied blocks or the perimeter.
*/
function spawnSpecialPresets(
canvas: Canvas,
pieces: readonly WildernessPiece[],
presetNames: readonly string[],
gridWidth: number,
gridHeight: number,
rng: Rng,
stats: WildernessStats,
reservedBlocks?: ReadonlySet<string>,
): { placedPieces: Set<string>; placedRects: PlacedPresetRect[] } {
const innerWidth = gridWidth - 2
const innerHeight = gridHeight - 2
const totalSlots = innerWidth * innerHeight
const placedPieces = new Set<string>()
const placedRects: PlacedPresetRect[] = []
if (totalSlots <= 0 || presetNames.length === 0) return { placedPieces, placedRects }
const occupied: boolean[][] = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false))
for (let x = 0; x < gridWidth; x += 1) {
occupied[0]![x] = true
occupied[gridHeight - 1]![x] = true
}
for (let y = 0; y < gridHeight; y += 1) {
occupied[y]![0] = true
occupied[y]![gridWidth - 1] = true
}
if (reservedBlocks && gridWidth >= 8 && gridHeight >= 8) {
for (const key of reservedBlocks) {
const [bxStr, byStr] = key.split(',')
const bx = Number(bxStr)
const by = Number(byStr)
if (occupied[by]?.[bx] !== undefined) {
occupied[by]![bx] = true
}
}
}
// Handle continuous river + bridge layout for Blood Moor when requested on standard grids
let remainingPresets = [...presetNames]
const wantsRiver =
gridWidth >= 8 &&
gridHeight >= 6 &&
remainingPresets.some(n => n.startsWith('Act 1 - River Upper')) &&
remainingPresets.some(n => n.startsWith('Act 1 - River Lower')) &&
remainingPresets.some(n => n.startsWith('Act 1 - Bridge'))
if (wantsRiver) {
const inX = Math.floor(gridWidth / 2)
const outY = Math.floor(gridHeight / 2)
if (
stampAct1RiverAndBridge(
canvas,
pieces,
gridWidth,
gridHeight,
inX,
outY,
occupied,
placedPieces,
placedRects,
stats,
)
) {
remainingPresets = remainingPresets.filter(
n =>
!n.startsWith('Act 1 - River Upper') &&
!n.startsWith('Act 1 - River Lower') &&
!n.startsWith('Act 1 - Bridge'),
)
}
}
for (const presetName of remainingPresets) {
const piece = pieces.find(p => p.name === presetName || p.name.startsWith(presetName))
if (!piece || piece.levels.length === 0) continue
const variantIndex = piece.levels.length > 1 ? rng.int(0, piece.levels.length - 1) : 0
const ds1 = piece.levels[variantIndex]!
const blocksX = Math.max(1, Math.floor(ds1.width / TILES_PER_BLOCK))
const blocksY = Math.max(1, Math.floor(ds1.height / TILES_PER_BLOCK))
const coords: { x: number; y: number }[] = []
for (let i = 0; i < totalSlots; i += 1) {
coords.push({ x: i % innerWidth, y: Math.floor(i / innerWidth) })
}
for (let i = 0; i < totalSlots; i += 1) {
const r1 = rng.int(0, totalSlots - 1)
const r2 = rng.int(0, totalSlots - 1)
const tmp = coords[r1]!
coords[r1] = coords[r2]!
coords[r2] = tmp
}
let placed = false
for (let i = 0; i < totalSlots; i += 1) {
const bx = coords[i]!.x + 1
const by = coords[i]!.y + 1
if (bx < 1 || by < 1 || bx + blocksX > gridWidth - 1 || by + blocksY > gridHeight - 1) continue
let blocked = false
for (let dy = 0; dy < blocksY; dy += 1) {
for (let dx = 0; dx < blocksX; dx += 1) {
if (occupied[by + dy]![bx + dx]) {
blocked = true
break
}
}
if (blocked) break
}
if (blocked) continue
const tileX = bx * TILES_PER_BLOCK
const tileY = by * TILES_PER_BLOCK
stampDs1(canvas, ds1, tileX, tileY)
for (let dy = 0; dy < blocksY; dy += 1) {
for (let dx = 0; dx < blocksX; dx += 1) {
occupied[by + dy]![bx + dx] = true
}
}
stats.substitutions.push({
name: piece.name,
role: 'object',
enabled: true,
clusters: 1,
})
placedPieces.add(piece.name)
placedRects.push({
name: piece.name,
x: tileX,
y: tileY,
w: ds1.width,
h: ds1.height,
})
placed = true
break
}
if (!placed) {
stats.notes.push(`special preset "${presetName}" could not be placed (no free interior block)`)
}
}
return { placedPieces, placedRects }
}
/* ------------------------------------------------------------------------- *
* Entry point
* ------------------------------------------------------------------------- */
/**
* Derive the block grid for a level whose `Levels.txt` size is unset.
*/
function resolveUnsetSize(request: WildernessRequest): { sizeX: number; sizeY: number; source: string } {
if (request.sizeOverride !== undefined) {
return { sizeX: request.sizeOverride.x, sizeY: request.sizeOverride.y, source: 'sizeOverride' }
}
const barricade = request.substitutions.find(row => /barricade/i.test(row.name))
const level = barricade?.levels[0]
if (level !== undefined) {
return { sizeX: level.width, sizeY: level.height, source: `LvlSub "${barricade!.name}" piece extent` }
}
return { sizeX: 160, sizeY: 64, source: 'D2MOO canonical barricade size' }
}
/**
* Assemble the 15 sequential 16x48 siege strips for Act 5 - Siege 1 (Bloody Foothills, LevelId 110).
*/
function stampAct5SiegeStrips(
canvas: Canvas,
pieces: readonly WildernessPiece[],
stats: WildernessStats,
): Set<string> {
const stripOrder = [
'Act 5 - Siege To Barricade',
'Act 5 - Siege Strip 13',
'Act 5 - Siege Strip 12',
'Act 5 - Siege Strip 11',
'Act 5 - Siege Strip 10',
'Act 5 - Siege Strip 9',
'Act 5 - Siege Strip 8',
'Act 5 - Siege Strip 7',
'Act 5 - Siege Strip 6',
'Act 5 - Siege Strip 5',
'Act 5 - Siege Strip 4',
'Act 5 - Siege Strip 3',
'Act 5 - Siege Strip 2',
'Act 5 - Siege Strip 1',
'Act 5 - Siege To Town',
]
const placed = new Set<string>()
for (let i = 0; i < stripOrder.length; i += 1) {
const name = stripOrder[i]!
const piece = pieces.find(p => p.name === name)
const ds1 = piece?.levels[0]
if (!ds1) continue
stampDs1(canvas, ds1, i * 16, 0)
placed.add(name)
stats.substitutions.push({ name, role: 'object', enabled: true, clusters: 1 })
}
return placed
}
/**
* Assemble the 6 presets for Act 3 Travincal (LevelId 83, 64x64).
*/
function stampAct3Travincal(
canvas: Canvas,
pieces: readonly WildernessPiece[],
stats: WildernessStats,
): Set<string> {
const placements: Array<{ name: string; x: number; y: number }> = [
{ name: 'Act 3 - Travincal NW', x: 0, y: 0 },
{ name: 'Act 3 - Travincal N', x: 16, y: 0 },
{ name: 'Act 3 - Travincal NE', x: 48, y: 0 },
{ name: 'Act 3 - Travincal SW', x: 0, y: 32 },
{ name: 'Act 3 - Travincal S', x: 16, y: 32 },
{ name: 'Act 3 - Travincal SE', x: 48, y: 32 },
]
const placed = new Set<string>()
for (const item of placements) {
const piece = pieces.find(p => p.name.trim() === item.name.trim())
const ds1 = piece?.levels[0]
if (!ds1) continue
stampDs1(canvas, ds1, item.x, item.y)
placed.add(item.name)
stats.substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 })
}
return placed
}
/**
* Stamp the 48x16 bridge preset for Act 3 Kurast Causeway (LevelId 82).
*/
function stampAct3KurastCauseway(
canvas: Canvas,
pieces: readonly WildernessPiece[],
stats: WildernessStats,
): Set<string> {
const piece = pieces.find(p => p.name === 'Act 3 - Bridge')
const ds1 = piece?.levels[0]
const placed = new Set<string>()
if (ds1) {
stampDs1(canvas, ds1, 0, 0)
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
return placed
}
/**
* Assemble the 64x192 (2x6 blocks of 32x32) river and clearing layout for Act 3 Jungles (LevelId 76, 77, 78).
*
* In `TravL.ds1` (`Jungle Tail` at `y = 0..31`) and `TransL.ds1` (`Jungle Head` at `y = 160..191`),
* the Kurast river channel flows through the East column (`x = 32..63`, water at `x = 38..60`).
* Placing North-South river presets with West path openings (`Act 3 - Jungle NS W` / `RivNSoW`) in the
* East column (`x = 32`) aligns the river channel (`32 + [5..28] = [37..60]`) seamlessly from `y = 0`
* to `y = 192`, while placing Clearing presets that open East in the West column (`x = 0`) connects
* every clearing across `x = 32` directly to the main riverside jungle trail.
*/
function stampAct3Jungle(
canvas: Canvas,
levelId: number,
pieces: readonly WildernessPiece[],
rng: Rng,
stats: WildernessStats,
): Set<string> {
const placed = new Set<string>()
const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail')
const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head')
if (tailPiece?.levels[0]) {
stampDs1(canvas, tailPiece.levels[0], 0, 0)
placed.add(tailPiece.name)
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
}
if (headPiece?.levels[0]) {
stampDs1(canvas, headPiece.levels[0], 0, 160)
placed.add(headPiece.name)
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
}
const clearingPrefix =
levelId === 76
? 'Act 3 - Clearing Webby'
: levelId === 77
? 'Act 3 - Clearing Boggy'
: 'Act 3 - Clearing Pygmy'
const allClearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0)
// Prefer clearings that open East (` E` or ` EW` or ` SE` or ` NE`) to meet the East river column
const eastOpeningClearings = allClearingPieces.filter(p => /\b(E|EW|SE|NE)\b/i.test(p.name))
const clearingPool = eastOpeningClearings.length > 0 ? eastOpeningClearings : allClearingPieces
const allRiverPieces = pieces.filter(
p =>
p.name.startsWith('Act 3 - Jungle ') &&
p.name !== 'Act 3 - Jungle Head' &&
p.name !== 'Act 3 - Jungle Tail' &&
p.levels.length > 0,
)
// Prefer North-South river segments that open West (`NS W` / `NS`) to align with Tail/Head at x=32
const nsWestRivers = allRiverPieces.filter(p => /NS W|NS$/i.test(p.name))
const riverPool = nsWestRivers.length > 0 ? nsWestRivers : allRiverPieces
for (let row = 0; row < 4; row += 1) {
const y = 32 + row * 32
// West column (x = 0): Jungle Clearing opening East
if (clearingPool.length > 0) {
const chosenClearing = clearingPool[rng.int(0, clearingPool.length - 1)]!
const ds1 = chosenClearing.levels[rng.int(0, chosenClearing.levels.length - 1)]!
stampDs1(canvas, ds1, 0, y)
placed.add(chosenClearing.name)
stats.substitutions.push({ name: chosenClearing.name, role: 'object', enabled: true, clusters: 1 })
}
// East column (x = 32): Continuous North-South River channel
if (riverPool.length > 0) {
const chosenRiver = riverPool[rng.int(0, riverPool.length - 1)]!
const ds1 = chosenRiver.levels[rng.int(0, chosenRiver.levels.length - 1)]!
stampDs1(canvas, ds1, 32, y)
placed.add(chosenRiver.name)
stats.substitutions.push({ name: chosenRiver.name, role: 'object', enabled: true, clusters: 1 })
}
}
return placed
}
/**
* Lay a 3-tile-wide main dirt highway connecting the South Border Gate (`Bord1o.ds1` at `x = inX*8 + 3..5`),
* the Central Crossroads (`hubX, hubY`), the River Bridge (if present), and the East Border Gate
* (`Bord4o.ds1` at `y = outY*8 + 3..5`), plus 2-tile-wide spur paths to every spawned landmark.
*/
function stampAct1DirtRoads(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
): void {
const makeRoadFloor = (): Ds1Floor => ({
prop1: 194,
sequence: rng.int(0, 6) === 0 ? 25 : 0,
style: 5,
unknown1: 0,
unknown2: 0,
hidden: false,
})
const isInsideProtectedPreset = (x: number, y: number): boolean => {
for (const r of placedRects) {
if (r.name === 'Act 1 - River') continue
if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) {
return true
}
}
return false
}
const paintRoadTile = (x: number, y: number, clearStrayClutter: boolean): void => {
if (x < 4 || x >= canvas.width - 4 || y < 4 || y >= canvas.height - 4) return
if (isInsideProtectedPreset(x, y)) return
const cell = canvas.cells[y]?.[x]
if (!cell) return
const existingFloor = cell.floors[0]
// Never overwrite water (`style 2/3`) or wooden bridge planks (`style 4`)
if (existingFloor && (existingFloor.style === 2 || existingFloor.style === 3 || existingFloor.style === 4)) {
return
}
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) {
if (clearStrayClutter) {
// Clear random LvlSub bush/tree clutter on the main highway spine
;(cell.walls as Ds1Wall[]).length = 0
} else {
return
}
}
;(cell.floors as Ds1Floor[])[0] = makeRoadFloor()
}
const hubX = inX * TILES_PER_BLOCK + 4
const hubY = outY * TILES_PER_BLOCK + 4
const southGateY = (gridHeight - 1) * TILES_PER_BLOCK + 3
const eastGateX = (gridWidth - 1) * TILES_PER_BLOCK + 3
const northEnd = Math.max(12, Math.floor(canvas.height * 0.2))
const westEnd = Math.max(12, Math.floor(canvas.width * 0.2))
// 1. 3-tile-wide South-to-North main highway (`x = hubX - 1, hubX, hubX + 1`)
for (let y = northEnd; y <= southGateY; y += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
paintRoadTile(hubX + dx, y, true)
}
}
// 2. 3-tile-wide West-to-East main highway (`y = hubY - 1, hubY, hubY + 1`)
for (let x = westEnd; x <= eastGateX; x += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
paintRoadTile(x, hubY + dy, true)
}
}
// 3. 2-tile-wide spur roads connecting each major spawned landmark to the main highway
const paintSpurSegment = (x0: number, y0: number, x1: number, y1: number): void => {
let curX = x0
let curY = y0
let steps = 0
const maxSteps = canvas.width + canvas.height
while ((curX !== x1 || curY !== y1) && steps < maxSteps) {
for (let dy = 0; dy <= 1; dy += 1) {
for (let dx = 0; dx <= 1; dx += 1) {
paintRoadTile(curX + dx, curY + dy, false)
}
}
if (curX !== x1) curX += x1 > curX ? 1 : -1
else if (curY !== y1) curY += y1 > curY ? 1 : -1
steps += 1
}
}
for (const r of placedRects) {
if (
r.name === 'Act 1 - River' ||
/Stone Fill|Swamp Fill|Tree Fill/i.test(r.name)
) {
continue
}
const centerX = Math.floor(r.x + r.w / 2)
const centerY = Math.floor(r.y + r.h / 2)
// Pick the edge of the preset closest to the main crossroads
const distToNS = Math.abs(centerX - hubX)
const distToEW = Math.abs(centerY - hubY)
if (distToNS <= distToEW) {
const doorX = centerX < hubX ? r.x + r.w : r.x - 1
const doorY = Math.max(r.y + 2, Math.min(r.y + r.h - 3, centerY))
paintSpurSegment(doorX, doorY, hubX, doorY)
} else {
const doorX = Math.max(r.x + 2, Math.min(r.x + r.w - 3, centerX))
const doorY = centerY < hubY ? r.y + r.h : r.y - 1
paintSpurSegment(doorX, doorY, doorX, hubY)
}
}
}
/**
* Lay connected stone wall divider lines (`style=2, prop1=129`) across the interior of Act 1
* wilderness levels (LevelId 2..7), using exact Blizzard tile sequences:
* - Continuous East-West wall: `style=2, sequence=0, type=2` (`s2.q0.t2`)
* - Continuous North-South wall: `style=2, sequence=0, type=1` (`s2.q0.t1`)
* - Corner intersection: `style=2, sequence=0, type=3` (`s2.q0.t3`)
* - East-West gate end-caps: `sequence=1, type=2` (West/Right-of-gap) & `sequence=2, type=2` (East/Left-of-gap)
* - North-South gate end-caps: `sequence=1, type=1` (North/Bottom-of-gap) & `sequence=2, type=1` (South/Top-of-gap)
*/
function stampAct1InteriorStoneWalls(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
): void {
if (gridWidth < 8 || gridHeight < 8) return
const makeWall = (type: number, sequence: number): Ds1Wall => ({
prop1: 129,
sequence,
style: 2,
type,
unknown1: 0,
unknown2: 0,
hidden: false,
})
const placeWall = (x: number, y: number, wall: Ds1Wall): void => {
const cell = canvas.cells[y]?.[x]
if (!cell) return
const targetWalls = cell.walls as Ds1Wall[]
if (targetWalls.length > 0) {
targetWalls[0] = wall
} else {
targetWalls.push(wall)
}
}
// Offset stone wall dividers by 2 blocks (16 tiles) from the main road spine
const offsetBlockY = rng.int(0, 1) === 0 ? -2 : 2
const offsetBlockX = rng.int(0, 1) === 0 ? -2 : 2
const wallY = Math.max(16, Math.min(canvas.height - 16, (outY + offsetBlockY) * TILES_PER_BLOCK))
const wallX = Math.max(16, Math.min(canvas.width - 16, (inX + offsetBlockX) * TILES_PER_BLOCK))
const isCellBlockedForWall = (x: number, y: number): boolean => {
if (x < 10 || x >= canvas.width - 10 || y < 10 || y >= canvas.height - 10) return true
// Leave 2-tile margin around all placed special presets (houses, caves, rivers, corrals)
for (const r of placedRects) {
if (x >= r.x - 2 && x < r.x + r.w + 2 && y >= r.y - 2 && y < r.y + r.h + 2) {
return true
}
}
// Leave 3-tile wide archway gate openings wherever a dirt road (style 5), bridge (style 4), or water (style 2/3) passes
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
const c = canvas.cells[y + dy]?.[x + dx]
if (!c) continue
if (c.floors.some(f => !f.hidden && (f.style === 5 || f.style === 4 || f.style === 2 || f.style === 3))) {
return true
}
}
}
const cell = canvas.cells[y]?.[x]
if (!cell) return true
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return true
return false
}
// Build valid tile masks along wallY (EW) and wallX (NS), inserting periodic pasture gates
const validEW: boolean[] = Array.from({ length: canvas.width }, (_, x) => {
const isPastureGate = (x % 24) >= 11 && (x % 24) <= 13 && Math.abs(x - wallX) > 3
return !isPastureGate && !isCellBlockedForWall(x, wallY)
})
const validNS: boolean[] = Array.from({ length: canvas.height }, (_, y) => {
const isPastureGate = (y % 24) >= 11 && (y % 24) <= 13 && Math.abs(y - wallY) > 3
return !isPastureGate && !isCellBlockedForWall(wallX, y)
})
const extractRuns = (mask: readonly boolean[]): Array<{ start: number; end: number }> => {
const runs: Array<{ start: number; end: number }> = []
let start = -1
for (let i = 0; i < mask.length; i += 1) {
if (mask[i]) {
if (start === -1) start = i
} else if (start !== -1) {
if (i - start >= 4) runs.push({ start, end: i - 1 })
start = -1
}
}
if (start !== -1 && mask.length - start >= 4) {
runs.push({ start, end: mask.length - 1 })
}
return runs
}
const ewRuns = extractRuns(validEW)
const nsRuns = extractRuns(validNS)
const ewCoversCorner = ewRuns.some(r => wallX >= r.start && wallX <= r.end)
const nsCoversCorner = nsRuns.some(r => wallY >= r.start && wallY <= r.end)
const hasCornerJoin = ewCoversCorner && nsCoversCorner
// Stamp East-West stone wall segments (`type = 2`)
for (const run of ewRuns) {
for (let x = run.start; x <= run.end; x += 1) {
if (x === wallX && hasCornerJoin) {
placeWall(x, wallY, makeWall(3, 0)) // Corner / Cross join (`s2.q0.t3`)
} else if (x === run.start) {
placeWall(x, wallY, makeWall(2, 1)) // West / Right-of-gap End-Cap (`s2.q1.t2`)
} else if (x === run.end) {
placeWall(x, wallY, makeWall(2, 2)) // East / Left-of-gap End-Cap (`s2.q2.t2`)
} else {
placeWall(x, wallY, makeWall(2, 0)) // Continuous EW Stone Wall (`s2.q0.t2`)
}
}
}
// Stamp North-South stone wall segments (`type = 1`)
for (const run of nsRuns) {
for (let y = run.start; y <= run.end; y += 1) {
if (y === wallY && hasCornerJoin) {
placeWall(wallX, y, makeWall(3, 0)) // Corner / Cross join (`s2.q0.t3`)
} else if (y === run.start) {
placeWall(wallX, y, makeWall(1, 1)) // North / Bottom-of-gap End-Cap (`s2.q1.t1`)
} else if (y === run.end) {
placeWall(wallX, y, makeWall(1, 2)) // South / Top-of-gap End-Cap (`s2.q2.t1`)
} else {
placeWall(wallX, y, makeWall(1, 0)) // Continuous NS Stone Wall (`s2.q0.t1`)
}
}
}
}
/* ------------------------------------------------------------------------- *
* Outdoor Population Phase: Waypoints, Roadside Shrines/Chests, SuperUniques & Flavie
* (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`)
* ------------------------------------------------------------------------- */
/** Outdoor levels in Act 1 and Act 2 that carry a Waypoint (`Levels.txt` `Waypoint != 255`). */
const OUTDOOR_WAYPOINT_LEVELS = new Set([3, 4, 5, 6, 42, 43, 44])
function isTileClear(
canvas: Canvas,
tx: number,
ty: number,
placedRects: readonly PlacedPresetRect[],
padding = 1,
): boolean {
if (tx < padding || ty < padding || tx >= canvas.width - padding || ty >= canvas.height - padding) {
return false
}
for (const r of placedRects) {
if (tx >= r.x - padding && tx < r.x + r.w + padding && ty >= r.y - padding && ty < r.y + r.h + padding) {
return false
}
}
for (let dy = -padding; dy <= padding; dy += 1) {
const row = canvas.cells[ty + dy]
if (!row) return false
for (let dx = -padding; dx <= padding; dx += 1) {
const cell = row[tx + dx]
if (!cell) return false
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false
}
}
return true
}
function isNearDirtRoad(
dirtPathGrid: Uint8Array | undefined,
width: number,
height: number,
tx: number,
ty: number,
minDist: number,
maxDist: number,
): boolean {
if (!dirtPathGrid) return false
let minDistFound = 999
for (let dy = -maxDist; dy <= maxDist; dy += 1) {
const ny = ty + dy
if (ny < 0 || ny >= height) continue
for (let dx = -maxDist; dx <= maxDist; dx += 1) {
const nx = tx + dx
if (nx < 0 || nx >= width) continue
if (dirtPathGrid[ny * width + nx] !== 0) {
const d = Math.max(Math.abs(dx), Math.abs(dy))
if (d < minDistFound) minDistFound = d
}
}
}
return minDistFound >= minDist && minDistFound <= maxDist
}
/**
* Automatically locate and place an outdoor Waypoint (`DRLGOUTDOORS_SpawnAct12Waypoint`)
* on a flat, open area near the main dirt road when not already provided by a preset.
*/
function spawnAct12Waypoint(
canvas: Canvas,
levelId: number,
act: number,
dirtPathGrid: Uint8Array | undefined,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
stats: WildernessStats,
): void {
if (!OUTDOOR_WAYPOINT_LEVELS.has(levelId)) return
// Check if a preset already placed a Waypoint object (Act 1 WP id 37/52, Act 2 WM id 10/306)
const existingWp = canvas.objects.find(
o => o.type === 2 && (o.id === 37 || o.id === 52 || o.id === 10 || o.id === 306),
)
if (existingWp !== undefined) {
stats.waypointsSpawned = 1
stats.waypointTile = {
x: Math.floor(existingWp.x / SUB_TILES_PER_TILE),
y: Math.floor(existingWp.y / SUB_TILES_PER_TILE),
}
return
}
const roadCandidates: { x: number; y: number }[] = []
const openCandidates: { x: number; y: number }[] = []
const margin = Math.min(16, Math.floor(Math.min(canvas.width, canvas.height) / 4))
for (let y = margin; y < canvas.height - margin; y += 2) {
for (let x = margin; x < canvas.width - margin; x += 2) {
if (!isTileClear(canvas, x, y, placedRects, 2)) continue
if (isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 3, 6)) {
roadCandidates.push({ x, y })
} else {
openCandidates.push({ x, y })
}
}
}
const pool = roadCandidates.length > 0 ? roadCandidates : openCandidates
const chosen = pool.length > 0
? pool[rng.int(0, pool.length - 1)]!
: { x: Math.floor(canvas.width / 2), y: Math.floor(canvas.height / 2) }
const wpId = act === 2 ? 10 : 37
canvas.objects.push({
type: 2,
id: wpId,
x: chosen.x * SUB_TILES_PER_TILE + 2,
y: chosen.y * SUB_TILES_PER_TILE + 2,
flags: 0,
})
stats.waypointsSpawned = 1
stats.waypointTile = { x: chosen.x, y: chosen.y }
stats.notes.push(`spawned outdoor waypoint (id=${String(wpId)}) at tile (${String(chosen.x)}, ${String(chosen.y)})`)
}
/**
* Scatter roadside shrines, healing wells, chests, and barrels/rocks (`DRLGOUTDOORS_SpawnAct12Shrines`)
* along dirt road edges and near ruins/cottages.
*/
function spawnAct12Shrines(
canvas: Canvas,
levelId: number,
act: number,
dirtPathGrid: Uint8Array | undefined,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
stats: WildernessStats,
): void {
const isAct1Wild = levelId >= 2 && levelId <= 7
const isAct2Wild = levelId >= 41 && levelId <= 46
if (!isAct1Wild && !isAct2Wild) return
const margin = Math.min(10, Math.floor(Math.min(canvas.width, canvas.height) / 5))
const candidates: { x: number; y: number }[] = []
for (let y = margin; y < canvas.height - margin; y += 2) {
for (let x = margin; x < canvas.width - margin; x += 2) {
if (!isTileClear(canvas, x, y, placedRects, 1)) continue
// Prefer roadside (2..5 tiles from road) or near preset ruins
const nearRoad = isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 2, 5)
if (nearRoad || dirtPathGrid === undefined) {
candidates.push({ x, y })
}
}
}
if (candidates.length === 0) {
for (let y = margin; y < canvas.height - margin; y += 4) {
for (let x = margin; x < canvas.width - margin; x += 4) {
if (isTileClear(canvas, x, y, placedRects, 1)) candidates.push({ x, y })
}
}
}
if (candidates.length === 0) return
const placedCoords: { x: number; y: number }[] = canvas.objects.map(o => ({
x: Math.floor(o.x / SUB_TILES_PER_TILE),
y: Math.floor(o.y / SUB_TILES_PER_TILE),
}))
const pickSpot = (): { x: number; y: number } | null => {
for (let attempt = 0; attempt < 24; attempt += 1) {
const spot = candidates[rng.int(0, candidates.length - 1)]!
const tooClose = placedCoords.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < 4)
if (!tooClose) {
placedCoords.push(spot)
return spot
}
}
return null
}
const shrinePool = act === 2 ? [54, 55, 152, 227] : [29, 82, 83, 84]
const wellId = act === 2 ? 234 : 31
const chestPool = act === 2 ? [32, 33, 44] : [5, 89, 90, 93]
const scatterPool = act === 2 ? [48] : [17, 18, 25]
let shrinesSpawned = 0
let interactivesSpawned = 0
// 1..2 Shrines
const shrineCount = rng.int(1, 2)
for (let i = 0; i < shrineCount; i += 1) {
const spot = pickSpot()
if (!spot) break
const id = shrinePool[rng.int(0, shrinePool.length - 1)]!
canvas.objects.push({
type: 2,
id,
x: spot.x * SUB_TILES_PER_TILE + 2,
y: spot.y * SUB_TILES_PER_TILE + 2,
flags: 0,
})
shrinesSpawned += 1
}
// 1 Healing Well
const wellSpot = pickSpot()
if (wellSpot) {
canvas.objects.push({
type: 2,
id: wellId,
x: wellSpot.x * SUB_TILES_PER_TILE + 2,
y: wellSpot.y * SUB_TILES_PER_TILE + 2,
flags: 0,
})
shrinesSpawned += 1
}
// 1..3 Chests
const chestCount = rng.int(1, 3)
for (let i = 0; i < chestCount; i += 1) {
const spot = pickSpot()
if (!spot) break
const id = chestPool[rng.int(0, chestPool.length - 1)]!
canvas.objects.push({
type: 2,
id,
x: spot.x * SUB_TILES_PER_TILE + 2,
y: spot.y * SUB_TILES_PER_TILE + 2,
flags: 0,
})
interactivesSpawned += 1
}
// 2..4 Barrels / Campfires / Stash Rocks
const scatterCount = rng.int(2, 4)
for (let i = 0; i < scatterCount; i += 1) {
const spot = pickSpot()
if (!spot) break
const id = scatterPool[rng.int(0, scatterPool.length - 1)]!
canvas.objects.push({
type: 2,
id,
x: spot.x * SUB_TILES_PER_TILE + 2,
y: spot.y * SUB_TILES_PER_TILE + 2,
flags: 0,
})
interactivesSpawned += 1
}
stats.shrinesSpawned = shrinesSpawned
stats.interactivesSpawned = interactivesSpawned
}
/**
* Anchor fixed Act 1 SuperUniques (Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven)
* to their landmark presets and place Guard NPC Flavie (`rogue2`) at the Blood Moor <-> Cold Plains border gate.
*/
function spawnLandmarkEntities(
canvas: Canvas,
levelId: number,
act1Plan: Act1OutdoorPlan | undefined,
placedRects: readonly PlacedPresetRect[],
gridWidth: number,
gridHeight: number,
stats: WildernessStats,
): void {
const entities: LandmarkEntity[] = []
const cx = Math.floor(canvas.width / 2)
const cy = Math.floor(canvas.height / 2)
// 1. Guard Flavie (rogue2) at Blood Moor (levelId 2) <-> Cold Plains (levelId 3) open border gate
if (levelId === 2 || levelId === 3) {
const targetGateLevel = levelId === 2 ? 3 : 2
const anchor = act1Plan?.anchors.find(
a => a.toLevelId === targetGateLevel || /Cold Plains|Blood Moor/i.test(a.label),
)
let fx: number
let fy: number
if (anchor !== undefined) {
const dx = Math.sign(cx - anchor.interiorCell.x) * 2
const dy = Math.sign(cy - anchor.interiorCell.y) * 2
fx = Math.max(4, Math.min(canvas.width - 5, anchor.interiorCell.x + dx))
fy = Math.max(4, Math.min(canvas.height - 5, anchor.interiorCell.y + dy))
} else {
const openings = getPerimeterOpenings(levelId, gridWidth, gridHeight)
// Level 2 has exit at (9,5) or east/north border; Level 3 has entrance at south/west border
let chosenKey: string | undefined
for (const k of openings) {
const [bx, by] = k.split(',').map(Number)
if (levelId === 2 && bx === gridWidth - 1) chosenKey = k
if (levelId === 3 && by === gridHeight - 1) chosenKey = k
}
if (!chosenKey) chosenKey = openings.values().next().value
if (chosenKey) {
const [bx, by] = chosenKey.split(',').map(Number)
const rawX = (bx ?? 0) * TILES_PER_BLOCK + 4
const rawY = (by ?? 0) * TILES_PER_BLOCK + 4
const dx = Math.sign(cx - rawX) * 3
const dy = Math.sign(cy - rawY) * 3
fx = Math.max(4, Math.min(canvas.width - 5, rawX + dx))
fy = Math.max(4, Math.min(canvas.height - 5, rawY + dy))
} else {
fx = cx
fy = cy
}
}
entities.push({
role: 'npc_guard',
id: 'Flavie',
monsterId: 'rogue2',
name: 'Flavie',
landmark: 'Border Gate (Blood Moor <-> Cold Plains)',
tileX: fx,
tileY: fy,
modifiers: [],
minionCount: [0, 0],
minionMonsterId: '',
})
canvas.objects.push({
type: 1,
id: 52,
x: fx * SUB_TILES_PER_TILE + 2,
y: fy * SUB_TILES_PER_TILE + 2,
flags: 0,
})
}
// 2. Bishibosh (Cold Plains - Level 3 - Act 1 - Fallen Camp Bishibosh)
if (levelId === 3) {
const rect = placedRects.find(r => /Fallen Camp Bishibosh|Bivouac/i.test(r.name))
const tx = rect ? Math.floor(rect.x + rect.w / 2) : Math.floor(canvas.width * 0.45)
const ty = rect ? Math.floor(rect.y + rect.h / 2) : Math.floor(canvas.height * 0.45)
entities.push({
role: 'superunique',
id: 'Bishibosh',
monsterId: 'fallenshaman1',
name: 'Bishibosh',
landmark: rect?.name ?? 'Act 1 - Fallen Camp Bishibosh',
tileX: tx,
tileY: ty,
modifiers: ['magicresistant', 'fireenchant'],
minionCount: [5, 7],
minionMonsterId: 'fallen1',
})
canvas.objects.push({
type: 1,
id: 70,
x: tx * SUB_TILES_PER_TILE + 2,
y: ty * SUB_TILES_PER_TILE + 2,
flags: 0,
})
}
// 3. Rakanishu (Stony Field - Level 4 - Act 1 - Cairn Stones)
if (levelId === 4) {
const rect = placedRects.find(r => /Cairn Stones/i.test(r.name))
const tx = rect ? Math.floor(rect.x + rect.w / 2) : Math.floor(canvas.width * 0.52)
const ty = rect ? Math.floor(rect.y + rect.h / 2) : Math.floor(canvas.height * 0.48)
entities.push({
role: 'superunique',
id: 'Rakanishu',
monsterId: 'fallen1',
name: 'Rakanishu',
landmark: rect?.name ?? 'Act 1 - Cairn Stones',
tileX: tx,
tileY: ty,
modifiers: ['lightenchant', 'fast'],
minionCount: [6, 8],
minionMonsterId: 'fallen1',
})
canvas.objects.push({
type: 1,
id: 71,
x: tx * SUB_TILES_PER_TILE + 2,
y: ty * SUB_TILES_PER_TILE + 2,
flags: 0,
})
}
// 4. Treehead WoodFist (Dark Wood - Level 5 - Act 1 - Inifus)
if (levelId === 5) {
const rect = placedRects.find(r => /Inifus/i.test(r.name))
const tx = rect ? Math.floor(rect.x + rect.w / 2) : Math.floor(canvas.width * 0.48)
const ty = rect ? Math.floor(rect.y + rect.h / 2) : Math.floor(canvas.height * 0.52)
entities.push({
role: 'superunique',
id: 'Treehead WoodFist',
monsterId: 'brute1',
name: 'Treehead WoodFist',
landmark: rect?.name ?? 'Act 1 - Inifus',
tileX: tx,
tileY: ty,
modifiers: ['strong', 'fast'],
minionCount: [3, 4],
minionMonsterId: 'brute1',
})
canvas.objects.push({
type: 1,
id: 72,
x: tx * SUB_TILES_PER_TILE + 2,
y: ty * SUB_TILES_PER_TILE + 2,
flags: 0,
})
}
// 5. Blood Raven (Burial Grounds - Level 17 or Graveyard preset)
const graveyardRect = placedRects.find(r => /Graveyard/i.test(r.name))
if (levelId === 17 || graveyardRect !== undefined) {
const tx = graveyardRect ? Math.floor(graveyardRect.x + graveyardRect.w / 2) : cx
const ty = graveyardRect ? Math.floor(graveyardRect.y + graveyardRect.h / 2) : cy
entities.push({
role: 'superunique',
id: 'Blood Raven',
monsterId: 'corruptrogue1',
name: 'Blood Raven',
landmark: graveyardRect?.name ?? 'Act 1 - Graveyard',
tileX: tx,
tileY: ty,
modifiers: ['fireenchant', 'fast'],
minionCount: [6, 8],
minionMonsterId: 'zombie1',
})
canvas.objects.push({
type: 1,
id: 73,
x: tx * SUB_TILES_PER_TILE + 2,
y: ty * SUB_TILES_PER_TILE + 2,
flags: 0,
})
}
stats.landmarkEntities = entities
}
export function generateWilderness(request: WildernessRequest): WildernessResult {
const where = `level ${String(request.levelId)} (${request.levelName})`
if (request.pieces.length === 0) throw new Error(`${where}: no LvlPrest pieces for this level type`)
let sizeX = request.sizeX
let sizeY = request.sizeY
let sizeSource = 'Levels.txt'
if (!Number.isFinite(sizeX) || !Number.isFinite(sizeY) || sizeX <= 0 || sizeY <= 0) {
const resolved = resolveUnsetSize(request)
sizeX = resolved.sizeX
sizeY = resolved.sizeY
sizeSource = resolved.source
}
const gridWidth = Math.floor(sizeX / TILES_PER_BLOCK)
const gridHeight = Math.floor(sizeY / TILES_PER_BLOCK)
const groundTile = request.groundTile ?? CANONICAL_GROUND_TILES[request.levelTypeName] ?? deriveGroundTile(request.pieces)
if (gridWidth < 3 || gridHeight < 3) {
const bridgePiece = request.pieces.find(p => p.levels.some(l => l.width >= gridWidth * TILES_PER_BLOCK && l.height >= gridHeight * TILES_PER_BLOCK)) ?? request.pieces[0]
if (bridgePiece && bridgePiece.levels[0]) {
const bridgeDs1 = bridgePiece.levels[0]
const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, bridgeDs1.wallLayers, bridgeDs1.floorLayers, bridgeDs1.substitutionType === 1 || bridgeDs1.substitutionType === 2 ? 1 : 0)
if (groundTile !== null) fillGround(canvas, groundTile, 0, 0, canvas.width, canvas.height)
stampDs1(canvas, bridgeDs1, 0, 0)
return {
level: {
version: bridgeDs1.version,
width: canvas.width,
height: canvas.height,
act: bridgeDs1.act,
substitutionType: bridgeDs1.substitutionType,
wallLayers: canvas.wallLayers,
floorLayers: canvas.floorLayers,
cells: canvas.cells,
objects: canvas.objects,
npcPathOffset: null,
},
stats: {
levelId: request.levelId,
levelName: request.levelName,
levelTypeName: request.levelTypeName,
seed: request.seed,
sizeX,
sizeY,
sizeSource,
tilesPerBlock: TILES_PER_BLOCK,
blockGrid: { width: gridWidth, height: gridHeight },
dt1Mask: wildernessDt1Mask(request.levelTypeName),
subType: request.subType,
subTheme: Math.max(0, Math.min(4, Math.floor(request.subTheme))),
groundTile,
groundCells: canvas.width * canvas.height,
borderPiecesAvailable: 0,
borderBlocks: 0,
borderUsage: {},
roadCells: 0,
roadSegments: 0,
anchors: 0,
specialPresets: [],
entrances: [],
substitutions: [],
substitutedClusters: 0,
objects: canvas.objects.length,
cells: canvas.width * canvas.height,
unresolved: [],
unimplementedPasses: UNIMPLEMENTED_PASSES,
notes: ['stamped full connector piece for narrow level'],
},
}
}
throw new Error(`${where}: ${String(sizeX)}x${String(sizeY)} cells is only ${String(gridWidth)}x${String(gridHeight)} blocks, too small for a bordered level`)
}
const stats: WildernessStats = {
substitutions: [], borderPieces: {}, unresolved: [], notes: [],
borderStamped: 0, groundCells: 0, groundTile: null, sizeSource,
roadCells: 0, roadSegments: 0, anchors: 0, specialPresets: [], entrances: [],
}
let wallLayers = 1
let floorLayers = 1
let substitutionType = 0
let version = 0
let act = 1
const allLevels: Ds1[] = []
for (const piece of request.pieces) for (const level of piece.levels) allLevels.push(level)
for (const row of [...request.substitutions, ...(request.shrineSubstitutions ?? [])]) {
for (const level of row.levels) allLevels.push(level)
}
for (const level of allLevels) {
wallLayers = Math.max(wallLayers, level.wallLayers)
floorLayers = Math.max(floorLayers, level.floorLayers)
substitutionType = Math.max(substitutionType, level.substitutionType)
version = Math.max(version, level.version)
act = level.act
}
const substitutionLayers = substitutionType === 1 || substitutionType === 2 ? 1 : 0
const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, wallLayers, floorLayers, substitutionLayers)
const rng = new Rng(request.seed)
stats.groundTile = groundTile
if (groundTile === null) {
throw new Error(`${where}: no piece carries a floor tile to use as ground`)
}
stats.groundCells = fillGround(canvas, groundTile, 0, 0, canvas.width, canvas.height)
// Special case: Act 5 Bloody Foothills (110),
// Act 3 Travincal (83), and Act 3 Jungles (76, 77, 78) are assembled from
// deterministic multi-preset layouts rather than rectangular perimeter borders.
if (
request.levelId === 110 ||
request.levelTypeName === 'Act 5 - Siege' ||
request.levelId === 83 ||
(request.levelId >= 76 && request.levelId <= 78)
) {
let placedSpecial: Set<string>
if (request.levelId === 83) {
placedSpecial = stampAct3Travincal(canvas, request.pieces, stats)
} else if (request.levelId >= 76 && request.levelId <= 78) {
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats)
} else {
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, stats)
}
let finalGroundCells = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (row === undefined) continue
for (let x = 0; x < canvas.width; x += 1) {
const c = row[x]
if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) finalGroundCells += 1
}
}
stats.groundCells = finalGroundCells
return {
level: {
version: version === 0 ? 18 : version,
width: canvas.width,
height: canvas.height,
act,
substitutionType,
wallLayers: canvas.wallLayers,
floorLayers: canvas.floorLayers,
cells: canvas.cells,
objects: canvas.objects,
npcPathOffset: null,
},
stats: {
levelId: request.levelId,
levelName: request.levelName,
levelTypeName: request.levelTypeName,
seed: request.seed,
sizeX,
sizeY,
sizeSource,
tilesPerBlock: TILES_PER_BLOCK,
blockGrid: { width: gridWidth, height: gridHeight },
dt1Mask: wildernessDt1Mask(request.levelTypeName),
subType: request.subType,
subTheme: 0,
groundTile,
groundCells: stats.groundCells,
borderPiecesAvailable: 0,
borderBlocks: 0,
borderUsage: {},
roadCells: stats.roadCells,
roadSegments: stats.roadSegments,
anchors: stats.anchors,
entrances: stats.entrances,
specialPresets: stats.specialPresets,
substitutions: stats.substitutions,
substitutedClusters: placedSpecial.size,
objects: canvas.objects.length,
cells: canvas.width * canvas.height,
unresolved: stats.unresolved,
unimplementedPasses: UNIMPLEMENTED_PASSES,
notes: stats.notes,
},
}
}
// Filter pieces for Act 3 Kurast districts (79 Slums, 80 Burbs, 81 Metro) and Act 5 dirt (111) vs snow (112, 117)
let levelPieces = request.pieces
if (request.levelId === 79) {
const slumsOnly = request.pieces.filter(p => /slums/i.test(p.name))
if (slumsOnly.length > 0) levelPieces = slumsOnly
} else if (request.levelId === 80) {
const burbsOnly = request.pieces.filter(p => /burbs/i.test(p.name))
if (burbsOnly.length > 0) levelPieces = burbsOnly
} else if (request.levelId === 81) {
const metroOnly = request.pieces.filter(p => /metro/i.test(p.name))
if (metroOnly.length > 0) levelPieces = metroOnly
} else if (request.levelId === 111) {
levelPieces = request.pieces.filter(p => !/snow/i.test(p.name))
} else if (request.levelId === 112 || request.levelId === 117) {
const snowOnly = request.pieces.filter(p => /snow/i.test(p.name) || /ruins/i.test(p.name))
if (snowOnly.length > 0) levelPieces = snowOnly
}
const borderPieces = levelPieces.filter(piece => piece.border)
const fillPieces = levelPieces.filter(piece => !piece.border)
// Step 1-3: Act 1 Outdoor anchor & road planning + dirt road bitmask rasterization
//
// The planner was written for Act 1 and its hard-coded per-level table only
// covers Act 1, but nothing in it is Act 1 specific once the caller supplies
// the seams: `request.gates` comes from the world graph, so every act can be
// planned the same way. Without gates we keep the old Act 1 only behaviour so
// existing packs and the generator verifier reproduce byte for byte.
let act1Plan: Act1OutdoorPlan | undefined
let dirtPathGrid: Uint8Array | undefined
if (request.levelTypeName === 'Act 1 - Wilderness' || request.gates !== undefined) {
act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng)
stats.anchors = act1Plan.anchors.length
stats.entrances = toEntrances(act1Plan.anchors)
const polylines = buildRoadNetworkPolylines(act1Plan, gridWidth, gridHeight, rng)
if (polylines.length > 0) {
const roadRes = generateDirtPaths(canvas, polylines)
dirtPathGrid = roadRes.dirtPathGrid
stats.roadCells = roadRes.roadCells
stats.roadSegments = roadRes.roadSegments
}
}
// Step 4A: Lay directional border around perimeter (respecting open gates & Town Transitions)
layBorder(
canvas,
borderPieces,
gridWidth,
gridHeight,
rng,
stats,
request.levelId,
act1Plan?.openGates,
act1Plan?.claimedBorderBlocks,
)
// Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.)
const allPlacedRects: PlacedPresetRect[] = []
if (act1Plan !== undefined) {
for (const preset of act1Plan.specialPresets) {
const originX = Math.min(preset.bx * TILES_PER_BLOCK, Math.max(canvas.width - preset.ds1.width, 0))
const originY = Math.min(preset.by * TILES_PER_BLOCK, Math.max(canvas.height - preset.ds1.height, 0))
stampDs1(canvas, preset.ds1, originX, originY)
stats.specialPresets.push(preset.name)
stats.substitutions.push({ name: preset.name, role: 'object', enabled: true, clusters: 1 })
allPlacedRects.push({
name: preset.name,
x: originX,
y: originY,
w: preset.ds1.width,
h: preset.ds1.height,
})
}
}
let specialPresetNames = request.specialPresets
if (specialPresetNames === undefined) {
if (request.levelId === 2) {
// Blood Moor has a chance (50%) of spawning river water + bridge, or pond
const hasRiver = rng.int(0, 1) === 0
specialPresetNames = hasRiver
? [
'Act 1 - DOE Entrance',
'Act 1 - River Upper',
'Act 1 - River Lower',
'Act 1 - Bridge',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
]
: [
'Act 1 - DOE Entrance',
'Act 1 - Pond',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
]
} else {
specialPresetNames = SPECIAL_PRESETS_BY_LEVEL[request.levelId] ?? []
}
}
if (act1Plan !== undefined) {
specialPresetNames = specialPresetNames.filter(name => !stats.specialPresets.includes(name))
}
const inX = Math.floor(gridWidth / 2)
const outY = Math.floor(gridHeight / 2)
const reservedRoadBlocks = new Set<string>()
if (request.levelId >= 2 && request.levelId <= 7) {
for (let by = 1; by < gridHeight - 1; by += 1) reservedRoadBlocks.add(`${String(inX)},${String(by)}`)
for (let bx = 1; bx < gridWidth - 1; bx += 1) reservedRoadBlocks.add(`${String(bx)},${String(outY)}`)
}
if (act1Plan !== undefined) {
for (let by = 0; by < gridHeight; by += 1) {
for (let bx = 0; bx < gridWidth; bx += 1) {
if (act1Plan.claimedBlocks[by]?.[bx]) {
reservedRoadBlocks.add(`${String(bx)},${String(by)}`)
}
}
}
}
const { placedPieces: placedSpecialPieces, placedRects } = spawnSpecialPresets(
canvas,
fillPieces,
specialPresetNames,
gridWidth,
gridHeight,
rng,
stats,
reservedRoadBlocks,
)
allPlacedRects.push(...placedRects)
// Step 4C: Run LvlSub substitutions with strict road and preset obstacle avoidance
const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme)))
const objectRows = request.substitutions.filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')
const borderRows = [...request.substitutions, ...(request.shrineSubstitutions ?? [])]
.filter(row => classifySubstitutionRole(row.name, row.type) === 'border')
const substituted = applySubstitutions(
canvas,
[...borderRows, ...objectRows, ...(request.shrineSubstitutions ?? []).filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')],
themeIndex,
gridWidth,
gridHeight,
rng,
stats,
act1Plan?.claimedBlocks,
dirtPathGrid,
)
// For Act 1 outdoor wilderness maps (LevelId 2..7), lay dirt road networks and
// interior stone wall divider lines across the meadow after random clutter substitutions.
if (request.levelId >= 2 && request.levelId <= 7) {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, allPlacedRects, rng)
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, allPlacedRects, rng)
}
// Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie)
const populationRng = new Rng(request.seed ^ 0x6c07)
spawnAct12Waypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats)
spawnAct12Shrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats)
spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats)
for (const piece of fillPieces) {
if (!placedSpecialPieces.has(piece.name)) {
stats.notes.push(`fill piece "${piece.name}" is not placed by this port`)
}
}
let finalGroundCells = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (row === undefined) continue
for (let x = 0; x < canvas.width; x += 1) {
const c = row[x]
if (c !== undefined) {
if (!c.floors.some(f => !f.hidden && f.prop1 !== 0)) {
const floor = c.floors[0]
if (floor !== undefined) {
Object.assign(floor, { prop1: 2, style: groundTile.style, sequence: groundTile.sequence, hidden: false })
}
}
finalGroundCells += 1
}
}
}
stats.groundCells = finalGroundCells
if (!Number.isFinite(canvas.width * canvas.height) || canvas.width * canvas.height > (1 << 22)) {
throw new Error(`${where}: synthesized map is ${String(canvas.width)}x${String(canvas.height)} cells, outside the supported bound`)
}
return {
level: {
version: version === 0 ? 18 : version,
width: canvas.width,
height: canvas.height,
act,
substitutionType,
wallLayers: canvas.wallLayers,
floorLayers: canvas.floorLayers,
cells: canvas.cells,
objects: canvas.objects,
npcPathOffset: null,
},
stats: {
levelId: request.levelId,
levelName: request.levelName,
levelTypeName: request.levelTypeName,
seed: request.seed,
sizeX,
sizeY,
sizeSource,
tilesPerBlock: TILES_PER_BLOCK,
blockGrid: { width: gridWidth, height: gridHeight },
dt1Mask: wildernessDt1Mask(request.levelTypeName),
subType: request.subType,
subTheme: themeIndex,
groundTile,
groundCells: stats.groundCells,
borderPiecesAvailable: borderPieces.length,
borderBlocks: stats.borderStamped,
borderUsage: stats.borderPieces,
roadCells: stats.roadCells,
roadSegments: stats.roadSegments,
anchors: stats.anchors,
entrances: stats.entrances,
specialPresets: stats.specialPresets,
substitutions: stats.substitutions,
substitutedClusters: substituted + placedSpecialPieces.size,
objects: canvas.objects.length,
waypointsSpawned: stats.waypointsSpawned ?? 0,
waypointTile: stats.waypointTile ?? null,
shrinesSpawned: stats.shrinesSpawned ?? 0,
interactivesSpawned: stats.interactivesSpawned ?? 0,
landmarkEntities: stats.landmarkEntities ?? [],
cells: canvas.width * canvas.height,
unresolved: stats.unresolved,
unimplementedPasses: UNIMPLEMENTED_PASSES,
notes: stats.notes,
},
}
}