7261 lines
253 KiB
TypeScript
7261 lines
253 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,
|
||
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 { cell, actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts'
|
||
import type { D2Table } from './acts.ts'
|
||
export { actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts'
|
||
import { lookupObject } from './object-lookup.ts'
|
||
import type { ObjectsTable } from './objects.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
|
||
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */
|
||
readonly animSpeed?: number
|
||
}
|
||
|
||
/**
|
||
* 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[]
|
||
/** Optional loaded Levels.txt table to drive act resolution, waypoints, and object queries. */
|
||
readonly levelsTable?: D2Table
|
||
/**
|
||
* 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 seam constraints from predecessor/connected levels for 1:1 spatial alignment. */
|
||
readonly seamConstraints?: readonly SeamConstraint[]
|
||
/** Optional forced gate variant indices per block key (`${x},${y}`). */
|
||
readonly openGates?: ReadonlyMap<string, number>
|
||
/** Optional set of block keys (`${x},${y}`) claimed by transition/facade presets to skip during border placement. */
|
||
readonly claimedBorderBlocks?: ReadonlySet<string>
|
||
/** 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 | undefined
|
||
/** Exact block coordinate along the perimeter edge (0..gridWidth-1 or 0..gridHeight-1) */
|
||
readonly blockCoord?: number | undefined
|
||
/** Optional exact tile coordinate within the level */
|
||
readonly tileCoord?: { readonly x: number; readonly y: number } | undefined
|
||
}
|
||
|
||
/** Explicit spatial alignment constraint across a level seam. */
|
||
export interface SeamConstraint {
|
||
readonly fromLevelId?: number | undefined
|
||
readonly toLevelId?: number | undefined
|
||
readonly side: Side
|
||
readonly blockCoord: number
|
||
readonly tileCoord?: { readonly x: number; readonly y: number } | undefined
|
||
}
|
||
|
||
/** Placed gate opening along the level's perimeter. */
|
||
export interface GateOpening {
|
||
readonly side: Side
|
||
readonly toLevelId: number
|
||
readonly blockCoord: number
|
||
readonly tileCoord: { readonly x: number; readonly y: number }
|
||
}
|
||
|
||
/** The generated level. */
|
||
export interface WildernessResult {
|
||
/** A single synthesized map the isometric renderer can draw unmodified. */
|
||
readonly level: Ds1
|
||
/** What the generator did, for reporting and tests. */
|
||
readonly stats: Record<string, unknown> & {
|
||
readonly landmarkEntities?: LandmarkEntity[]
|
||
readonly notes?: string[]
|
||
readonly unresolved?: string[]
|
||
readonly gateOpenings?: readonly GateOpening[]
|
||
readonly groundTransitions?: GroundTransitionResult
|
||
readonly animSpeed?: number
|
||
}
|
||
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */
|
||
readonly animSpeed?: number
|
||
}
|
||
|
||
/** 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`, `Rarity`, or piece counts).
|
||
* Defaults to 1 (or `rarityFrameIndex` / `rarity` / `Rarity`) if omitted or <= 0.
|
||
*/
|
||
readonly weight?: number
|
||
/**
|
||
* Direct DT1 `rarityFrameIndex`, if sourced from a DT1 tile.
|
||
*/
|
||
readonly rarityFrameIndex?: number
|
||
/**
|
||
* Direct DT1 `rarity` (alternative field name used in some loaders/representations).
|
||
*/
|
||
readonly rarity?: number
|
||
/**
|
||
* Direct DT1 `Rarity` (uppercase field name used in native structs).
|
||
*/
|
||
readonly Rarity?: number
|
||
/** Optional library slot index where the tile originated. */
|
||
readonly libraryIndex?: number
|
||
/** Optional tile index within the DT1 library. */
|
||
readonly tileIndex?: number
|
||
/** Optional tile type (0 for floor). */
|
||
readonly type?: 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; libraryIndex: number; tileIndex: number }
|
||
>()
|
||
|
||
for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) {
|
||
if ((dt1Mask & (1 << libIndex)) === 0) continue
|
||
const lib = libraries[libIndex]
|
||
if (!lib) continue
|
||
for (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) {
|
||
const tile = lib.tiles[tIndex]!
|
||
if (tile.type !== 0) continue
|
||
const rawRarity =
|
||
(tile as unknown as { rarity?: number; Rarity?: number }).rarity ??
|
||
(tile as unknown as { rarity?: number; Rarity?: number }).Rarity ??
|
||
tile.rarityFrameIndex
|
||
const rarity = Math.max(0, rawRarity | 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, libraryIndex: libIndex, tileIndex: tIndex })
|
||
} 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 (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) {
|
||
const tile = lib.tiles[tIndex]!
|
||
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, libraryIndex: libIndex, tileIndex: tIndex })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const result: WeightedGroundTile[] = Array.from(counts.values()).map(e => ({
|
||
style: e.style,
|
||
sequence: e.sequence,
|
||
weight: e.weight,
|
||
rarityFrameIndex: e.weight,
|
||
rarity: e.weight,
|
||
Rarity: e.weight,
|
||
libraryIndex: e.libraryIndex,
|
||
tileIndex: e.tileIndex,
|
||
type: 0,
|
||
}))
|
||
result.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
}
|
||
|
||
/** Procedural terrain pass telemetry counters (Issue #62). */
|
||
export interface WildernessTelemetry {
|
||
readonly proceduralRoadCells: number
|
||
readonly proceduralDividerCells: number
|
||
readonly proceduralWaterCells: number
|
||
readonly proceduralWallCells: 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
|
||
subTheme?: number
|
||
proceduralRoadCells?: number
|
||
proceduralDividerCells?: number
|
||
proceduralWaterCells?: number
|
||
proceduralWallCells?: number
|
||
drlgVertices?: readonly DrlgVertex[] | undefined
|
||
roadPolylines?: readonly (readonly { readonly x: number; readonly y: number }[])[] | undefined
|
||
gateOpenings?: GateOpening[]
|
||
groundTransitions?: GroundTransitionResult | undefined
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
])
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* 8-Neighbor Bitmask Auto-Tiling & Ground Transitions (Issue #70)
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
export const NEIGHBOR_MASK_NE = 0x80
|
||
export const NEIGHBOR_MASK_E = 0x40
|
||
export const NEIGHBOR_MASK_SE = 0x20
|
||
export const NEIGHBOR_MASK_N = 0x10
|
||
export const NEIGHBOR_MASK_S = 0x08
|
||
export const NEIGHBOR_MASK_NW = 0x04
|
||
export const NEIGHBOR_MASK_W = 0x02
|
||
export const NEIGHBOR_MASK_SW = 0x01
|
||
|
||
export interface NeighborOffset {
|
||
readonly dx: number
|
||
readonly dy: number
|
||
readonly mask: number
|
||
readonly name: 'NE' | 'E' | 'SE' | 'N' | 'S' | 'NW' | 'W' | 'SW'
|
||
}
|
||
|
||
export const NEIGHBOR_OFFSETS: readonly NeighborOffset[] = [
|
||
{ dx: 1, dy: -1, mask: NEIGHBOR_MASK_NE, name: 'NE' },
|
||
{ dx: 1, dy: 0, mask: NEIGHBOR_MASK_E, name: 'E' },
|
||
{ dx: 1, dy: 1, mask: NEIGHBOR_MASK_SE, name: 'SE' },
|
||
{ dx: 0, dy: -1, mask: NEIGHBOR_MASK_N, name: 'N' },
|
||
{ dx: 0, dy: 1, mask: NEIGHBOR_MASK_S, name: 'S' },
|
||
{ dx: -1, dy: -1, mask: NEIGHBOR_MASK_NW, name: 'NW' },
|
||
{ dx: -1, dy: 0, mask: NEIGHBOR_MASK_W, name: 'W' },
|
||
{ dx: -1, dy: 1, mask: NEIGHBOR_MASK_SW, name: 'SW' },
|
||
] as const
|
||
|
||
/**
|
||
* Computes the 8-neighbor bitmask (0..255) for cell (x, y) on a canvas
|
||
* using the given predicate to test whether each neighbor matches.
|
||
*
|
||
* Web port custom algorithm: NOT a Blizzard original DRLG API. Blizzard's native engine
|
||
* handled room tile neighbor blending via compiled lookup routines and hardcoded tile transition rules.
|
||
*
|
||
* Coordinates outside the canvas bounds safely evaluate to false.
|
||
*/
|
||
export function computeNeighborMask(
|
||
canvas: Canvas,
|
||
x: number,
|
||
y: number,
|
||
predicate: (cell: Canvas['cells'][number][number] | undefined, nx: number, ny: number) => boolean,
|
||
): number {
|
||
let mask = 0
|
||
const width = canvas.width
|
||
const height = canvas.height
|
||
|
||
for (let i = 0; i < NEIGHBOR_OFFSETS.length; i += 1) {
|
||
const { dx, dy, mask: bit } = NEIGHBOR_OFFSETS[i]!
|
||
const nx = x + dx
|
||
const ny = y + dy
|
||
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
|
||
const cell = canvas.cells[ny]?.[nx]
|
||
if (predicate(cell, nx, ny)) {
|
||
mask |= bit
|
||
}
|
||
}
|
||
}
|
||
|
||
return mask
|
||
}
|
||
|
||
/**
|
||
* 256-entry 8-neighbor bitmask lookup table for water edge/shore transition tiles.
|
||
*
|
||
* Maps neighbor bitmask of water cells:
|
||
* - 0xFF (all 8 neighbors are water): interior deep water (sequence 24 in River.dt1 style 2).
|
||
* - Other bitmasks: boundary water tiles (sequences 0..23 in River.dt1 style 2).
|
||
*/
|
||
function createWaterEdgeTileLut(): Readonly<Uint8Array> {
|
||
const lut = new Uint8Array(256)
|
||
for (let mask = 0; mask < 256; mask += 1) {
|
||
if (mask === 0xFF) {
|
||
lut[mask] = 24
|
||
continue
|
||
}
|
||
const hasN = (mask & NEIGHBOR_MASK_N) !== 0
|
||
const hasS = (mask & NEIGHBOR_MASK_S) !== 0
|
||
const hasE = (mask & NEIGHBOR_MASK_E) !== 0
|
||
const hasW = (mask & NEIGHBOR_MASK_W) !== 0
|
||
const hasNE = (mask & NEIGHBOR_MASK_NE) !== 0
|
||
const hasNW = (mask & NEIGHBOR_MASK_NW) !== 0
|
||
const hasSE = (mask & NEIGHBOR_MASK_SE) !== 0
|
||
const hasSW = (mask & NEIGHBOR_MASK_SW) !== 0
|
||
|
||
// Inner corners (concave diagonal inlets where all 4 cardinals are water, but a diagonal corner is land)
|
||
if (hasN && hasS && hasE && hasW) {
|
||
if (!hasNW) { lut[mask] = 16; continue }
|
||
if (!hasNE) { lut[mask] = 17; continue }
|
||
if (!hasSW) { lut[mask] = 18; continue }
|
||
if (!hasSE) { lut[mask] = 19; continue }
|
||
lut[mask] = 24
|
||
continue
|
||
}
|
||
|
||
// Outer corners (convex corners)
|
||
if (hasS && hasE && !hasN && !hasW) { lut[mask] = 1; continue } // NW outer corner
|
||
if (hasS && hasW && !hasN && !hasE) { lut[mask] = 5; continue } // NE outer corner
|
||
if (hasN && hasE && !hasS && !hasW) { lut[mask] = 9; continue } // SW outer corner
|
||
if (hasN && hasW && !hasS && !hasE) { lut[mask] = 13; continue } // SE outer corner
|
||
|
||
// Cardinal straight shores
|
||
if (hasE && !hasW) { lut[mask] = 0; continue } // West shore (water to East)
|
||
if (hasW && !hasE) { lut[mask] = 4; continue } // East shore (water to West)
|
||
if (hasS && !hasN) { lut[mask] = 8; continue } // North shore (water to South)
|
||
if (hasN && !hasS) { lut[mask] = 12; continue } // South shore (water to North)
|
||
|
||
// Channels / peninsulas
|
||
if (hasN && hasS) { lut[mask] = 20; continue } // Vertical channel
|
||
if (hasE && hasW) { lut[mask] = 21; continue } // Horizontal channel
|
||
|
||
// Caps / isolated
|
||
if (hasS) { lut[mask] = 22; continue }
|
||
if (hasN) { lut[mask] = 23; continue }
|
||
if (hasE) { lut[mask] = 2; continue }
|
||
if (hasW) { lut[mask] = 6; continue }
|
||
|
||
// Fallback
|
||
lut[mask] = 0
|
||
}
|
||
return lut
|
||
}
|
||
|
||
export const WATER_EDGE_TILE_LUT: Readonly<Uint8Array> = createWaterEdgeTileLut()
|
||
|
||
/** Query parameters for DRLGROOMTILE_GetTileCache. */
|
||
export interface TileCacheQuery {
|
||
/** Tile type (0 = floor, 1 = left wall, etc.). Defaults to 0 (floor). */
|
||
readonly type?: number | undefined
|
||
/** Filter by specific style if provided. */
|
||
readonly style?: number | undefined
|
||
/** Filter by specific sequence if provided. */
|
||
readonly sequence?: number | undefined
|
||
/** Seed or PRNG roll in [0, 1) or integer seed for deterministic weighted selection. */
|
||
readonly seed: number | Rng
|
||
/** Optional cell coordinates (x, y) to derive a deterministic hash if seed is provided. */
|
||
readonly x?: number | undefined
|
||
readonly y?: number | undefined
|
||
}
|
||
|
||
/** Result returned by DRLGROOMTILE_GetTileCache. */
|
||
export interface TileCacheResult {
|
||
readonly style: number
|
||
readonly sequence: number
|
||
readonly type: number
|
||
readonly libraryIndex: number
|
||
readonly tileIndex: number
|
||
readonly weight: number
|
||
readonly rarityFrameIndex: number
|
||
}
|
||
|
||
/**
|
||
* Blizzard DRLGROOMTILE_GetTileCache reimplementation.
|
||
*
|
||
* Scans active DT1 libraries according to dwDt1Mask and retrieves candidate
|
||
* tiles matching the query, performing a weighted selection proportional to tile
|
||
* rarity (`rarityFrameIndex` / `rarity` / `Rarity`).
|
||
*/
|
||
export function DRLGROOMTILE_GetTileCache(
|
||
libraries: readonly Dt1[],
|
||
dt1Mask: number,
|
||
query: TileCacheQuery,
|
||
): TileCacheResult | null {
|
||
const targetType = query.type ?? 0
|
||
const targetStyle = query.style
|
||
const targetSequence = query.sequence
|
||
|
||
const candidates: TileCacheResult[] = []
|
||
let totalWeight = 0
|
||
|
||
for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) {
|
||
if ((dt1Mask & (1 << libIndex)) === 0) continue
|
||
const lib = libraries[libIndex]
|
||
if (!lib) continue
|
||
|
||
for (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) {
|
||
const tile = lib.tiles[tIndex]!
|
||
if (tile.type !== targetType) continue
|
||
if (targetStyle !== undefined && tile.style !== targetStyle) continue
|
||
if (targetSequence !== undefined && tile.sequence !== targetSequence) continue
|
||
|
||
const rawRarity =
|
||
(tile as unknown as { rarity?: number; Rarity?: number }).rarity ??
|
||
(tile as unknown as { rarity?: number; Rarity?: number }).Rarity ??
|
||
tile.rarityFrameIndex
|
||
const weight = Math.max(1, rawRarity | 0)
|
||
|
||
candidates.push({
|
||
style: tile.style,
|
||
sequence: tile.sequence,
|
||
type: tile.type,
|
||
libraryIndex: libIndex,
|
||
tileIndex: tIndex,
|
||
weight,
|
||
rarityFrameIndex: weight,
|
||
})
|
||
totalWeight += weight
|
||
}
|
||
}
|
||
|
||
if (candidates.length === 0) return null
|
||
|
||
// Determine roll in [0, 1)
|
||
let roll: number
|
||
if (typeof query.seed === 'number') {
|
||
if (query.seed >= 0 && query.seed < 1 && !Number.isInteger(query.seed)) {
|
||
roll = query.seed
|
||
} else if (query.x !== undefined && query.y !== undefined) {
|
||
roll = cellHash(query.x, query.y, query.seed)
|
||
} else {
|
||
roll = cellHash(candidates.length, totalWeight, query.seed)
|
||
}
|
||
} else {
|
||
roll = query.seed.next()
|
||
}
|
||
|
||
let target = roll * totalWeight
|
||
for (let i = 0; i < candidates.length; i += 1) {
|
||
const candidate = candidates[i]!
|
||
if (target < candidate.weight) {
|
||
return candidate
|
||
}
|
||
target -= candidate.weight
|
||
}
|
||
|
||
return candidates[candidates.length - 1] ?? null
|
||
}
|
||
|
||
export interface GroundTransitionOptions {
|
||
readonly libraries?: readonly Dt1[] | undefined
|
||
readonly dt1Mask?: number | undefined
|
||
readonly levelTypeName?: string | undefined
|
||
readonly levelId?: number | undefined
|
||
readonly rng?: Rng | undefined
|
||
readonly placedRects?: readonly PlacedPresetRect[] | undefined
|
||
}
|
||
|
||
export interface GroundTransitionResult {
|
||
readonly transitionCells: number
|
||
readonly roadTransitions: number
|
||
readonly waterTransitions: number
|
||
}
|
||
|
||
function isRoadCell(floor: Ds1Floor | undefined): boolean {
|
||
if (!floor || floor.hidden || floor.prop1 === 0) return false
|
||
return (
|
||
floor.prop1 === 194 ||
|
||
(floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) ||
|
||
(floor.style === 1 && floor.prop1 === 194)
|
||
)
|
||
}
|
||
|
||
function isWaterCell(floor: Ds1Floor | undefined): boolean {
|
||
if (!floor || floor.hidden || floor.prop1 === 0) return false
|
||
return (
|
||
floor.style === 2 ||
|
||
floor.style === 3 ||
|
||
floor.style === 16 ||
|
||
floor.style === 29 ||
|
||
floor.style === 38 ||
|
||
floor.style === 39 ||
|
||
floor.style === 40 ||
|
||
(floor.style === 20 && floor.sequence === 3)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Analyzes adjacency between contrasting floor styles (road vs grass/ground, water vs shore)
|
||
* across the canvas, computes 8-neighbor bitmasks, and applies transition edge tiles to smooth
|
||
* boundaries.
|
||
*
|
||
* Web port custom algorithm: NOT a Blizzard original DRLG API. This two-phase transition
|
||
* smoother was engineered specifically for the TypeScript canvas representation to eliminate
|
||
* visual tile seams.
|
||
*
|
||
* Employs two-phase snapshot execution (collect updates first, then apply) to avoid order-dependent
|
||
* mutation cascade. Guarantees 0 missing tiles when DT1 libraries are supplied by strictly verifying
|
||
* tile availability against active DT1 libraries.
|
||
*/
|
||
export function applyGroundTransitions(
|
||
canvas: Canvas,
|
||
options?: GroundTransitionOptions,
|
||
): GroundTransitionResult {
|
||
// Ground transition auto-tiling LUTs (DIRT_PATH_TILE_LUT, WATER_EDGE_TILE_LUT) are specific
|
||
// to Act 1 Wilderness dirt roads and river water. Skip for other level types.
|
||
if (options?.levelTypeName !== undefined && options.levelTypeName !== 'Act 1 - Wilderness') {
|
||
return { transitionCells: 0, roadTransitions: 0, waterTransitions: 0 }
|
||
}
|
||
|
||
// Build set of available floor tiles if libraries are provided
|
||
const availableTiles = new Set<string>()
|
||
if (options?.libraries && options.libraries.length > 0) {
|
||
const mask = options.dt1Mask ?? 0xFFFFFFFF
|
||
for (let libIndex = 0; libIndex < options.libraries.length; libIndex += 1) {
|
||
if ((mask & (1 << libIndex)) === 0) continue
|
||
const lib = options.libraries[libIndex]
|
||
if (!lib) continue
|
||
for (const tile of lib.tiles) {
|
||
if (tile.type === 0) {
|
||
availableTiles.add(`${String(tile.style)}:${String(tile.sequence)}`)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const isTileAvailable = (style: number, sequence: number): boolean => {
|
||
if (availableTiles.size === 0) return true
|
||
return availableTiles.has(`${String(style)}:${String(sequence)}`)
|
||
}
|
||
|
||
const isProtected = (x: number, y: number): boolean => {
|
||
if (!options?.placedRects || options.placedRects.length === 0) return false
|
||
for (const r of options.placedRects) {
|
||
// River and Bridge presets are intended to have water/road blended
|
||
if (/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
|
||
}
|
||
|
||
interface PendingUpdate {
|
||
readonly x: number
|
||
readonly y: number
|
||
readonly style: number
|
||
readonly sequence: number
|
||
readonly prop1?: number
|
||
readonly isRoad?: boolean
|
||
readonly isWater?: boolean
|
||
}
|
||
const pendingUpdates: PendingUpdate[] = []
|
||
|
||
const width = canvas.width
|
||
const height = canvas.height
|
||
|
||
// Phase 1: Snapshot scan
|
||
for (let y = 0; y < height; y += 1) {
|
||
const row = canvas.cells[y]
|
||
if (!row) continue
|
||
for (let x = 0; x < width; x += 1) {
|
||
if (isProtected(x, y)) continue
|
||
const cell = row[x]
|
||
const floor = cell?.floors[0]
|
||
if (!floor || floor.hidden || floor.prop1 === 0) continue
|
||
|
||
if (isRoadCell(floor)) {
|
||
if (floor.style === 0) {
|
||
const roadMask = computeNeighborMask(canvas, x, y, (nCell) => {
|
||
return isRoadCell(nCell?.floors[0])
|
||
})
|
||
|
||
if (roadMask === 0xFF) {
|
||
// Interior road cell
|
||
if (floor.sequence !== 1) {
|
||
if (isTileAvailable(0, 1)) {
|
||
pendingUpdates.push({ x, y, style: 0, sequence: 1, prop1: 2, isRoad: true })
|
||
}
|
||
}
|
||
} else {
|
||
// Boundary road cell
|
||
const targetSeq = DIRT_PATH_TILE_LUT[roadMask] ?? 0
|
||
if (targetSeq !== 0 && targetSeq !== floor.sequence) {
|
||
if (isTileAvailable(0, targetSeq)) {
|
||
pendingUpdates.push({ x, y, style: 0, sequence: targetSeq, prop1: 2, isRoad: true })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if (isWaterCell(floor)) {
|
||
const waterMask = computeNeighborMask(canvas, x, y, (nCell) => {
|
||
return isWaterCell(nCell?.floors[0])
|
||
})
|
||
|
||
if (waterMask === 0xFF) {
|
||
// Interior deep water
|
||
if (floor.style === 2 && floor.sequence !== 24) {
|
||
if (isTileAvailable(2, 24)) {
|
||
pendingUpdates.push({ x, y, style: 2, sequence: 24, prop1: 2, isWater: true })
|
||
}
|
||
}
|
||
} else {
|
||
// Boundary water cell
|
||
if (floor.style === 2) {
|
||
const targetSeq = WATER_EDGE_TILE_LUT[waterMask] ?? 0
|
||
if (targetSeq !== floor.sequence) {
|
||
if (isTileAvailable(2, targetSeq)) {
|
||
pendingUpdates.push({ x, y, style: 2, sequence: targetSeq, prop1: 2, isWater: true })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Phase 2: Apply updates
|
||
let roadTransitions = 0
|
||
let waterTransitions = 0
|
||
|
||
for (const update of pendingUpdates) {
|
||
const cell = canvas.cells[update.y]?.[update.x]
|
||
if (!cell) continue
|
||
const floor = cell.floors[0]
|
||
if (!floor) continue
|
||
|
||
if (floor.style === update.style && floor.sequence === update.sequence) continue
|
||
|
||
Object.assign(floor, {
|
||
style: update.style,
|
||
sequence: update.sequence,
|
||
prop1: update.prop1 ?? floor.prop1,
|
||
hidden: false,
|
||
})
|
||
|
||
if (update.isRoad) roadTransitions += 1
|
||
if (update.isWater) waterTransitions += 1
|
||
}
|
||
|
||
return {
|
||
transitionCells: roadTransitions + waterTransitions,
|
||
roadTransitions,
|
||
waterTransitions,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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`.
|
||
*
|
||
* Corresponds to Blizzard DRLG subsystem algorithms:
|
||
* - Act 2 Desert: `DRLGOUTDESR_PlaceBorders` (D2Common.0x6FD7D9B0) placing numeric borders 1..8.
|
||
* - Act 3 Kurast: `DRLGOUTJUNG_BuildLowerKurast` (D2Common.0x6FD7FE50),
|
||
* `DRLGOUTJUNG_BuildKurastBazaar` (D2Common.0x6FD7FFA0), and
|
||
* `DRLGOUTJUNG_BuildUpperKurast` (D2Common.0x6FD800E0) placing cardinal compass borders N..SW.
|
||
*
|
||
* 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 (9=SW, 10=NW, 11=NE, 12=SE)
|
||
*
|
||
* Act 2:
|
||
* - Desert Border 1..12 (1..4 straight edges, 5..12 corners)
|
||
* - Desert Cliff Left Wall/Path/Ends/King Tomb -> West edge
|
||
* - Desert Cliff Right Wall/Path/Ends/King Tomb -> East edge
|
||
* - Desert Cliff Top / Top King Tomb -> North edge
|
||
*
|
||
* Act 3:
|
||
* - Slums/Burbs/Metro Border N, S, E, W, NE, NW, SE, SW
|
||
*
|
||
* Act 4:
|
||
* - Mesa Border 1..12 (1..4 straight edges, 5..12 corners)
|
||
*
|
||
* Act 5:
|
||
* - Barricade Cliff Border 1..12 & Snow variants (isCliff: true)
|
||
* - Barricade Ravine Border 1..12 & Snow variants (isCliff: false)
|
||
* - Barricade Ravine-Cliff Border 5 (SW corner transition)
|
||
* - Barricade Cliff Ravine Border 7 (NE corner transition)
|
||
* - Snow Border 1..12
|
||
*
|
||
* Cave entrances ("clfcave", "clfcave2", "Cave Right", "Cave Left") 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(/\b(?:border|edge|brdr|cliff)\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 = South edge
|
||
// 2 = West edge
|
||
// 3 = North edge
|
||
// 4 = East edge
|
||
// 5, 9 = SW corner
|
||
// 6, 10 = NW corner (including 6A, 6B, 6C)
|
||
// 7, 11 = NE corner
|
||
// 8, 12 = SE corner
|
||
const numMatch = name.match(/(?:border|edge|brdr_?snow|brdr|cliff|ravine)\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 || num === 9) return { orientation: 'corner-sw', isCliff }
|
||
if (num === 6 || num === 10) return { orientation: 'corner-nw', isCliff }
|
||
if (num === 7 || num === 11) return { orientation: 'corner-ne', isCliff }
|
||
if (num === 8 || num === 12) return { orientation: 'corner-se', isCliff }
|
||
}
|
||
|
||
// Directional names (Act 2 Desert Cliff Left/Right/Top, entrance/exit btm/top, cardinal words)
|
||
if (/\b(?:north[\s-]?west|nw)\b/i.test(name)) return { orientation: 'corner-nw', isCliff }
|
||
if (/\b(?:north[\s-]?east|ne)\b/i.test(name)) return { orientation: 'corner-ne', isCliff }
|
||
if (/\b(?:south[\s-]?west|sw)\b/i.test(name)) return { orientation: 'corner-sw', isCliff }
|
||
if (/\b(?:south[\s-]?east|se)\b/i.test(name)) return { orientation: 'corner-se', isCliff }
|
||
if (/\b(?:north|top)\b/i.test(name)) return { orientation: 'north', isCliff }
|
||
if (/\b(?:south|bottom|btm)\b/i.test(name)) return { orientation: 'south', isCliff }
|
||
if (/\b(?:east|right|rght)\b/i.test(name)) return { orientation: 'east', isCliff }
|
||
if (/\b(?:west|left)\b/i.test(name)) return { orientation: 'west', isCliff }
|
||
|
||
return { orientation: null, isCliff }
|
||
}
|
||
|
||
/**
|
||
* Determine the perimeter orientation for a block cell on a `gridWidth x gridHeight` grid.
|
||
*/
|
||
export 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.
|
||
*/
|
||
export 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 === true) {
|
||
const cliffMatches = matches.filter(c => c.isCliff)
|
||
if (cliffMatches.length > 0) {
|
||
return cliffMatches[rng.int(0, cliffMatches.length - 1)]!.piece
|
||
}
|
||
} else if (preferCliff === false) {
|
||
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,
|
||
gates?: readonly PlannedGate[],
|
||
seamConstraints?: readonly SeamConstraint[],
|
||
): 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
|
||
}
|
||
|
||
const minX = gridWidth > 2 ? 1 : 0
|
||
const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1
|
||
const minY = gridHeight > 2 ? 1 : 0
|
||
const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1
|
||
|
||
if (gates !== undefined && gates.length > 0) {
|
||
for (const gate of gates) {
|
||
let coord = gate.blockCoord
|
||
if (coord === undefined && seamConstraints !== undefined) {
|
||
const match = seamConstraints.find(
|
||
c =>
|
||
c.side === gate.side &&
|
||
(c.toLevelId === gate.toLevelId ||
|
||
c.fromLevelId === gate.toLevelId ||
|
||
c.toLevelId === levelId ||
|
||
c.fromLevelId === levelId),
|
||
) ?? seamConstraints.find(c => c.side === gate.side)
|
||
if (match !== undefined) {
|
||
coord = match.blockCoord
|
||
}
|
||
}
|
||
if (coord === undefined && gate.tileCoord !== undefined) {
|
||
coord = (gate.side === 'north' || gate.side === 'south')
|
||
? Math.floor(gate.tileCoord.x / TILES_PER_BLOCK)
|
||
: Math.floor(gate.tileCoord.y / TILES_PER_BLOCK)
|
||
}
|
||
|
||
switch (gate.side) {
|
||
case 'north': {
|
||
const bx = coord !== undefined
|
||
? Math.max(minX, Math.min(maxX, coord))
|
||
: Math.floor(gridWidth / 2)
|
||
openings.add(key(bx, 0))
|
||
break
|
||
}
|
||
case 'south': {
|
||
const bx = coord !== undefined
|
||
? Math.max(minX, Math.min(maxX, coord))
|
||
: Math.floor(gridWidth / 2)
|
||
openings.add(key(bx, gridHeight - 1))
|
||
break
|
||
}
|
||
case 'west': {
|
||
const by = coord !== undefined
|
||
? Math.max(minY, Math.min(maxY, coord))
|
||
: Math.floor(gridHeight / 2)
|
||
openings.add(key(0, by))
|
||
break
|
||
}
|
||
case 'east': {
|
||
const by = coord !== undefined
|
||
? Math.max(minY, Math.min(maxY, coord))
|
||
: Math.floor(gridHeight / 2)
|
||
openings.add(key(gridWidth - 1, by))
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return openings
|
||
}
|
||
|
||
if (seamConstraints !== undefined && seamConstraints.length > 0) {
|
||
for (const c of seamConstraints) {
|
||
switch (c.side) {
|
||
case 'north': {
|
||
const bx = Math.max(minX, Math.min(maxX, c.blockCoord))
|
||
openings.add(key(bx, 0))
|
||
break
|
||
}
|
||
case 'south': {
|
||
const bx = Math.max(minX, Math.min(maxX, c.blockCoord))
|
||
openings.add(key(bx, gridHeight - 1))
|
||
break
|
||
}
|
||
case 'west': {
|
||
const by = Math.max(minY, Math.min(maxY, c.blockCoord))
|
||
openings.add(key(0, by))
|
||
break
|
||
}
|
||
case 'east': {
|
||
const by = Math.max(minY, Math.min(maxY, c.blockCoord))
|
||
openings.add(key(gridWidth - 1, by))
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return openings
|
||
}
|
||
|
||
// Outdoor wilderness levels across Acts 1..5:
|
||
// Default entrance on South edge, exit on East edge so borders are never sealed shut
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 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, Facades).
|
||
* @param gates - optional planned gates from the world graph.
|
||
* @param seamConstraints - optional seam constraints for spatial alignment.
|
||
* @returns the number of pieces stamped.
|
||
*/
|
||
export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders(
|
||
canvas: Canvas,
|
||
pieces: readonly WildernessPiece[],
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
rng: Rng,
|
||
stats: WildernessStats,
|
||
levelId?: number,
|
||
openGates?: ReadonlyMap<string, number>,
|
||
claimedBorderBlocks?: ReadonlySet<string>,
|
||
gates?: readonly PlannedGate[],
|
||
seamConstraints?: readonly SeamConstraint[],
|
||
): number {
|
||
if (stats.proceduralWallCells === undefined) {
|
||
stats.proceduralWallCells = 0
|
||
}
|
||
|
||
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 in clockwise ring walk, then corners last,
|
||
// matching D2MOO DRLGOUTPLACE_PlaceAct1245OutdoorBorders where corner presets
|
||
// overwrite straight edge presets at vertices to form seamless joins.
|
||
const straightCells: { x: number; y: number }[] = []
|
||
// North edge (moving East)
|
||
for (let x = 1; x < gridWidth - 1; x += 1) straightCells.push({ x, y: 0 })
|
||
// East edge (moving South)
|
||
for (let y = 1; y < gridHeight - 1; y += 1) straightCells.push({ x: gridWidth - 1, y })
|
||
// South edge (moving West)
|
||
for (let x = gridWidth - 2; x >= 1; x -= 1) straightCells.push({ x, y: gridHeight - 1 })
|
||
// West edge (moving North)
|
||
for (let y = gridHeight - 2; y >= 1; y -= 1) straightCells.push({ x: 0, y })
|
||
|
||
// Corner join pieces stamped last: NW, NE, SE, SW
|
||
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, gates, seamConstraints) : new Set<string>()
|
||
if (openGates !== undefined) {
|
||
for (const gateKey of openGates.keys()) {
|
||
openings.add(gateKey)
|
||
}
|
||
}
|
||
|
||
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)
|
||
const isOpen = !isCorner && openings.has(key)
|
||
// North, West, NW, NE, SW prefer cliff pieces if available (e.g. Act 1 wilderness mountain boundaries),
|
||
// but openings must not prefer cliffs to ensure open gate variants are selected.
|
||
const preferCliff =
|
||
!isOpen &&
|
||
(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 {
|
||
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
|
||
}
|
||
|
||
export const layBorder = DRLGOUTPLACE_PlaceAct1245OutdoorBorders
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* 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.
|
||
*/
|
||
export 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'
|
||
}
|
||
|
||
export interface SpecialPresetPlacement {
|
||
readonly name: string
|
||
readonly ds1: Ds1
|
||
readonly bx: number
|
||
readonly by: number
|
||
readonly blocksX: number
|
||
readonly blocksY: number
|
||
}
|
||
|
||
export interface Act1OutdoorPlan {
|
||
readonly openGates: Map<string, number>
|
||
readonly claimedBorderBlocks: Set<string>
|
||
readonly claimedBlocks: boolean[][]
|
||
readonly roadObstacleBlocks: boolean[][]
|
||
readonly specialPresets: SpecialPresetPlacement[]
|
||
readonly anchors: RoadAnchor[]
|
||
readonly gateOpenings?: GateOpening[]
|
||
}
|
||
|
||
/** 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.
|
||
*/
|
||
export function planAct1OutdoorLevel(
|
||
request: WildernessRequest,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
rng: Rng,
|
||
stats?: WildernessStats,
|
||
): 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 gateOpenings: GateOpening[] = []
|
||
|
||
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,
|
||
forcedBlockCoord?: number,
|
||
forcedTileCoord?: { readonly x: number; readonly y: number },
|
||
): void => {
|
||
const side: Side = edge === 'top' ? 'north' : edge === 'bottom' ? 'south' : edge === 'left' ? 'west' : 'east'
|
||
|
||
let blockCoord = forcedBlockCoord
|
||
let tileCoord = forcedTileCoord
|
||
|
||
if (blockCoord === undefined && request.seamConstraints !== undefined) {
|
||
const match = request.seamConstraints.find(c => {
|
||
if (c.side !== side) return false
|
||
if (toLevelId !== undefined && toLevelId !== -1) {
|
||
return c.toLevelId === toLevelId || c.fromLevelId === toLevelId
|
||
}
|
||
return c.toLevelId === request.levelId || c.fromLevelId === request.levelId
|
||
}) ?? request.seamConstraints.find(c => c.side === side)
|
||
|
||
if (match !== undefined) {
|
||
blockCoord = match.blockCoord
|
||
tileCoord = tileCoord ?? match.tileCoord
|
||
}
|
||
}
|
||
|
||
if (blockCoord === undefined && tileCoord !== undefined) {
|
||
blockCoord = (side === 'north' || side === 'south')
|
||
? Math.floor(tileCoord.x / TILES_PER_BLOCK)
|
||
: Math.floor(tileCoord.y / TILES_PER_BLOCK)
|
||
}
|
||
|
||
if (edge === 'left') {
|
||
const minY = gridHeight > 2 ? 1 : 0
|
||
const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1
|
||
let by: number
|
||
if (blockCoord !== undefined) {
|
||
by = Math.max(minY, Math.min(maxY, blockCoord))
|
||
if (claimedBorderBlocks.has(`0,${String(by)}`)) {
|
||
for (let tries = 0; tries < gridHeight; tries += 1) {
|
||
if (!claimedBorderBlocks.has(`0,${String(by)}`) && !openGates.has(`0,${String(by)}`)) break
|
||
by = minY + ((by - minY + 1) % (maxY - minY + 1))
|
||
}
|
||
}
|
||
} else {
|
||
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
|
||
const anchorCell = tileCoord ?? { x: 3, y: 8 * by + 3 }
|
||
const interiorCell = tileCoord ? { x: tileCoord.x + 8, y: tileCoord.y } : { x: 11, y: 8 * by + 3 }
|
||
anchors.push({
|
||
label,
|
||
anchorCell,
|
||
interiorCell,
|
||
toLevelId,
|
||
side: 'west',
|
||
kind: 'gate',
|
||
})
|
||
gateOpenings.push({
|
||
side: 'west',
|
||
toLevelId: toLevelId ?? -1,
|
||
blockCoord: by,
|
||
tileCoord: anchorCell,
|
||
})
|
||
} else if (edge === 'right') {
|
||
const minY = gridHeight > 2 ? 1 : 0
|
||
const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1
|
||
let by: number
|
||
if (blockCoord !== undefined) {
|
||
by = Math.max(minY, Math.min(maxY, blockCoord))
|
||
if (claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`)) {
|
||
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 = minY + ((by - minY + 1) % (maxY - minY + 1))
|
||
}
|
||
}
|
||
} else {
|
||
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
|
||
const anchorCell = tileCoord ?? { x: 8 * (gridWidth - 1) + 3, y: 8 * by + 3 }
|
||
const interiorCell = tileCoord ? { x: tileCoord.x - 8, y: tileCoord.y } : { x: 8 * (gridWidth - 2) + 3, y: 8 * by + 3 }
|
||
anchors.push({
|
||
label,
|
||
anchorCell,
|
||
interiorCell,
|
||
toLevelId,
|
||
side: 'east',
|
||
kind: 'gate',
|
||
})
|
||
gateOpenings.push({
|
||
side: 'east',
|
||
toLevelId: toLevelId ?? -1,
|
||
blockCoord: by,
|
||
tileCoord: anchorCell,
|
||
})
|
||
} else if (edge === 'top') {
|
||
const minX = gridWidth > 2 ? 1 : 0
|
||
const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1
|
||
let bx: number
|
||
if (blockCoord !== undefined) {
|
||
bx = Math.max(minX, Math.min(maxX, blockCoord))
|
||
if (claimedBorderBlocks.has(`${String(bx)},0`)) {
|
||
for (let tries = 0; tries < gridWidth; tries += 1) {
|
||
if (!claimedBorderBlocks.has(`${String(bx)},0`) && !openGates.has(`${String(bx)},0`)) break
|
||
bx = minX + ((bx - minX + 1) % (maxX - minX + 1))
|
||
}
|
||
}
|
||
} else {
|
||
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
|
||
const anchorCell = tileCoord ?? { x: 8 * bx + 3, y: 3 }
|
||
const interiorCell = tileCoord ? { x: tileCoord.x, y: tileCoord.y + 8 } : { x: 8 * bx + 3, y: 11 }
|
||
anchors.push({
|
||
label,
|
||
anchorCell,
|
||
interiorCell,
|
||
toLevelId,
|
||
side: 'north',
|
||
kind: 'gate',
|
||
})
|
||
gateOpenings.push({
|
||
side: 'north',
|
||
toLevelId: toLevelId ?? -1,
|
||
blockCoord: bx,
|
||
tileCoord: anchorCell,
|
||
})
|
||
} else {
|
||
const minX = gridWidth > 2 ? 1 : 0
|
||
const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1
|
||
let bx: number
|
||
if (blockCoord !== undefined) {
|
||
bx = Math.max(minX, Math.min(maxX, blockCoord))
|
||
if (claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`)) {
|
||
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 = minX + ((bx - minX + 1) % (maxX - minX + 1))
|
||
}
|
||
}
|
||
} else {
|
||
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
|
||
const anchorCell = tileCoord ?? { x: 8 * bx + 3, y: 8 * (gridHeight - 1) + 3 }
|
||
const interiorCell = tileCoord ? { x: tileCoord.x, y: tileCoord.y - 8 } : { x: 8 * bx + 3, y: 8 * (gridHeight - 2) + 3 }
|
||
anchors.push({
|
||
label,
|
||
anchorCell,
|
||
interiorCell,
|
||
toLevelId,
|
||
side: 'south',
|
||
kind: 'gate',
|
||
})
|
||
gateOpenings.push({
|
||
side: 'south',
|
||
toLevelId: toLevelId ?? -1,
|
||
blockCoord: bx,
|
||
tileCoord: anchorCell,
|
||
})
|
||
}
|
||
}
|
||
|
||
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) {
|
||
stats?.unresolved.push(`preset piece not found: ${prefix}`)
|
||
stats?.notes.push(`failed to place preset: ${label} (missing piece ${prefix})`)
|
||
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) {
|
||
stats?.unresolved.push(`no free interior box for preset: ${label} (${prefix})`)
|
||
stats?.notes.push(`failed to place preset: ${label} (no free interior box ${String(blocksX)}x${String(blocksY)})`)
|
||
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.
|
||
*
|
||
* Note on Blizzard Act 2 transition:
|
||
* Blizzard original DRLGOUTDESR_PlaceDesertTransitionToTown (D2Common.0x6FD7D430 inlined)
|
||
* handles the Act 2 transition between Rocky Waste (41) and Lut Gholein (40) via
|
||
* LVLPREST_ACT2_DESERT_TRANSITION_N or LVLPREST_ACT2_DESERT_TRANSITION_W. In this web port,
|
||
* Act 2 transitions are resolved via seamless border connections rather than dedicated transition presets.
|
||
*/
|
||
const placeTownTransition = (side: Side, toLevelId?: number, forcedBlockCoord?: 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 = forcedBlockCoord !== undefined
|
||
? Math.max(1, Math.min(gridHeight - 6, forcedBlockCoord))
|
||
: 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 })
|
||
const anchorCell = { x: 7, y: 8 * by + 19 }
|
||
anchors.push({
|
||
label: 'Town E Transition',
|
||
anchorCell,
|
||
interiorCell: { x: 11, y: 8 * by + 19 },
|
||
toLevelId,
|
||
side: 'west',
|
||
kind: 'gate',
|
||
})
|
||
gateOpenings.push({
|
||
side: 'west',
|
||
toLevelId: toLevelId ?? 1,
|
||
blockCoord: by,
|
||
tileCoord: anchorCell,
|
||
})
|
||
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 = forcedBlockCoord !== undefined
|
||
? Math.max(1, Math.min(gridWidth - 8, forcedBlockCoord))
|
||
: 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 })
|
||
const anchorCell = { x: 8 * bx + 29, y: 7 }
|
||
anchors.push({
|
||
label: 'Town S Transition',
|
||
anchorCell,
|
||
interiorCell: { x: 8 * bx + 29, y: 11 },
|
||
toLevelId,
|
||
side: 'north',
|
||
kind: 'gate',
|
||
})
|
||
gateOpenings.push({
|
||
side: 'north',
|
||
toLevelId: toLevelId ?? 1,
|
||
blockCoord: bx,
|
||
tileCoord: anchorCell,
|
||
})
|
||
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) {
|
||
stats?.unresolved.push('failed to place Graveyard preset')
|
||
stats?.notes.push('failed to place Graveyard preset (missing piece or grid too small)')
|
||
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
|
||
const anchorCell = { x: 2 * 8 + 3, y: 8 * (gridHeight - 1) + 3 }
|
||
anchors.push({
|
||
label: 'Burial Grounds Gate',
|
||
anchorCell,
|
||
interiorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 2) + 3 },
|
||
toLevelId: 3,
|
||
side: 'south',
|
||
kind: 'gate',
|
||
})
|
||
gateOpenings.push({
|
||
side: 'south',
|
||
toLevelId: 3,
|
||
blockCoord: 2,
|
||
tileCoord: anchorCell,
|
||
})
|
||
}
|
||
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) {
|
||
if (!addInteriorPresetWithRoad('Act 1 - DOE Entrance', 'Den of Evil Entrance', undefined, 8)) {
|
||
stats?.notes.push('Den of Evil Entrance preset failed to place')
|
||
}
|
||
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 {
|
||
stats?.notes.push('Bivouac preset failed to place (no free box)')
|
||
}
|
||
}
|
||
} else if (levelId === 3) {
|
||
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Cave Level 1 Entrance', undefined, 9)) {
|
||
stats?.notes.push('Cave Level 1 Entrance preset failed to place')
|
||
}
|
||
if (!addInteriorPresetWithRoad('Act 1 - Bivouac', 'Bivouac Camp', 'south')) {
|
||
stats?.notes.push('Bivouac Camp preset failed to place')
|
||
}
|
||
} else if (levelId === 4) {
|
||
if (!addInteriorPresetWithRoad('Act 1 - Cairn Stones', 'Cairn Stones', 'south', 38)) {
|
||
stats?.notes.push('Cairn Stones preset failed to place')
|
||
}
|
||
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Entrance', undefined, 10)) {
|
||
stats?.notes.push('Underground Passage Entrance preset failed to place')
|
||
}
|
||
} else if (levelId === 5) {
|
||
if (!addInteriorPresetWithRoad('Act 1 - Inifus', 'Tree of Inifuss', 'south')) {
|
||
stats?.notes.push('Tree of Inifuss preset failed to place')
|
||
}
|
||
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Exit', undefined, 10)) {
|
||
stats?.notes.push('Underground Passage Exit preset failed to place')
|
||
}
|
||
} 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.
|
||
if (!addInteriorPresetWithRoad('Act 1 - Tower 1', 'Forgotten Tower', 'south', 20)) {
|
||
stats?.notes.push('Forgotten Tower preset failed to place')
|
||
}
|
||
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Hole Level 1 Entrance', undefined, 11)) {
|
||
stats?.notes.push('Hole Level 1 Entrance preset failed to place')
|
||
}
|
||
} else if (levelId === 7) {
|
||
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Pit Level 1 Entrance', undefined, 12)) {
|
||
stats?.notes.push('Pit Level 1 Entrance preset failed to place')
|
||
}
|
||
} else if (levelId === 39) {
|
||
if (!addInteriorPresetWithRoad('Act 1 - Corral Fill', 'Cow Corral', 'south')) {
|
||
stats?.notes.push('Cow Corral preset failed to place')
|
||
}
|
||
if (!addInteriorPresetWithRoad('Act 1 - Bivouac', 'Cow Bivouac', 'east')) {
|
||
stats?.notes.push('Cow Bivouac preset failed to place')
|
||
}
|
||
}
|
||
}
|
||
|
||
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, townSeam.blockCoord)
|
||
for (const gate of plannedGates) {
|
||
if (placedTown && gate === townSeam) continue
|
||
let blockCoord = gate.blockCoord
|
||
let tileCoord = gate.tileCoord
|
||
if (blockCoord === undefined && request.seamConstraints !== undefined) {
|
||
const match = request.seamConstraints.find(c => {
|
||
if (c.side !== gate.side) return false
|
||
return c.toLevelId === gate.toLevelId || c.fromLevelId === gate.toLevelId
|
||
}) ?? request.seamConstraints.find(c => c.side === gate.side)
|
||
if (match !== undefined) {
|
||
blockCoord = match.blockCoord
|
||
tileCoord = tileCoord ?? match.tileCoord
|
||
}
|
||
}
|
||
addBorderGate(SIDE_TO_EDGE[gate.side], gate.variant ?? 3, gate.label, gate.toLevelId, blockCoord, tileCoord)
|
||
}
|
||
if (id === 17) {
|
||
if (!placeGraveyardPreset(false)) {
|
||
stats?.unresolved.push('Burial Grounds Graveyard preset failed to place')
|
||
stats?.notes.push('Burial Grounds Graveyard preset failed to place')
|
||
}
|
||
}
|
||
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
|
||
const coldPlainsConstraint = request.seamConstraints?.find(c => c.toLevelId === 3 || c.fromLevelId === 3)
|
||
let exitEdge: 'right' | 'bottom'
|
||
if (coldPlainsConstraint?.side === 'east') {
|
||
exitEdge = 'right'
|
||
} else if (coldPlainsConstraint?.side === 'south') {
|
||
exitEdge = 'bottom'
|
||
} else {
|
||
exitEdge = rng.int(0, 1) === 0 ? 'right' : 'bottom'
|
||
}
|
||
if (useWest && placeTownTransition('west', 1)) {
|
||
addBorderGate(exitEdge, 3, 'Cold Plains Exit', 3, coldPlainsConstraint?.blockCoord, coldPlainsConstraint?.tileCoord)
|
||
} else if (placeTownTransition('north', 1)) {
|
||
addBorderGate(exitEdge, 3, 'Cold Plains Exit', 3, coldPlainsConstraint?.blockCoord, coldPlainsConstraint?.tileCoord)
|
||
} else {
|
||
addBorderGate('left', 3, 'Town Entry', 1)
|
||
addBorderGate(exitEdge, 3, 'Cold Plains Exit', 3, coldPlainsConstraint?.blockCoord, coldPlainsConstraint?.tileCoord)
|
||
}
|
||
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)) {
|
||
stats?.notes.push('Burial Grounds Graveyard preset failed, falling back to border gate')
|
||
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')
|
||
}
|
||
|
||
if (stats !== undefined) {
|
||
stats.gateOpenings = gateOpenings
|
||
}
|
||
|
||
return {
|
||
openGates,
|
||
claimedBorderBlocks,
|
||
claimedBlocks,
|
||
roadObstacleBlocks,
|
||
specialPresets,
|
||
anchors,
|
||
gateOpenings,
|
||
}
|
||
}
|
||
|
||
export interface DrlgVertex {
|
||
readonly x: number
|
||
readonly y: number
|
||
readonly type: 'gate' | 'preset' | 'hub' | 'waypoint' | 'inflection'
|
||
readonly label?: string | undefined
|
||
readonly id?: number | undefined
|
||
}
|
||
|
||
export interface DrlgRoadNetwork {
|
||
readonly vertices: DrlgVertex[]
|
||
readonly polylines: { x: number; y: number }[][]
|
||
}
|
||
|
||
export type DrlgVertexResult = DrlgVertex[] & {
|
||
readonly vertices: DrlgVertex[]
|
||
readonly polylines: { x: number; y: number }[][]
|
||
readonly lines: { x: number; y: number }[][]
|
||
}
|
||
|
||
/**
|
||
* Catmull-Rom spline interpolation between 4 control points at parameter t [0, 1].
|
||
*/
|
||
export function catmullRomPoint(
|
||
p0: { readonly x: number; readonly y: number },
|
||
p1: { readonly x: number; readonly y: number },
|
||
p2: { readonly x: number; readonly y: number },
|
||
p3: { readonly x: number; readonly y: number },
|
||
t: number,
|
||
): { x: number; y: number } {
|
||
const t2 = t * t
|
||
const t3 = t2 * t
|
||
const x = 0.5 * (
|
||
(2 * p1.x) +
|
||
(-p0.x + p2.x) * t +
|
||
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
|
||
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3
|
||
)
|
||
const y = 0.5 * (
|
||
(2 * p1.y) +
|
||
(-p0.y + p2.y) * t +
|
||
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
|
||
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3
|
||
)
|
||
return { x, y }
|
||
}
|
||
|
||
/**
|
||
* Sample a Catmull-Rom spline through a sequence of control points,
|
||
* steering clear of obstacle blocks.
|
||
*/
|
||
export function sampleCatmullRomSpline(
|
||
controlPoints: readonly { readonly x: number; readonly y: number }[],
|
||
bounds: { readonly width: number; readonly height: number },
|
||
roadObstacleBlocks?: boolean[][],
|
||
): { x: number; y: number }[] {
|
||
if (controlPoints.length === 0) return []
|
||
if (controlPoints.length === 1) return [{ x: controlPoints[0]!.x, y: controlPoints[0]!.y }]
|
||
|
||
const result: { x: number; y: number }[] = []
|
||
const n = controlPoints.length
|
||
|
||
const isObstacle = (cx: number, cy: number): boolean => {
|
||
if (!roadObstacleBlocks) return false
|
||
const bx = Math.floor(cx / TILES_PER_BLOCK)
|
||
const by = Math.floor(cy / TILES_PER_BLOCK)
|
||
return roadObstacleBlocks[by]?.[bx] === true
|
||
}
|
||
|
||
const addPoint = (rawX: number, rawY: number) => {
|
||
let px = Math.max(0, Math.min(bounds.width - 1, Math.round(rawX)))
|
||
let py = Math.max(0, Math.min(bounds.height - 1, Math.round(rawY)))
|
||
|
||
if (isObstacle(px, py)) {
|
||
const obx = Math.floor(px / TILES_PER_BLOCK)
|
||
const oby = Math.floor(py / TILES_PER_BLOCK)
|
||
const centerTileX = obx * TILES_PER_BLOCK + 4
|
||
const centerTileY = oby * TILES_PER_BLOCK + 4
|
||
const dirX = px - centerTileX
|
||
const dirY = py - centerTileY
|
||
|
||
const stepX = dirX >= 0 ? 1 : -1
|
||
const stepY = dirY >= 0 ? 1 : -1
|
||
if (Math.abs(dirX) >= Math.abs(dirY)) {
|
||
px = stepX > 0 ? (obx + 1) * TILES_PER_BLOCK : obx * TILES_PER_BLOCK - 1
|
||
} else {
|
||
py = stepY > 0 ? (oby + 1) * TILES_PER_BLOCK : oby * TILES_PER_BLOCK - 1
|
||
}
|
||
px = Math.max(0, Math.min(bounds.width - 1, px))
|
||
py = Math.max(0, Math.min(bounds.height - 1, py))
|
||
}
|
||
|
||
const last = result[result.length - 1]
|
||
if (!last || last.x !== px || last.y !== py) {
|
||
result.push({ x: px, y: py })
|
||
}
|
||
}
|
||
|
||
for (let i = 0; i < n - 1; i += 1) {
|
||
const p0 = i > 0 ? controlPoints[i - 1]! : {
|
||
x: 2 * controlPoints[0]!.x - controlPoints[1]!.x,
|
||
y: 2 * controlPoints[0]!.y - controlPoints[1]!.y,
|
||
}
|
||
const p1 = controlPoints[i]!
|
||
const p2 = controlPoints[i + 1]!
|
||
const p3 = i + 2 < n ? controlPoints[i + 2]! : {
|
||
x: 2 * controlPoints[n - 1]!.x - controlPoints[n - 2]!.x,
|
||
y: 2 * controlPoints[n - 1]!.y - controlPoints[n - 2]!.y,
|
||
}
|
||
|
||
const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y)
|
||
const steps = Math.max(3, Math.ceil(dist / 1.0))
|
||
for (let s = 0; s <= steps; s += 1) {
|
||
const t = s / steps
|
||
const pt = catmullRomPoint(p0, p1, p2, p3, t)
|
||
addPoint(pt.x, pt.y)
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Find clear block path connecting start to end block avoiding obstacle blocks.
|
||
*/
|
||
function findClearBlockCorridor(
|
||
startBx: number,
|
||
startBy: number,
|
||
endBx: number,
|
||
endBy: number,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
roadObstacleBlocks: boolean[][],
|
||
): { bx: number; by: number }[] {
|
||
if (startBx === endBx && startBy === endBy) {
|
||
return [{ bx: startBx, by: startBy }]
|
||
}
|
||
|
||
interface Node {
|
||
bx: number
|
||
by: number
|
||
g: number
|
||
h: number
|
||
f: number
|
||
parent: Node | null
|
||
}
|
||
|
||
const openSet: Node[] = []
|
||
const closedSet = new Set<string>()
|
||
|
||
const keyOf = (bx: number, by: number) => `${String(bx)},${String(by)}`
|
||
const h = (bx: number, by: number) => Math.hypot(endBx - bx, endBy - by)
|
||
|
||
const startNode: Node = {
|
||
bx: startBx,
|
||
by: startBy,
|
||
g: 0,
|
||
h: h(startBx, startBy),
|
||
f: h(startBx, startBy),
|
||
parent: null,
|
||
}
|
||
openSet.push(startNode)
|
||
|
||
const DIRS = [
|
||
{ dx: 1, dy: 0 },
|
||
{ dx: -1, dy: 0 },
|
||
{ dx: 0, dy: 1 },
|
||
{ dx: 0, dy: -1 },
|
||
{ dx: 1, dy: 1 },
|
||
{ dx: -1, dy: 1 },
|
||
{ dx: 1, dy: -1 },
|
||
{ dx: -1, dy: -1 },
|
||
]
|
||
|
||
let bestNode = startNode
|
||
let bestDist = h(startBx, startBy)
|
||
|
||
while (openSet.length > 0) {
|
||
let bestIdx = 0
|
||
for (let i = 1; i < openSet.length; i += 1) {
|
||
if (openSet[i]!.f < openSet[bestIdx]!.f) {
|
||
bestIdx = i
|
||
}
|
||
}
|
||
const current = openSet.splice(bestIdx, 1)[0]!
|
||
|
||
if (current.bx === endBx && current.by === endBy) {
|
||
const path: { bx: number; by: number }[] = []
|
||
let curr: Node | null = current
|
||
while (curr !== null) {
|
||
path.unshift({ bx: curr.bx, by: curr.by })
|
||
curr = curr.parent
|
||
}
|
||
return path
|
||
}
|
||
|
||
closedSet.add(keyOf(current.bx, current.by))
|
||
|
||
if (current.h < bestDist) {
|
||
bestDist = current.h
|
||
bestNode = current
|
||
}
|
||
|
||
for (const dir of DIRS) {
|
||
const nbx = current.bx + dir.dx
|
||
const nby = current.by + dir.dy
|
||
if (nbx < 1 || nbx >= gridWidth - 1 || nby < 1 || nby >= gridHeight - 1) continue
|
||
if (closedSet.has(keyOf(nbx, nby))) continue
|
||
|
||
if (
|
||
!(nbx === endBx && nby === endBy) &&
|
||
roadObstacleBlocks[nby]?.[nbx] === true
|
||
) {
|
||
continue
|
||
}
|
||
|
||
if (dir.dx !== 0 && dir.dy !== 0) {
|
||
if (
|
||
roadObstacleBlocks[current.by]?.[current.bx + dir.dx] === true &&
|
||
roadObstacleBlocks[current.by + dir.dy]?.[current.bx] === true
|
||
) {
|
||
continue
|
||
}
|
||
}
|
||
|
||
const moveCost = dir.dx !== 0 && dir.dy !== 0 ? 1.414 : 1.0
|
||
const g = current.g + moveCost
|
||
const nodeH = h(nbx, nby)
|
||
const existing = openSet.find(n => n.bx === nbx && n.by === nby)
|
||
if (existing) {
|
||
if (g < existing.g) {
|
||
existing.g = g
|
||
existing.f = g + existing.h
|
||
existing.parent = current
|
||
}
|
||
} else {
|
||
openSet.push({
|
||
bx: nbx,
|
||
by: nby,
|
||
g,
|
||
h: nodeH,
|
||
f: g + nodeH,
|
||
parent: current,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
const path: { bx: number; by: number }[] = []
|
||
let curr: Node | null = bestNode
|
||
while (curr !== null) {
|
||
path.unshift({ bx: curr.bx, by: curr.by })
|
||
curr = curr.parent
|
||
}
|
||
return path
|
||
}
|
||
|
||
/**
|
||
* Build macro control vertex graph and smooth Catmull-Rom spline polylines
|
||
* connecting anchors to a central hub (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_SpawnAct1DirtPaths`).
|
||
*/
|
||
export function createDrlgVertices(
|
||
plan: Act1OutdoorPlan,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
rng: Rng,
|
||
): DrlgVertexResult {
|
||
if (plan.anchors.length < 2) {
|
||
const empty: DrlgVertex[] = []
|
||
return Object.assign(empty, {
|
||
vertices: empty,
|
||
polylines: [],
|
||
lines: [],
|
||
}) as DrlgVertexResult
|
||
}
|
||
|
||
// 1. Central Crossroads Hub
|
||
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 + 4, y: hubBy * TILES_PER_BLOCK + 4 }
|
||
const hubVertex: DrlgVertex = {
|
||
x: hubCell.x,
|
||
y: hubCell.y,
|
||
type: 'hub',
|
||
label: 'Central Hub',
|
||
}
|
||
|
||
const allVertices: DrlgVertex[] = [hubVertex]
|
||
|
||
// 2. Add anchors and special presets
|
||
for (const anchor of plan.anchors) {
|
||
let vType: DrlgVertex['type'] = 'gate'
|
||
if (/waypoint/i.test(anchor.label)) {
|
||
vType = 'waypoint'
|
||
} else if (anchor.kind === 'preset') {
|
||
vType = 'preset'
|
||
}
|
||
allVertices.push({
|
||
x: anchor.interiorCell.x,
|
||
y: anchor.interiorCell.y,
|
||
type: vType,
|
||
label: anchor.label,
|
||
id: anchor.toLevelId,
|
||
})
|
||
}
|
||
|
||
for (const preset of plan.specialPresets) {
|
||
const covered = plan.anchors.some(a =>
|
||
a.label.toLowerCase().includes(preset.name.toLowerCase()) ||
|
||
preset.name.toLowerCase().includes(a.label.toLowerCase()),
|
||
)
|
||
if (!covered) {
|
||
const px = preset.bx * TILES_PER_BLOCK + Math.floor((preset.blocksX * TILES_PER_BLOCK) / 2)
|
||
const py = preset.by * TILES_PER_BLOCK + Math.floor((preset.blocksY * TILES_PER_BLOCK) / 2)
|
||
allVertices.push({
|
||
x: px,
|
||
y: py,
|
||
type: /waypoint/i.test(preset.name) ? 'waypoint' : 'preset',
|
||
label: preset.name,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 3. Connect each anchor to the central hub via Catmull-Rom spline with meander and obstacle steering
|
||
const polylines: { x: number; y: number }[][] = []
|
||
const canvasBounds = { width: gridWidth * TILES_PER_BLOCK, height: gridHeight * TILES_PER_BLOCK }
|
||
|
||
for (const anchor of plan.anchors) {
|
||
const startCell = anchor.interiorCell
|
||
const startBx = Math.floor(startCell.x / TILES_PER_BLOCK)
|
||
const startBy = Math.floor(startCell.y / TILES_PER_BLOCK)
|
||
|
||
const blockPath = findClearBlockCorridor(
|
||
startBx,
|
||
startBy,
|
||
hubBx,
|
||
hubBy,
|
||
gridWidth,
|
||
gridHeight,
|
||
plan.roadObstacleBlocks,
|
||
)
|
||
|
||
const controlPoints: { x: number; y: number }[] = [
|
||
anchor.anchorCell,
|
||
anchor.interiorCell,
|
||
]
|
||
|
||
if (blockPath.length > 2) {
|
||
let lastDirX = blockPath[1]!.bx - blockPath[0]!.bx
|
||
let lastDirY = blockPath[1]!.by - blockPath[0]!.by
|
||
let blocksSinceControl = 1
|
||
|
||
for (let i = 1; i < blockPath.length - 1; i += 1) {
|
||
const curr = blockPath[i]!
|
||
const next = blockPath[i + 1]!
|
||
const dirX = next.bx - curr.bx
|
||
const dirY = next.by - curr.by
|
||
const isTurn = dirX !== lastDirX || dirY !== lastDirY
|
||
blocksSinceControl += 1
|
||
|
||
if (isTurn || blocksSinceControl >= 3) {
|
||
const mx = rng.int(-1, 1)
|
||
const my = rng.int(-1, 1)
|
||
let cx = curr.bx * TILES_PER_BLOCK + 4 + mx
|
||
let cy = curr.by * TILES_PER_BLOCK + 4 + my
|
||
if (plan.roadObstacleBlocks[Math.floor(cy / TILES_PER_BLOCK)]?.[Math.floor(cx / TILES_PER_BLOCK)] === true) {
|
||
cx = curr.bx * TILES_PER_BLOCK + 4
|
||
cy = curr.by * TILES_PER_BLOCK + 4
|
||
}
|
||
const inflectionVertex: DrlgVertex = {
|
||
x: cx,
|
||
y: cy,
|
||
type: 'inflection',
|
||
label: `Inflection (${anchor.label})`,
|
||
}
|
||
allVertices.push(inflectionVertex)
|
||
controlPoints.push({ x: cx, y: cy })
|
||
lastDirX = dirX
|
||
lastDirY = dirY
|
||
blocksSinceControl = 0
|
||
}
|
||
}
|
||
} else {
|
||
const dist = Math.hypot(hubCell.x - startCell.x, hubCell.y - startCell.y)
|
||
if (dist >= 14) {
|
||
const midX = Math.round((startCell.x + hubCell.x) / 2)
|
||
const midY = Math.round((startCell.y + hubCell.y) / 2)
|
||
const perpX = -(hubCell.y - startCell.y) / dist
|
||
const perpY = (hubCell.x - startCell.x) / dist
|
||
const meanderMag = rng.int(-2, 2)
|
||
let cx = Math.round(midX + perpX * meanderMag)
|
||
let cy = Math.round(midY + perpY * meanderMag)
|
||
if (plan.roadObstacleBlocks[Math.floor(cy / TILES_PER_BLOCK)]?.[Math.floor(cx / TILES_PER_BLOCK)] === true) {
|
||
cx = midX
|
||
cy = midY
|
||
}
|
||
const inflectionVertex: DrlgVertex = {
|
||
x: cx,
|
||
y: cy,
|
||
type: 'inflection',
|
||
label: `Inflection (${anchor.label})`,
|
||
}
|
||
allVertices.push(inflectionVertex)
|
||
controlPoints.push({ x: cx, y: cy })
|
||
}
|
||
}
|
||
|
||
controlPoints.push(hubCell)
|
||
|
||
const splineLine = sampleCatmullRomSpline(controlPoints, canvasBounds, plan.roadObstacleBlocks)
|
||
polylines.push(splineLine)
|
||
}
|
||
|
||
return Object.assign([...allVertices], {
|
||
vertices: allVertices,
|
||
polylines,
|
||
lines: polylines,
|
||
}) as DrlgVertexResult
|
||
}
|
||
|
||
export const DRLGVER_CreateVertices = createDrlgVertices
|
||
|
||
/**
|
||
* Build smooth Catmull-Rom spline road polylines connecting all anchors to a central hub
|
||
* (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_SpawnAct1DirtPaths`).
|
||
*/
|
||
export function buildOutdoorRoadPolylines(
|
||
plan: Act1OutdoorPlan,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
rng: Rng,
|
||
): { x: number; y: number }[][] {
|
||
return createDrlgVertices(plan, gridWidth, gridHeight, rng).polylines
|
||
}
|
||
|
||
export const buildRoadNetworkPolylines = buildOutdoorRoadPolylines
|
||
|
||
/**
|
||
* Rasterize a 2-3 cell wide line segment onto the dirt path grid (`sub_6FD75F60`).
|
||
*/
|
||
export function rasterizeDirtPathSegment(
|
||
grid: Uint8Array,
|
||
width: number,
|
||
height: number,
|
||
x1: number,
|
||
y1: number,
|
||
x2: number,
|
||
y2: number,
|
||
roadWidth: number = 2,
|
||
roadObstacleBlocks?: boolean[][],
|
||
): void {
|
||
const markWidth = (x: number, y: number): void => {
|
||
const limit = Math.max(2, Math.min(3, roadWidth))
|
||
for (let dy = 0; dy < limit; dy += 1) {
|
||
for (let dx = 0; dx < limit; dx += 1) {
|
||
const px = x + dx
|
||
const py = y + dy
|
||
if (px >= 0 && px < width && py >= 0 && py < height) {
|
||
if (roadObstacleBlocks) {
|
||
const obx = Math.floor(px / TILES_PER_BLOCK)
|
||
const oby = Math.floor(py / TILES_PER_BLOCK)
|
||
if (roadObstacleBlocks[oby]?.[obx] === true) {
|
||
continue
|
||
}
|
||
}
|
||
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
|
||
|
||
markWidth(x, y)
|
||
if (dx >= dy) {
|
||
let err = 0
|
||
for (let i = 0; i < dx; i += 1) {
|
||
err += dy
|
||
if (err > dx) {
|
||
y += sy
|
||
markWidth(x, y)
|
||
err -= dx
|
||
}
|
||
x += sx
|
||
markWidth(x, y)
|
||
}
|
||
} else {
|
||
let err = 0
|
||
for (let i = 0; i < dy; i += 1) {
|
||
err += dx
|
||
if (err > dy) {
|
||
x += sx
|
||
markWidth(x, y)
|
||
err -= dy
|
||
}
|
||
y += sy
|
||
markWidth(x, y)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Rasterize all road polylines into a 2D bitmask grid and apply
|
||
* `DIRT_PATH_TILE_LUT` (`DRLG_OUTDOORS_GenerateDirtPath`).
|
||
*/
|
||
export function generateDirtPaths(
|
||
canvas: Canvas,
|
||
polylines: readonly (readonly { x: number; y: number }[])[],
|
||
roadObstacleBlocks?: boolean[][],
|
||
): { 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, 2, roadObstacleBlocks)
|
||
roadSegments += 1
|
||
}
|
||
}
|
||
|
||
const isRoad = (nx: number, ny: number): boolean => {
|
||
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return false
|
||
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.
|
||
*/
|
||
/**
|
||
* Atomically replace a target canvas region with a substitution DS1 (`DRLGTILESUB_ReplaceSubPreset`).
|
||
*
|
||
* Synchronously updates all 4 visual/collision layers (floors, walls, roofs `type === 15`, and shadows)
|
||
* plus the substitution group tags and DS1 objects, clearing stale procedural clutter on cells
|
||
* modified by the substitution patch.
|
||
*/
|
||
export function stampSubstitutionPatch(
|
||
canvas: Canvas,
|
||
patch: Ds1,
|
||
originX: number,
|
||
originY: number,
|
||
): number {
|
||
let written = 0
|
||
for (let y = 0; y < patch.height; y += 1) {
|
||
const cy = originY + y
|
||
if (cy < 0 || cy >= canvas.height) continue
|
||
const srcRow = patch.cells[y]
|
||
const dstRow = canvas.cells[cy]
|
||
if (srcRow === undefined || dstRow === undefined) continue
|
||
|
||
for (let x = 0; x < patch.width; x += 1) {
|
||
const cx = originX + x
|
||
if (cx < 0 || cx >= canvas.width) continue
|
||
const srcCell = srcRow[x]
|
||
const dstCell = dstRow[cx]
|
||
if (srcCell === undefined || dstCell === undefined) continue
|
||
|
||
const hasFloor = srcCell.floors.some(f => !f.hidden && f.prop1 !== 0)
|
||
const hasWallOrRoof = srcCell.walls.some(w => !w.hidden && w.prop1 !== 0)
|
||
const hasShadow = srcCell.shadows.some(s => !s.hidden && s.prop1 !== 0)
|
||
const hasSub = srcCell.substitutions.some(s => s.value !== 0)
|
||
|
||
if (!hasFloor && !hasWallOrRoof && !hasShadow && !hasSub) continue
|
||
|
||
const targetFloors = dstCell.floors as Ds1Floor[]
|
||
const targetWalls = dstCell.walls as Ds1Wall[]
|
||
const targetShadows = dstCell.shadows as Ds1Floor[]
|
||
const targetSubs = dstCell.substitutions as Ds1Substitution[]
|
||
|
||
// Atomic 4-layer sync (Floor, Wall, Roof, Shadow): when a substitution cell overwrites
|
||
// a tile, replace existing floor/wall/roof/shadow slots cleanly so underlying procedural
|
||
// walls or shadows do not bleed into the substituted structure.
|
||
if (hasFloor) {
|
||
for (let i = 0; i < srcCell.floors.length; i += 1) {
|
||
const floor = srcCell.floors[i]
|
||
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
|
||
if (i < targetFloors.length) targetFloors[i] = floor
|
||
else targetFloors.push(floor)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasWallOrRoof || hasFloor) {
|
||
// Clear pre-existing procedural walls/roofs on this substituted cell before applying patch walls/roofs
|
||
for (let i = 0; i < targetWalls.length; i += 1) {
|
||
targetWalls[i] = { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||
}
|
||
for (let i = 0; i < srcCell.walls.length; i += 1) {
|
||
const wall = srcCell.walls[i]
|
||
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
|
||
if (i < targetWalls.length) targetWalls[i] = wall
|
||
else targetWalls.push(wall)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasShadow || hasFloor) {
|
||
for (let i = 0; i < targetShadows.length; i += 1) {
|
||
targetShadows[i] = { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||
}
|
||
for (let i = 0; i < srcCell.shadows.length; i += 1) {
|
||
const shadow = srcCell.shadows[i]
|
||
if (shadow !== undefined && !shadow.hidden && shadow.prop1 !== 0) {
|
||
if (i < targetShadows.length) targetShadows[i] = shadow
|
||
else targetShadows.push(shadow)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasSub) {
|
||
for (let i = 0; i < srcCell.substitutions.length; i += 1) {
|
||
const sub = srcCell.substitutions[i]
|
||
if (sub !== undefined && sub.value !== 0) {
|
||
if (i < targetSubs.length) targetSubs[i] = sub
|
||
else targetSubs.push(sub)
|
||
}
|
||
}
|
||
}
|
||
|
||
written += 1
|
||
}
|
||
}
|
||
|
||
// Copy DS1 objects from the substitution patch
|
||
const subOriginX = originX * SUB_TILES_PER_TILE
|
||
const subOriginY = originY * SUB_TILES_PER_TILE
|
||
const maxSubX = canvas.width * SUB_TILES_PER_TILE
|
||
const maxSubY = canvas.height * SUB_TILES_PER_TILE
|
||
for (const obj of patch.objects) {
|
||
const ox = subOriginX + obj.x
|
||
const oy = subOriginY + obj.y
|
||
if (ox >= 0 && ox < maxSubX && oy >= 0 && oy < maxSubY) {
|
||
canvas.objects.push({ ...obj, x: ox, y: oy })
|
||
}
|
||
}
|
||
|
||
return written
|
||
}
|
||
|
||
/**
|
||
* Build a Fisher-Yates randomized permutation of all grid-aligned candidate offsets
|
||
* within `(spanX, spanY)` for `Trials == -1` exhaustive substitution scan (`DRLGTILESUB_DoSubstitutions`).
|
||
*/
|
||
export function buildSubstitutionCandidatePermutation(
|
||
spanX: number,
|
||
spanY: number,
|
||
gridSize: number,
|
||
rng: Rng,
|
||
): { ox: number; oy: number }[] {
|
||
const step = Math.max(1, Math.floor(gridSize))
|
||
const candidates: { ox: number; oy: number }[] = []
|
||
const snap = (value: number, span: number): number => {
|
||
const limit = Math.max(Math.floor((span - 1) / step), 0)
|
||
return Math.min(Math.floor(value / step), limit) * step
|
||
}
|
||
|
||
for (let oy = 0; oy < spanY; oy += step) {
|
||
for (let ox = 0; ox < spanX; ox += step) {
|
||
candidates.push({ ox: snap(ox, spanX), oy: snap(oy, spanY) })
|
||
}
|
||
}
|
||
|
||
// Fisher-Yates shuffle using the level's deterministic PRNG
|
||
for (let i = candidates.length - 1; i > 0; i -= 1) {
|
||
const j = rng.int(0, i)
|
||
const tmp = candidates[i]!
|
||
candidates[i] = candidates[j]!
|
||
candidates[j] = tmp
|
||
}
|
||
|
||
return candidates
|
||
}
|
||
|
||
/**
|
||
* Scatter one `LvlSub` row's pieces across the level's interior (`DRLGTILESUB_DoSubstitutions`).
|
||
*
|
||
* Implements Blizzard D2 1.13c `DrlgTileSub.cpp`:
|
||
* 1. Reads theme-indexed `Max[themeIndex]` and `Trials[themeIndex]` (`0..4`).
|
||
* 2. When `Trials == -1`, shuffles all candidate grid positions via Fisher-Yates permutation
|
||
* and scans until up to `wantedMax` non-overlapping clusters are placed.
|
||
* 3. When `Trials > 0`, performs up to `Trials` random coordinate draws per cluster.
|
||
* 4. Selects randomly among `row.levels` variants (`DRLGTILESUB_PickSubPreset`) and atomically
|
||
* replaces floor, wall, roof, and shadow layers (`stampSubstitutionPatch`).
|
||
*/
|
||
export function applySubstitution(
|
||
canvas: Canvas,
|
||
row: WildernessSubstitution,
|
||
themeIndex: number,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
rng: Rng,
|
||
claimedBlocks?: boolean[][],
|
||
dirtPathGrid?: Uint8Array,
|
||
maxOverride?: number,
|
||
): number {
|
||
if (row.levels.length === 0) return 0
|
||
const baseLevel = row.levels[0]!
|
||
const rawMax = Math.max(0, Math.floor(row.max[themeIndex] ?? 0))
|
||
const max = maxOverride !== undefined ? Math.min(rawMax, maxOverride) : rawMax
|
||
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(baseLevel.width / TILES_PER_BLOCK), 1)
|
||
const blocksY = Math.max(Math.floor(baseLevel.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, bw: number, bh: number): void => {
|
||
if (claimedBlocks === undefined) return
|
||
for (let dy = 0; dy < bh; dy += 1) {
|
||
for (let dx = 0; dx < bw; dx += 1) {
|
||
if (claimedBlocks[by + dy] !== undefined) {
|
||
claimedBlocks[by + dy]![bx + dx] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const pickVariant = (): Ds1 => {
|
||
if (row.levels.length === 1) return row.levels[0]!
|
||
return row.levels[rng.int(0, row.levels.length - 1)]!
|
||
}
|
||
|
||
let stamped = 0
|
||
|
||
if (trials === -1) {
|
||
// 1.13c DRLGTILESUB_DoSubstitutions: when nTrials == -1, permute all candidate positions
|
||
// and place up to `max` clusters without repeating failed slots.
|
||
const candidates = buildSubstitutionCandidatePermutation(spanX, spanY, gridSize, rng)
|
||
let candidateIdx = 0
|
||
for (let cluster = 0; cluster < max && candidateIdx < candidates.length; cluster += 1) {
|
||
let placed = false
|
||
while (candidateIdx < candidates.length && !placed) {
|
||
const cand = candidates[candidateIdx]!
|
||
candidateIdx += 1
|
||
const bx = 1 + cand.ox
|
||
const by = 1 + cand.oy
|
||
if (!canPlaceSubstitutionCluster(bx, by, blocksX, blocksY, claimedBlocks, dirtPathGrid, canvas.width)) {
|
||
continue
|
||
}
|
||
const chosenPatch = pickVariant()
|
||
stampSubstitutionPatch(canvas, chosenPatch, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK)
|
||
recordClaim(bx, by, blocksX, blocksY)
|
||
placed = true
|
||
}
|
||
if (placed) stamped += 1
|
||
}
|
||
return stamped
|
||
}
|
||
|
||
for (let cluster = 0; cluster < max; cluster += 1) {
|
||
let placed = false
|
||
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
|
||
}
|
||
const chosenPatch = pickVariant()
|
||
stampSubstitutionPatch(canvas, chosenPatch, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK)
|
||
recordClaim(bx, by, blocksX, blocksY)
|
||
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` (`0..4`).
|
||
* @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.
|
||
*/
|
||
export function applySubstitutions(
|
||
canvas: Canvas,
|
||
rows: readonly WildernessSubstitution[],
|
||
themeIndex: number,
|
||
gridWidth: number,
|
||
gridHeight: number,
|
||
rng: Rng,
|
||
stats: WildernessStats,
|
||
claimedBlocks?: boolean[][],
|
||
dirtPathGrid?: Uint8Array,
|
||
): number {
|
||
let total = 0
|
||
let sharedBudget = 1
|
||
const clampedTheme = Math.max(0, Math.min(4, Math.floor(themeIndex)))
|
||
for (const row of rows) {
|
||
const role = classifySubstitutionRole(row.name, row.type)
|
||
const chance = row.prob[clampedTheme] ?? 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[clampedTheme] ?? 0))
|
||
const wanted = Math.min(rowMax, allowance)
|
||
if (wanted > 0) {
|
||
const single = applySubstitution(canvas, row, clampedTheme, gridWidth, gridHeight, rng, claimedBlocks, dirtPathGrid, wanted)
|
||
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 BLOOD_MOOR_RIVER_PRESETS: readonly string[] = [
|
||
'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',
|
||
]
|
||
|
||
export const BLOOD_MOOR_POND_PRESETS: readonly string[] = [
|
||
'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',
|
||
]
|
||
|
||
/**
|
||
* Special preset piece lists stamped per level ID.
|
||
*
|
||
* Traceability to Blizzard DRLG outdoor logic:
|
||
* - Act 2 Desert levels (41-46):
|
||
* Corresponds to D2MOO `DRLGOUTDESR_AddExits` (0x6FD7D9F0) for level exits:
|
||
* - Level 41 (Rocky Waste): Stony Tomb (`Act 2 - Desert Tomb 1`)
|
||
* - Level 42 (Dry Hills): Halls of the Dead (`Act 2 - Desert Tomb 1`)
|
||
* - Level 43 (Far Oasis): Maggot Lair (`Act 2 - Desert Lair 1`, plus `DRLGOUTDESR_PlaceFillsInFarOasis`)
|
||
* - Level 44 (Lost City): Ancient Tunnels (`Act 2 - Desert Ruins Sewer`, plus `DRLGOUTDESR_PlaceRuinsInLostCity` Elder / 16x16 / 16x08 / 08x16)
|
||
* - Level 45 (Valley of Snakes): Claw Viper Temple (`Act 2 - Desert Tomb 2`)
|
||
* - Level 46 (Canyon of the Magi): Tal Rasha Tombs & Arcane Sanctuary warp (`DRLGOUTDESR_PlaceTombEntriesInCanyon`)
|
||
* - Act 3 Kurast levels (79-81):
|
||
* Corresponds to D2MOO `DRLGOUTJUNG_SpawnRandomPreset` (0x6FD80230) and Kurast interior presets
|
||
* called within `DRLGOUTJUNG_BuildLowerKurast` (0x6FD7FE50), `DRLGOUTJUNG_BuildKurastBazaar` (0x6FD7FFA0),
|
||
* and `DRLGOUTJUNG_BuildUpperKurast` (0x6FD800E0).
|
||
*/
|
||
export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]>> = {
|
||
2: BLOOD_MOOR_RIVER_PRESETS,
|
||
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, placedRects)
|
||
}
|
||
|
||
/** 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>,
|
||
stats?: WildernessStats,
|
||
): void {
|
||
const paintedRoadCells = new Set<string>()
|
||
|
||
const isInsideProtectedPreset = (x: number, y: number): boolean => {
|
||
for (const r of placedRects) {
|
||
if (theme.isProtectedPreset ? !theme.isProtectedPreset(r.name) : false) {
|
||
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
|
||
}
|
||
const belongsToPreset = placedRects.some(r => x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h)
|
||
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) {
|
||
if (belongsToPreset) {
|
||
return
|
||
}
|
||
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)
|
||
paintedRoadCells.add(`${x},${y}`)
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
const paintSplinePath = (
|
||
controlPoints: readonly { readonly x: number; readonly y: number }[],
|
||
brushRadius: number,
|
||
clearStrayClutter: boolean,
|
||
): void => {
|
||
if (controlPoints.length < 2) return
|
||
|
||
for (let i = 0; i < controlPoints.length - 1; i += 1) {
|
||
const p0 = i > 0 ? controlPoints[i - 1]! : {
|
||
x: 2 * controlPoints[0]!.x - controlPoints[1]!.x,
|
||
y: 2 * controlPoints[0]!.y - controlPoints[1]!.y,
|
||
}
|
||
const p1 = controlPoints[i]!
|
||
const p2 = controlPoints[i + 1]!
|
||
const p3 = i + 2 < controlPoints.length ? controlPoints[i + 2]! : {
|
||
x: 2 * controlPoints[controlPoints.length - 1]!.x - controlPoints[controlPoints.length - 2]!.x,
|
||
y: 2 * controlPoints[controlPoints.length - 1]!.y - controlPoints[controlPoints.length - 2]!.y,
|
||
}
|
||
|
||
const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y)
|
||
const steps = Math.max(3, Math.ceil(dist / 1.0))
|
||
for (let s = 0; s <= steps; s += 1) {
|
||
const t = s / steps
|
||
const pt = catmullRomPoint(p0, p1, p2, p3, t)
|
||
const cx = Math.round(pt.x)
|
||
const cy = Math.round(pt.y)
|
||
|
||
if (brushRadius >= 1.5) {
|
||
for (let dy = -1; dy <= 1; dy += 1) {
|
||
for (let dx = -1; dx <= 1; dx += 1) {
|
||
paintRoadTile(cx + dx, cy + dy, clearStrayClutter)
|
||
}
|
||
}
|
||
} else {
|
||
for (let dy = 0; dy <= 1; dy += 1) {
|
||
for (let dx = 0; dx <= 1; dx += 1) {
|
||
paintRoadTile(cx + dx, cy + dy, clearStrayClutter)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 1. 3-tile-wide South-to-North main highway curved via Catmull-Rom spline
|
||
const midNY = Math.round((northEnd + hubY) / 2)
|
||
const mNorth = rng.int(-2, 2)
|
||
const cpNorth = { x: hubX + mNorth, y: midNY }
|
||
if (isInsideProtectedPreset(cpNorth.x, cpNorth.y)) cpNorth.x = hubX
|
||
|
||
const midSY = Math.round((hubY + southGateY) / 2)
|
||
const mSouth = rng.int(-2, 2)
|
||
const cpSouth = { x: hubX + mSouth, y: midSY }
|
||
if (isInsideProtectedPreset(cpSouth.x, cpSouth.y)) cpSouth.x = hubX
|
||
|
||
paintSplinePath([
|
||
{ x: hubX, y: northEnd },
|
||
cpNorth,
|
||
{ x: hubX, y: hubY },
|
||
cpSouth,
|
||
{ x: hubX, y: southGateY },
|
||
], 1.5, true)
|
||
|
||
// 2. 3-tile-wide West-to-East main highway curved via Catmull-Rom spline
|
||
const midWX = Math.round((westEnd + hubX) / 2)
|
||
const mWest = rng.int(-2, 2)
|
||
const cpWest = { x: midWX, y: hubY + mWest }
|
||
if (isInsideProtectedPreset(cpWest.x, cpWest.y)) cpWest.y = hubY
|
||
|
||
const midEX = Math.round((hubX + eastGateX) / 2)
|
||
const mEast = rng.int(-2, 2)
|
||
const cpEast = { x: midEX, y: hubY + mEast }
|
||
if (isInsideProtectedPreset(cpEast.x, cpEast.y)) cpEast.y = hubY
|
||
|
||
paintSplinePath([
|
||
{ x: westEnd, y: hubY },
|
||
cpWest,
|
||
{ x: hubX, y: hubY },
|
||
cpEast,
|
||
{ x: eastGateX, y: hubY },
|
||
], 1.5, true)
|
||
|
||
// If openGates contains non-standard gate blocks, connect them into the highway network via smooth spline
|
||
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 hub
|
||
const midX = Math.round((gateX + hubX) / 2) + rng.int(-1, 1)
|
||
const midY = Math.round((gateY + hubY) / 2) + rng.int(-1, 1)
|
||
paintSplinePath([
|
||
{ x: gateX, y: 4 },
|
||
{ x: midX, y: midY },
|
||
{ x: hubX, y: hubY },
|
||
], 1.5, true)
|
||
} else if (bx === 0 && Math.abs(gateY - hubY) > 2) {
|
||
// West gate branch to hub
|
||
const midX = Math.round((gateX + hubX) / 2) + rng.int(-1, 1)
|
||
const midY = Math.round((gateY + hubY) / 2) + rng.int(-1, 1)
|
||
paintSplinePath([
|
||
{ x: 4, y: gateY },
|
||
{ x: midX, y: midY },
|
||
{ x: hubX, y: hubY },
|
||
], 1.5, true)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 2-tile-wide spur roads connecting each major spawned landmark to the main highway via smooth spline
|
||
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)
|
||
let doorX: number
|
||
let doorY: number
|
||
let targetX: number
|
||
let targetY: number
|
||
if (distToNS <= distToEW) {
|
||
doorX = centerX < hubX ? r.x + r.w : r.x - 1
|
||
doorY = Math.max(r.y + 2, Math.min(r.y + r.h - 3, centerY))
|
||
targetX = hubX
|
||
targetY = doorY
|
||
} else {
|
||
doorX = Math.max(r.x + 2, Math.min(r.x + r.w - 3, centerX))
|
||
doorY = centerY < hubY ? r.y + r.h : r.y - 1
|
||
targetX = doorX
|
||
targetY = hubY
|
||
}
|
||
|
||
const midX = Math.round((doorX + targetX) / 2) + rng.int(-1, 1)
|
||
const midY = Math.round((doorY + targetY) / 2) + rng.int(-1, 1)
|
||
paintSplinePath([
|
||
{ x: doorX, y: doorY },
|
||
{ x: midX, y: midY },
|
||
{ x: targetX, y: targetY },
|
||
], 1.0, false)
|
||
}
|
||
|
||
if (stats) {
|
||
stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + paintedRoadCells.size
|
||
}
|
||
}
|
||
|
||
/**
|
||
* General outdoor divider network.
|
||
* @deprecated Procedural cross divider networks were removed in Issue #65 to restore
|
||
* Blizzard DRLG fidelity. In original Diablo II, wilderness barriers are formed naturally
|
||
* by cliff borders, water bodies, and LvlSub substitutions without artificial cross stone walls.
|
||
*/
|
||
export function stampThemedDividers(
|
||
_canvas: Canvas,
|
||
_gridWidth: number,
|
||
_gridHeight: number,
|
||
_inX: number,
|
||
_outY: number,
|
||
_placedRects: readonly PlacedPresetRect[],
|
||
_rng: Rng,
|
||
_theme: OutdoorDividerTheme,
|
||
stats?: WildernessStats,
|
||
): void {
|
||
if (stats) {
|
||
stats.proceduralDividerCells = 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>,
|
||
stats?: WildernessStats,
|
||
): void {
|
||
stampThemedRoads(
|
||
canvas,
|
||
gridWidth,
|
||
gridHeight,
|
||
inX,
|
||
outY,
|
||
placedRects,
|
||
rng,
|
||
{
|
||
roadFloor: (r) => ({
|
||
prop1: 194,
|
||
sequence: r.int(0, 5) === 0 ? 1 : 0,
|
||
style: 0,
|
||
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,
|
||
stats,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
stats?: WildernessStats,
|
||
): void {
|
||
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
|
||
wallStyle: 2,
|
||
wallProp1: 129,
|
||
roadStyles: [0],
|
||
}, stats)
|
||
}
|
||
|
||
/**
|
||
* 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>,
|
||
stats?: WildernessStats,
|
||
): 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) => name !== 'Act 1 - River',
|
||
isIgnoredSpurPreset: (name) => /Fill|Berms/i.test(name),
|
||
},
|
||
openGates,
|
||
stats,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
stats?: WildernessStats,
|
||
): void {
|
||
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
|
||
wallStyle: 1,
|
||
wallProp1: 129,
|
||
roadStyles: [1],
|
||
}, stats)
|
||
}
|
||
|
||
/**
|
||
* 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>,
|
||
stats?: WildernessStats,
|
||
): 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) => name !== 'Act 1 - River',
|
||
isIgnoredSpurPreset: (name) => /Fill/i.test(name),
|
||
},
|
||
openGates,
|
||
stats,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
stats?: WildernessStats,
|
||
): void {
|
||
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
|
||
wallStyle: 1,
|
||
wallProp1: 129,
|
||
roadStyles: [1],
|
||
}, stats)
|
||
}
|
||
|
||
/**
|
||
* 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>,
|
||
stats?: WildernessStats,
|
||
): 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) => name !== 'Act 1 - River',
|
||
isIgnoredSpurPreset: (name) => /Fill|Filler/i.test(name),
|
||
},
|
||
openGates,
|
||
stats,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
stats?: WildernessStats,
|
||
): void {
|
||
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
|
||
wallStyle: 2,
|
||
wallProp1: 129,
|
||
roadStyles: [1],
|
||
}, stats)
|
||
}
|
||
|
||
/**
|
||
* 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>,
|
||
levelsTable?: D2Table,
|
||
stats?: WildernessStats,
|
||
): 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, stats)
|
||
return
|
||
} else if (levelId >= 79 && levelId <= 82) {
|
||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
|
||
return
|
||
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
|
||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
|
||
return
|
||
} else if (levelId >= 2 && levelId <= 7) {
|
||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
|
||
return
|
||
// Level type name takes second precedence
|
||
} else if (levelTypeName === 'Act 2 - Desert') {
|
||
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
|
||
return
|
||
} else if (levelTypeName === 'Act 3 - Kurast') {
|
||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
|
||
return
|
||
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
|
||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
|
||
return
|
||
} else if (levelTypeName === 'Act 1 - Wilderness') {
|
||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
|
||
return
|
||
}
|
||
|
||
// Unified actOfLevel fallback
|
||
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||
if (effectiveAct === 2) {
|
||
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
|
||
} else if (effectiveAct === 3) {
|
||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
|
||
} else if (effectiveAct === 5) {
|
||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
|
||
} else if (effectiveAct === 1) {
|
||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
levelsTable?: D2Table,
|
||
stats?: WildernessStats,
|
||
): 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, stats)
|
||
return
|
||
} else if (levelId >= 79 && levelId <= 82) {
|
||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
|
||
return
|
||
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
|
||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
|
||
return
|
||
} else if (levelId >= 2 && levelId <= 7) {
|
||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
|
||
return
|
||
}
|
||
|
||
// Level type name takes second precedence
|
||
if (levelTypeName === 'Act 2 - Desert') {
|
||
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
|
||
return
|
||
} else if (levelTypeName === 'Act 3 - Kurast') {
|
||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
|
||
return
|
||
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
|
||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
|
||
return
|
||
} else if (levelTypeName === 'Act 1 - Wilderness') {
|
||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
|
||
return
|
||
}
|
||
|
||
// Unified actOfLevel fallback
|
||
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||
if (effectiveAct === 2) {
|
||
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
|
||
} else if (effectiveAct === 3) {
|
||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
|
||
} else if (effectiveAct === 5) {
|
||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
|
||
} else if (effectiveAct === 1) {
|
||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Outdoor Population Phase: Waypoints, Roadside Shrines/Chests, SuperUniques & Flavie
|
||
* (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`)
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
export interface ActPopulationConfig {
|
||
readonly waypointId: number
|
||
readonly shrinePool: readonly number[]
|
||
readonly wellId: number
|
||
readonly chestPool: readonly number[]
|
||
readonly scatterPool: readonly number[]
|
||
}
|
||
|
||
/**
|
||
* Population object ID pool per Act for outdoor wilderness generation (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`).
|
||
*
|
||
* Each ID is a local DS1 Type 2 object record ID mapped through `object-lookup-data.ts` to Blizzard's `Objects.txt`:
|
||
* - `waypointId`:
|
||
* - Act 1: 37 -> Objects.txt row 119 (`Waypoint`, token WP)
|
||
* - Act 2: 10 -> Objects.txt row 156 (`Waypoint`, token WM)
|
||
* - Act 3: 1 -> Objects.txt row 237 (`Waypoint`, token WZ)
|
||
* - Act 4: 0 -> Objects.txt row 238 (`Waypoint`, token WV)
|
||
* - Act 5: 12 -> Objects.txt row 38 (`Waypoint`, token WT)
|
||
* - `wellId`:
|
||
* - Act 1: 31 -> Objects.txt row 84 (Healing Well, token HW)
|
||
* - Acts 2..5: 234 -> Objects.txt row 234 (Outdoor Well, token QY)
|
||
* - `shrinePool`:
|
||
* - Act 1: 29 (Shrine SF), 82 (Shrine SH), 83 (Shrine BC), 84 (Dummy SG)
|
||
* - Act 2: 54 (Z1), 55 (Z2), 103 (FL), 104 (A3), 105 (A4), 126 (IS)
|
||
* - Act 3: 14 (S1), 85 (BC), 111 (F3), 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO)
|
||
* - Act 4: 56 (Z5), 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO)
|
||
* - Act 5: 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO)
|
||
* - `chestPool`:
|
||
* - Act 1: 5 (chest L1), 89..94 (caskets C1..C6)
|
||
* - Act 2: 32 (CL), 33 (A1), 44..46 (R5, S1, CT)
|
||
* - Act 3: 65 (X1), 66 (X2), 67 (1R), 80 (OB), 81 (AF)
|
||
* - Act 4: 19 (XQ), 20 (QS), 24 (HO), 25 (HN)
|
||
* - Act 5: 24 (YP), 25 (6A), 26 (YZ), 27 (6Q), 28 (6B), 29 (6C)
|
||
* - `scatterPool`:
|
||
* - Act 1: 17 (BR), 18 (BF), 25 (TT)
|
||
* - Act 2: 14 (TT), 48 (BR), 122 (TT)
|
||
* - Act 3: 4 (FB), 5 (TO), 48 (DE), 179 (BR)
|
||
* - Act 4: 40 (HG), 63 (FB), 179 (BR)
|
||
* - Act 5: 13 (FB), 179 (BR), 4 (B6)
|
||
*/
|
||
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],
|
||
},
|
||
}
|
||
|
||
/**
|
||
* Validates outdoor population configuration against object-lookup metadata or an optional loaded Objects.txt table.
|
||
*/
|
||
export function validateActPopulationConfig(
|
||
config: ActPopulationConfig,
|
||
act: number,
|
||
objectsTable?: ObjectsTable | D2Table,
|
||
): { valid: boolean; totalChecked: number; unmapped: number[]; errors: string[] } {
|
||
const unmapped: number[] = []
|
||
let totalChecked = 0
|
||
const check = (id: number) => {
|
||
totalChecked += 1
|
||
const entry = lookupObject(act, 2, id)
|
||
if (!entry) {
|
||
unmapped.push(id)
|
||
} else if (objectsTable) {
|
||
if ('byId' in objectsTable) {
|
||
if (entry.objectsTxtId >= 0 && !objectsTable.byId.has(entry.objectsTxtId)) {
|
||
unmapped.push(id)
|
||
}
|
||
} else if ('header' in objectsTable && 'rows' in objectsTable) {
|
||
const idCol = objectsTable.header.indexOf('Id')
|
||
const found = idCol !== -1 && objectsTable.rows.some(row => Number(row[idCol]) === entry.objectsTxtId)
|
||
if (entry.objectsTxtId >= 0 && !found) {
|
||
unmapped.push(id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
check(config.waypointId)
|
||
check(config.wellId)
|
||
for (const id of config.shrinePool) check(id)
|
||
for (const id of config.chestPool) check(id)
|
||
for (const id of config.scatterPool) check(id)
|
||
|
||
const errors = unmapped.map(id => `DS1 Type 2 object ID ${id} is unmapped or missing in Objects.txt`)
|
||
return { valid: unmapped.length === 0, totalChecked, unmapped, errors }
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
function validateWaypointLandingBuffer(
|
||
canvas: Canvas,
|
||
tx: number,
|
||
ty: number,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
padding = 2,
|
||
): boolean {
|
||
if (tx < padding || ty < padding || tx >= canvas.width - padding || ty >= canvas.height - padding) {
|
||
return false
|
||
}
|
||
|
||
for (const r of placedRects) {
|
||
const isOpenPreset = /(clearing|camp|bivouac)/i.test(r.name)
|
||
if (!isOpenPreset) {
|
||
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 (!cell.floors.some(f => !f.hidden && f.prop1 !== 0)) return false
|
||
}
|
||
}
|
||
|
||
for (const obj of canvas.objects) {
|
||
const ox = Math.floor(obj.x / SUB_TILES_PER_TILE)
|
||
const oy = Math.floor(obj.y / SUB_TILES_PER_TILE)
|
||
if (Math.abs(ox - tx) <= padding && Math.abs(oy - ty) <= padding) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 1.13c DRLGOUTDOORS cell validity mask for waypoint spawning (D2Common.dll ≈0x6FD92700).
|
||
* Cell flags tested against 0x1B81: (flags & 0x1B81) === 0 indicates cell is valid.
|
||
*/
|
||
export const DRLG_GRID_SPAWN_INVALID_MASK = 0x1b81
|
||
|
||
/** Flag set on pGrid[1] when a general outdoor waypoint is placed (0x10000). */
|
||
export const DRLG_GRID_FLAG_WAYPOINT = 0x10000
|
||
|
||
/** Flag set on pGrid[1] when Cold Plains special waypoint is placed (0x20000). */
|
||
export const DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS = 0x20000
|
||
|
||
/**
|
||
* Rolls a limited random number in [0, max - 1] matching 1.13c SEED_RollLimitedRandomNumber.
|
||
* Each call consumes exactly 1 RNG roll.
|
||
*/
|
||
export function rollLimited(rng: Rng, max: number): number {
|
||
if (max <= 0) return 0
|
||
return Math.floor(rng.next() * max)
|
||
}
|
||
|
||
/**
|
||
* Tests if an 8x8 cell grid block is valid for spawning an outdoor waypoint
|
||
* matching 1.13c DRLGOUTDOORS inline test (D2Common.dll ≈0x6FD92700):
|
||
* `test [grid], 0x1b81` => `(flags & 0x1B81) === 0`.
|
||
*
|
||
* @param grid - Grid containing per-cell 32-bit flags (2D number array, 1D array/Uint32Array, or grid object).
|
||
* @param x - Grid block X coordinate (1..nGridWidth-2).
|
||
* @param y - Grid block Y coordinate (1..nGridHeight-2).
|
||
* @param width - Optional width for 1D grid layouts.
|
||
* @returns true if valid for spawn ((flags & 0x1B81) === 0), false otherwise.
|
||
*/
|
||
export function TestGridCellSpawnValid(
|
||
grid: { width?: number; height?: number; flags?: Uint32Array | readonly number[] | readonly (readonly number[])[] } | readonly (readonly number[])[] | Uint32Array | readonly number[],
|
||
x: number,
|
||
y: number,
|
||
width?: number,
|
||
): boolean {
|
||
let flags = 0
|
||
if (Array.isArray(grid)) {
|
||
if (grid.length > 0 && Array.isArray(grid[0])) {
|
||
const row = (grid as readonly (readonly number[])[])[y]
|
||
if (!row || x < 0 || x >= row.length) return false
|
||
flags = row[x] ?? 0
|
||
} else {
|
||
const w = width ?? 1
|
||
if (x < 0 || y < 0) return false
|
||
const idx = y * w + x
|
||
flags = (grid as readonly number[])[idx] ?? 0
|
||
}
|
||
} else if (grid instanceof Uint32Array) {
|
||
const w = width ?? 1
|
||
if (x < 0 || y < 0) return false
|
||
const idx = y * w + x
|
||
flags = grid[idx] ?? 0
|
||
} else if (grid && typeof grid === 'object' && ('flags' in grid || 'width' in grid)) {
|
||
const g = grid as { width?: number; height?: number; flags?: Uint32Array | readonly number[] | readonly (readonly number[])[] }
|
||
const w = g.width ?? width ?? 1
|
||
const gFlags = g.flags
|
||
if (Array.isArray(gFlags)) {
|
||
if (gFlags.length > 0 && Array.isArray(gFlags[0])) {
|
||
const row = (gFlags as readonly (readonly number[])[])[y]
|
||
if (!row || x < 0 || x >= row.length) return false
|
||
flags = row[x] ?? 0
|
||
} else {
|
||
if (x < 0 || y < 0) return false
|
||
flags = (gFlags as readonly number[])[y * w + x] ?? 0
|
||
}
|
||
} else if (gFlags instanceof Uint32Array) {
|
||
if (x < 0 || y < 0) return false
|
||
flags = gFlags[y * w + x] ?? 0
|
||
}
|
||
}
|
||
|
||
// 1.13c inline mask: test [grid], 0x1b81 => (flags & 0x1B81) === 0
|
||
return (flags & DRLG_GRID_SPAWN_INVALID_MASK) === 0
|
||
}
|
||
|
||
/**
|
||
* Automatically locate and place an outdoor Waypoint (`DRLGOUTDOORS_SpawnAct12Waypoint`)
|
||
* matching 1.13c disassembly (PE timestamp 0x4B95C439, D2Common.dll ≈0x6FD92700).
|
||
*
|
||
* Priority 1: Check if a preset already placed a Waypoint object.
|
||
*
|
||
* Branch A (Cold Plains special, levelId == 3, LEVEL_COLDPLAINS):
|
||
* - Find connection to LEVEL_BLOODMOOR (levelId 2).
|
||
* - Find the boundary grid cell linking to Blood Moor.
|
||
* - Boundary clamp:
|
||
* if (j == 0) j = 1;
|
||
* if (i == 0) i = 1;
|
||
* if (j == nGridWidth - 1) j -= 1;
|
||
* if (i == nGridHeight - 1) i -= 1;
|
||
* - Set flag 0x20000 on pGrid[1].
|
||
* - Return immediately, consuming exactly 0 RNG rolls.
|
||
*
|
||
* Branch B (General path):
|
||
* - nWidth = nGridWidth - 2; nArea = nWidth * (nGridHeight - 2);
|
||
* - Build coordinate array: pCoord[i] = (i % nWidth, Math.floor(i / nWidth))
|
||
* - Shuffle: exactly nArea rounds, each round 2 rolls:
|
||
* r1 = rollLimited(rng, nArea)
|
||
* r2 = rollLimited(rng, nArea)
|
||
* swap(pCoord[r1], pCoord[r2])
|
||
* (Notice: this is NOT Fisher-Yates, it is Blizzard's 2*nArea swap shuffle!
|
||
* Do not change it to Fisher-Yates, comment that it's intentional for 1.13c parity.)
|
||
* - Iterate through shuffled pCoord:
|
||
* nX = pCoord[i].x + 1; nY = pCoord[i].y + 1;
|
||
* if (TestGridCellSpawnValid(grid, nX, nY)):
|
||
* Place waypoint here (flag 0x10000);
|
||
* break;
|
||
*/
|
||
export function spawnOutdoorWaypoint(
|
||
canvas: Canvas,
|
||
levelId: number,
|
||
act: number,
|
||
dirtPathGrid: Uint8Array | undefined,
|
||
placedRects: readonly PlacedPresetRect[],
|
||
rng: Rng,
|
||
stats: WildernessStats,
|
||
levelTypeName?: string,
|
||
levelsTable?: D2Table,
|
||
seamConstraints?: readonly SeamConstraint[],
|
||
): void {
|
||
if (!hasOutdoorWaypoint(levelId, levelsTable)) return
|
||
|
||
// Priority 1: 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, levelsTable)
|
||
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
|
||
const wpId = config.waypointId
|
||
|
||
const nGridWidth = Math.floor(canvas.width / TILES_PER_BLOCK)
|
||
const nGridHeight = Math.floor(canvas.height / TILES_PER_BLOCK)
|
||
if (nGridWidth < 3 || nGridHeight < 3) return
|
||
|
||
// Allocate 8x8 cell grid (pGrid[1] flags)
|
||
const grid: number[][] = Array.from({ length: nGridHeight }, () => new Array(nGridWidth).fill(0))
|
||
|
||
// Mark outer perimeter border blocks with 0x0080 (border flag, part of 0x1B81)
|
||
for (let x = 0; x < nGridWidth; x += 1) {
|
||
grid[0]![x] |= 0x0080
|
||
grid[nGridHeight - 1]![x] |= 0x0080
|
||
}
|
||
for (let y = 0; y < nGridHeight; y += 1) {
|
||
grid[y]![0] |= 0x0080
|
||
grid[y]![nGridWidth - 1] |= 0x0080
|
||
}
|
||
|
||
// Mark blocks overlapping placed preset rectangles with 0x0001 (occupied by preset)
|
||
for (const rect of placedRects) {
|
||
const bx0 = Math.max(0, Math.floor(rect.x / TILES_PER_BLOCK))
|
||
const by0 = Math.max(0, Math.floor(rect.y / TILES_PER_BLOCK))
|
||
const bx1 = Math.min(nGridWidth - 1, Math.floor((rect.x + rect.w - 1) / TILES_PER_BLOCK))
|
||
const by1 = Math.min(nGridHeight - 1, Math.floor((rect.y + rect.h - 1) / TILES_PER_BLOCK))
|
||
for (let by = by0; by <= by1; by += 1) {
|
||
for (let bx = bx0; bx <= bx1; bx += 1) {
|
||
grid[by]![bx] |= 0x0001
|
||
}
|
||
}
|
||
}
|
||
|
||
// Mark blocks with walls or missing floors as 0x1000 (impassable/collision)
|
||
for (let by = 1; by < nGridHeight - 1; by += 1) {
|
||
for (let bx = 1; bx < nGridWidth - 1; bx += 1) {
|
||
let blocked = false
|
||
for (let dy = 0; dy < TILES_PER_BLOCK; dy += 1) {
|
||
const ty = by * TILES_PER_BLOCK + dy
|
||
const row = canvas.cells[ty]
|
||
if (!row) {
|
||
blocked = true
|
||
break
|
||
}
|
||
for (let dx = 0; dx < TILES_PER_BLOCK; dx += 1) {
|
||
const tx = bx * TILES_PER_BLOCK + dx
|
||
const cell = row[tx]
|
||
if (!cell) {
|
||
blocked = true
|
||
break
|
||
}
|
||
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) {
|
||
blocked = true
|
||
break
|
||
}
|
||
if (!cell.floors.some(f => !f.hidden && f.prop1 !== 0)) {
|
||
blocked = true
|
||
break
|
||
}
|
||
}
|
||
if (blocked) break
|
||
}
|
||
if (blocked) {
|
||
grid[by]![bx] |= 0x1000
|
||
}
|
||
}
|
||
}
|
||
|
||
// Mark blocks containing pre-existing objects as 0x0001
|
||
for (const obj of canvas.objects) {
|
||
const obx = Math.floor(obj.x / (SUB_TILES_PER_TILE * TILES_PER_BLOCK))
|
||
const oby = Math.floor(obj.y / (SUB_TILES_PER_TILE * TILES_PER_BLOCK))
|
||
if (oby >= 0 && oby < nGridHeight && obx >= 0 && obx < nGridWidth) {
|
||
grid[oby]![obx] |= 0x0001
|
||
}
|
||
}
|
||
|
||
// Branch A: Cold Plains special (levelId == 3, LEVEL_COLDPLAINS)
|
||
if (levelId === 3) {
|
||
// 1. Find connection to LEVEL_BLOODMOOR (levelId 2).
|
||
// 2. Find the boundary grid cell linking to Blood Moor.
|
||
let j = 0
|
||
let i = Math.floor(nGridHeight / 2)
|
||
|
||
const gate = stats.gateOpenings?.find(g => g.toLevelId === 2)
|
||
if (gate !== undefined) {
|
||
if (gate.side === 'west') {
|
||
j = 0
|
||
i = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2))
|
||
} else if (gate.side === 'east') {
|
||
j = nGridWidth - 1
|
||
i = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2))
|
||
} else if (gate.side === 'north') {
|
||
i = 0
|
||
j = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2))
|
||
} else if (gate.side === 'south') {
|
||
i = nGridHeight - 1
|
||
j = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2))
|
||
}
|
||
} else {
|
||
const sc = seamConstraints?.find(c => c.toLevelId === 2 || c.fromLevelId === 2)
|
||
if (sc !== undefined) {
|
||
if (sc.side === 'west') {
|
||
j = 0
|
||
i = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2))
|
||
} else if (sc.side === 'east') {
|
||
j = nGridWidth - 1
|
||
i = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2))
|
||
} else if (sc.side === 'north') {
|
||
i = 0
|
||
j = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2))
|
||
} else if (sc.side === 'south') {
|
||
i = nGridHeight - 1
|
||
j = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Boundary clamp:
|
||
if (j === 0) j = 1
|
||
if (i === 0) i = 1
|
||
if (j === nGridWidth - 1) j -= 1
|
||
if (i === nGridHeight - 1) i -= 1
|
||
|
||
j = Math.max(1, Math.min(nGridWidth - 2, j))
|
||
i = Math.max(1, Math.min(nGridHeight - 2, i))
|
||
|
||
// 4. Set flag 0x20000 on pGrid[1]
|
||
grid[i]![j] |= DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS
|
||
|
||
let chosenX = j * TILES_PER_BLOCK + 4
|
||
let chosenY = i * TILES_PER_BLOCK + 4
|
||
if (!validateWaypointLandingBuffer(canvas, chosenX, chosenY, placedRects, 2)) {
|
||
let found = false
|
||
for (let dy = 2; dy <= 5; dy += 1) {
|
||
for (let dx = 2; dx <= 5; dx += 1) {
|
||
const tx = j * TILES_PER_BLOCK + dx
|
||
const ty = i * TILES_PER_BLOCK + dy
|
||
if (validateWaypointLandingBuffer(canvas, tx, ty, placedRects, 1)) {
|
||
chosenX = tx
|
||
chosenY = ty
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if (found) break
|
||
}
|
||
}
|
||
|
||
canvas.objects.push({
|
||
type: 2,
|
||
id: wpId,
|
||
x: chosenX * SUB_TILES_PER_TILE + 2,
|
||
y: chosenY * SUB_TILES_PER_TILE + 2,
|
||
flags: 0,
|
||
})
|
||
stats.waypointsSpawned = 1
|
||
stats.waypointTile = { x: chosenX, y: chosenY }
|
||
stats.notes.push(
|
||
`spawned outdoor waypoint (id=${String(wpId)}) at tile (${String(chosenX)}, ${String(chosenY)}) [Cold Plains special grid cell (${String(j)}, ${String(i)})]`,
|
||
)
|
||
// 5. Return immediately! This consumes 0 RNG rolls.
|
||
return
|
||
}
|
||
|
||
// Branch B: General path
|
||
const nWidth = nGridWidth - 2
|
||
const nHeight = nGridHeight - 2
|
||
const nArea = nWidth * nHeight
|
||
if (nArea <= 0) return
|
||
|
||
// Build coordinate array: for i in 0..nArea-1: pCoord[i] = (i % nWidth, Math.floor(i / nWidth))
|
||
const pCoord: { x: number; y: number }[] = []
|
||
for (let i = 0; i < nArea; i += 1) {
|
||
pCoord.push({
|
||
x: i % nWidth,
|
||
y: Math.floor(i / nWidth),
|
||
})
|
||
}
|
||
|
||
// Shuffle: exactly nArea rounds, each round 2 rolls:
|
||
// r1 = rollLimited(rng, nArea)
|
||
// r2 = rollLimited(rng, nArea)
|
||
// swap(pCoord[r1], pCoord[r2])
|
||
// (Notice: this is NOT Fisher-Yates, it is Blizzard's 2*nArea swap shuffle!
|
||
// Do not change it to Fisher-Yates, comment that it's intentional for 1.13c parity.)
|
||
for (let round = 0; round < nArea; round += 1) {
|
||
const r1 = rollLimited(rng, nArea)
|
||
const r2 = rollLimited(rng, nArea)
|
||
const temp = pCoord[r1]!
|
||
pCoord[r1] = pCoord[r2]!
|
||
pCoord[r2] = temp
|
||
}
|
||
|
||
// Iterate through shuffled pCoord:
|
||
// nX = pCoord[i].x + 1; nY = pCoord[i].y + 1;
|
||
// if (TestGridCellSpawnValid(grid, nX, nY)): // 1.13c inline mask: test [grid], 0x1b81 => (flags & 0x1B81) === 0
|
||
// Place waypoint here (flag 0x10000);
|
||
// break;
|
||
let placed = false
|
||
for (let i = 0; i < nArea; i += 1) {
|
||
const nX = pCoord[i]!.x + 1
|
||
const nY = pCoord[i]!.y + 1
|
||
if (TestGridCellSpawnValid(grid, nX, nY)) {
|
||
grid[nY]![nX] |= DRLG_GRID_FLAG_WAYPOINT
|
||
|
||
let chosenX = nX * TILES_PER_BLOCK + 4
|
||
let chosenY = nY * TILES_PER_BLOCK + 4
|
||
if (!validateWaypointLandingBuffer(canvas, chosenX, chosenY, placedRects, 2)) {
|
||
let found = false
|
||
for (let dy = 2; dy <= 5; dy += 1) {
|
||
for (let dx = 2; dx <= 5; dx += 1) {
|
||
const tx = nX * TILES_PER_BLOCK + dx
|
||
const ty = nY * TILES_PER_BLOCK + dy
|
||
if (validateWaypointLandingBuffer(canvas, tx, ty, placedRects, 1)) {
|
||
chosenX = tx
|
||
chosenY = ty
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if (found) break
|
||
}
|
||
}
|
||
|
||
canvas.objects.push({
|
||
type: 2,
|
||
id: wpId,
|
||
x: chosenX * SUB_TILES_PER_TILE + 2,
|
||
y: chosenY * SUB_TILES_PER_TILE + 2,
|
||
flags: 0,
|
||
})
|
||
stats.waypointsSpawned = 1
|
||
stats.waypointTile = { x: chosenX, y: chosenY }
|
||
stats.notes.push(
|
||
`spawned outdoor waypoint (id=${String(wpId)}) at tile (${String(chosenX)}, ${String(chosenY)}) [grid cell (${String(nX)}, ${String(nY)})]`,
|
||
)
|
||
placed = true
|
||
break
|
||
}
|
||
}
|
||
|
||
// Fallback if no cell passed TestGridCellSpawnValid
|
||
if (!placed && pCoord.length > 0) {
|
||
const fallbackCoord = pCoord[0]!
|
||
const nX = fallbackCoord.x + 1
|
||
const nY = fallbackCoord.y + 1
|
||
grid[nY]![nX] |= DRLG_GRID_FLAG_WAYPOINT
|
||
const chosenX = nX * TILES_PER_BLOCK + 4
|
||
const chosenY = nY * TILES_PER_BLOCK + 4
|
||
canvas.objects.push({
|
||
type: 2,
|
||
id: wpId,
|
||
x: chosenX * SUB_TILES_PER_TILE + 2,
|
||
y: chosenY * SUB_TILES_PER_TILE + 2,
|
||
flags: 0,
|
||
})
|
||
stats.waypointsSpawned = 1
|
||
stats.waypointTile = { x: chosenX, y: chosenY }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
levelsTable?: D2Table,
|
||
): void {
|
||
if (levelId <= 0 || levelId === 108 || levelId === 110) return
|
||
|
||
let 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 (levelsTable) {
|
||
const row = levelsTable.rows.find(candidate => Number(cell(levelsTable, candidate, 'Id')) === levelId)
|
||
if (row && cell(levelsTable, row, 'DrlgType') === '3') {
|
||
const isTown = [1, 40, 75, 103, 109].includes(levelId)
|
||
if (!isTown) isOutdoorLevel = true
|
||
}
|
||
}
|
||
|
||
if (!isOutdoorLevel) return
|
||
|
||
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||
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)
|
||
}
|
||
}
|
||
|
||
// Secondary exploration paths: avoid the main road and its immediate shoulder (within 3 tiles)
|
||
const secondaryCandidates = dirtPathGrid !== undefined
|
||
? candidates.filter(c => !isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, c.x, c.y, 0, 3))
|
||
: candidates
|
||
|
||
const pickShrineSpot = (candidateList: readonly { x: number; y: number }[], minDist: number): { x: number; y: number } | null => {
|
||
const indices = Array.from({ length: candidateList.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 = candidateList[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 along secondary exploration paths (>= 10 tiles, relaxing to >= 7 if constrained)
|
||
for (let i = 0; i < targetShrineCount; i += 1) {
|
||
const spot = (secondaryCandidates.length > 0
|
||
? (pickShrineSpot(secondaryCandidates, 10) ?? pickShrineSpot(secondaryCandidates, 7))
|
||
: null) ?? (pickShrineSpot(candidates, 10) ?? pickShrineSpot(candidates, 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 DRLGOUTDOORS_SpawnAct12Waypoint = spawnOutdoorWaypoint
|
||
export const spawnAct12Waypoint = spawnOutdoorWaypoint
|
||
export const DRLGOUTDOORS_SpawnAct12Shrines = spawnOutdoorShrines
|
||
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`)
|
||
const animSpeed = request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed
|
||
|
||
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: [],
|
||
gateOpenings: [],
|
||
proceduralRoadCells: 0,
|
||
proceduralDividerCells: 0,
|
||
proceduralWaterCells: 0,
|
||
proceduralWallCells: 0,
|
||
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: [],
|
||
proceduralRoadCells: 0, proceduralDividerCells: 0, proceduralWaterCells: 0, proceduralWallCells: 0,
|
||
gateOpenings: [],
|
||
}
|
||
|
||
let wallLayers = 1
|
||
let floorLayers = 1
|
||
let substitutionType = 0
|
||
let version = 0
|
||
const inferredAct = actOfLevel(request.levelId, request.levelTypeName, undefined, request.levelsTable)
|
||
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, specialPlacedRects)
|
||
} 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, request.levelsTable, request.seamConstraints)
|
||
spawnOutdoorShrines(canvas, request.levelId, act, undefined, [], populationRng, stats, request.shrineSubstitutions, 0, request.levelTypeName, request.levelsTable)
|
||
} else {
|
||
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, rng, stats)
|
||
}
|
||
spawnLandmarkEntities(
|
||
canvas,
|
||
request.levelId,
|
||
undefined,
|
||
specialPlacedRects,
|
||
gridWidth,
|
||
gridHeight,
|
||
stats,
|
||
landmarkDefs,
|
||
)
|
||
const groundTransitions = applyGroundTransitions(canvas, {
|
||
libraries: request.dt1Libraries,
|
||
dt1Mask: wildernessDt1Mask(request.levelTypeName),
|
||
levelTypeName: request.levelTypeName,
|
||
levelId: request.levelId,
|
||
rng,
|
||
placedRects: specialPlacedRects,
|
||
})
|
||
stats.groundTransitions = groundTransitions
|
||
|
||
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,
|
||
groundTransitions: stats.groundTransitions,
|
||
borderPiecesAvailable: 0,
|
||
borderBlocks: 0,
|
||
borderUsage: {},
|
||
roadCells: stats.roadCells,
|
||
roadSegments: stats.roadSegments,
|
||
anchors: stats.anchors,
|
||
entrances: stats.entrances,
|
||
specialPresets: stats.specialPresets,
|
||
proceduralRoadCells: stats.proceduralRoadCells ?? 0,
|
||
proceduralDividerCells: stats.proceduralDividerCells ?? 0,
|
||
proceduralWaterCells: stats.proceduralWaterCells ?? 0,
|
||
proceduralWallCells: stats.proceduralWallCells ?? 0,
|
||
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 || request.seamConstraints !== undefined) {
|
||
act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng, stats)
|
||
stats.anchors = act1Plan.anchors.length
|
||
stats.entrances = toEntrances(act1Plan.anchors)
|
||
if (act1Plan.gateOpenings !== undefined) {
|
||
stats.gateOpenings = act1Plan.gateOpenings
|
||
}
|
||
const vertexResult = createDrlgVertices(act1Plan, gridWidth, gridHeight, rng)
|
||
stats.drlgVertices = vertexResult.vertices
|
||
stats.roadPolylines = vertexResult.polylines
|
||
const polylines = vertexResult.polylines
|
||
if (polylines.length > 0) {
|
||
const roadRes = generateDirtPaths(canvas, polylines, act1Plan.roadObstacleBlocks)
|
||
dirtPathGrid = roadRes.dirtPathGrid
|
||
stats.roadCells = roadRes.roadCells
|
||
stats.roadSegments = roadRes.roadSegments
|
||
stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + roadRes.roadCells
|
||
}
|
||
}
|
||
|
||
// Step 4A: Lay directional border around perimeter (respecting open gates & Town Transitions)
|
||
DRLGOUTPLACE_PlaceAct1245OutdoorBorders(
|
||
canvas,
|
||
borderPieces,
|
||
gridWidth,
|
||
gridHeight,
|
||
rng,
|
||
stats,
|
||
request.levelId,
|
||
request.openGates ?? act1Plan?.openGates,
|
||
request.claimedBorderBlocks ?? act1Plan?.claimedBorderBlocks,
|
||
request.gates,
|
||
request.seamConstraints,
|
||
)
|
||
|
||
// 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 ? BLOOD_MOOR_RIVER_PRESETS : BLOOD_MOOR_POND_PRESETS
|
||
} 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,
|
||
request.levelsTable,
|
||
stats,
|
||
)
|
||
|
||
// 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 4D: Smooth boundaries between contrasting ground types (roads, water, shore)
|
||
// using DRLGROOMTILE auto-tiling bitmasks.
|
||
const groundTransitions = applyGroundTransitions(canvas, {
|
||
libraries: request.dt1Libraries,
|
||
dt1Mask: wildernessDt1Mask(request.levelTypeName),
|
||
levelTypeName: request.levelTypeName,
|
||
levelId: request.levelId,
|
||
rng,
|
||
placedRects: allPlacedRects,
|
||
})
|
||
stats.groundTransitions = groundTransitions
|
||
|
||
// 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, request.levelsTable, request.seamConstraints)
|
||
spawnOutdoorShrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, request.levelTypeName, request.levelsTable)
|
||
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,
|
||
groundTransitions: stats.groundTransitions,
|
||
borderPiecesAvailable: borderPieces.length,
|
||
borderBlocks: stats.borderStamped,
|
||
borderUsage: stats.borderPieces,
|
||
roadCells: stats.roadCells,
|
||
roadSegments: stats.roadSegments,
|
||
anchors: stats.anchors,
|
||
entrances: stats.entrances,
|
||
gateOpenings: stats.gateOpenings ?? act1Plan?.gateOpenings ?? [],
|
||
specialPresets: stats.specialPresets,
|
||
drlgVertices: stats.drlgVertices,
|
||
roadPolylines: stats.roadPolylines,
|
||
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,
|
||
proceduralRoadCells: stats.proceduralRoadCells ?? 0,
|
||
proceduralDividerCells: stats.proceduralDividerCells ?? 0,
|
||
proceduralWaterCells: stats.proceduralWaterCells ?? 0,
|
||
proceduralWallCells: stats.proceduralWallCells ?? 0,
|
||
cells: canvas.width * canvas.height,
|
||
unresolved: stats.unresolved,
|
||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
||
notes: stats.notes,
|
||
...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}),
|
||
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||
},
|
||
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||
}
|
||
}
|