827 lines
36 KiB
TypeScript
827 lines
36 KiB
TypeScript
/**
|
|
* Act I outdoor level maps from the DRLG port (M5 adapter).
|
|
*
|
|
* The 1:1 port of D2MOO's DRLG (src/game/drlg/drlg-*.ts) produces the level exactly as D2Common builds
|
|
* it: rooms, their tile lists (DRLGROOMTILE_AddRoomMapTiles), preset units and warps. This module turns
|
|
* one generated level into what the rest of the game consumes:
|
|
* - a DS1-shaped canvas ({@link Ds1}) for the isometric renderer (`buildIsoMapScene`), one cell per
|
|
* level tile, holding the exact (style, sequence, type) of every tile D2 placed;
|
|
* - the DT1 libraries those tiles come from, with a per-cell library mask so the renderer only draws
|
|
* variants from the library D2 picked the tile from;
|
|
* - DS1-shaped objects (the same (type, id) encoding a preset DS1 uses, so the bake resolves them
|
|
* through its existing object path);
|
|
* - the level's entrances (seams with their real sides from the DRLG level links, and warps from the
|
|
* UNIT_TILE preset units D2 creates for them) and SuperUnique landmarks.
|
|
*
|
|
* Nothing here decides anything about the layout: every value comes from the verified DRLG output
|
|
* (the schema-3 dump, identical for the native oracle and the port), and anything unexpected throws.
|
|
* The input is the normalized {@link DrlgLevelInput} so the same code maps the oracle's JSON and the
|
|
* port's in-memory dump.
|
|
*/
|
|
|
|
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../../formats/ds1.ts'
|
|
import type { Dt1 } from '../../formats/dt1.ts'
|
|
import { dt1MaskAllows } from '../d2map.ts'
|
|
import type { DrlgDump, DumpCoord, DumpLevel, DumpTile, DumpTileRef } from './drlg-dump.ts'
|
|
import { DRLG_GetActNoFromLevelId } from './drlg-drlg.ts'
|
|
import { DRLGPRESET_GetObjectIndexFromObjPreset } from './drlg-preset.ts'
|
|
import { normalizeDrlgPath } from './drlg-source.ts'
|
|
import { DATATBLS_GetLevelDefRecord, DATATBLS_GetLvlTypesTxtRecord, DATATBLS_GetMonPresetTxtActSection } from './drlg-tables.ts'
|
|
import type { DrlgTables } from './drlg-tables.ts'
|
|
import {
|
|
ALTDIR_EAST, ALTDIR_NORTH, ALTDIR_SOUTH, ALTDIR_WEST, DRLGTYPE_OUTDOOR, GRID2_LVL_LINK,
|
|
} from './drlg-types.ts'
|
|
|
|
/** Tiles per outdoor grid cell (the 8x8 blocks of D2DrlgOutdoorInfoStrc.pGrid). */
|
|
const TILES_PER_GRID_CELL = 8
|
|
/** Sub-tiles per tile (DUNGEON_GameTileToSubtileCoords). */
|
|
const SUBTILES_PER_TILE = 5
|
|
|
|
// D2MapTileFlags (D2DrlgDrlg.h)
|
|
const MAPTILE_HIDDEN = 0x8
|
|
const MAPTILE_LAYER_SHIFT = 14
|
|
const MAPTILE_LAYER_MASK = 0x7
|
|
|
|
// D2TileType (D2CMP.h)
|
|
const TILETYPE_FLOOR = 0
|
|
const TILETYPE_WALL_TOP_CORNER_RIGHT = 3
|
|
const TILETYPE_WALL_TOP_CORNER_LEFT = 4
|
|
const TILETYPE_SHADOW = 13
|
|
|
|
// D2C_UnitTypes
|
|
const UNIT_MONSTER = 1
|
|
const UNIT_OBJECT = 2
|
|
const UNIT_TILE = 5
|
|
|
|
/** DS1 object `type` values (the encoding DRLGPRESET_ParseDS1File reads). */
|
|
const DS1_TYPE_MONSTER = 1
|
|
const DS1_TYPE_OBJECT = 2
|
|
/** Object ids below this are ObjPreset slots; `objects.txt id + 150` otherwise (DRLGPRESET_ParseDS1File, v > 5). */
|
|
const DS1_OBJPRESET_SLOTS = 150
|
|
/**
|
|
* Objects.txt class D2Game refuses to create from a preset unit (SUnit.cpp:1003, SUNIT_CreatePresetUnit:
|
|
* `if (nClassId == 573) return`), so it never exists in game.
|
|
*/
|
|
const OBJECT_CLASS_INVALID_PRESET = 573
|
|
|
|
/**
|
|
* MonPlace.txt rows D2Game turns into a fixed monster at the preset position instead of a random pack
|
|
* (D2Game MonsterRegion.cpp:243-300, `switch (nMonPlace)`), mapped to the monster planner's landmark id.
|
|
* Only the cases reachable from Act I outdoor presets are listed: 5 -> MONSTER_BLOODRAVEN.
|
|
*/
|
|
const MONPLACE_LANDMARKS: ReadonlyMap<number, string> = new Map([[5, 'Blood Raven']])
|
|
|
|
/** Axis-aligned rectangle in world tiles (D2DrlgCoordStrc). */
|
|
export interface DrlgRect {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly width: number
|
|
readonly height: number
|
|
}
|
|
|
|
/** Which array of the owner room's D2DrlgRoomTilesStrc a tile lives in. */
|
|
export type DrlgTileKind = 'wall' | 'floor' | 'shadow'
|
|
|
|
/** One D2DrlgTileDataStrc, placed in level coordinates. */
|
|
export interface DrlgTileInput {
|
|
/** Identity of the tile data (owner level, room, array, index), so owned and linked copies dedupe. */
|
|
readonly key: string
|
|
readonly kind: DrlgTileKind
|
|
/** Level-local tile position: owner room position + nPosX/nPosY - level position (0..W, 0..H). */
|
|
readonly x: number
|
|
readonly y: number
|
|
/** D2DrlgTileDataStrc.dwFlags (D2MapTileFlags). */
|
|
readonly flags: number
|
|
/** D2DrlgTileDataStrc.nTileType. */
|
|
readonly tileType: number
|
|
/** pTile: the DT1 tile D2 picked (normalized library path, tile index), or null when none matched. */
|
|
readonly tile: { readonly library: string; readonly index: number } | null
|
|
}
|
|
|
|
/** One D2PresetUnitStrc of a room, after DRLGROOMTILE_AddRoomMapTiles. */
|
|
export interface DrlgUnitInput {
|
|
/** Room index in the level's room chain. */
|
|
readonly room: number
|
|
/** D2C_UnitTypes. */
|
|
readonly type: number
|
|
/** nIndex as D2 stores it (after DRLGPRESET_ParseDS1File's MonPreset / ObjPreset conversion). */
|
|
readonly index: number
|
|
readonly mode: number
|
|
/** Level-local sub-tile position (room-relative nXpos/nYpos + 5 * room offset). */
|
|
readonly subX: number
|
|
readonly subY: number
|
|
readonly spawned: number
|
|
}
|
|
|
|
/** One D2RoomTileStrc (a warp of a room, sub_6FD77F00). */
|
|
export interface DrlgWarpInput {
|
|
readonly room: number
|
|
/** pLvlWarpTxtRecord->dwLevelId: the LvlWarp.txt `Id`, the class id of the warp's UNIT_TILE unit. */
|
|
readonly lvlWarpId: number
|
|
/** pDrlgRoom->pLevel->nLevelId of the destination room. */
|
|
readonly toLevelId: number
|
|
}
|
|
|
|
/** One level-to-level D2DrlgOrthStrc of pOutdoors->pRoomData. */
|
|
export interface DrlgOrthInput {
|
|
readonly level: number
|
|
/** ALTDIR_*: where the neighbour lies (0 west, 1 north, 2 east, 3 south). */
|
|
readonly dir: number
|
|
readonly box: DrlgRect
|
|
}
|
|
|
|
/** Everything the adapter reads about one generated level. */
|
|
export interface DrlgLevelInput {
|
|
readonly levelId: number
|
|
readonly drlgType: number
|
|
readonly levelType: number
|
|
readonly coord: DrlgRect
|
|
/** pOutdoors->dwFlags (D2DrlgOutdoorInfoStrc: link directions, river bank, ...). */
|
|
readonly flags: number
|
|
readonly orths: readonly DrlgOrthInput[]
|
|
/** pOutdoors->pGrid[2] (packed grid-2 info) as dumped at the levelGrid stage. */
|
|
readonly grid2: { readonly w: number; readonly h: number; readonly cells: readonly number[] }
|
|
readonly tiles: readonly DrlgTileInput[]
|
|
readonly units: readonly DrlgUnitInput[]
|
|
readonly warps: readonly DrlgWarpInput[]
|
|
}
|
|
|
|
/** Tables the mapping needs besides the DRLG output. */
|
|
export interface DrlgMapContext {
|
|
readonly tables: DrlgTables
|
|
/** Parsed DT1 libraries keyed by {@link normalizeDrlgPath}. */
|
|
readonly dt1: ReadonlyMap<string, Dt1>
|
|
/** SuperUniques.txt `Superunique` column, by row. */
|
|
readonly superUniqueIds: readonly string[]
|
|
}
|
|
|
|
/** Which edge of the level an entrance sits on (world-graph `Side`). */
|
|
export type DrlgSide = 'north' | 'east' | 'south' | 'west'
|
|
|
|
/**
|
|
* A way into or out of the level: the fields of a `WildernessEntrance` (what the bake's
|
|
* buildSceneLinks reads) except the display label, which the bake adds, plus `span` for seams.
|
|
*/
|
|
export interface DrlgEntrance {
|
|
/** Anchor cell (tiles, level-local). */
|
|
readonly x: number
|
|
readonly y: number
|
|
/** One step inside from the anchor. */
|
|
readonly interiorX: number
|
|
readonly interiorY: number
|
|
readonly toLevelId: number
|
|
readonly side: DrlgSide | null
|
|
readonly kind: 'gate' | 'preset'
|
|
/**
|
|
* Seams only: the stretch of this level's edge (tiles along the side, `from` inclusive, `to`
|
|
* exclusive) where the opening is. The bake searches the walkable gap inside it.
|
|
*/
|
|
readonly span?: { readonly from: number; readonly to: number }
|
|
/** Warps only: the UNIT_TILE position in sub-tiles (level-local) and its LvlWarp.txt Id. */
|
|
readonly warp?: { readonly subX: number; readonly subY: number; readonly lvlWarpId: number }
|
|
}
|
|
|
|
/** A fixed monster the planner anchors to its preset position. */
|
|
export interface DrlgLandmark {
|
|
/** The monster planner's id (SuperUniques.txt `Superunique`, or 'Blood Raven'). */
|
|
readonly id: string
|
|
readonly tileX: number
|
|
readonly tileY: number
|
|
readonly subX: number
|
|
readonly subY: number
|
|
}
|
|
|
|
/** One tile reference written into the canvas, with the library D2 took it from. */
|
|
export interface DrlgCellRef {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly kind: DrlgTileKind
|
|
readonly style: number
|
|
readonly sequence: number
|
|
readonly type: number
|
|
/** Index into {@link DrlgLevelMap.dt1Names}. */
|
|
readonly library: number
|
|
readonly hidden: boolean
|
|
}
|
|
|
|
export interface DrlgLevelMapStats {
|
|
readonly walls: number
|
|
readonly floors: number
|
|
readonly shadows: number
|
|
/** Type-4 halves skipped because the renderer draws them with their type-3 tile. */
|
|
readonly cornerCompanions: number
|
|
/** Tiles whose pTile is null (D2CMP found no tile): D2 draws nothing for them. */
|
|
readonly withoutTile: number
|
|
/** Linked tiles of other rooms drawn inside this level's canvas. */
|
|
readonly linkedInCanvas: number
|
|
/** Linked tiles outside the canvas (belong to the neighbour's canvas). */
|
|
readonly linkedOutside: number
|
|
readonly objects: number
|
|
readonly monsters: number
|
|
readonly skippedInvalidObjects: number
|
|
}
|
|
|
|
/** One mapped level. */
|
|
export interface DrlgLevelMap {
|
|
readonly levelId: number
|
|
readonly coord: DrlgRect
|
|
readonly flags: number
|
|
/** The canvas: (width + 1) x (height + 1) cells, like a DS1 of the level's size. */
|
|
readonly ds1: Ds1
|
|
/** DT1 libraries (normalized archive paths) in renderer library order; per-cell `dt1Mask` bits index it. */
|
|
readonly dt1Names: readonly string[]
|
|
readonly entrances: readonly DrlgEntrance[]
|
|
readonly landmarks: readonly DrlgLandmark[]
|
|
/** Every tile reference written into the canvas (for the DT1-mask ambiguity audit). */
|
|
readonly refs: readonly DrlgCellRef[]
|
|
readonly stats: DrlgLevelMapStats
|
|
}
|
|
|
|
/**
|
|
* DT1 libraries DRLGROOMTILE_LoadDT1FilesForRoom loads for every room regardless of its mask
|
|
* (DrlgRoomTile.cpp:1249-1256): they come first so a cell mask can always name them.
|
|
*/
|
|
export const DRLG_UNIVERSAL_DT1: readonly string[] = [
|
|
'DATA\\GLOBAL\\TILES\\Act1\\Outdoors\\Blank.dt1',
|
|
'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\InvisWal.dt1',
|
|
'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\Warp.dt1',
|
|
].map(normalizeDrlgPath)
|
|
|
|
/**
|
|
* The Act I levels D2 generates with the outdoor DRLG (Levels.txt DrlgType 3), ascending.
|
|
*
|
|
* @param tables - the DRLG tables.
|
|
* @returns level ids.
|
|
*/
|
|
export function act1OutdoorLevelIds(tables: DrlgTables): number[] {
|
|
const out: number[] = []
|
|
for (let id = 1; id < tables.nLevelsTxtRecordCount; id += 1) {
|
|
if (DRLG_GetActNoFromLevelId(id) !== 0) break
|
|
if (DATATBLS_GetLevelDefRecord(tables, id).dwDrlgType === DRLGTYPE_OUTDOOR) out.push(id)
|
|
}
|
|
return out
|
|
}
|
|
|
|
const SIDE_OF_ALTDIR: Readonly<Record<number, DrlgSide>> = {
|
|
[ALTDIR_WEST]: 'west',
|
|
[ALTDIR_NORTH]: 'north',
|
|
[ALTDIR_EAST]: 'east',
|
|
[ALTDIR_SOUTH]: 'south',
|
|
}
|
|
|
|
function sideOfAltDir(dir: number, what: string): DrlgSide {
|
|
const side = SIDE_OF_ALTDIR[dir]
|
|
if (side === undefined) throw new Error(`${what}: direction ${dir} is not an ALTDIR`)
|
|
return side
|
|
}
|
|
|
|
function emptyWall(): Ds1Wall {
|
|
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
|
|
}
|
|
|
|
function emptyFloor(): Ds1Floor {
|
|
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
|
|
}
|
|
|
|
/** Mutable per-cell collector. */
|
|
interface CellBuild {
|
|
walls: { layer: number; order: number; wall: Ds1Wall }[]
|
|
floors: { layer: number; order: number; floor: Ds1Floor }[]
|
|
shadows: Ds1Floor[]
|
|
mask: number
|
|
}
|
|
|
|
/**
|
|
* The DT1 library order for a level: the universal libraries, then the libraries of the level's
|
|
* LvlTypes row in `File 1..32` order. Only libraries some tile uses are listed (plus the universal
|
|
* three, which DRLGROOMTILE_LoadDT1FilesForRoom always loads).
|
|
*/
|
|
function libraryOrder(tables: DrlgTables, levelType: number, used: ReadonlySet<string>, levelId: number): string[] {
|
|
const order: string[] = [...DRLG_UNIVERSAL_DT1]
|
|
const listed = new Set(order)
|
|
for (const f of DATATBLS_GetLvlTypesTxtRecord(tables, levelType).szFile) {
|
|
if (f.length <= 1) continue
|
|
const key = normalizeDrlgPath(f)
|
|
if (listed.has(key) || !used.has(key)) continue
|
|
order.push(key)
|
|
listed.add(key)
|
|
}
|
|
for (const lib of used) {
|
|
if (!listed.has(lib)) {
|
|
throw new Error(`level ${levelId}: tile library ${lib} is neither universal nor in LvlTypes row ${levelType}`)
|
|
}
|
|
}
|
|
if (order.length > 32) throw new Error(`level ${levelId}: ${order.length} DT1 libraries do not fit a 32-bit cell mask`)
|
|
return order
|
|
}
|
|
|
|
/**
|
|
* Map one generated Act I outdoor level.
|
|
*
|
|
* @param input - the level, from the DRLG dump.
|
|
* @param ctx - tables, DT1 libraries and names.
|
|
* @returns the mapped level.
|
|
*/
|
|
export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): DrlgLevelMap {
|
|
const { levelId, coord } = input
|
|
if (input.drlgType !== DRLGTYPE_OUTDOOR) throw new Error(`level ${levelId}: not an outdoor level (DrlgType ${input.drlgType})`)
|
|
const nAct = DRLG_GetActNoFromLevelId(levelId)
|
|
const cellsX = coord.width + 1
|
|
const cellsY = coord.height + 1
|
|
|
|
// ---- tiles -> canvas ----
|
|
const cells: CellBuild[] = []
|
|
for (let i = 0; i < cellsX * cellsY; i += 1) cells.push({ walls: [], floors: [], shadows: [], mask: 0 })
|
|
const seen = new Set<string>()
|
|
const used = new Set<string>()
|
|
const placed: { t: DrlgTileInput; style: number; sequence: number; type: number; hidden: boolean }[] = []
|
|
let cornerCompanions = 0
|
|
let withoutTile = 0
|
|
let linkedInCanvas = 0
|
|
let linkedOutside = 0
|
|
const topCornerRights = new Set<string>()
|
|
const topCornerLefts: { t: DrlgTileInput; style: number; sequence: number }[] = []
|
|
|
|
for (const t of input.tiles) {
|
|
if (seen.has(t.key)) continue
|
|
seen.add(t.key)
|
|
const linked = !t.key.startsWith(`${levelId}/`)
|
|
if (t.x < 0 || t.y < 0 || t.x >= cellsX || t.y >= cellsY) {
|
|
if (!linked) throw new Error(`level ${levelId}: owned tile ${t.key} at (${t.x}, ${t.y}) is outside the ${cellsX}x${cellsY} canvas`)
|
|
linkedOutside += 1
|
|
continue
|
|
}
|
|
if (linked) linkedInCanvas += 1
|
|
if (t.tile === null) {
|
|
withoutTile += 1
|
|
continue
|
|
}
|
|
const lib = ctx.dt1.get(t.tile.library)
|
|
if (lib === undefined) throw new Error(`level ${levelId}: tile ${t.key} uses DT1 ${t.tile.library}, which is not loaded`)
|
|
const header = lib.tiles[t.tile.index]
|
|
if (header === undefined) throw new Error(`level ${levelId}: tile ${t.key}: ${t.tile.library} has no tile #${t.tile.index}`)
|
|
if (header.type !== t.tileType) {
|
|
throw new Error(`level ${levelId}: tile ${t.key}: nTileType ${t.tileType} but DT1 tile #${t.tile.index} of ${t.tile.library} is type ${header.type}`)
|
|
}
|
|
const kindOk = t.kind === 'floor' ? header.type === TILETYPE_FLOOR
|
|
: t.kind === 'shadow' ? header.type === TILETYPE_SHADOW
|
|
: header.type !== TILETYPE_FLOOR && header.type !== TILETYPE_SHADOW
|
|
if (!kindOk) throw new Error(`level ${levelId}: tile ${t.key} is a ${t.kind} of DT1 type ${header.type}`)
|
|
if (header.type === TILETYPE_WALL_TOP_CORNER_LEFT) {
|
|
// D2 stores the left half of a top corner as its own tile data next to the type-3 tile
|
|
// (DRLGROOMTILE_InitWallTileData); buildIsoMapScene draws that half itself for every type-3 wall.
|
|
topCornerLefts.push({ t, style: header.style, sequence: header.sequence })
|
|
continue
|
|
}
|
|
if (header.type === TILETYPE_WALL_TOP_CORNER_RIGHT) topCornerRights.add(`${t.x},${t.y},${header.style},${header.sequence}`)
|
|
used.add(t.tile.library)
|
|
placed.push({ t, style: header.style, sequence: header.sequence, type: header.type, hidden: (t.flags & MAPTILE_HIDDEN) !== 0 })
|
|
}
|
|
for (const c of topCornerLefts) {
|
|
if (!topCornerRights.has(`${c.t.x},${c.t.y},${c.style},${c.sequence}`)) {
|
|
throw new Error(`level ${levelId}: top-corner-left tile ${c.t.key} at (${c.t.x}, ${c.t.y}) has no top-corner-right partner`)
|
|
}
|
|
cornerCompanions += 1
|
|
}
|
|
|
|
const dt1Names = libraryOrder(ctx.tables, input.levelType, used, levelId)
|
|
const libIndex = new Map(dt1Names.map((name, i) => [name, i]))
|
|
const refs: DrlgCellRef[] = []
|
|
let order = 0
|
|
let walls = 0
|
|
let floors = 0
|
|
let shadows = 0
|
|
for (const p of placed) {
|
|
const cell = cells[p.t.y * cellsX + p.t.x]!
|
|
const library = libIndex.get(p.t.tile!.library)!
|
|
cell.mask |= 1 << library
|
|
const layer = (p.t.flags >>> MAPTILE_LAYER_SHIFT) & MAPTILE_LAYER_MASK
|
|
// prop1 only marks the slot as used for the renderer; hidden tiles (warp lit tiles, etc.) are kept
|
|
// hidden exactly as D2 adds them.
|
|
if (p.t.kind === 'floor') {
|
|
cell.floors.push({ layer, order: order++, floor: { prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden } })
|
|
floors += 1
|
|
} else if (p.t.kind === 'shadow') {
|
|
cell.shadows.push({ prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden })
|
|
shadows += 1
|
|
} else {
|
|
cell.walls.push({ layer, order: order++, wall: { prop1: 1, sequence: p.sequence, style: p.style, type: p.type, unknown1: 0, unknown2: 0, hidden: p.hidden } })
|
|
walls += 1
|
|
}
|
|
refs.push({ x: p.t.x, y: p.t.y, kind: p.t.kind, style: p.style, sequence: p.sequence, type: p.type, library, hidden: p.hidden })
|
|
}
|
|
|
|
let wallLayers = 1
|
|
let floorLayers = 1
|
|
let shadowLayers = 1
|
|
for (const c of cells) {
|
|
wallLayers = Math.max(wallLayers, c.walls.length)
|
|
floorLayers = Math.max(floorLayers, c.floors.length)
|
|
shadowLayers = Math.max(shadowLayers, c.shadows.length)
|
|
}
|
|
const byLayer = <T extends { layer: number; order: number }>(a: T, b: T): number => a.layer - b.layer || a.order - b.order
|
|
const rows: Ds1Cell[][] = []
|
|
for (let y = 0; y < cellsY; y += 1) {
|
|
const row: Ds1Cell[] = []
|
|
for (let x = 0; x < cellsX; x += 1) {
|
|
const c = cells[y * cellsX + x]!
|
|
const cw = c.walls.sort(byLayer).map(w => w.wall)
|
|
const cf = c.floors.sort(byLayer).map(f => f.floor)
|
|
const cs = [...c.shadows]
|
|
while (cw.length < wallLayers) cw.push(emptyWall())
|
|
while (cf.length < floorLayers) cf.push(emptyFloor())
|
|
while (cs.length < shadowLayers) cs.push(emptyFloor())
|
|
row.push({ walls: cw, floors: cf, shadows: cs, substitutions: [], ...(c.mask !== 0 ? { dt1Mask: c.mask >>> 0 } : {}) })
|
|
}
|
|
rows.push(row)
|
|
}
|
|
|
|
// ---- units -> DS1 objects, landmarks, warp entrances ----
|
|
const objSlotOf = new Map<number, number>()
|
|
for (let slot = DS1_OBJPRESET_SLOTS - 1; slot >= 0; slot -= 1) {
|
|
objSlotOf.set(DRLGPRESET_GetObjectIndexFromObjPreset(nAct, slot), slot) // lowest slot wins
|
|
}
|
|
const monSection = DATATBLS_GetMonPresetTxtActSection(ctx.tables, nAct)
|
|
if (monSection === null) throw new Error(`level ${levelId}: MonPreset has no act ${nAct + 1} section`)
|
|
const nMonStats = ctx.tables.nMonStatsTxtRecordCount
|
|
const nSU = ctx.tables.nSuperUniquesTxtRecordCount
|
|
const monIdOf = new Map<number, number>()
|
|
for (let i = monSection.count - 1; i >= 0; i -= 1) {
|
|
const r = ctx.tables.monPreset[monSection.start + i]!
|
|
let unit: number
|
|
switch (r.nType) {
|
|
case 0: unit = r.wPlace + nSU + nMonStats; break
|
|
case 1: unit = r.wPlace; break
|
|
case 2: unit = r.wPlace + nMonStats; break
|
|
default: continue // DRLGPRESET_ParseDS1File maps it to -1, which is never allocated
|
|
}
|
|
monIdOf.set(unit, i) // lowest DS1 id wins (duplicates carry the same Place)
|
|
}
|
|
|
|
const objects: Ds1Object[] = []
|
|
const landmarks: DrlgLandmark[] = []
|
|
const entrances: DrlgEntrance[] = []
|
|
let nObjects = 0
|
|
let nMonsters = 0
|
|
let skippedInvalidObjects = 0
|
|
for (const u of input.units) {
|
|
switch (u.type) {
|
|
case UNIT_OBJECT: {
|
|
if (u.index === OBJECT_CLASS_INVALID_PRESET) { skippedInvalidObjects += 1; break }
|
|
if (u.index < 0) throw new Error(`level ${levelId}: object unit with class ${u.index}`)
|
|
const slot = objSlotOf.get(u.index)
|
|
objects.push({ type: DS1_TYPE_OBJECT, id: slot ?? u.index + DS1_OBJPRESET_SLOTS, x: u.subX, y: u.subY, flags: u.spawned })
|
|
nObjects += 1
|
|
break
|
|
}
|
|
case UNIT_MONSTER: {
|
|
const id = monIdOf.get(u.index)
|
|
if (id === undefined) throw new Error(`level ${levelId}: monster unit ${u.index} has no act ${nAct + 1} MonPreset row`)
|
|
objects.push({ type: DS1_TYPE_MONSTER, id, x: u.subX, y: u.subY, flags: u.spawned })
|
|
nMonsters += 1
|
|
const tileX = Math.floor(u.subX / SUBTILES_PER_TILE)
|
|
const tileY = Math.floor(u.subY / SUBTILES_PER_TILE)
|
|
if (u.index >= nMonStats && u.index < nMonStats + nSU) {
|
|
const su = ctx.superUniqueIds[u.index - nMonStats]
|
|
if (su === undefined || su === '') throw new Error(`level ${levelId}: SuperUnique row ${u.index - nMonStats} has no id`)
|
|
landmarks.push({ id: su, tileX, tileY, subX: u.subX, subY: u.subY })
|
|
} else if (u.index >= nMonStats + nSU) {
|
|
const special = MONPLACE_LANDMARKS.get(u.index - nMonStats - nSU)
|
|
if (special !== undefined) landmarks.push({ id: special, tileX, tileY, subX: u.subX, subY: u.subY })
|
|
}
|
|
break
|
|
}
|
|
case UNIT_TILE: {
|
|
const matches = input.warps.filter(w => w.room === u.room && w.lvlWarpId === u.index)
|
|
const to = [...new Set(matches.map(w => w.toLevelId))]
|
|
if (to.length !== 1) {
|
|
throw new Error(`level ${levelId} room ${u.room}: UNIT_TILE ${u.index} matches ${to.length} warp destinations (${to.join(', ')})`)
|
|
}
|
|
const cx = Math.floor(u.subX / SUBTILES_PER_TILE)
|
|
const cy = Math.floor(u.subY / SUBTILES_PER_TILE)
|
|
entrances.push({
|
|
x: cx,
|
|
y: cy,
|
|
interiorX: cx,
|
|
interiorY: cy,
|
|
toLevelId: to[0]!,
|
|
side: null,
|
|
kind: 'preset',
|
|
warp: { subX: u.subX, subY: u.subY, lvlWarpId: u.index },
|
|
})
|
|
break
|
|
}
|
|
default:
|
|
throw new Error(`level ${levelId} room ${u.room}: unexpected preset unit type ${u.type} (index ${u.index})`)
|
|
}
|
|
}
|
|
|
|
// ---- seams ----
|
|
entrances.push(...seamEntrances(input))
|
|
|
|
const ds1: Ds1 = {
|
|
version: 18,
|
|
width: cellsX,
|
|
height: cellsY,
|
|
act: nAct + 1,
|
|
substitutionType: 0,
|
|
wallLayers,
|
|
floorLayers,
|
|
cells: rows,
|
|
objects,
|
|
npcPathOffset: null,
|
|
}
|
|
return {
|
|
levelId,
|
|
coord,
|
|
flags: input.flags,
|
|
ds1,
|
|
dt1Names,
|
|
entrances,
|
|
landmarks,
|
|
refs,
|
|
stats: {
|
|
walls, floors, shadows, cornerCompanions, withoutTile, linkedInCanvas, linkedOutside,
|
|
objects: nObjects, monsters: nMonsters, skippedInvalidObjects,
|
|
},
|
|
}
|
|
}
|
|
|
|
/** How far the renderer's tile choice can stray from D2's for one mapped level. */
|
|
export interface DrlgDt1Ambiguity {
|
|
/** DT1 libraries of the level ({@link DrlgLevelMap.dt1Names}). */
|
|
readonly libraries: number
|
|
/** Tile references written into the canvas. */
|
|
readonly refs: number
|
|
/**
|
|
* References whose exact (style, sequence, type) group under their cell's DT1 mask holds more than
|
|
* one tile. The renderer draws the variant with its own per-cell hash (scene fidelity 'layout'); D2
|
|
* rolled it with the room seed (DRLGROOMTILE_GetTileCache), and the port records that roll.
|
|
*/
|
|
readonly variantGroups: number
|
|
/** References whose group under the cell mask spans more than one library: the renderer may draw a tile of a library D2 did not take it from. */
|
|
readonly crossLibrary: number
|
|
/** The same count with one pool for the whole level (no cell masks): what the cell masks prevent. */
|
|
readonly crossLibraryLevelWide: number
|
|
}
|
|
|
|
/**
|
|
* The DT1-mask ambiguity audit of a mapped level: resolves every tile reference the way
|
|
* `buildIsoMapScene` does (exact style:sequence:type key, {@link dt1MaskAllows}) and counts the
|
|
* references whose candidates are not pinned down to D2's library.
|
|
*
|
|
* @param map - the mapped level.
|
|
* @param libraries - the parsed libraries of `map.dt1Names`, in that order.
|
|
* @returns the counts.
|
|
*/
|
|
export function auditDrlgDt1Ambiguity(map: DrlgLevelMap, libraries: readonly Dt1[]): DrlgDt1Ambiguity {
|
|
if (libraries.length !== map.dt1Names.length) {
|
|
throw new Error(`level ${map.levelId}: ${libraries.length} libraries for ${map.dt1Names.length} DT1 names`)
|
|
}
|
|
// style:sequence:type -> tiles per library
|
|
const groups = new Map<string, number[]>()
|
|
libraries.forEach((library, at) => {
|
|
for (const tile of library.tiles) {
|
|
const key = `${tile.style}:${tile.sequence}:${tile.type}`
|
|
let counts = groups.get(key)
|
|
if (counts === undefined) {
|
|
counts = new Array<number>(libraries.length).fill(0)
|
|
groups.set(key, counts)
|
|
}
|
|
counts[at]! += 1
|
|
}
|
|
})
|
|
let variantGroups = 0
|
|
let crossLibrary = 0
|
|
let crossLibraryLevelWide = 0
|
|
for (const ref of map.refs) {
|
|
const counts = groups.get(`${ref.style}:${ref.sequence}:${ref.type}`)
|
|
if (counts === undefined || counts[ref.library] === 0) {
|
|
throw new Error(`level ${map.levelId}: ${ref.kind} ${ref.style}:${ref.sequence}:${ref.type} at (${ref.x}, ${ref.y}) is not in its library ${map.dt1Names[ref.library]}`)
|
|
}
|
|
const mask = map.ds1.cells[ref.y]?.[ref.x]?.dt1Mask
|
|
if (mask === undefined) throw new Error(`level ${map.levelId}: cell (${ref.x}, ${ref.y}) holds a tile but has no DT1 mask`)
|
|
let tilesInCell = 0
|
|
let librariesInCell = 0
|
|
let librariesInLevel = 0
|
|
counts.forEach((n, library) => {
|
|
if (n === 0) return
|
|
librariesInLevel += 1
|
|
if (!dt1MaskAllows(mask, library)) return
|
|
librariesInCell += 1
|
|
tilesInCell += n
|
|
})
|
|
if (tilesInCell > 1) variantGroups += 1
|
|
if (librariesInCell > 1) crossLibrary += 1
|
|
if (librariesInLevel > 1) crossLibraryLevelWide += 1
|
|
}
|
|
return { libraries: libraries.length, refs: map.refs.length, variantGroups, crossLibrary, crossLibraryLevelWide }
|
|
}
|
|
|
|
/**
|
|
* The level's seams to its outdoor/preset neighbours, one per level link (pOutdoors->pRoomData).
|
|
*
|
|
* Outdoor-outdoor links are the grid-2 cells DRLGOUTPLACE_PlaceAct1245OutdoorBorders flags
|
|
* GRID2_LVL_LINK: the border cell in the middle of each linked vertex edge, which gets the open border
|
|
* preset (picked file 3, or 4 on the Burial Grounds). Links whose vertex edge carries flag 2 (the town
|
|
* transition, the Monastery Gate approach) get no border cell: their opening is the neighbour-specific
|
|
* preset, somewhere along the shared edge, so the whole shared stretch is the span.
|
|
*/
|
|
function seamEntrances(input: DrlgLevelInput): DrlgEntrance[] {
|
|
const { levelId, coord, grid2 } = input
|
|
const out: DrlgEntrance[] = []
|
|
if (grid2.w * TILES_PER_GRID_CELL !== coord.width || grid2.h * TILES_PER_GRID_CELL !== coord.height) {
|
|
throw new Error(`level ${levelId}: grid-2 ${grid2.w}x${grid2.h} does not cover ${coord.width}x${coord.height} tiles`)
|
|
}
|
|
const linkCellsOf = new Map<number, { gx: number; gy: number }[]>()
|
|
for (let gy = 0; gy < grid2.h; gy += 1) {
|
|
for (let gx = 0; gx < grid2.w; gx += 1) {
|
|
if (!(grid2.cells[gy * grid2.w + gx]! & GRID2_LVL_LINK)) continue
|
|
const onW = gx === 0
|
|
const onE = gx === grid2.w - 1
|
|
const onN = gy === 0
|
|
const onS = gy === grid2.h - 1
|
|
if (Number(onW) + Number(onE) + Number(onN) + Number(onS) !== 1) {
|
|
throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) is not on exactly one border`)
|
|
}
|
|
const dir = onW ? ALTDIR_WEST : onE ? ALTDIR_EAST : onN ? ALTDIR_NORTH : ALTDIR_SOUTH
|
|
// World tiles covered by the cell along its side.
|
|
const horizontal = dir === ALTDIR_NORTH || dir === ALTDIR_SOUTH
|
|
const from = (horizontal ? coord.x + gx * TILES_PER_GRID_CELL : coord.y + gy * TILES_PER_GRID_CELL)
|
|
const to = from + TILES_PER_GRID_CELL
|
|
const owners = input.orths.filter(o => {
|
|
if (o.dir !== dir) return false
|
|
const lo = horizontal ? o.box.x : o.box.y
|
|
const hi = lo + (horizontal ? o.box.width : o.box.height)
|
|
return from >= lo && to <= hi
|
|
})
|
|
if (owners.length !== 1) {
|
|
throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) matches ${owners.length} neighbour links`)
|
|
}
|
|
const list = linkCellsOf.get(owners[0]!.level) ?? []
|
|
list.push({ gx, gy })
|
|
linkCellsOf.set(owners[0]!.level, list)
|
|
}
|
|
}
|
|
|
|
for (const o of input.orths) {
|
|
const side = sideOfAltDir(o.dir, `level ${levelId} link to ${o.level}`)
|
|
const horizontal = side === 'north' || side === 'south'
|
|
const cellsOnLink = linkCellsOf.get(o.level) ?? []
|
|
let from: number
|
|
let to: number
|
|
if (cellsOnLink.length > 1) {
|
|
throw new Error(`level ${levelId}: ${cellsOnLink.length} level-link cells for the link to ${o.level}`)
|
|
} else if (cellsOnLink.length === 1) {
|
|
const c = cellsOnLink[0]!
|
|
from = (horizontal ? c.gx : c.gy) * TILES_PER_GRID_CELL
|
|
to = from + TILES_PER_GRID_CELL
|
|
} else {
|
|
// Shared edge of the two rectangles, in level-local tiles along the side.
|
|
const lo = horizontal ? Math.max(coord.x, o.box.x) : Math.max(coord.y, o.box.y)
|
|
const hi = horizontal
|
|
? Math.min(coord.x + coord.width, o.box.x + o.box.width)
|
|
: Math.min(coord.y + coord.height, o.box.y + o.box.height)
|
|
if (hi <= lo) throw new Error(`level ${levelId}: the link to ${o.level} (${side}) shares no edge`)
|
|
from = lo - (horizontal ? coord.x : coord.y)
|
|
to = hi - (horizontal ? coord.x : coord.y)
|
|
}
|
|
const mid = Math.floor((from + to - 1) / 2)
|
|
const edgeX = side === 'west' ? 0 : side === 'east' ? coord.width - 1 : mid
|
|
const edgeY = side === 'north' ? 0 : side === 'south' ? coord.height - 1 : mid
|
|
const inX = side === 'west' ? 1 : side === 'east' ? coord.width - 2 : mid
|
|
const inY = side === 'north' ? 1 : side === 'south' ? coord.height - 2 : mid
|
|
out.push({
|
|
x: edgeX,
|
|
y: edgeY,
|
|
interiorX: inX,
|
|
interiorY: inY,
|
|
toLevelId: o.level,
|
|
side,
|
|
kind: 'gate',
|
|
span: { from, to },
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Which tile array a {@link DumpTileRef} names, as the adapter's tile kind. */
|
|
const TILE_KIND_OF_ARRAY = { w: 'wall', f: 'floor', r: 'shadow' } as const
|
|
|
|
/**
|
|
* The adapter input for one level of a schema-3 DRLG dump.
|
|
*
|
|
* The native oracle's JSON and the port's `dumpAct1` have the same shape, so both go through the same
|
|
* mapping and a comparison of the two compares like with like. The dump must come from an act run with
|
|
* activation: every room of the level activated, `mapTiles` being the final state after all of them.
|
|
*
|
|
* - tiles: each room's own tile data, i.e. the entries DRLGROOMTILE_InitWallTileData /
|
|
* InitFloorTileData / InitShadowTileData filled (`pTileGrid->nWalls/nFloors/nShadows` of the
|
|
* `pTiles` arrays; the rest of each allocation stays zeroed and is never drawn), then the tiles the
|
|
* room's pMapLinks point at (DRLGROOMTILE_LinkedTileDataManager's merge of shared room edges, which
|
|
* reaches rooms of the neighbouring level across a seam).
|
|
* - units: `mapTiles.units`, the room's pPresetUnits after DRLGROOMTILE_AddRoomMapTiles (so with the
|
|
* UNIT_TILE units of DRLGROOMTILE_AddWarp), moved from room-relative to level-local sub-tiles.
|
|
* - warps: the room's pRoomTiles chain.
|
|
*
|
|
* @param dump - schema-3 dump of an act run with activation.
|
|
* @param levelId - the level to map.
|
|
* @param tables - the DRLG tables the dump was made with (for LvlWarp.txt rows).
|
|
* @returns the adapter input.
|
|
*/
|
|
export function drlgLevelInputFromDump(dump: DrlgDump, levelId: number, tables: DrlgTables): DrlgLevelInput {
|
|
if (dump.schema !== 3) throw new Error(`DRLG dump schema ${String(dump.schema)}, expected 3`)
|
|
const act = dump.act.find(a => a.id === levelId)
|
|
if (act === undefined) throw new Error(`level ${levelId}: not in the dump's act section`)
|
|
const levelsById = new Map<number, DumpLevel>(dump.levels.map(l => [l.id, l]))
|
|
const level = levelsById.get(levelId)
|
|
if (level === undefined) throw new Error(`level ${levelId}: not generated in the dump`)
|
|
const levelGrid = level.levelGrid
|
|
if (!levelGrid) throw new Error(`level ${levelId}: the dump has no levelGrid stage (not an outdoor level)`)
|
|
const grid2 = levelGrid.grids[2]
|
|
if (!grid2) throw new Error(`level ${levelId}: pGrid[2] was never allocated`)
|
|
if (level.activation.length !== level.rooms.length) {
|
|
throw new Error(`level ${levelId}: ${level.activation.length} of ${level.rooms.length} rooms activated (the dump must be made with activation)`)
|
|
}
|
|
const [levelX, levelY, levelW, levelH] = act.coord
|
|
// pOutdoors->pCoord is a union over nWidth, nHeight, nGridWidth, nGridHeight (8-tile grid cells).
|
|
const [, , gridW, gridH] = levelGrid.coord
|
|
if (gridW * TILES_PER_GRID_CELL !== levelW || gridH * TILES_PER_GRID_CELL !== levelH) {
|
|
throw new Error(`level ${levelId}: outdoor grid ${gridW}x${gridH} does not cover the ${levelW}x${levelH} tiles of the level`)
|
|
}
|
|
|
|
const place = (key: string, kind: DrlgTileKind, room: DumpCoord, t: DumpTile): DrlgTileInput => ({
|
|
key,
|
|
kind,
|
|
x: room[0] + t[2] - levelX,
|
|
y: room[1] + t[3] - levelY,
|
|
flags: t[5] >>> 0,
|
|
tileType: t[7],
|
|
tile: t[6] === null ? null : { library: normalizeDrlgPath(t[6][0]), index: t[6][1] },
|
|
})
|
|
const initialised = (level: DumpLevel, room: number, array: 'w' | 'f' | 'r'): { list: readonly DumpTile[]; count: number } => {
|
|
const mt = level.activation[room]!.mapTiles
|
|
const [list, count] = array === 'w' ? [mt.walls, mt.nWalls] : array === 'f' ? [mt.floors, mt.nFloors] : [mt.roofs, mt.nShadows]
|
|
if (count < 0 || count > list.length) {
|
|
throw new Error(`level ${level.id} room ${room}: ${count} initialised ${TILE_KIND_OF_ARRAY[array]} tiles but ${list.length} allocated`)
|
|
}
|
|
return { list, count }
|
|
}
|
|
|
|
const tiles: DrlgTileInput[] = []
|
|
const units: DrlgUnitInput[] = []
|
|
const warps: DrlgWarpInput[] = []
|
|
level.rooms.forEach((room, r) => {
|
|
const what = `level ${levelId} room ${r}`
|
|
const mt = level.activation[r]!.mapTiles
|
|
for (const array of ['w', 'f', 'r'] as const) {
|
|
const { list, count } = initialised(level, r, array)
|
|
for (let i = 0; i < count; i += 1) {
|
|
tiles.push(place(`${levelId}/${r}/${array}/${i}`, TILE_KIND_OF_ARRAY[array], room.coord, list[i]!))
|
|
}
|
|
}
|
|
for (const [, ref] of mt.links) {
|
|
if (ref === null) throw new Error(`${what}: a map link without a tile`)
|
|
const [refLevel, refRoom, array, index] = ref
|
|
const owner = levelsById.get(refLevel)
|
|
const ownerRoom = owner?.rooms[refRoom]
|
|
if (owner === undefined || ownerRoom === undefined || owner.activation[refRoom] === undefined) {
|
|
throw new Error(`${what}: linked tile ${ref.join('/')} is in a room the dump did not activate`)
|
|
}
|
|
const { list, count } = initialised(owner, refRoom, array)
|
|
if (index < 0 || index >= count) {
|
|
throw new Error(`${what}: linked tile ${ref.join('/')} is not one of the owner's ${count} initialised tiles`)
|
|
}
|
|
tiles.push(place(ref.join('/'), TILE_KIND_OF_ARRAY[array], ownerRoom.coord, list[index]!))
|
|
}
|
|
const subDX = SUBTILES_PER_TILE * (room.coord[0] - levelX)
|
|
const subDY = SUBTILES_PER_TILE * (room.coord[1] - levelY)
|
|
for (const [type, index, mode, x, y, spawned] of mt.units) {
|
|
units.push({ room: r, type, index, mode, subX: x + subDX, subY: y + subDY, spawned })
|
|
}
|
|
for (const [destination, row] of mt.warps) {
|
|
if (destination === null) throw new Error(`${what}: a warp without a destination room`)
|
|
const record = tables.lvlWarp[row]
|
|
if (record === undefined) throw new Error(`${what}: warp to level ${destination[0]} has LvlWarp.txt row ${row}`)
|
|
warps.push({ room: r, lvlWarpId: record.dwLevelId, toLevelId: destination[0] })
|
|
}
|
|
})
|
|
|
|
const orths: DrlgOrthInput[] = levelGrid.roomData.map(o => {
|
|
if (o.level < 0 || o.box === null) throw new Error(`level ${levelId}: an outdoor link without a neighbour level`)
|
|
return { level: o.level, dir: o.dir, box: { x: o.box[0], y: o.box[1], width: o.box[2], height: o.box[3] } }
|
|
})
|
|
return {
|
|
levelId,
|
|
drlgType: act.drlgType,
|
|
levelType: act.levelType,
|
|
coord: { x: levelX, y: levelY, width: levelW, height: levelH },
|
|
flags: levelGrid.flags >>> 0,
|
|
orths,
|
|
grid2,
|
|
tiles,
|
|
units,
|
|
warps,
|
|
}
|
|
}
|