3768 lines
136 KiB
TypeScript
3768 lines
136 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, 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,
|
||
},
|
||
}
|
||
}
|