5425 lines
188 KiB
TypeScript
5425 lines
188 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`. This port supports multi-DT1 weighted ground tile blending
|
||
* via `fillGround` using `dwDt1Mask` / `RarityFrameIndex` or candidate ground tiles,
|
||
* with single-tile fallback for backward compatibility — see {@link WildernessRequest.groundTile}.
|
||
*/
|
||
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall, Ds1Substitution } from '../formats/ds1.ts'
|
||
import type { Dt1 } from '../formats/dt1.ts'
|
||
import { Rng } from './rng.ts'
|
||
import { SUB_TILES_PER_TILE } from './map.ts'
|
||
import type { Side } from './world-graph.ts'
|
||
import {
|
||
initAct2OutdoorLevel,
|
||
initAct3OutdoorLevel,
|
||
generateAct3Jungle,
|
||
stampAct3KurastCauseway,
|
||
initAct4OutdoorLevel,
|
||
initAct5OutdoorLevel,
|
||
type Act2TopographyStats,
|
||
type Act3TopographyStats,
|
||
type Act4TopographyStats,
|
||
type Act5TopographyStats,
|
||
} from './wilderness-acts.ts'
|
||
import {
|
||
DRLG_GenerateJungles,
|
||
buildJungleTopology,
|
||
type JungleTopology,
|
||
type JungleZoneConnection,
|
||
type JungleLayoutStats,
|
||
type JungleRiverNetwork,
|
||
} from './wilderness-jungles.ts'
|
||
import {
|
||
stampAct5SiegeStrips,
|
||
type SiegeTopographyStats,
|
||
} from './wilderness-siege.ts'
|
||
import type { D2Table } from './acts.ts'
|
||
import { readSuperUniques, CANONICAL_ELITE_MODIFIERS } from './monsters.ts'
|
||
import type { SuperUnique } from './monsters.ts'
|
||
|
||
export {
|
||
DRLG_GenerateJungles,
|
||
buildJungleTopology,
|
||
type JungleTopology,
|
||
type JungleZoneConnection,
|
||
type JungleLayoutStats,
|
||
type JungleRiverNetwork,
|
||
stampAct5SiegeStrips,
|
||
type SiegeTopographyStats,
|
||
}
|
||
|
||
/** 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.
|
||
* Can also accept an array of {@link WeightedGroundTile} candidates for multi-tile blending.
|
||
*
|
||
* 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 or DT1 libraries,
|
||
* recording the choice in `stats.groundTile` (and `stats.groundTiles`).
|
||
*/
|
||
readonly groundTile?: { readonly style: number; readonly sequence: number } | readonly WeightedGroundTile[]
|
||
/**
|
||
* Array of candidate ground tiles with relative weights for multi-tile ground blending.
|
||
* When provided, `fillGround` blends these tiles across the canvas.
|
||
*/
|
||
readonly groundTiles?: readonly WeightedGroundTile[]
|
||
/**
|
||
* DT1 libraries loaded for the level. When supplied without explicit groundTile(s),
|
||
* candidate ground tiles are derived using dwDt1Mask and RarityFrameIndex.
|
||
*/
|
||
readonly dt1Libraries?: readonly Dt1[]
|
||
/**
|
||
* 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[]
|
||
/** Optional `SuperUniques.txt` table for data-driven outdoor boss and landmark generation. */
|
||
readonly superuniques?: D2Table
|
||
/** Optional `MonPreset.txt` table for data-driven DS1 spawner object IDs. */
|
||
readonly monpreset?: D2Table
|
||
/** Optional custom landmark entity definitions overriding CANONICAL_LANDMARK_ENTITIES. */
|
||
readonly customLandmarks?: readonly LandmarkEntityDef[]
|
||
}
|
||
|
||
/** 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> & {
|
||
readonly landmarkEntities?: LandmarkEntity[]
|
||
}
|
||
}
|
||
|
||
/** 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 },
|
||
}
|
||
|
||
/** Canonical default open ground floor tile pools per wilderness level type. */
|
||
export const CANONICAL_GROUND_POOLS: Readonly<Record<string, readonly WeightedGroundTile[]>> = {
|
||
'Act 1 - Wilderness': [{ style: 0, sequence: 0, weight: 1 }],
|
||
'Act 2 - Desert': [{ style: 0, sequence: 1, weight: 1 }],
|
||
'Act 3 - Jungle': [{ style: 0, sequence: 0, weight: 1 }],
|
||
'Act 3 - Kurast': [{ style: 0, sequence: 0, weight: 1 }],
|
||
'Act 4 - Mesa': [{ style: 10, sequence: 0, weight: 1 }],
|
||
'Act 4 - Lava': [{ style: 20, sequence: 3, weight: 1 }],
|
||
'Act 5 - Siege': [{ style: 0, sequence: 0, weight: 1 }],
|
||
'Act 5 - Barricade': [{ style: 0, sequence: 0, weight: 1 }],
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* 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. */
|
||
export function emptyWall(): Ds1Wall {
|
||
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||
}
|
||
|
||
/** A blank floor or shadow cell. */
|
||
export function emptyFloor(): Ds1Floor {
|
||
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||
}
|
||
|
||
/** The mutable map under construction. */
|
||
export 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.
|
||
*/
|
||
export 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.
|
||
*/
|
||
export 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
|
||
}
|
||
|
||
/** A candidate ground tile with an optional weighting/rarity. */
|
||
export interface WeightedGroundTile {
|
||
readonly style: number
|
||
readonly sequence: number
|
||
/**
|
||
* Relative selection weight (e.g. from `RarityFrameIndex` or piece counts).
|
||
* Defaults to 1 (or `rarityFrameIndex`) if omitted or <= 0.
|
||
*/
|
||
readonly weight?: number
|
||
/**
|
||
* Direct DT1 `rarityFrameIndex`, if sourced from a DT1 tile.
|
||
*/
|
||
readonly rarityFrameIndex?: number
|
||
}
|
||
|
||
/** Configuration options for ground tile blending. */
|
||
export interface FillGroundOptions {
|
||
/** PRNG seed or Rng instance for deterministic tile generation. */
|
||
readonly seed?: number | Rng
|
||
/** Blending mode: 'cdf' (cell-by-cell weighted draw) or 'spatial' (coherent noise patches). Default: 'spatial'. */
|
||
readonly mode?: 'cdf' | 'spatial'
|
||
/** Spatial noise scale in cells (default: 6). Higher values produce broader patches. */
|
||
readonly spatialScale?: number
|
||
/** Weight of spatial coherence vs cell jitter in [0, 1] (default: 0.6). */
|
||
readonly spatialCoherence?: number
|
||
}
|
||
|
||
/**
|
||
* Fast, high-quality 32-bit integer hash for 2D coordinates and a seed.
|
||
* Returns a uniform pseudo-random number in [0, 1).
|
||
*/
|
||
export function cellHash(x: number, y: number, seed: number): number {
|
||
let h = (seed ^ Math.imul(x, 0x1b873593) ^ Math.imul(y, 0x85ebca6b)) >>> 0
|
||
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0
|
||
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0
|
||
h = (h ^ (h >>> 16)) >>> 0
|
||
return h / 4294967296
|
||
}
|
||
|
||
/**
|
||
* 2D smooth value noise with cubic Hermite interpolation.
|
||
* Returns a value in [0, 1).
|
||
*/
|
||
export function smoothNoise2D(x: number, y: number, scale: number, seed: number): number {
|
||
const invScale = 1 / Math.max(1, scale)
|
||
const gx = Math.floor(x * invScale)
|
||
const gy = Math.floor(y * invScale)
|
||
const fx = x * invScale - gx
|
||
const fy = y * invScale - gy
|
||
|
||
// Cubic Hermite smoothstep: 3t^2 - 2t^3
|
||
const sx = fx * fx * (3 - 2 * fx)
|
||
const sy = fy * fy * (3 - 2 * fy)
|
||
|
||
const n00 = cellHash(gx, gy, seed)
|
||
const n10 = cellHash(gx + 1, gy, seed)
|
||
const n01 = cellHash(gx, gy + 1, seed)
|
||
const n11 = cellHash(gx + 1, gy + 1, seed)
|
||
|
||
const nx0 = n00 + sx * (n10 - n00)
|
||
const nx1 = n01 + sx * (n11 - n01)
|
||
return nx0 + sy * (nx1 - nx0)
|
||
}
|
||
|
||
function isTileArray(
|
||
val: unknown,
|
||
): val is readonly unknown[] {
|
||
return Array.isArray(val)
|
||
}
|
||
|
||
/**
|
||
* Sample a single ground tile at (x, y) from weighted candidate tiles.
|
||
*/
|
||
export function sampleGroundTile(
|
||
tileOrTiles: WeightedGroundTile | readonly WeightedGroundTile[],
|
||
x: number,
|
||
y: number,
|
||
optionsOrSeed?: number | Rng | FillGroundOptions,
|
||
): WeightedGroundTile {
|
||
if (!isTileArray(tileOrTiles)) return tileOrTiles
|
||
if (tileOrTiles.length === 0) return { style: 0, sequence: 0 }
|
||
const tiles = tileOrTiles as readonly WeightedGroundTile[]
|
||
if (tiles.length === 1) return tiles[0]!
|
||
|
||
let seed = 0
|
||
let mode: 'cdf' | 'spatial' = 'spatial'
|
||
let spatialScale = 6
|
||
let spatialCoherence = 0.6
|
||
|
||
if (typeof optionsOrSeed === 'number') {
|
||
seed = optionsOrSeed >>> 0
|
||
} else if (optionsOrSeed instanceof Rng) {
|
||
seed = optionsOrSeed.seed >>> 0
|
||
} else if (optionsOrSeed && typeof optionsOrSeed === 'object') {
|
||
if (typeof optionsOrSeed.seed === 'number') seed = optionsOrSeed.seed >>> 0
|
||
else if (optionsOrSeed.seed instanceof Rng) seed = optionsOrSeed.seed.seed >>> 0
|
||
if (optionsOrSeed.mode !== undefined) mode = optionsOrSeed.mode
|
||
if (typeof optionsOrSeed.spatialScale === 'number' && optionsOrSeed.spatialScale > 0) {
|
||
spatialScale = optionsOrSeed.spatialScale
|
||
}
|
||
if (typeof optionsOrSeed.spatialCoherence === 'number') {
|
||
spatialCoherence = Math.max(0, Math.min(1, optionsOrSeed.spatialCoherence))
|
||
}
|
||
}
|
||
|
||
const valid = tileOrTiles.filter(t => {
|
||
const w = t.weight !== undefined ? t.weight : t.rarityFrameIndex !== undefined ? t.rarityFrameIndex : 1
|
||
return w > 0
|
||
})
|
||
const active = valid.length > 0 ? valid : tileOrTiles
|
||
if (active.length === 1) return active[0]!
|
||
|
||
let totalWeight = 0
|
||
for (const c of active) {
|
||
const w = c.weight !== undefined ? c.weight : c.rarityFrameIndex !== undefined ? c.rarityFrameIndex : 1
|
||
totalWeight += Math.max(0, w)
|
||
}
|
||
if (totalWeight <= 0) return active[0]!
|
||
|
||
const cdfs: number[] = []
|
||
let running = 0
|
||
for (let i = 0; i < active.length; i += 1) {
|
||
const w = active[i]!.weight !== undefined
|
||
? active[i]!.weight!
|
||
: active[i]!.rarityFrameIndex !== undefined
|
||
? active[i]!.rarityFrameIndex!
|
||
: 1
|
||
running += Math.max(0, w)
|
||
cdfs.push(running / totalWeight)
|
||
}
|
||
cdfs[cdfs.length - 1] = 1.0
|
||
|
||
let u: number
|
||
if (mode === 'cdf') {
|
||
u = cellHash(x, y, seed)
|
||
} else {
|
||
const smooth = smoothNoise2D(x, y, spatialScale, seed)
|
||
const jitter = cellHash(x, y, seed ^ 0x9e3779b9)
|
||
u = spatialCoherence * smooth + (1 - spatialCoherence) * jitter
|
||
}
|
||
|
||
for (let i = 0; i < cdfs.length; i += 1) {
|
||
if (u < cdfs[i]!) return active[i]!
|
||
}
|
||
return active[active.length - 1]!
|
||
}
|
||
|
||
/**
|
||
* Write one or more floor tiles into every cell of a rectangle with weighted random blending.
|
||
*
|
||
* Supports:
|
||
* 1. Single tile fallback (backward compatibility): All cells receive the single tile.
|
||
* 2. Multi-tile weighted distribution: Tiles are sampled via CDF sampling or spatial noise,
|
||
* naturally blending variations (e.g. pure grass, pebbled dirt, weeds) across the ground
|
||
* without jarring discontinuities.
|
||
*
|
||
* @param canvas - the canvas.
|
||
* @param tileOrTiles - single tile or array of weighted candidate tiles.
|
||
* @param x - left edge in cells.
|
||
* @param y - top edge in cells.
|
||
* @param width - cells.
|
||
* @param height - cells.
|
||
* @param optionsOrSeed - PRNG seed, Rng instance, or blending options.
|
||
* @returns the number of cells written.
|
||
*/
|
||
export function fillGround(
|
||
canvas: Canvas,
|
||
tileOrTiles: WeightedGroundTile | readonly WeightedGroundTile[],
|
||
x: number,
|
||
y: number,
|
||
width: number,
|
||
height: number,
|
||
optionsOrSeed?: number | Rng | FillGroundOptions,
|
||
): number {
|
||
if (!isTileArray(tileOrTiles)) {
|
||
const tile = tileOrTiles
|
||
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
|
||
Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false })
|
||
written += 1
|
||
}
|
||
}
|
||
return written
|
||
}
|
||
|
||
if (tileOrTiles.length === 0) return 0
|
||
const tiles = tileOrTiles as readonly WeightedGroundTile[]
|
||
|
||
if (tiles.length === 1) {
|
||
const tile = tiles[0]!
|
||
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
|
||
Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false })
|
||
written += 1
|
||
}
|
||
}
|
||
return written
|
||
}
|
||
|
||
const validCandidates: { style: number; sequence: number; weight: number }[] = []
|
||
for (const t of tileOrTiles) {
|
||
const w = t.weight !== undefined
|
||
? Math.max(0, t.weight)
|
||
: t.rarityFrameIndex !== undefined
|
||
? Math.max(0, t.rarityFrameIndex)
|
||
: 1
|
||
if (w > 0) {
|
||
validCandidates.push({ style: t.style, sequence: t.sequence, weight: w })
|
||
}
|
||
}
|
||
|
||
const active = validCandidates.length > 0
|
||
? validCandidates
|
||
: tileOrTiles.map(t => ({ style: t.style, sequence: t.sequence, weight: 1 }))
|
||
|
||
if (active.length === 1) {
|
||
const tile = active[0]!
|
||
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
|
||
Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false })
|
||
written += 1
|
||
}
|
||
}
|
||
return written
|
||
}
|
||
|
||
let totalWeight = 0
|
||
for (const c of active) totalWeight += c.weight
|
||
const cdfThresholds: number[] = []
|
||
let running = 0
|
||
for (let i = 0; i < active.length; i += 1) {
|
||
running += active[i]!.weight
|
||
cdfThresholds.push(running / totalWeight)
|
||
}
|
||
cdfThresholds[cdfThresholds.length - 1] = 1.0
|
||
|
||
const pickCandidate = (u: number): { style: number; sequence: number } => {
|
||
for (let i = 0; i < cdfThresholds.length; i += 1) {
|
||
if (u < cdfThresholds[i]!) return active[i]!
|
||
}
|
||
return active[active.length - 1]!
|
||
}
|
||
|
||
let seed = 0
|
||
let mode: 'cdf' | 'spatial' = 'spatial'
|
||
let spatialScale = 6
|
||
let spatialCoherence = 0.6
|
||
|
||
if (typeof optionsOrSeed === 'number') {
|
||
seed = optionsOrSeed >>> 0
|
||
} else if (optionsOrSeed instanceof Rng) {
|
||
seed = optionsOrSeed.seed >>> 0
|
||
} else if (optionsOrSeed && typeof optionsOrSeed === 'object') {
|
||
if (typeof optionsOrSeed.seed === 'number') seed = optionsOrSeed.seed >>> 0
|
||
else if (optionsOrSeed.seed instanceof Rng) seed = optionsOrSeed.seed.seed >>> 0
|
||
if (optionsOrSeed.mode !== undefined) mode = optionsOrSeed.mode
|
||
if (typeof optionsOrSeed.spatialScale === 'number' && optionsOrSeed.spatialScale > 0) {
|
||
spatialScale = optionsOrSeed.spatialScale
|
||
}
|
||
if (typeof optionsOrSeed.spatialCoherence === 'number') {
|
||
spatialCoherence = Math.max(0, Math.min(1, optionsOrSeed.spatialCoherence))
|
||
}
|
||
}
|
||
|
||
if (mode === 'cdf') {
|
||
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
|
||
const u = cellHash(cx, cy, seed)
|
||
const picked = pickCandidate(u)
|
||
Object.assign(floor, { prop1: 2, style: picked.style, sequence: picked.sequence, hidden: false })
|
||
written += 1
|
||
}
|
||
}
|
||
return written
|
||
}
|
||
|
||
interface CellEntry {
|
||
floor: Ds1Floor
|
||
v: number
|
||
index: number
|
||
}
|
||
const cellsToWrite: CellEntry[] = []
|
||
|
||
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
|
||
const smooth = smoothNoise2D(cx, cy, spatialScale, seed)
|
||
const jitter = cellHash(cx, cy, seed ^ 0x9e3779b9)
|
||
const v = spatialCoherence * smooth + (1 - spatialCoherence) * jitter
|
||
cellsToWrite.push({
|
||
floor,
|
||
v,
|
||
index: cy * canvas.width + cx,
|
||
})
|
||
}
|
||
}
|
||
|
||
if (cellsToWrite.length === 0) return 0
|
||
|
||
cellsToWrite.sort((a, b) => (a.v !== b.v ? a.v - b.v : a.index - b.index))
|
||
|
||
const totalCells = cellsToWrite.length
|
||
for (let r = 0; r < totalCells; r += 1) {
|
||
const u = (r + 0.5) / totalCells
|
||
const picked = pickCandidate(u)
|
||
Object.assign(cellsToWrite[r]!.floor, {
|
||
prop1: 2,
|
||
style: picked.style,
|
||
sequence: picked.sequence,
|
||
hidden: false,
|
||
})
|
||
}
|
||
|
||
return totalCells
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Ground selection
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* Extract candidate ground floor tiles from DT1 libraries filtered by dwDt1Mask.
|
||
*
|
||
* In Diablo II outdoor levels, DRLGROOMTILE_GetTileCache scans the libraries
|
||
* activated by the level type's dwDt1Mask (switch on nLevelType in DRLGOUTDOORS_GenerateLevel).
|
||
* Floor tiles (tile.type === 0) with positive RarityFrameIndex are accumulated
|
||
* into candidate ground tiles weighted by their rarity.
|
||
*
|
||
* @param libraries - All DT1 libraries resolved for the level (in slot order).
|
||
* @param dt1Mask - The bitmask selecting which DT1 slots are active for ground.
|
||
* @returns Array of weighted ground tiles.
|
||
*/
|
||
export function deriveGroundTilesFromDt1Libraries(
|
||
libraries: readonly Dt1[],
|
||
dt1Mask: number,
|
||
): WeightedGroundTile[] {
|
||
const counts = new Map<string, { style: number; sequence: number; weight: number }>()
|
||
|
||
for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) {
|
||
if ((dt1Mask & (1 << libIndex)) === 0) continue
|
||
const lib = libraries[libIndex]
|
||
if (!lib) continue
|
||
for (const tile of lib.tiles) {
|
||
if (tile.type !== 0) continue
|
||
const rarity = Math.max(0, tile.rarityFrameIndex | 0)
|
||
if (rarity <= 0) continue
|
||
const key = `${String(tile.style)}:${String(tile.sequence)}`
|
||
const existing = counts.get(key)
|
||
if (existing === undefined) {
|
||
counts.set(key, { style: tile.style, sequence: tile.sequence, weight: rarity })
|
||
} else {
|
||
existing.weight += rarity
|
||
}
|
||
}
|
||
}
|
||
|
||
if (counts.size === 0) {
|
||
for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) {
|
||
if ((dt1Mask & (1 << libIndex)) === 0) continue
|
||
const lib = libraries[libIndex]
|
||
if (!lib) continue
|
||
for (const tile of lib.tiles) {
|
||
if (tile.type !== 0) continue
|
||
const key = `${String(tile.style)}:${String(tile.sequence)}`
|
||
if (!counts.has(key)) {
|
||
counts.set(key, { style: tile.style, sequence: tile.sequence, weight: 1 })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return Array.from(counts.values()).map(e => ({
|
||
style: e.style,
|
||
sequence: e.sequence,
|
||
weight: e.weight,
|
||
rarityFrameIndex: e.weight,
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* Derive candidate ground tiles from the level's own border and fill pieces.
|
||
*
|
||
* Scans interior cells of each piece variant for visible floor tiles, tallying
|
||
* their occurrences to use as relative weights.
|
||
*
|
||
* @param pieces - Border and fill pieces.
|
||
* @returns Array of weighted ground tiles sorted by weight descending.
|
||
*/
|
||
export function deriveGroundTiles(pieces: readonly WildernessPiece[]): WeightedGroundTile[] {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const result = Array.from(counts.values()).map(e => ({
|
||
style: e.style,
|
||
sequence: e.sequence,
|
||
weight: e.count,
|
||
}))
|
||
result.sort((a, b) => b.weight - a.weight)
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
export function deriveGroundTile(pieces: readonly WildernessPiece[]): { style: number; sequence: number } | null {
|
||
const tiles = deriveGroundTiles(pieces)
|
||
if (tiles.length === 0) return null
|
||
return { style: tiles[0]!.style, sequence: tiles[0]!.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`. */
|
||
export 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 subtileX?: number
|
||
readonly subtileY?: number
|
||
readonly levelId?: number
|
||
readonly modifiers: readonly string[]
|
||
readonly minionCount: readonly [number, number]
|
||
readonly minionMonsterId: string
|
||
readonly objectId?: number
|
||
}
|
||
|
||
/** The report collector. */
|
||
export 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
|
||
groundTiles?: readonly WeightedGroundTile[] | undefined
|
||
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[]
|
||
act2Topography?: Act2TopographyStats
|
||
act3Topography?: Act3TopographyStats
|
||
act4Topography?: Act4TopographyStats
|
||
act5Topography?: Act5TopographyStats
|
||
jungleLayout?: JungleLayoutStats
|
||
jungleTopology?: JungleTopology
|
||
bridgesPlaced?: number
|
||
riverIslands?: number
|
||
riverForks?: number
|
||
meanderCount?: number
|
||
siegeTopography?: SiegeTopographyStats
|
||
}
|
||
|
||
/**
|
||
* 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',
|
||
|
||
'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 || levelId === 46) {
|
||
// Moo Moo Farm (Cow Level) & Canyon of the Magi: 0 exits!
|
||
return openings
|
||
}
|
||
|
||
// Outdoor wilderness levels across Acts 1, 2, 3, 4, 5:
|
||
// Act 1 (2..7), Act 2 desert (41..46), Act 3 Kurast (79..82),
|
||
// Act 4 Mesa (104..106), Act 5 outdoor (111..112, 117):
|
||
// Default entrance on South edge, exit on East edge
|
||
if (
|
||
(levelId >= 2 && levelId <= 7) ||
|
||
(levelId >= 41 && levelId <= 46) ||
|
||
(levelId >= 79 && levelId <= 82) ||
|
||
(levelId >= 104 && levelId <= 106) ||
|
||
(levelId >= 111 && levelId <= 112) ||
|
||
levelId === 117
|
||
) {
|
||
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 || id === 46) {
|
||
// Moo Moo Farm (39) & Canyon of the Magi (46): Closed border, no border gates
|
||
if (id === 39) 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
|
||
/**
|
||
* `BordType` 0 rows share one cluster across the whole level.
|
||
*
|
||
* This started at `Infinity`, so `Math.min(1, budget)` was always 1 and the
|
||
* subtraction below never bit: every `BordType` 0 row got its own cluster,
|
||
* which is the `BordType` 1 rule. Starting at 1 makes the cap real.
|
||
*/
|
||
let sharedBudget = 1
|
||
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 ? sharedBudget : 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) sharedBudget = Math.max(0, sharedBudget - 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',
|
||
],
|
||
76: [
|
||
'Act 3 - Spider Cavern',
|
||
'Act 3 - Spider Temple Entrance',
|
||
],
|
||
78: [
|
||
'Act 3 - Flayer Jungle Entrance',
|
||
'Act 3 - Flayer Dungeon',
|
||
],
|
||
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',
|
||
],
|
||
88: [
|
||
'Act 3 - Flayer Dungeon',
|
||
],
|
||
91: [
|
||
'Act 3 - Flayer Dungeon',
|
||
],
|
||
94: [
|
||
'Act 3 - Ruined Temple',
|
||
],
|
||
105: [
|
||
'Act 4 - Plains of Despair',
|
||
'Act 4 - Izual',
|
||
],
|
||
106: [
|
||
'Act 4 - Hellforge',
|
||
'Act 4 - Chaos Sanctuary Entrance',
|
||
],
|
||
107: [
|
||
'Act 4 - Hellforge',
|
||
'Act 4 - Chaos Sanctuary Entrance',
|
||
],
|
||
108: [
|
||
'Act 4 - Chaos Sanctuary Entrance',
|
||
'Act 4 - Hadriel',
|
||
],
|
||
110: [
|
||
'Act 5 - Barricade Prison 1',
|
||
'Act 5 - Barricade Building',
|
||
],
|
||
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',
|
||
],
|
||
114: [
|
||
'Act 5 - Frozen River',
|
||
'Act 5 - Anya',
|
||
],
|
||
115: [
|
||
'Act 5 - Glacial Trail',
|
||
'Act 5 - Drifter Cavern',
|
||
],
|
||
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',
|
||
],
|
||
124: [
|
||
'Act 5 - Halls of Vaught',
|
||
'Act 5 - Nihlathak',
|
||
],
|
||
}
|
||
|
||
/**
|
||
* 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 6 presets for Act 3 Travincal (LevelId 83, 64x64).
|
||
*/
|
||
function stampAct3Travincal(
|
||
canvas: Canvas,
|
||
pieces: readonly WildernessPiece[],
|
||
stats: WildernessStats,
|
||
placedRects?: PlacedPresetRect[],
|
||
): 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)
|
||
placedRects?.push({ name: item.name, x: item.x, y: item.y, w: ds1.width, h: ds1.height })
|
||
stats.substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 })
|
||
}
|
||
return placed
|
||
}
|
||
|
||
/**
|
||
* Assemble Act 3 Jungles (Spider Forest 76, Great Marsh 77, Flayer Jungle 78) using
|
||
* the authentic `DRLG_GenerateJungles` river network and branching topology pipeline.
|
||
*/
|
||
function stampAct3Jungle(
|
||
canvas: Canvas,
|
||
levelId: number,
|
||
pieces: readonly WildernessPiece[],
|
||
rng: Rng,
|
||
stats: WildernessStats,
|
||
placedRects?: PlacedPresetRect[],
|
||
): Set<string> {
|
||
return DRLG_GenerateJungles(canvas, levelId, pieces, rng, stats, stampDs1)
|
||
}
|
||
|
||
/** Theme configuration for outdoor road rasterization. */
|
||
export interface OutdoorRoadTheme {
|
||
readonly roadFloor: (rng: Rng) => Ds1Floor
|
||
readonly isProtectedPreset?: (presetName: string) => boolean
|
||
readonly isIgnoredSpurPreset?: (presetName: string) => boolean
|
||
}
|
||
|
||
/** Theme configuration for outdoor stone wall divider networks. */
|
||
export interface OutdoorDividerTheme {
|
||
readonly wallStyle: number
|
||
readonly wallProp1?: number
|
||
readonly roadStyles?: readonly number[]
|
||
}
|
||
|
||
/**
|
||
* General outdoor path and highway rasterizer connecting entrance, central crossroads,
|
||
* exit gates, and landmark presets across wilderness maps.
|
||
*/
|
||
export function stampThemedRoads(
|
||
canvas: Canvas,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
inX: number,
|
||
outY: number,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
theme: OutdoorRoadTheme,
|
||
openGates?: ReadonlyMap<string, number>,
|
||
): void {
|
||
const isInsideProtectedPreset = (x: number, y: number): boolean => {
|
||
for (const r of placedRects) {
|
||
if (theme.isProtectedPreset ? !theme.isProtectedPreset(r.name) : /River|Bridge/i.test(r.name)) {
|
||
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] = theme.roadFloor(rng)
|
||
}
|
||
|
||
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
|
||
let northEnd = Math.max(12, Math.floor(canvas.height * 0.2))
|
||
let westEnd = Math.max(12, Math.floor(canvas.width * 0.2))
|
||
|
||
if (openGates !== undefined) {
|
||
for (const key of openGates.keys()) {
|
||
const [bxStr, byStr] = key.split(',')
|
||
const bx = Number(bxStr)
|
||
const by = Number(byStr)
|
||
if (by === 0) northEnd = 4
|
||
if (bx === 0) westEnd = 4
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
// If openGates contains non-standard gate blocks, connect them into the highway network
|
||
if (openGates !== undefined) {
|
||
for (const key of openGates.keys()) {
|
||
const [bxStr, byStr] = key.split(',')
|
||
const bx = Number(bxStr)
|
||
const by = Number(byStr)
|
||
const gateX = bx * TILES_PER_BLOCK + 4
|
||
const gateY = by * TILES_PER_BLOCK + 4
|
||
if (by === 0 && Math.abs(gateX - hubX) > 2) {
|
||
// North gate branch to hubY
|
||
for (let y = 4; y <= hubY; y += 1) {
|
||
for (let dx = -1; dx <= 1; dx += 1) paintRoadTile(gateX + dx, y, true)
|
||
}
|
||
for (let x = Math.min(gateX, hubX); x <= Math.max(gateX, hubX); x += 1) {
|
||
for (let dy = -1; dy <= 1; dy += 1) paintRoadTile(x, hubY + dy, true)
|
||
}
|
||
} else if (bx === 0 && Math.abs(gateY - hubY) > 2) {
|
||
// West gate branch to hubX
|
||
for (let x = 4; x <= hubX; x += 1) {
|
||
for (let dy = -1; dy <= 1; dy += 1) paintRoadTile(x, gateY + dy, true)
|
||
}
|
||
for (let y = Math.min(gateY, hubY); y <= Math.max(gateY, hubY); y += 1) {
|
||
for (let dx = -1; dx <= 1; dx += 1) paintRoadTile(hubX + dx, y, 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 (theme.isIgnoredSpurPreset ? theme.isIgnoredSpurPreset(r.name) : /River|Bridge|Fill|Berms/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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* General outdoor divider network laying connected stone walls, sandstone berms,
|
||
* or balustrades with proper corner joins, gate archways, and endcaps.
|
||
*/
|
||
export function stampThemedDividers(
|
||
canvas: Canvas,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
inX: number,
|
||
outY: number,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
theme: OutdoorDividerTheme,
|
||
): void {
|
||
if (gridWidth < 6 || gridHeight < 6) return
|
||
|
||
const wallStyle = theme.wallStyle
|
||
const wallProp1 = theme.wallProp1 ?? 129
|
||
|
||
const makeWall = (type: number, sequence: number): Ds1Wall => ({
|
||
prop1: wallProp1,
|
||
sequence,
|
||
style: wallStyle,
|
||
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 divider lines 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(12, Math.min(canvas.height - 12, (outY + offsetBlockY) * TILES_PER_BLOCK))
|
||
const wallX = Math.max(12, Math.min(canvas.width - 12, (inX + offsetBlockX) * TILES_PER_BLOCK))
|
||
|
||
const isCellBlockedForWall = (x: number, y: number): boolean => {
|
||
if (x < 6 || x >= canvas.width - 6 || y < 6 || y >= canvas.height - 6) return true
|
||
// Leave 2-tile margin around all placed special presets
|
||
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 road, bridge, or water 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 === 1 || f.style === 4 || f.style === 2 || f.style === 3 || theme.roadStyles?.includes(f.style)))) {
|
||
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 divider 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 (`type=3`)
|
||
} else if (x === run.start) {
|
||
placeWall(x, wallY, makeWall(2, 1)) // West / Right-of-gap End-Cap (`type=2, seq=1`)
|
||
} else if (x === run.end) {
|
||
placeWall(x, wallY, makeWall(2, 2)) // East / Left-of-gap End-Cap (`type=2, seq=2`)
|
||
} else {
|
||
placeWall(x, wallY, makeWall(2, 0)) // Continuous EW Wall (`type=2, seq=0`)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Stamp North-South divider 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 (`type=3`)
|
||
} else if (y === run.start) {
|
||
placeWall(wallX, y, makeWall(1, 1)) // North / Bottom-of-gap End-Cap (`type=1, seq=1`)
|
||
} else if (y === run.end) {
|
||
placeWall(wallX, y, makeWall(1, 2)) // South / Top-of-gap End-Cap (`type=1, seq=2`)
|
||
} else {
|
||
placeWall(wallX, y, makeWall(1, 0)) // Continuous NS Wall (`type=1, seq=0`)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Act 1: Lay a 3-tile-wide dirt highway connecting the South Border Gate, central crossroads,
|
||
* river bridge, and East Border Gate, plus 2-tile-wide spur paths to landmarks.
|
||
*/
|
||
export function stampAct1DirtRoads(
|
||
canvas: Canvas,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
inX: number,
|
||
outY: number,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
openGates?: ReadonlyMap<string, number>,
|
||
): void {
|
||
stampThemedRoads(
|
||
canvas,
|
||
gridWidth,
|
||
gridHeight,
|
||
inX,
|
||
outY,
|
||
placedRects,
|
||
rng,
|
||
{
|
||
roadFloor: (r) => ({
|
||
prop1: 194,
|
||
sequence: r.int(0, 6) === 0 ? 25 : 0,
|
||
style: 5,
|
||
unknown1: 0,
|
||
unknown2: 0,
|
||
hidden: false,
|
||
}),
|
||
isProtectedPreset: (name) => name !== 'Act 1 - River',
|
||
isIgnoredSpurPreset: (name) => name === 'Act 1 - River' || /Stone Fill|Swamp Fill|Tree Fill|Fence Fill|Corral Fill/i.test(name),
|
||
},
|
||
openGates,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Act 1: Lay connected stone wall divider lines (`style=2, prop1=129`) across the interior
|
||
* meadow of Act 1 wilderness levels (LevelId 2..7).
|
||
*/
|
||
export function stampAct1InteriorStoneWalls(
|
||
canvas: Canvas,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
inX: number,
|
||
outY: number,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
): void {
|
||
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
|
||
wallStyle: 2,
|
||
wallProp1: 129,
|
||
roadStyles: [5],
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Act 2: Desert sandstone caravan routes connecting entrance to exit gates across dunes.
|
||
*/
|
||
export function stampAct2DesertRoads(
|
||
canvas: Canvas,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
inX: number,
|
||
outY: number,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
openGates?: ReadonlyMap<string, number>,
|
||
): void {
|
||
stampThemedRoads(
|
||
canvas,
|
||
gridWidth,
|
||
gridHeight,
|
||
inX,
|
||
outY,
|
||
placedRects,
|
||
rng,
|
||
{
|
||
roadFloor: (r) => ({
|
||
prop1: 194,
|
||
sequence: r.int(0, 5) === 0 ? 1 : 0,
|
||
style: 1,
|
||
unknown1: 0,
|
||
unknown2: 0,
|
||
hidden: false,
|
||
}),
|
||
isProtectedPreset: (name) => !/River|Bridge/i.test(name),
|
||
isIgnoredSpurPreset: (name) => /Fill|Berms/i.test(name),
|
||
},
|
||
openGates,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Act 2: Sandstone divider berms (style=1, prop1=129) separating desert dunes.
|
||
*/
|
||
export function stampAct2SandstoneBerms(
|
||
canvas: Canvas,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
inX: number,
|
||
outY: number,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
): void {
|
||
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
|
||
wallStyle: 1,
|
||
wallProp1: 129,
|
||
roadStyles: [1],
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Act 3: Kurast flagstone avenues across temples, plazas, and causeways.
|
||
*/
|
||
export function stampAct3KurastAvenues(
|
||
canvas: Canvas,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
inX: number,
|
||
outY: number,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
openGates?: ReadonlyMap<string, number>,
|
||
): void {
|
||
stampThemedRoads(
|
||
canvas,
|
||
gridWidth,
|
||
gridHeight,
|
||
inX,
|
||
outY,
|
||
placedRects,
|
||
rng,
|
||
{
|
||
roadFloor: (r) => ({
|
||
prop1: 194,
|
||
sequence: r.int(0, 5) === 0 ? 1 : 0,
|
||
style: 1,
|
||
unknown1: 0,
|
||
unknown2: 0,
|
||
hidden: false,
|
||
}),
|
||
isProtectedPreset: (name) => !/River|Bridge/i.test(name),
|
||
isIgnoredSpurPreset: (name) => /Fill/i.test(name),
|
||
},
|
||
openGates,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Act 3: Kurast balustrades across temples and causeways (style=1, prop1=129).
|
||
*/
|
||
export function stampAct3KurastBalustrades(
|
||
canvas: Canvas,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
inX: number,
|
||
outY: number,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
): void {
|
||
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
|
||
wallStyle: 1,
|
||
wallProp1: 129,
|
||
roadStyles: [1],
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Act 5: Snow march roads in Frigid Highlands & Arreat Plateau.
|
||
*/
|
||
export function stampAct5SnowRoads(
|
||
canvas: Canvas,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
inX: number,
|
||
outY: number,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
openGates?: ReadonlyMap<string, number>,
|
||
): void {
|
||
stampThemedRoads(
|
||
canvas,
|
||
gridWidth,
|
||
gridHeight,
|
||
inX,
|
||
outY,
|
||
placedRects,
|
||
rng,
|
||
{
|
||
roadFloor: (r) => ({
|
||
prop1: 194,
|
||
sequence: r.int(0, 5) === 0 ? 1 : 0,
|
||
style: 1,
|
||
unknown1: 0,
|
||
unknown2: 0,
|
||
hidden: false,
|
||
}),
|
||
isProtectedPreset: (name) => !/River|Bridge/i.test(name),
|
||
isIgnoredSpurPreset: (name) => /Fill|Filler/i.test(name),
|
||
},
|
||
openGates,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Act 5: Frozen trail divides in Frigid Highlands & Arreat Plateau (style=2, prop1=129).
|
||
*/
|
||
export function stampAct5FrozenTrailDivides(
|
||
canvas: Canvas,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
inX: number,
|
||
outY: number,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
): void {
|
||
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
|
||
wallStyle: 2,
|
||
wallProp1: 129,
|
||
roadStyles: [1],
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Dispatch outdoor road networks based on act, levelId, and levelTypeName.
|
||
*/
|
||
export function stampOutdoorRoads(
|
||
canvas: Canvas,
|
||
act: number,
|
||
levelId: number,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
inX: number,
|
||
outY: number,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
levelTypeName?: string,
|
||
openGates?: ReadonlyMap<string, number>,
|
||
): void {
|
||
if (levelId === 39) return // Cow Level has no roads
|
||
|
||
// Level ID takes highest precedence
|
||
if (levelId >= 41 && levelId <= 46) {
|
||
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||
} else if (levelId >= 79 && levelId <= 82) {
|
||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
|
||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||
} else if (levelId >= 2 && levelId <= 7) {
|
||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||
// Level type name takes second precedence
|
||
} else if (levelTypeName === 'Act 2 - Desert') {
|
||
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||
} else if (levelTypeName === 'Act 3 - Kurast') {
|
||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
|
||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||
} else if (levelTypeName === 'Act 1 - Wilderness') {
|
||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||
// Act number takes fallback precedence
|
||
} else if (act === 2) {
|
||
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||
} else if (act === 3) {
|
||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||
} else if (act === 5) {
|
||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||
} else if (act === 1) {
|
||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Dispatch outdoor boundary divider networks based on act, levelId, and levelTypeName.
|
||
*/
|
||
export function stampOutdoorDividers(
|
||
canvas: Canvas,
|
||
act: number,
|
||
levelId: number,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
inX: number,
|
||
outY: number,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
levelTypeName?: string,
|
||
): void {
|
||
if (levelId === 39) return // Cow Level has no dividers
|
||
|
||
// Level ID takes highest precedence
|
||
if (levelId >= 41 && levelId <= 46) {
|
||
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||
} else if (levelId >= 79 && levelId <= 82) {
|
||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
|
||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||
} else if (levelId >= 2 && levelId <= 7) {
|
||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||
// Level type name takes second precedence
|
||
} else if (levelTypeName === 'Act 2 - Desert') {
|
||
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||
} else if (levelTypeName === 'Act 3 - Kurast') {
|
||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
|
||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||
} else if (levelTypeName === 'Act 1 - Wilderness') {
|
||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||
// Act number takes fallback precedence
|
||
} else if (act === 2) {
|
||
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||
} else if (act === 3) {
|
||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||
} else if (act === 5) {
|
||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||
} else if (act === 1) {
|
||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Outdoor Population Phase: Waypoints, Roadside Shrines/Chests, SuperUniques & Flavie
|
||
* (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`)
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** Outdoor levels in Acts 1..5 that carry a Waypoint (`Levels.txt` `Waypoint != 255`). */
|
||
export const OUTDOOR_WAYPOINT_LEVELS = new Set([
|
||
3, 4, 5, 6, // Act 1: Cold Plains, Stony Field, Dark Wood, Black Marsh
|
||
42, 43, 44, 46, // Act 2: Dry Hills, Far Oasis, Lost City, Canyon of the Magi
|
||
76, 77, 78, 79, 80, 81, 83, // Act 3: Spider Forest, Great Marsh, Flayer Jungle, Lower Kurast, Kurast Bazaar, Upper Kurast, Travincal
|
||
106, 107, // Act 4: City of the Damned, River of Flame
|
||
111, 112, 117, // Act 5: Frigid Highlands, Arreat Plateau, Frozen Tundra
|
||
])
|
||
|
||
/**
|
||
* Resolves the numeric Act (1..5) for a given levelId and/or levelTypeName.
|
||
* Handles cases where DS1 preset levels report act = 1 by default.
|
||
*/
|
||
export function actOfLevel(levelId: number, levelTypeName?: string, fallbackAct?: number): number {
|
||
if (levelTypeName) {
|
||
const m = /^Act\s*([1-5])/i.exec(levelTypeName)
|
||
if (m && m[1]) return Number(m[1])
|
||
}
|
||
if (levelId >= 1 && levelId <= 39) return 1
|
||
if (levelId >= 40 && levelId <= 74) return 2
|
||
if (levelId >= 75 && levelId <= 102) return 3
|
||
if (levelId >= 103 && levelId <= 108) return 4
|
||
if (levelId >= 109 && levelId <= 136) return 5
|
||
if (fallbackAct !== undefined && fallbackAct >= 1 && fallbackAct <= 5) return fallbackAct
|
||
return 1
|
||
}
|
||
|
||
export interface ActPopulationConfig {
|
||
readonly waypointId: number
|
||
readonly shrinePool: readonly number[]
|
||
readonly wellId: number
|
||
readonly chestPool: readonly number[]
|
||
readonly scatterPool: readonly number[]
|
||
}
|
||
|
||
export const ACT_POPULATION_CONFIG: Readonly<Record<number, ActPopulationConfig>> = {
|
||
1: {
|
||
waypointId: 37,
|
||
shrinePool: [29, 82, 83, 84],
|
||
wellId: 31,
|
||
chestPool: [5, 89, 90, 91, 92, 93, 94],
|
||
scatterPool: [17, 18, 25],
|
||
},
|
||
2: {
|
||
waypointId: 10,
|
||
shrinePool: [54, 55, 103, 104, 105, 126],
|
||
wellId: 234,
|
||
chestPool: [32, 33, 44, 45, 46],
|
||
scatterPool: [14, 48, 122],
|
||
},
|
||
3: {
|
||
waypointId: 1,
|
||
shrinePool: [14, 85, 111, 152, 227, 231, 235, 236],
|
||
wellId: 234,
|
||
chestPool: [65, 66, 67, 80, 81],
|
||
scatterPool: [4, 5, 48, 179],
|
||
},
|
||
4: {
|
||
waypointId: 0,
|
||
shrinePool: [56, 152, 227, 231, 235, 236],
|
||
wellId: 234,
|
||
chestPool: [19, 20, 24, 25],
|
||
scatterPool: [40, 63, 179],
|
||
},
|
||
5: {
|
||
waypointId: 12,
|
||
shrinePool: [152, 227, 231, 235, 236],
|
||
wellId: 234,
|
||
chestPool: [24, 25, 26, 27, 28, 29],
|
||
scatterPool: [13, 179, 4],
|
||
},
|
||
}
|
||
|
||
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
|
||
if (dx === 0 && dy === 0 && !cell.floors.some(f => !f.hidden && f.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`)
|
||
* supporting authentic thematic clustering across Acts 1..5.
|
||
*
|
||
* In roadless wilderness (e.g. Far Oasis level 43, Flayer Jungle level 78, Arreat Plateau level 112),
|
||
* waypoints prioritize proximity to clearings, ruins, oases, or the central hub.
|
||
* In roadside wilderness, roadside candidates near landmarks or center are preferred.
|
||
*/
|
||
export function spawnOutdoorWaypoint(
|
||
canvas: Canvas,
|
||
levelId: number,
|
||
act: number,
|
||
dirtPathGrid: Uint8Array | undefined,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
stats: WildernessStats,
|
||
levelTypeName?: string,
|
||
): void {
|
||
if (!OUTDOOR_WAYPOINT_LEVELS.has(levelId)) return
|
||
|
||
// Check if a preset already placed a Waypoint object
|
||
const ALL_WAYPOINT_IDS = new Set([37, 52, 269, 10, 306, 1, 387, 0, 388, 12, 188])
|
||
const existingWp = canvas.objects.find(
|
||
o => o.type === 2 && ALL_WAYPOINT_IDS.has(o.id),
|
||
)
|
||
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 effectiveAct = actOfLevel(levelId, levelTypeName, act)
|
||
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
|
||
const wpId = config.waypointId
|
||
|
||
const isJungle = levelId >= 76 && levelId <= 78
|
||
const marginX = isJungle ? 4 : Math.min(16, Math.floor(canvas.width / 4))
|
||
const marginY = Math.min(16, Math.floor(canvas.height / 4))
|
||
const cx = isJungle ? 16 : Math.floor(canvas.width / 2)
|
||
const cy = Math.floor(canvas.height / 2)
|
||
const hubRadius = Math.max(8, Math.floor(Math.min(canvas.width, canvas.height) / 3))
|
||
|
||
const LANDMARK_REGEX = /(clearing|ruin|oasis|cottage|camp|building|temple|tomb|bivouac|shrine)/i
|
||
const landmarkRects = placedRects.filter(r => LANDMARK_REGEX.test(r.name))
|
||
|
||
const clearingCandidates: { x: number; y: number }[] = []
|
||
const centralHubCandidates: { x: number; y: number }[] = []
|
||
const roadsideCandidates: { x: number; y: number }[] = []
|
||
const openCandidates: { x: number; y: number }[] = []
|
||
|
||
const isNearLandmark = (tx: number, ty: number): boolean => {
|
||
for (const r of landmarkRects) {
|
||
const dx = Math.max(r.x - tx, 0, tx - (r.x + r.w - 1))
|
||
const dy = Math.max(r.y - ty, 0, ty - (r.y + r.h - 1))
|
||
const d = Math.max(dx, dy)
|
||
if (d >= 1 && d <= 7) return true
|
||
}
|
||
// For Act 3 Jungle clearings (when stamped without placedRects)
|
||
if (isJungle && landmarkRects.length === 0) {
|
||
if (tx >= 4 && tx < 30 && ty >= 32 && ty < 160) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
for (let y = marginY; y < canvas.height - marginY; y += 2) {
|
||
for (let x = marginX; x < canvas.width - marginX; x += 2) {
|
||
if (!isTileClear(canvas, x, y, placedRects, 2)) continue
|
||
|
||
const nearLandmark = isNearLandmark(x, y)
|
||
const nearRoad = isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 2, 6)
|
||
const inCentralHub = Math.hypot(x - cx, y - cy) <= hubRadius
|
||
|
||
if (nearLandmark) clearingCandidates.push({ x, y })
|
||
if (nearRoad) roadsideCandidates.push({ x, y })
|
||
if (inCentralHub) centralHubCandidates.push({ x, y })
|
||
openCandidates.push({ x, y })
|
||
}
|
||
}
|
||
|
||
if (openCandidates.length === 0) {
|
||
for (let y = marginY; y < canvas.height - marginY; y += 1) {
|
||
for (let x = marginX; x < canvas.width - marginX; x += 1) {
|
||
if (isTileClear(canvas, x, y, placedRects, 1)) {
|
||
openCandidates.push({ x, y })
|
||
if (isNearLandmark(x, y)) clearingCandidates.push({ x, y })
|
||
if (Math.hypot(x - cx, y - cy) <= hubRadius) centralHubCandidates.push({ x, y })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let pool: { x: number; y: number }[]
|
||
if (dirtPathGrid !== undefined) {
|
||
const landmarkRoad = clearingCandidates.filter(c => isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, c.x, c.y, 2, 6))
|
||
if (landmarkRoad.length > 0) {
|
||
pool = landmarkRoad
|
||
} else if (clearingCandidates.length > 0) {
|
||
pool = clearingCandidates
|
||
} else if (roadsideCandidates.length > 0) {
|
||
const roadHub = roadsideCandidates.filter(c => Math.hypot(c.x - cx, c.y - cy) <= hubRadius)
|
||
pool = roadHub.length > 0 ? roadHub : roadsideCandidates
|
||
} else if (centralHubCandidates.length > 0) {
|
||
pool = centralHubCandidates
|
||
} else {
|
||
pool = openCandidates
|
||
}
|
||
} else {
|
||
if (clearingCandidates.length > 0) {
|
||
pool = clearingCandidates
|
||
} else if (centralHubCandidates.length > 0) {
|
||
pool = centralHubCandidates
|
||
} else {
|
||
pool = openCandidates
|
||
}
|
||
}
|
||
|
||
const chosen = pool.length > 0
|
||
? pool[rng.int(0, pool.length - 1)]!
|
||
: { x: cx, y: cy }
|
||
|
||
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 outdoor shrines, healing wells, companion chests/scatter, and standalone interactives
|
||
* across Acts 1..5 (`DRLGOUTDOORS_SpawnAct12Shrines`).
|
||
*
|
||
* Implements:
|
||
* 1. Authentic thematic clustering: 60% chance for an accompanying companion object 2..4 tiles from each shrine.
|
||
* 2. Dispersed spatial distribution: Shrines spaced >= 10 tiles apart (relaxing to >= 7 if space constrained).
|
||
* 3. Act-specific shrine, well, chest, and scatter IDs.
|
||
* 4. Scaling based on `Levels.txt.SubShrine` / `LvlSub.txt` max clusters when provided.
|
||
*/
|
||
export function spawnOutdoorShrines(
|
||
canvas: Canvas,
|
||
levelId: number,
|
||
act: number,
|
||
dirtPathGrid: Uint8Array | undefined,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
stats: WildernessStats,
|
||
shrineSubstitutions?: readonly WildernessSubstitution[],
|
||
subTheme = 0,
|
||
levelTypeName?: string,
|
||
): void {
|
||
if (levelId <= 0 || levelId === 108 || levelId === 110) return
|
||
|
||
const isOutdoorLevel =
|
||
(levelId >= 2 && levelId <= 7) || levelId === 39 ||
|
||
(levelId >= 41 && levelId <= 46) ||
|
||
(levelId >= 76 && levelId <= 83) ||
|
||
(levelId >= 104 && levelId <= 107) ||
|
||
(levelId >= 111 && levelId <= 112) || levelId === 117 ||
|
||
(levelTypeName !== undefined && /wilderness|desert|jungle|kurast|mesa|lava|siege|barricade/i.test(levelTypeName))
|
||
if (!isOutdoorLevel) return
|
||
|
||
const effectiveAct = actOfLevel(levelId, levelTypeName, act)
|
||
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
|
||
|
||
const isJungle = levelId >= 76 && levelId <= 78
|
||
const marginX = isJungle ? 4 : Math.min(10, Math.floor(canvas.width / 5))
|
||
const marginY = Math.min(10, Math.floor(canvas.height / 5))
|
||
const candidates: { x: number; y: number }[] = []
|
||
for (let y = marginY; y < canvas.height - marginY; y += 2) {
|
||
for (let x = marginX; x < canvas.width - marginX; x += 2) {
|
||
if (!isTileClear(canvas, x, y, placedRects, 1)) continue
|
||
candidates.push({ x, y })
|
||
}
|
||
}
|
||
if (candidates.length === 0) {
|
||
for (let y = marginY; y < canvas.height - marginY; y += 1) {
|
||
for (let x = marginX; x < canvas.width - marginX; x += 1) {
|
||
if (isTileClear(canvas, x, y, placedRects, 1)) candidates.push({ x, y })
|
||
}
|
||
}
|
||
}
|
||
if (candidates.length === 0) return
|
||
|
||
const allPlacedObjects: { 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 placedShrineTiles: { x: number; y: number }[] = []
|
||
|
||
let shrinesSpawned = 0
|
||
let interactivesSpawned = 0
|
||
|
||
// 1. Determine target shrine count based on LvlSub / SubShrine max
|
||
let targetShrineCount = rng.int(1, 2)
|
||
if (shrineSubstitutions && shrineSubstitutions.length > 0) {
|
||
let maxFromSub = 0
|
||
for (const sub of shrineSubstitutions) {
|
||
const m = sub.max[subTheme] ?? sub.max[0] ?? 0
|
||
if (m > maxFromSub) maxFromSub = m
|
||
}
|
||
if (maxFromSub > 0) {
|
||
const cap = Math.min(4, maxFromSub)
|
||
targetShrineCount = rng.int(Math.min(2, cap), cap)
|
||
}
|
||
}
|
||
|
||
const pickShrineSpot = (minDist: number): { x: number; y: number } | null => {
|
||
const indices = Array.from({ length: candidates.length }, (_, i) => i)
|
||
for (let i = indices.length - 1; i > 0; i -= 1) {
|
||
const j = rng.int(0, i)
|
||
const tmp = indices[i]!
|
||
indices[i] = indices[j]!
|
||
indices[j] = tmp
|
||
}
|
||
for (const idx of indices) {
|
||
const spot = candidates[idx]!
|
||
const tooCloseToShrine = placedShrineTiles.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < minDist)
|
||
if (tooCloseToShrine) continue
|
||
const tooCloseToObject = allPlacedObjects.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < 4)
|
||
if (tooCloseToObject) continue
|
||
return spot
|
||
}
|
||
return null
|
||
}
|
||
|
||
// 2. Spawn Shrines with Dispersed Distribution (>= 10 tiles, relaxing to >= 7 if constrained)
|
||
for (let i = 0; i < targetShrineCount; i += 1) {
|
||
const spot = pickShrineSpot(10) ?? pickShrineSpot(7)
|
||
if (!spot) break
|
||
|
||
const shrineId = config.shrinePool[rng.int(0, config.shrinePool.length - 1)]!
|
||
canvas.objects.push({
|
||
type: 2,
|
||
id: shrineId,
|
||
x: spot.x * SUB_TILES_PER_TILE + 2,
|
||
y: spot.y * SUB_TILES_PER_TILE + 2,
|
||
flags: 0,
|
||
})
|
||
placedShrineTiles.push(spot)
|
||
allPlacedObjects.push(spot)
|
||
shrinesSpawned += 1
|
||
|
||
// 3. Thematic Clustering: 60% chance to spawn an accompanying companion object 2..4 tiles away
|
||
if (rng.int(0, 99) < 60) {
|
||
const isChest = rng.int(0, 99) < 40
|
||
const pool = isChest ? config.chestPool : config.scatterPool
|
||
const companionId = pool[rng.int(0, pool.length - 1)]!
|
||
|
||
const companionOffsets: { dx: number; dy: number }[] = []
|
||
for (let dy = -4; dy <= 4; dy += 1) {
|
||
for (let dx = -4; dx <= 4; dx += 1) {
|
||
const d = Math.hypot(dx, dy)
|
||
if (d >= 2 && d <= 4) {
|
||
companionOffsets.push({ dx, dy })
|
||
}
|
||
}
|
||
}
|
||
for (let oi = companionOffsets.length - 1; oi > 0; oi -= 1) {
|
||
const oj = rng.int(0, oi)
|
||
const tmp = companionOffsets[oi]!
|
||
companionOffsets[oi] = companionOffsets[oj]!
|
||
companionOffsets[oj] = tmp
|
||
}
|
||
|
||
for (const off of companionOffsets) {
|
||
const cx = spot.x + off.dx
|
||
const cy = spot.y + off.dy
|
||
if (isTileClear(canvas, cx, cy, placedRects, 1)) {
|
||
const occupied = allPlacedObjects.some(p => p.x === cx && p.y === cy)
|
||
if (!occupied) {
|
||
canvas.objects.push({
|
||
type: 2,
|
||
id: companionId,
|
||
x: cx * SUB_TILES_PER_TILE + 2,
|
||
y: cy * SUB_TILES_PER_TILE + 2,
|
||
flags: 0,
|
||
})
|
||
allPlacedObjects.push({ x: cx, y: cy })
|
||
interactivesSpawned += 1
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. Healing Well (1 well placed with >= 8 tiles distance from shrines)
|
||
const pickWellSpot = (): { x: number; y: number } | null => {
|
||
const indices = Array.from({ length: candidates.length }, (_, i) => i)
|
||
for (let i = indices.length - 1; i > 0; i -= 1) {
|
||
const j = rng.int(0, i)
|
||
const tmp = indices[i]!
|
||
indices[i] = indices[j]!
|
||
indices[j] = tmp
|
||
}
|
||
for (const idx of indices) {
|
||
const spot = candidates[idx]!
|
||
const farFromShrines = placedShrineTiles.every(p => Math.hypot(p.x - spot.x, p.y - spot.y) >= 8)
|
||
if (!farFromShrines) continue
|
||
const farFromObjects = allPlacedObjects.every(p => Math.hypot(p.x - spot.x, p.y - spot.y) >= 4)
|
||
if (!farFromObjects) continue
|
||
return spot
|
||
}
|
||
return null
|
||
}
|
||
|
||
const wellSpot = pickWellSpot()
|
||
if (wellSpot) {
|
||
canvas.objects.push({
|
||
type: 2,
|
||
id: config.wellId,
|
||
x: wellSpot.x * SUB_TILES_PER_TILE + 2,
|
||
y: wellSpot.y * SUB_TILES_PER_TILE + 2,
|
||
flags: 0,
|
||
})
|
||
allPlacedObjects.push(wellSpot)
|
||
shrinesSpawned += 1
|
||
}
|
||
|
||
// 5. Standalone Chests (1..2)
|
||
const pickStandaloneSpot = (minDist: number): { x: number; y: number } | null => {
|
||
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||
const spot = candidates[rng.int(0, candidates.length - 1)]!
|
||
const tooClose = allPlacedObjects.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < minDist)
|
||
if (!tooClose) {
|
||
allPlacedObjects.push(spot)
|
||
return spot
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
const chestCount = rng.int(1, 2)
|
||
for (let i = 0; i < chestCount; i += 1) {
|
||
const spot = pickStandaloneSpot(4)
|
||
if (!spot) break
|
||
const chestId = config.chestPool[rng.int(0, config.chestPool.length - 1)]!
|
||
canvas.objects.push({
|
||
type: 2,
|
||
id: chestId,
|
||
x: spot.x * SUB_TILES_PER_TILE + 2,
|
||
y: spot.y * SUB_TILES_PER_TILE + 2,
|
||
flags: 0,
|
||
})
|
||
interactivesSpawned += 1
|
||
}
|
||
|
||
// 6. Standalone Scatter: Barrels / Campfires / Stash Rocks (2..3)
|
||
const scatterCount = rng.int(2, 3)
|
||
for (let i = 0; i < scatterCount; i += 1) {
|
||
const spot = pickStandaloneSpot(4)
|
||
if (!spot) break
|
||
const scatterId = config.scatterPool[rng.int(0, config.scatterPool.length - 1)]!
|
||
canvas.objects.push({
|
||
type: 2,
|
||
id: scatterId,
|
||
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
|
||
}
|
||
|
||
export const spawnAct12Waypoint = spawnOutdoorWaypoint
|
||
export const spawnAct12Shrines = spawnOutdoorShrines
|
||
|
||
/** Specification of a landmark entity definition for data-driven spawning. */
|
||
export interface LandmarkEntityDef {
|
||
readonly id: string
|
||
readonly name: string
|
||
readonly role: 'superunique' | 'npc_guard'
|
||
readonly act: number
|
||
readonly levelIds: readonly number[]
|
||
readonly monsterId: string
|
||
readonly presetMatch?: RegExp
|
||
readonly defaultLandmark: string
|
||
readonly modifiers: readonly string[]
|
||
readonly minionCount: readonly [number, number]
|
||
readonly minionMonsterId: string
|
||
readonly objectId: number
|
||
readonly defaultRelativePos?: { readonly x: number; readonly y: number }
|
||
readonly resolvePosition?: (
|
||
canvas: Canvas,
|
||
levelId: number,
|
||
act1Plan: Act1OutdoorPlan | undefined,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
) => { tileX: number; tileY: number; landmark: string } | null
|
||
}
|
||
|
||
/**
|
||
* Canonical landmark SuperUniques and special NPCs across Acts 1-5.
|
||
*/
|
||
export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
|
||
// Act 1
|
||
{
|
||
id: 'Flavie',
|
||
name: 'Flavie',
|
||
role: 'npc_guard',
|
||
act: 1,
|
||
levelIds: [2, 3],
|
||
monsterId: 'rogue2',
|
||
defaultLandmark: 'Border Gate (Blood Moor <-> Cold Plains)',
|
||
modifiers: [],
|
||
minionCount: [0, 0],
|
||
minionMonsterId: '',
|
||
objectId: 52,
|
||
resolvePosition: (canvas, levelId, act1Plan, _placedRects, gridWidth, gridHeight) => {
|
||
if (levelId !== 2 && levelId !== 3) return null
|
||
const cx = Math.floor(canvas.width / 2)
|
||
const cy = Math.floor(canvas.height / 2)
|
||
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)
|
||
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
|
||
}
|
||
}
|
||
return {
|
||
tileX: fx,
|
||
tileY: fy,
|
||
landmark: 'Border Gate (Blood Moor <-> Cold Plains)',
|
||
}
|
||
},
|
||
},
|
||
{
|
||
id: 'Bishibosh',
|
||
name: 'Bishibosh',
|
||
role: 'superunique',
|
||
act: 1,
|
||
levelIds: [3],
|
||
monsterId: 'fallenshaman1',
|
||
presetMatch: /Fallen Camp Bishibosh|Bivouac/i,
|
||
defaultLandmark: 'Act 1 - Fallen Camp Bishibosh',
|
||
modifiers: ['magicresistant', 'fireenchant'],
|
||
minionCount: [5, 7],
|
||
minionMonsterId: 'fallen1',
|
||
objectId: 70,
|
||
defaultRelativePos: { x: 0.45, y: 0.45 },
|
||
},
|
||
{
|
||
id: 'Rakanishu',
|
||
name: 'Rakanishu',
|
||
role: 'superunique',
|
||
act: 1,
|
||
levelIds: [4],
|
||
monsterId: 'fallen1',
|
||
presetMatch: /Cairn Stones/i,
|
||
defaultLandmark: 'Act 1 - Cairn Stones',
|
||
modifiers: ['lightenchant', 'fast'],
|
||
minionCount: [6, 8],
|
||
minionMonsterId: 'fallen1',
|
||
objectId: 71,
|
||
defaultRelativePos: { x: 0.52, y: 0.48 },
|
||
},
|
||
{
|
||
id: 'Treehead WoodFist',
|
||
name: 'Treehead WoodFist',
|
||
role: 'superunique',
|
||
act: 1,
|
||
levelIds: [5],
|
||
monsterId: 'brute1',
|
||
presetMatch: /Inifus/i,
|
||
defaultLandmark: 'Act 1 - Inifus',
|
||
modifiers: ['strong', 'fast'],
|
||
minionCount: [3, 4],
|
||
minionMonsterId: 'brute1',
|
||
objectId: 72,
|
||
defaultRelativePos: { x: 0.48, y: 0.52 },
|
||
},
|
||
{
|
||
id: 'Blood Raven',
|
||
name: 'Blood Raven',
|
||
role: 'superunique',
|
||
act: 1,
|
||
levelIds: [17],
|
||
monsterId: 'corruptrogue1',
|
||
presetMatch: /Graveyard/i,
|
||
defaultLandmark: 'Act 1 - Graveyard',
|
||
modifiers: ['fireenchant', 'fast'],
|
||
minionCount: [6, 8],
|
||
minionMonsterId: 'zombie1',
|
||
objectId: 73,
|
||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||
},
|
||
{
|
||
id: 'The Countess',
|
||
name: 'The Countess',
|
||
role: 'superunique',
|
||
act: 1,
|
||
levelIds: [6, 25],
|
||
monsterId: 'corruptrogue5',
|
||
presetMatch: /Tower/i,
|
||
defaultLandmark: 'Act 1 - Tower 1',
|
||
modifiers: ['fireenchant'],
|
||
minionCount: [4, 6],
|
||
minionMonsterId: 'corruptrogue5',
|
||
objectId: 74,
|
||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||
},
|
||
// Act 2
|
||
{
|
||
id: 'Creeping Feature',
|
||
name: 'Creeping Feature',
|
||
role: 'superunique',
|
||
act: 2,
|
||
levelIds: [41, 55, 59],
|
||
monsterId: 'mummy1',
|
||
presetMatch: /Desert Tomb 1|Stony Tomb/i,
|
||
defaultLandmark: 'Act 2 - Desert Tomb 1',
|
||
modifiers: ['strong', 'coldenchant'],
|
||
minionCount: [4, 6],
|
||
minionMonsterId: 'mummy1',
|
||
objectId: 75,
|
||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||
},
|
||
{
|
||
id: 'Bloodwitch the Wild',
|
||
name: 'Bloodwitch the Wild',
|
||
role: 'superunique',
|
||
act: 2,
|
||
levelIds: [45, 58, 60],
|
||
monsterId: 'huntress2',
|
||
presetMatch: /Desert Tomb 2|Claw Viper|Valley Ruin 1/i,
|
||
defaultLandmark: 'Act 2 - Desert Tomb 2',
|
||
modifiers: ['cursed', 'strong'],
|
||
minionCount: [4, 6],
|
||
minionMonsterId: 'huntress2',
|
||
objectId: 76,
|
||
defaultRelativePos: { x: 0.48, y: 0.52 },
|
||
},
|
||
{
|
||
id: 'Beetleburst',
|
||
name: 'Beetleburst',
|
||
role: 'superunique',
|
||
act: 2,
|
||
levelIds: [43],
|
||
monsterId: 'beetle2',
|
||
presetMatch: /Desert Oasis|Desert Lair/i,
|
||
defaultLandmark: 'Act 2 - Desert Oasis 1',
|
||
modifiers: ['magicresistant'],
|
||
minionCount: [4, 6],
|
||
minionMonsterId: 'beetle2',
|
||
objectId: 77,
|
||
defaultRelativePos: { x: 0.52, y: 0.48 },
|
||
},
|
||
{
|
||
id: 'Dark Elder',
|
||
name: 'Dark Elder',
|
||
role: 'superunique',
|
||
act: 2,
|
||
levelIds: [44],
|
||
monsterId: 'plaguebearer1',
|
||
presetMatch: /Desert Ruins Elder/i,
|
||
defaultLandmark: 'Act 2 - Desert Ruins Elder',
|
||
modifiers: ['fast', 'magicresistant'],
|
||
minionCount: [4, 6],
|
||
minionMonsterId: 'plaguebearer1',
|
||
objectId: 78,
|
||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||
},
|
||
{
|
||
id: 'Fangskin',
|
||
name: 'Fangskin',
|
||
role: 'superunique',
|
||
act: 2,
|
||
levelIds: [45, 61],
|
||
monsterId: 'clawviper3',
|
||
presetMatch: /Desert Valley Ruin 2|Valley Warp|Claw Viper Altar/i,
|
||
defaultLandmark: 'Act 2 - Desert Valley Ruin 2',
|
||
modifiers: ['lightenchant', 'fast'],
|
||
minionCount: [4, 6],
|
||
minionMonsterId: 'clawviper3',
|
||
objectId: 79,
|
||
defaultRelativePos: { x: 0.55, y: 0.45 },
|
||
},
|
||
// Act 3
|
||
{
|
||
id: 'Stormtree',
|
||
name: 'Stormtree',
|
||
role: 'superunique',
|
||
act: 3,
|
||
levelIds: [78],
|
||
monsterId: 'thornhulk3',
|
||
presetMatch: /Flayer Jungle Entrance|Jungle Gate|Flayer Entrance/i,
|
||
defaultLandmark: 'Act 3 - Flayer Jungle Entrance',
|
||
modifiers: ['lightenchant', 'fast'],
|
||
minionCount: [4, 6],
|
||
minionMonsterId: 'thornhulk3',
|
||
objectId: 80,
|
||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||
},
|
||
{
|
||
id: 'Sszark the Burning',
|
||
name: 'Sszark the Burning',
|
||
role: 'superunique',
|
||
act: 3,
|
||
levelIds: [76, 84],
|
||
monsterId: 'spider2',
|
||
presetMatch: /Spider Cavern|Spider/i,
|
||
defaultLandmark: 'Act 3 - Spider Cavern',
|
||
modifiers: ['strong', 'cursed'],
|
||
minionCount: [4, 6],
|
||
minionMonsterId: 'spider2',
|
||
objectId: 81,
|
||
defaultRelativePos: { x: 0.48, y: 0.52 },
|
||
},
|
||
{
|
||
id: 'Witch Doctor Endugu',
|
||
name: 'Witch Doctor Endugu',
|
||
role: 'superunique',
|
||
act: 3,
|
||
levelIds: [78, 88, 91],
|
||
monsterId: 'flayershaman4',
|
||
presetMatch: /Flayer Dungeon/i,
|
||
defaultLandmark: 'Act 3 - Flayer Dungeon',
|
||
modifiers: ['magicresistant', 'fireenchant'],
|
||
minionCount: [4, 6],
|
||
minionMonsterId: 'flayer4',
|
||
objectId: 82,
|
||
defaultRelativePos: { x: 0.52, y: 0.48 },
|
||
},
|
||
{
|
||
id: 'Battlemaid Sarina',
|
||
name: 'Battlemaid Sarina',
|
||
role: 'superunique',
|
||
act: 3,
|
||
levelIds: [80, 94],
|
||
monsterId: 'corruptrogue8',
|
||
presetMatch: /Burbs Temple|Ruined Temple|MetroTemple/i,
|
||
defaultLandmark: 'Act 3 - Burbs Temple',
|
||
modifiers: ['fast', 'spectralhit'],
|
||
minionCount: [4, 6],
|
||
minionMonsterId: 'corruptrogue8',
|
||
objectId: 83,
|
||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||
},
|
||
// Act 4
|
||
{
|
||
id: 'Izual',
|
||
name: 'Izual',
|
||
role: 'superunique',
|
||
act: 4,
|
||
levelIds: [105],
|
||
monsterId: 'izual',
|
||
presetMatch: /Plains of Despair|Izual/i,
|
||
defaultLandmark: 'Act 4 - Plains of Despair',
|
||
modifiers: ['coldenchant'],
|
||
minionCount: [0, 0],
|
||
minionMonsterId: 'izual',
|
||
objectId: 84,
|
||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||
},
|
||
{
|
||
id: 'Hephasto the Armorer',
|
||
name: 'Hephasto the Armorer',
|
||
role: 'superunique',
|
||
act: 4,
|
||
levelIds: [106, 107],
|
||
monsterId: 'hephasto',
|
||
presetMatch: /Hellforge/i,
|
||
defaultLandmark: 'Act 4 - Hellforge',
|
||
modifiers: ['spectralhit', 'aura'],
|
||
minionCount: [0, 0],
|
||
minionMonsterId: 'hephasto',
|
||
objectId: 85,
|
||
defaultRelativePos: { x: 0.52, y: 0.48 },
|
||
},
|
||
{
|
||
id: 'Hadriel',
|
||
name: 'Hadriel',
|
||
role: 'npc_guard',
|
||
act: 4,
|
||
levelIds: [106, 107, 108],
|
||
monsterId: 'hadriel',
|
||
presetMatch: /Chaos Sanctuary Entrance|Hadriel/i,
|
||
defaultLandmark: 'Act 4 - Chaos Sanctuary Entrance',
|
||
modifiers: [],
|
||
minionCount: [0, 0],
|
||
minionMonsterId: '',
|
||
objectId: 86,
|
||
defaultRelativePos: { x: 0.48, y: 0.52 },
|
||
},
|
||
// Act 5
|
||
{
|
||
id: 'Dac Farren',
|
||
name: 'Dac Farren',
|
||
role: 'superunique',
|
||
act: 5,
|
||
levelIds: [110],
|
||
monsterId: 'demonimp1',
|
||
presetMatch: /Barricade Prison 1|Barricade Ruins/i,
|
||
defaultLandmark: 'Act 5 - Barricade Prison 1',
|
||
modifiers: ['coldenchant'],
|
||
minionCount: [4, 6],
|
||
minionMonsterId: 'demonimp1',
|
||
objectId: 87,
|
||
defaultRelativePos: { x: 0.35, y: 0.5 },
|
||
},
|
||
{
|
||
id: 'Shenk the Overseer',
|
||
name: 'Shenk the Overseer',
|
||
role: 'superunique',
|
||
act: 5,
|
||
levelIds: [110, 111],
|
||
monsterId: 'overseer2',
|
||
presetMatch: /Barricade Building|Shenk|Siege Strip 15|Siege To Barricade/i,
|
||
defaultLandmark: 'Act 5 - Barricade Building',
|
||
modifiers: ['strong'],
|
||
minionCount: [15, 20],
|
||
minionMonsterId: 'enslaved1',
|
||
objectId: 88,
|
||
defaultRelativePos: { x: 0.75, y: 0.5 },
|
||
},
|
||
{
|
||
id: 'Eyeback the Unleashed',
|
||
name: 'Eyeback the Unleashed',
|
||
role: 'superunique',
|
||
act: 5,
|
||
levelIds: [111],
|
||
monsterId: 'bloodlord2',
|
||
presetMatch: /Barricade Hell Portal N|Barricade 1/i,
|
||
defaultLandmark: 'Act 5 - Barricade Hell Portal N',
|
||
modifiers: ['fast', 'strong'],
|
||
minionCount: [3, 5],
|
||
minionMonsterId: 'bloodlord2',
|
||
objectId: 89,
|
||
defaultRelativePos: { x: 0.45, y: 0.55 },
|
||
},
|
||
{
|
||
id: 'Thresh Socket',
|
||
name: 'Thresh Socket',
|
||
role: 'superunique',
|
||
act: 5,
|
||
levelIds: [112],
|
||
monsterId: 'siegebeast1',
|
||
presetMatch: /Barricade Snow Unique/i,
|
||
defaultLandmark: 'Act 5 - Barricade Snow Unique',
|
||
modifiers: ['cursed'],
|
||
minionCount: [2, 4],
|
||
minionMonsterId: 'siegebeast1',
|
||
objectId: 90,
|
||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||
},
|
||
{
|
||
id: 'Bonesaw Breaker',
|
||
name: 'Bonesaw Breaker',
|
||
role: 'superunique',
|
||
act: 5,
|
||
levelIds: [115],
|
||
monsterId: 'reanimatedhorde2',
|
||
presetMatch: /Glacial Trail|Drifter Cavern/i,
|
||
defaultLandmark: 'Act 5 - Glacial Trail',
|
||
modifiers: ['strong', 'magicresistant'],
|
||
minionCount: [4, 6],
|
||
minionMonsterId: 'reanimatedhorde2',
|
||
objectId: 91,
|
||
defaultRelativePos: { x: 0.52, y: 0.48 },
|
||
},
|
||
{
|
||
id: 'Frozenstein',
|
||
name: 'Frozenstein',
|
||
role: 'superunique',
|
||
act: 5,
|
||
levelIds: [114],
|
||
monsterId: 'yeti2',
|
||
presetMatch: /Frozen River|Anya/i,
|
||
defaultLandmark: 'Act 5 - Frozen River',
|
||
modifiers: ['coldenchant', 'manahit'],
|
||
minionCount: [4, 6],
|
||
minionMonsterId: 'yeti2',
|
||
objectId: 92,
|
||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||
},
|
||
{
|
||
id: 'Nihlathak',
|
||
name: 'Nihlathak',
|
||
role: 'superunique',
|
||
act: 5,
|
||
levelIds: [124],
|
||
monsterId: 'nihlathakboss',
|
||
presetMatch: /Halls of Vaught|Nihlathak/i,
|
||
defaultLandmark: 'Act 5 - Halls of Vaught',
|
||
modifiers: ['coldenchant'],
|
||
minionCount: [6, 8],
|
||
minionMonsterId: 'iceprowler2',
|
||
objectId: 93,
|
||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||
},
|
||
]
|
||
|
||
/**
|
||
* Refactor landmark and outdoor boss generation into a table/data-driven pipeline
|
||
* based on `SuperUniques.txt` and `MonPreset.txt`.
|
||
*/
|
||
export function buildLandmarkEntitiesFromTables(
|
||
superuniques?: D2Table,
|
||
monpreset?: D2Table,
|
||
baseDefs: readonly LandmarkEntityDef[] = CANONICAL_LANDMARK_ENTITIES,
|
||
): LandmarkEntityDef[] {
|
||
if (!superuniques && !monpreset) {
|
||
return [...baseDefs]
|
||
}
|
||
|
||
const suMap = new Map<string, SuperUnique>()
|
||
if (superuniques) {
|
||
const suList = readSuperUniques(superuniques)
|
||
for (const su of suList) {
|
||
suMap.set(su.id.toLowerCase(), su)
|
||
if (su.nameKey) {
|
||
suMap.set(su.nameKey.toLowerCase(), su)
|
||
}
|
||
}
|
||
}
|
||
|
||
const actPlaceIndex = new Map<string, number>()
|
||
if (monpreset) {
|
||
const actIdx = monpreset.header.findIndex(h => /^act$/i.test(h.trim()))
|
||
const placeIdx = monpreset.header.findIndex(h => /^place$/i.test(h.trim()))
|
||
const objectIdIdx = monpreset.header.findIndex(h => /^(objectid|id|object)$/i.test(h.trim()))
|
||
if (actIdx !== -1 && placeIdx !== -1) {
|
||
const perActCount = new Map<number, number>()
|
||
for (const row of monpreset.rows) {
|
||
const actVal = Number(row[actIdx])
|
||
const placeVal = row[placeIdx]?.trim()
|
||
if (Number.isFinite(actVal) && placeVal) {
|
||
const currentIdx = perActCount.get(actVal) ?? 0
|
||
perActCount.set(actVal, currentIdx + 1)
|
||
const customId = objectIdIdx !== -1 ? Number(row[objectIdIdx]) : currentIdx
|
||
actPlaceIndex.set(
|
||
`${actVal}:${placeVal.toLowerCase()}`,
|
||
Number.isFinite(customId) ? customId : currentIdx,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return baseDefs.map(def => {
|
||
let monsterId = def.monsterId
|
||
let name = def.name
|
||
let modifiers = def.modifiers
|
||
let minionCount = def.minionCount
|
||
let minionMonsterId = def.minionMonsterId
|
||
let objectId = def.objectId
|
||
|
||
if (superuniques) {
|
||
const su = suMap.get(def.id.toLowerCase()) ?? suMap.get(def.name.toLowerCase())
|
||
if (su) {
|
||
if (su.monsterId) {
|
||
monsterId = su.monsterId
|
||
if (!minionMonsterId) {
|
||
minionMonsterId = su.monsterId
|
||
}
|
||
}
|
||
if (su.nameKey && su.nameKey.trim() !== '') {
|
||
name = su.nameKey.trim()
|
||
}
|
||
if (su.modifiers.length > 0) {
|
||
const mappedMods: string[] = []
|
||
for (const modId of su.modifiers) {
|
||
const found = CANONICAL_ELITE_MODIFIERS.find(m => m.id === modId)
|
||
if (found) mappedMods.push(found.name)
|
||
}
|
||
if (mappedMods.length > 0) {
|
||
modifiers = mappedMods
|
||
}
|
||
}
|
||
minionCount = [su.minMinions, su.maxMinions]
|
||
}
|
||
}
|
||
|
||
if (monpreset) {
|
||
const candidates = [
|
||
def.id.toLowerCase(),
|
||
def.name.toLowerCase(),
|
||
def.monsterId.toLowerCase(),
|
||
def.defaultLandmark.toLowerCase(),
|
||
]
|
||
for (const [key, id] of actPlaceIndex) {
|
||
const [actStr, placeName] = key.split(':')
|
||
if (Number(actStr) === def.act) {
|
||
if (
|
||
candidates.some(c => c.includes(placeName) || placeName.includes(c)) ||
|
||
(def.presetMatch && def.presetMatch.test(placeName))
|
||
) {
|
||
objectId = id
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
...def,
|
||
monsterId,
|
||
name,
|
||
modifiers,
|
||
minionCount,
|
||
minionMonsterId,
|
||
objectId,
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Anchor outdoor SuperUniques and special NPCs to their landmark presets or border gates.
|
||
*/
|
||
export function spawnLandmarkEntities(
|
||
canvas: Canvas,
|
||
levelId: number,
|
||
act1Plan: Act1OutdoorPlan | undefined,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
stats: WildernessStats,
|
||
defs: readonly LandmarkEntityDef[] = CANONICAL_LANDMARK_ENTITIES,
|
||
): void {
|
||
const entities: LandmarkEntity[] = []
|
||
|
||
for (const def of defs) {
|
||
let tileX: number
|
||
let tileY: number
|
||
let landmark: string
|
||
|
||
if (def.resolvePosition) {
|
||
const resolved = def.resolvePosition(canvas, levelId, act1Plan, placedRects, gridWidth, gridHeight)
|
||
if (!resolved) continue
|
||
tileX = resolved.tileX
|
||
tileY = resolved.tileY
|
||
landmark = resolved.landmark
|
||
} else {
|
||
const rect = def.presetMatch ? placedRects.find(r => def.presetMatch!.test(r.name)) : undefined
|
||
if (rect !== undefined) {
|
||
tileX = Math.floor(rect.x + rect.w / 2)
|
||
tileY = Math.floor(rect.y + rect.h / 2)
|
||
landmark = rect.name
|
||
} else if (def.levelIds.includes(levelId)) {
|
||
tileX = Math.floor(canvas.width * (def.defaultRelativePos?.x ?? 0.5))
|
||
tileY = Math.floor(canvas.height * (def.defaultRelativePos?.y ?? 0.5))
|
||
landmark = def.defaultLandmark
|
||
} else {
|
||
continue
|
||
}
|
||
}
|
||
|
||
const subtileX = tileX * SUB_TILES_PER_TILE + 2
|
||
const subtileY = tileY * SUB_TILES_PER_TILE + 2
|
||
|
||
entities.push({
|
||
role: def.role,
|
||
id: def.id,
|
||
monsterId: def.monsterId,
|
||
name: def.name,
|
||
landmark,
|
||
tileX,
|
||
tileY,
|
||
subtileX,
|
||
subtileY,
|
||
levelId,
|
||
modifiers: def.modifiers,
|
||
minionCount: def.minionCount,
|
||
minionMonsterId: def.minionMonsterId,
|
||
objectId: def.objectId,
|
||
})
|
||
|
||
canvas.objects.push({
|
||
type: 1,
|
||
id: def.objectId,
|
||
x: subtileX,
|
||
y: subtileY,
|
||
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)
|
||
|
||
let groundTiles: WeightedGroundTile[] | null = null
|
||
|
||
if (request.groundTiles && request.groundTiles.length > 0) {
|
||
groundTiles = [...request.groundTiles]
|
||
} else if (request.groundTile) {
|
||
if (isTileArray(request.groundTile)) {
|
||
groundTiles = [...(request.groundTile as readonly WeightedGroundTile[])]
|
||
} else {
|
||
const single = request.groundTile as { readonly style: number; readonly sequence: number }
|
||
groundTiles = [{ style: single.style, sequence: single.sequence, weight: 1 }]
|
||
}
|
||
} else if (request.dt1Libraries && request.dt1Libraries.length > 0) {
|
||
const mask = wildernessDt1Mask(request.levelTypeName)
|
||
const fromDt1 = deriveGroundTilesFromDt1Libraries(request.dt1Libraries, mask)
|
||
if (fromDt1.length > 0) groundTiles = fromDt1
|
||
}
|
||
|
||
if (groundTiles === null || groundTiles.length === 0) {
|
||
const canonicalPool = CANONICAL_GROUND_POOLS[request.levelTypeName]
|
||
if (canonicalPool && canonicalPool.length > 0) {
|
||
groundTiles = [...canonicalPool]
|
||
} else {
|
||
const canonical = CANONICAL_GROUND_TILES[request.levelTypeName]
|
||
if (canonical !== undefined) {
|
||
groundTiles = [{ style: canonical.style, sequence: canonical.sequence, weight: 1 }]
|
||
} else {
|
||
const fromPieces = deriveGroundTiles(request.pieces)
|
||
if (fromPieces.length > 0) groundTiles = fromPieces
|
||
}
|
||
}
|
||
}
|
||
|
||
const primaryGroundTile = groundTiles && groundTiles.length > 0
|
||
? { style: groundTiles[0]!.style, sequence: groundTiles[0]!.sequence }
|
||
: null
|
||
const groundTile = primaryGroundTile
|
||
|
||
if ((gridWidth < 3 || gridHeight < 3) && request.levelId !== 82) {
|
||
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 (groundTiles !== null) fillGround(canvas, groundTiles, 0, 0, canvas.width, canvas.height, { seed: request.seed })
|
||
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: primaryGroundTile,
|
||
groundTiles: groundTiles ?? undefined,
|
||
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, groundTiles: undefined, sizeSource,
|
||
roadCells: 0, roadSegments: 0, anchors: 0, specialPresets: [], entrances: [],
|
||
}
|
||
|
||
let wallLayers = 1
|
||
let floorLayers = 1
|
||
let substitutionType = 0
|
||
let version = 0
|
||
const inferredAct = actOfLevel(request.levelId, request.levelTypeName)
|
||
let act = inferredAct
|
||
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)
|
||
if (level.act > 0) act = level.act
|
||
}
|
||
if (act === 1 && inferredAct > 1) {
|
||
act = inferredAct
|
||
}
|
||
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)
|
||
const landmarkDefs = buildLandmarkEntitiesFromTables(
|
||
request.superuniques,
|
||
request.monpreset,
|
||
request.customLandmarks ?? CANONICAL_LANDMARK_ENTITIES,
|
||
)
|
||
|
||
stats.groundTile = primaryGroundTile
|
||
stats.groundTiles = groundTiles ?? undefined
|
||
if (primaryGroundTile === null || groundTiles === null || groundTiles.length === 0) {
|
||
throw new Error(`${where}: no piece carries a floor tile to use as ground`)
|
||
}
|
||
stats.groundCells = fillGround(canvas, groundTiles, 0, 0, canvas.width, canvas.height, { seed: request.seed })
|
||
|
||
// Special case: Act 5 Bloody Foothills (110),
|
||
// Act 3 Travincal (83), Act 3 Kurast Causeway (82), 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 === 82 ||
|
||
(request.levelId >= 76 && request.levelId <= 78)
|
||
) {
|
||
const specialPlacedRects: PlacedPresetRect[] = []
|
||
let placedSpecial: Set<string>
|
||
if (request.levelId === 83) {
|
||
placedSpecial = stampAct3Travincal(canvas, request.pieces, stats, specialPlacedRects)
|
||
} else if (request.levelId === 82) {
|
||
placedSpecial = stampAct3KurastCauseway(canvas, request.pieces, stats)
|
||
} else if (request.levelId >= 76 && request.levelId <= 78) {
|
||
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats, specialPlacedRects)
|
||
const populationRng = new Rng(request.seed ^ 0x6c07)
|
||
spawnOutdoorWaypoint(canvas, request.levelId, act, undefined, [], populationRng, stats, request.levelTypeName)
|
||
spawnOutdoorShrines(canvas, request.levelId, act, undefined, [], populationRng, stats, request.shrineSubstitutions, 0, request.levelTypeName)
|
||
} else {
|
||
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, rng, stats)
|
||
}
|
||
spawnLandmarkEntities(
|
||
canvas,
|
||
request.levelId,
|
||
undefined,
|
||
specialPlacedRects,
|
||
gridWidth,
|
||
gridHeight,
|
||
stats,
|
||
landmarkDefs,
|
||
)
|
||
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,
|
||
// The special-preset path clamps `SubTheme` exactly like the main path
|
||
// does; it used to report a flat 0, so every special level looked like
|
||
// theme 0 in the verification reports.
|
||
subTheme: Math.max(0, Math.min(4, Math.floor(request.subTheme))),
|
||
groundTile: primaryGroundTile,
|
||
groundTiles: groundTiles ?? undefined,
|
||
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,
|
||
act2Topography: stats.act2Topography,
|
||
act3Topography: stats.act3Topography,
|
||
act4Topography: stats.act4Topography,
|
||
act5Topography: stats.act5Topography,
|
||
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,
|
||
...(stats.jungleLayout ? { jungleLayout: stats.jungleLayout } : {}),
|
||
...(stats.jungleTopology ? { jungleTopology: stats.jungleTopology } : {}),
|
||
...(stats.bridgesPlaced !== undefined ? { bridgesPlaced: stats.bridgesPlaced } : {}),
|
||
...(stats.riverIslands !== undefined ? { riverIslands: stats.riverIslands } : {}),
|
||
...(stats.riverForks !== undefined ? { riverForks: stats.riverForks } : {}),
|
||
...(stats.meanderCount !== undefined ? { meanderCount: stats.meanderCount } : {}),
|
||
...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}),
|
||
},
|
||
}
|
||
}
|
||
|
||
// 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 !== 39 &&
|
||
((request.levelId >= 2 && request.levelId <= 7) ||
|
||
(request.levelId >= 41 && request.levelId <= 46) ||
|
||
(request.levelId >= 79 && request.levelId <= 82) ||
|
||
request.levelId === 111 ||
|
||
request.levelId === 112 ||
|
||
request.levelId === 117 ||
|
||
request.levelTypeName === 'Act 1 - Wilderness' ||
|
||
request.levelTypeName === 'Act 2 - Desert' ||
|
||
request.levelTypeName === 'Act 3 - Kurast' ||
|
||
request.levelTypeName === 'Act 5 - Barricade' ||
|
||
request.levelTypeName === 'Act 5 - Siege' ||
|
||
act === 1 ||
|
||
act === 2 ||
|
||
act === 3 ||
|
||
act === 5)
|
||
) {
|
||
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,
|
||
)
|
||
|
||
// Stamp outdoor road networks and boundary divider lines across the wilderness
|
||
// after random clutter substitutions.
|
||
stampOutdoorRoads(
|
||
canvas,
|
||
act,
|
||
request.levelId,
|
||
gridWidth,
|
||
gridHeight,
|
||
inX,
|
||
outY,
|
||
allPlacedRects,
|
||
rng,
|
||
request.levelTypeName,
|
||
act1Plan?.openGates,
|
||
)
|
||
stampOutdoorDividers(
|
||
canvas,
|
||
act,
|
||
request.levelId,
|
||
gridWidth,
|
||
gridHeight,
|
||
inX,
|
||
outY,
|
||
allPlacedRects,
|
||
rng,
|
||
request.levelTypeName,
|
||
)
|
||
|
||
// Act 2 Desert Topography: Canyons, oasis pools, sand caravan trails, tomb entrances (DRLGOUTDESR_InitAct2OutdoorLevel)
|
||
if (
|
||
(request.levelId >= 41 && request.levelId <= 46) ||
|
||
request.levelTypeName === 'Act 2 - Desert'
|
||
) {
|
||
initAct2OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
|
||
}
|
||
|
||
// Act 3 Kurast Urban Districts Topography: Avenues, canals, bridges (DRLGOUTPLACE_InitAct3OutdoorLevel)
|
||
if (
|
||
(request.levelId >= 79 && request.levelId <= 81) ||
|
||
request.levelTypeName === 'Act 3 - Kurast'
|
||
) {
|
||
initAct3OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
|
||
}
|
||
|
||
// Act 4 Mesa / Lava Topography: Lava chasms, rocky bridges, plateaus (DRLGOUTDOORS_InitAct4OutdoorLevel)
|
||
if (
|
||
(request.levelId >= 104 && request.levelId <= 106) ||
|
||
request.levelTypeName === 'Act 4 - Mesa' ||
|
||
request.levelTypeName === 'Act 4 - Lava'
|
||
) {
|
||
initAct4OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
|
||
}
|
||
|
||
// Act 5 Siege / Barricades Topography: Barricades, trenches, frozen lakes (DRLGOUTSIEGE_InitAct5OutdoorLevel)
|
||
if (
|
||
(request.levelId >= 111 && request.levelId <= 117) ||
|
||
request.levelTypeName === 'Act 5 - Barricade'
|
||
) {
|
||
initAct5OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
|
||
}
|
||
|
||
// Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie)
|
||
const populationRng = new Rng(request.seed ^ 0x6c07)
|
||
spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName)
|
||
spawnOutdoorShrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, request.levelTypeName)
|
||
spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats, landmarkDefs)
|
||
|
||
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) {
|
||
const tile = groundTiles && groundTiles.length > 1
|
||
? sampleGroundTile(groundTiles, x, y, { seed: request.seed })
|
||
: primaryGroundTile!
|
||
Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.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: primaryGroundTile,
|
||
groundTiles: groundTiles ?? undefined,
|
||
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 ?? [],
|
||
act2Topography: stats.act2Topography,
|
||
act3Topography: stats.act3Topography,
|
||
act4Topography: stats.act4Topography,
|
||
act5Topography: stats.act5Topography,
|
||
cells: canvas.width * canvas.height,
|
||
unresolved: stats.unresolved,
|
||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
||
notes: stats.notes,
|
||
...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}),
|
||
},
|
||
}
|
||
}
|