1117 lines
44 KiB
TypeScript
1117 lines
44 KiB
TypeScript
/**
|
||
* 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 } from '../formats/ds1.ts'
|
||
import { Rng } from './rng.ts'
|
||
import { SUB_TILES_PER_TILE } from './map.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 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
|
||
targetRow[targetX] = cell
|
||
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
|
||
}
|
||
|
||
/** 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
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
const UNIMPLEMENTED_PASSES: readonly string[] = [
|
||
'DRLGVER_CreateVertices',
|
||
'DRLGOUTPLACE_CreateLevelConnections',
|
||
'DRLGOUTWILD_InitAct1OutdoorLevel',
|
||
'DRLGOUTDESR_InitAct2OutdoorLevel',
|
||
'DRLGOUTPLACE_InitAct3OutdoorLevel',
|
||
'DRLGOUTDOORS_InitAct4OutdoorLevel',
|
||
'DRLGOUTSIEGE_InitAct5OutdoorLevel',
|
||
'DRLG_GenerateJungles',
|
||
'DRLGOUTDOORS_SpawnAct1DirtPaths',
|
||
'DRLG_OUTDOORS_GenerateDirtPath',
|
||
'DRLGOUTDOORS_SpawnAct12Waypoint',
|
||
'DRLGOUTDOORS_SpawnAct12Shrines',
|
||
'DRLGOUTDOORS_SpawnAct3Mephisto',
|
||
]
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Border
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* 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. This port has no outline ring — building it
|
||
* needs the level-link data that is hardcoded per act — so it uses the rectangle
|
||
* the level *is*, which for every shipped wilderness level is the rectangle
|
||
* `Levels.txt` declares.
|
||
*
|
||
* @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
|
||
}
|
||
|
||
/**
|
||
* Lay the level's border pieces around the block grid's outer ring.
|
||
*
|
||
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` is the model: a piece is stamped at a
|
||
* block cell, the box it covers is claimed, and later stamps overwrite earlier
|
||
* ones. Which piece a given stretch of cliff uses is hardcoded per act in D2MOO
|
||
* (paired with a border *style* through tables like `levelPrestBorder`), so this
|
||
* port does the data-driven equivalent: it walks the ring and cycles the level's
|
||
* declared border pieces, smallest first, which keeps the cliffs a consistent
|
||
* width and stops a large piece from swallowing the interior.
|
||
*
|
||
* @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.
|
||
* @returns the number of pieces stamped.
|
||
*/
|
||
function layBorder(
|
||
canvas: Canvas,
|
||
pieces: readonly WildernessPiece[],
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
rng: Rng,
|
||
stats: WildernessStats,
|
||
): number {
|
||
if (pieces.length === 0) {
|
||
stats.unresolved.push('no border piece for this level type')
|
||
return 0
|
||
}
|
||
const ordered = [...pieces].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)
|
||
})
|
||
const ring = borderRing(gridWidth, gridHeight)
|
||
let cursor = rng.int(0, ordered.length - 1)
|
||
let stamped = 0
|
||
for (const cell of ring) {
|
||
const piece = ordered[cursor]!
|
||
cursor = (cursor + 1) % ordered.length
|
||
const variant = rng.int(0, piece.levels.length - 1)
|
||
const level = piece.levels[variant % piece.levels.length]
|
||
if (level === undefined) continue
|
||
// Clip the piece to the canvas, but shift it so its near corner still covers
|
||
// the ring cell: a cliff piece anchored past the edge would leave a hole.
|
||
const originX = Math.min(cell.x * TILES_PER_BLOCK, Math.max(canvas.width - level.width, 0))
|
||
const originY = Math.min(cell.y * TILES_PER_BLOCK, Math.max(canvas.height - level.height, 0))
|
||
stampDs1(canvas, level, originX, originY)
|
||
stats.borderPieces[piece.name] = (stats.borderPieces[piece.name] ?? 0) + 1
|
||
stats.borderStamped += 1
|
||
stamped += 1
|
||
}
|
||
return stamped
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Substitutions
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* Scatter one `LvlSub` row's pieces across the level's interior.
|
||
*
|
||
* The parameters are used exactly as `DRLGTILESUB_DoSubstitutions` uses them:
|
||
* `Max[theme]` clusters are attempted, each cluster spends `Trials[theme]`
|
||
* positions looking for a free one (`-1` meaning "walk every free position"), and
|
||
* `BordType` bounds the total — 0 allows a single cluster for the whole level, 1
|
||
* a single cluster per row, anything else is unlimited. Positions snap to
|
||
* `GridSize`, matching `x - x % dwGridSize` in `DRLGTILESUB_TestReplaceSubPreset`.
|
||
*
|
||
* The difference from D2MOO is granularity. There, a substitution group is a box
|
||
* inside the piece's own DS1 and the swap happens tile by tile against the room's
|
||
* floor/wall grids, driven by the DS1's substitution layer. `src/formats/ds1.ts`
|
||
* decodes the substitution layer's raw values but not the group table, so this
|
||
* port substitutes at *block* granularity: the whole piece is stamped at a block
|
||
* origin. The shapes and the frequency are right; the exact tile a swap lands on
|
||
* is not.
|
||
*
|
||
* @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.
|
||
* @returns how many clusters were stamped.
|
||
*/
|
||
function applySubstitution(
|
||
canvas: Canvas,
|
||
row: WildernessSubstitution,
|
||
themeIndex: number,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
rng: Rng,
|
||
): 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
|
||
}
|
||
|
||
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)
|
||
stampDs1(canvas, level, (1 + snappedX) * TILES_PER_BLOCK, (1 + snappedY) * TILES_PER_BLOCK)
|
||
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)
|
||
stampDs1(canvas, level, (1 + offsetX) * TILES_PER_BLOCK, (1 + offsetY) * TILES_PER_BLOCK)
|
||
placed = true
|
||
}
|
||
}
|
||
if (placed) stamped += 1
|
||
}
|
||
return stamped
|
||
}
|
||
|
||
/**
|
||
* Run the `Prob` gate and then the cluster loop for every substitution row.
|
||
*
|
||
* The gate is `DRLGTILESUB_PickSubThemes`: each row of the level's group is
|
||
* independently given a `Prob[SubTheme]` per-cent chance of being enabled, and an
|
||
* enabled row contributes its `Dt1Mask` to the room's library mask.
|
||
*
|
||
* @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.
|
||
* @returns the number of clusters stamped.
|
||
*/
|
||
function applySubstitutions(
|
||
canvas: Canvas,
|
||
rows: readonly WildernessSubstitution[],
|
||
themeIndex: number,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
rng: Rng,
|
||
stats: WildernessStats,
|
||
): 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)
|
||
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[]>> = {
|
||
39: [
|
||
'Act 1 - Bivouac',
|
||
'Act 1 - Pond',
|
||
'Act 1 - Corral Fill',
|
||
'Act 1 - Swamp Fill 1',
|
||
'Act 1 - Swamp Fill 2',
|
||
'Act 1 - Stone Fill 1',
|
||
'Act 1 - Stone Fill 2',
|
||
],
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
function spawnSpecialPresets(
|
||
canvas: Canvas,
|
||
pieces: readonly WildernessPiece[],
|
||
presetNames: readonly string[],
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
rng: Rng,
|
||
stats: WildernessStats,
|
||
): Set<string> {
|
||
const innerWidth = gridWidth - 2
|
||
const innerHeight = gridHeight - 2
|
||
const totalSlots = innerWidth * innerHeight
|
||
const placedPieces = new Set<string>()
|
||
if (totalSlots <= 0 || presetNames.length === 0) return placedPieces
|
||
|
||
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
|
||
}
|
||
|
||
for (const presetName of presetNames) {
|
||
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
|
||
|
||
stampDs1(canvas, ds1, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK)
|
||
|
||
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)
|
||
placed = true
|
||
break
|
||
}
|
||
|
||
if (!placed) {
|
||
stats.notes.push(`special preset "${presetName}" could not be placed (no free interior block)`)
|
||
}
|
||
}
|
||
|
||
return placedPieces
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Entry point
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* Derive the block grid for a level whose `Levels.txt` size is unset.
|
||
*
|
||
* `Act 5 - Barricade 1` and `2` ship `SizeX = SizeY = -1`. The engine does not
|
||
* read a size for them: `DRLGOUTSIEGE_ConnectBarricadeAndSiege` positions the
|
||
* barricade level against the siege level and sizes it from the barricade piece
|
||
* itself. The barricade piece is the `LvlSub` row with `GridSize = 2` whose name
|
||
* is `Barricade`, so its DS1's extent is the level's extent — that is what this
|
||
* reproduces.
|
||
*
|
||
* @param request - the request.
|
||
* @returns the size in cells and where it came from.
|
||
* @throws when no barricade piece is available to size the level from.
|
||
*/
|
||
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' }
|
||
}
|
||
|
||
/**
|
||
* Generate one wilderness level.
|
||
*
|
||
* The pipeline follows `DRLGOUTDOORS_GenerateLevel`: derive the block grid from
|
||
* the level's declared size, lay the ground, walk the outer ring stamping the
|
||
* border pieces, then scatter the `LvlSub` substitutions according to
|
||
* `SubType`/`SubTheme`. What it does **not** do is the per-act work that decides
|
||
* the level's outline and where it meets its neighbours — see
|
||
* {@link UNIMPLEMENTED_PASSES} and the module header.
|
||
*
|
||
* @param request - the real table data for one `DrlgType == 3` level.
|
||
* @returns the synthesized map plus a report of what was done.
|
||
* @throws when the level cannot be built: a size that yields no usable grid, or
|
||
* an unset size with nothing to derive one from. The message always names the
|
||
* level, because an empty map silently baked into an asset pack is worse than a
|
||
* crash.
|
||
*/
|
||
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: {},
|
||
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,
|
||
}
|
||
|
||
// Layer counts must be known before the canvas exists, because a cell's layer
|
||
// arrays are fixed at creation.
|
||
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)
|
||
|
||
const borderPieces = request.pieces.filter(piece => piece.border)
|
||
const fillPieces = request.pieces.filter(piece => !piece.border)
|
||
layBorder(canvas, borderPieces, gridWidth, gridHeight, rng, stats)
|
||
|
||
const specialPresetNames = request.specialPresets ?? SPECIAL_PRESETS_BY_LEVEL[request.levelId] ?? []
|
||
const placedSpecialPieces = spawnSpecialPresets(canvas, fillPieces, specialPresetNames, gridWidth, gridHeight, rng, stats)
|
||
|
||
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')
|
||
// Secondary border pieces (`BordType` rows) are laid through the same cluster
|
||
// machinery, which is where their `GridSize`/`BordType` parameters belong.
|
||
const substituted = applySubstitutions(
|
||
canvas,
|
||
[...borderRows, ...objectRows, ...(request.shrineSubstitutions ?? []).filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')],
|
||
themeIndex,
|
||
gridWidth,
|
||
gridHeight,
|
||
rng,
|
||
stats,
|
||
)
|
||
for (const piece of fillPieces) {
|
||
if (!placedSpecialPieces.has(piece.name)) {
|
||
stats.notes.push(`fill piece "${piece.name}" is not placed by this port`)
|
||
}
|
||
}
|
||
|
||
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,
|
||
substitutions: stats.substitutions,
|
||
substitutedClusters: substituted + placedSpecialPieces.size,
|
||
objects: canvas.objects.length,
|
||
cells: canvas.width * canvas.height,
|
||
unresolved: stats.unresolved,
|
||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
||
notes: stats.notes,
|
||
},
|
||
}
|
||
}
|