diablo2-web/src/game/d2map.ts

1126 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Diablo II's own map projection: a DS1 level plus the DT1 libraries it
* references, composited into something renderable and walkable.
*
* This is deliberately *not* `map.ts`. That module places one library on a
* rectangular grid, which is what the synthetic fixtures look like. Real Diablo
* II levels use an isometric lattice, and the difference is not cosmetic:
*
* 1. **Cells are diamonds.** A cell at `(cx, cy)` sits at
* `((cx - cy) * 80, (cx + cy) * 40)` — the projection OpenDiablo2's viewport
* calls `WorldToOrtho`. The 5×5 collision grid inside a cell is therefore
* 16×8 per sub-tile, not 32×32.
* 2. **A DS1 cell's `style` picks the library, not the tile.** Style indexes the
* level type's `File 1..File 32` list (`LvlTypes.txt`), and each DT1 in that
* list numbers its own tiles by `sequence` — so a lookup keyed on
* `style:sequence` inside one merged library (what `map.ts` does) finds
* nothing as soon as a level uses more than one library.
* 3. **Empty slots are explicit.** A cell carries a fixed number of wall slots
* and most of them are placeholders: in Act 1's town, 4275 of 4674 wall
* entries have `prop1 == 0`. Drawing them paints garbage, which is why the
* reference renderer tests `prop1 != 0`.
* 4. **Walls hang above their cell.** A wall's art starts at a negative block
* `y` and is shifted down by `-minBlockY` when decoded, so it must be drawn
* at `cell + minBlockY + 80`; floors ignore that term entirely.
*/
import type { Ds1 } from '../formats/ds1.ts'
import type { Dt1, Dt1Tile } from '../formats/dt1.ts'
import { isAnimatedTile } from '../formats/dt1.ts'
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
import { SUB_TILES_PER_TILE } from './map.ts'
/** Cell width in screen pixels (the isometric diamond's width). */
export const ORTHO_CELL_WIDTH = 80
/** Cell height in screen pixels (half the diamond's width: a 2:1 projection). */
export const ORTHO_CELL_HEIGHT = 40
/** Sub-tile width in screen pixels. */
export const ORTHO_SUB_TILE_WIDTH = ORTHO_CELL_WIDTH / SUB_TILES_PER_TILE
/** Sub-tile height in screen pixels. */
export const ORTHO_SUB_TILE_HEIGHT = ORTHO_CELL_HEIGHT / SUB_TILES_PER_TILE
/** Horizontal shift applied to every tile bitmap (half a cell). */
const TILE_ANCHOR_X = -80
/** Vertical shift added to walls: the tail of `YAdjust = minBlockY + 80`. */
const WALL_SURFACE_HEIGHT = 80
/**
* DT1 `type` of a shadow tile.
*
* The engine asks for the shadow art with this type (`d2maprenderer/renderer.go`
* `getImageCacheRecord(style, sequence, 13, ...)`), so a DS1 shadow layer's
* reference is resolved against type 13 tiles, not against floors or walls.
*/
const SHADOW_TILE_TYPE = 13
/**
* DT1 `type` of a floor tile.
*
* DS1 floor records carry no type field; the engine resolves them as DT1 type 0
* (`d2mapstamp/stamp.go` asks for `TileData(style, sequence, 0)`), and it is the
* only type a floor slot may draw.
*/
const FLOOR_TILE_TYPE = 0
/**
* DS1 wall type of a roof tile.
*
* OpenDiablo2's tile-type enum puts `TileRoof` at 15 (`d2enum/tile.go`), and the
* engine draws roofs in a pass of their own, last of all, with a different
* vertical offset: `YAdjust = -roofHeight` instead of `minBlockY + 80`
* (`d2maprenderer/tile_cache.go`). A roof therefore has to stay out of the wall
* painter order and out of the wall offset rule.
*/
const ROOF_WALL_TYPE = 15
/**
* DS1 wall types of the two halves of a "top" (north) corner.
*
* The engine never draws the right half alone: `DRLGROOMTILE_InitWallTileData`
* (`D2Common/DRLG/RoomTile.cpp`) adds a `TILETYPE_WALL_TOP_CORNER_RIGHT` wall and
* then, in the same cell and with the same packed tile information, a second
* `TILETYPE_WALL_TOP_CORNER_LEFT` tile — fetched from the tile cache with the same
* style/sequence. Without that companion the corner is missing its left face, which
* is what a "broken corner block" looks like on screen.
*/
const TOP_CORNER_RIGHT_TYPE = 3
/** The companion half of {@link TOP_CORNER_RIGHT_TYPE}. */
const TOP_CORNER_LEFT_TYPE = 4
/** DS1 wall type for left-facing door wall (`TILETYPE_WALL_LEFT_DOOR`). */
const LEFT_DOOR_WALL_TYPE = 8
/** DS1 wall type for right-facing door wall (`TILETYPE_WALL_RIGHT_DOOR`). */
const RIGHT_DOOR_WALL_TYPE = 9
/**
* Blizzard D2 1.13c 16-bit Multi-Layer Collision Mask Flags (`D2C_CollisionMaskFlags`).
* Reference: D2Common.dll (v1.13c) / D2MOO `include/D2Collision.h`.
*/
export const COLLIDE_NONE = 0x0000
/** 'black space' in arcane sanctuary, cliff walls, water/river walk barriers. Blocks ground walking. */
export const COLLIDE_WALL = 0x0001
/** Tile-based solid obstacles that block line of sight (LoS) and ranged targeting. */
export const COLLIDE_VISIBLE = 0x0002
/** Obstacles that block flying projectiles / missiles and leap/teleport barriers. */
export const COLLIDE_MISSILE_BARRIER = 0x0004
/** Blocks player movement specifically (but not necessarily monsters). */
export const COLLIDE_NOPLAYER = 0x0008
/** Preset floor/tile flag. */
export const COLLIDE_PRESET = 0x0010
/** Returned if the sub-tile is out-of-bounds / invalid. */
export const COLLIDE_BLANK = 0x0020
/** Occupied by an active missile. */
export const COLLIDE_MISSILE = 0x0040
/** Occupied by a player unit. */
export const COLLIDE_PLAYER = 0x0080
/** Water / river terrain flag. */
export const COLLIDE_WATER = 0x00c0
/** Occupied by a monster unit. */
export const COLLIDE_MONSTER = 0x0100
/** Occupied by a ground item. */
export const COLLIDE_ITEM = 0x0200
/** Solid object (shrine, chest, barrel, obstacle). */
export const COLLIDE_OBJECT = 0x0400
/** Closed door barrier (can be cleared via `collisionResetMask` when opened). */
export const COLLIDE_DOOR = 0x0800
/** Unit / pathing restriction flag. */
export const COLLIDE_NO_PATH = 0x1000
/** Attackable pet / summon presence. */
export const COLLIDE_PET = 0x2000
export const COLLIDE_4000 = 0x4000
/** Dead monster corpse / portal flag. */
export const COLLIDE_CORPSE = 0x8000
export const COLLIDE_ALL_MASK = 0xffff
/** Composite mask for invalid sub-tiles (`D2Collision.h`). */
export const COLLIDE_MASK_INVALID =
COLLIDE_BLANK | COLLIDE_MISSILE_BARRIER | COLLIDE_VISIBLE | COLLIDE_WALL
/** Composite mask for player ground pathfinding (`COLLIDE_MASK_PLAYER_PATH`). */
export const COLLIDE_MASK_PLAYER_PATH =
COLLIDE_WALL | COLLIDE_NOPLAYER | COLLIDE_OBJECT | COLLIDE_DOOR | COLLIDE_NO_PATH
/** Composite mask for flying skills / leap (`COLLIDE_MASK_PLAYER_FLYING`). */
export const COLLIDE_MASK_PLAYER_FLYING =
COLLIDE_DOOR | COLLIDE_MISSILE_BARRIER
/** Composite mask for Whirlwind (`COLLIDE_MASK_PLAYER_WW`). */
export const COLLIDE_MASK_PLAYER_WW =
COLLIDE_WALL | COLLIDE_OBJECT | COLLIDE_DOOR
/** Composite mask for projectiles / missiles (arrows, bolts, spells). */
export const COLLIDE_MASK_MISSILE =
COLLIDE_MISSILE_BARRIER | COLLIDE_OBJECT | COLLIDE_DOOR
/** Composite mask for Line-of-Sight (LoS) raycasting. */
export const COLLIDE_MASK_LOS =
COLLIDE_VISIBLE | COLLIDE_DOOR
/** Result of a 1.13c sub-tile `COLLISION_RayTrace` check. */
export interface RayTraceResult {
/** True if the ray struck a sub-tile matching the collision mask before reaching the target. */
readonly hit: boolean
/** Sub-tile X where the ray terminated (either the hit sub-tile or the target sub-tile). */
readonly hitSubX: number
/** Sub-tile Y where the ray terminated (either the hit sub-tile or the target sub-tile). */
readonly hitSubY: number
/** Scene-space X corresponding to `hitSubX` / `hitSubY`. */
readonly hitX: number
/** Scene-space Y corresponding to `hitSubX` / `hitSubY`. */
readonly hitY: number
/** Number of sub-tile steps traversed along the ray. */
readonly steps: number
}
/** One tile to draw, in screen pixels. */
export interface IsoDraw {
/** Index into {@link IsoMapScene.frames}. */
readonly frameIndex: number
/**
* Sequence of frame indices for animated tiles (e.g. water ripples, torches, fountains, lava).
*
* When present and containing more than 1 frame, the tile is animated and
* the renderer can cycle through these frame indices over time or game ticks.
*/
readonly animatedFrames?: readonly number[] | undefined
/** Screen x of the bitmap's left edge, origin applied. */
readonly x: number
/** Screen y of the bitmap's top edge, origin applied. */
readonly y: number
/** Cell coordinates, for depth sorting and entity insertion. */
readonly cellX: number
readonly cellY: number
/** Library index this tile came from, for diagnostics. */
readonly library: number
/** The library's tile index, for diagnostics. */
readonly tile: number
/** Roof elevation height from DT1 tile record, if this is a roof tile. */
readonly roofHeight?: number
}
/** A renderable, walkable Diablo II level. */
export interface IsoMapScene {
/** One frame per distinct tile this level actually uses. */
readonly frames: readonly SpriteFrame[]
/** The same frames as a sheet, ready for the atlas packer. */
readonly sheet: SpriteSheet
/** Cells horizontally. */
readonly cellsX: number
/** Cells vertically. */
readonly cellsY: number
/** Scene width in pixels, origin applied. */
readonly widthPx: number
/** Scene height in pixels, origin applied. */
readonly heightPx: number
/** Screen x added to every coordinate so the minimum is 0. */
readonly originX: number
/** Screen y added to every coordinate so the minimum is 0. */
readonly originY: number
/** Floor draws, in cell order. */
readonly floors: readonly IsoDraw[]
/** Wall draws, in painter's order (isometric depth). */
readonly walls: readonly IsoDraw[]
/**
* Roof draws, in painter's order.
*
* The engine paints roofs in a pass of their own, after everything else
* (`d2maprenderer/renderer.go` pass 4), so they must not be mixed into
* {@link IsoMapScene.walls}: a roof covers the floor, the walls *and* whatever
* walks under it.
*/
readonly roofs: readonly IsoDraw[]
/** Collision grid, row-major, 1 = blocked, 5×5 per cell. */
readonly blocked: Uint8Array
/**
* 16-bit multi-layer collision masks per sub-tile (`D2C_CollisionMaskFlags`).
* Row-major, 5×5 per cell (`gridWidth * gridHeight`).
*/
readonly collisionMasks: Uint16Array
/** Collision grid width in sub-tiles. */
readonly gridWidth: number
/** Collision grid height in sub-tiles. */
readonly gridHeight: number
/** References that resolved to no tile (missing library or sequence). */
readonly missingTiles: number
/**
* The unresolved references themselves, capped for display.
*
* Diablo II's DS1 files contain a few slots that reference nothing in any
* library — Act 2's town has one wall at `style 30`, a style no Act 2 library
* defines. The game draws nothing for those, so the count is reported rather
* than treated as a decoding failure; a real chain break shows up as hundreds
* of them, which is why the caller can still threshold on the count.
*/
readonly missingRefs: readonly string[]
/**
* Wall references whose art needed a taller bitmap than the tile's declared
* height (an expected quirk of real libraries, not an error).
*/
readonly clippedTiles: number
/** References that several libraries could satisfy (first library wins). */
readonly duplicateRefs: number
/**
* Sub-tiles that became blocked **only** because a shadow layer blocked them.
*
* The engine unions the sub-tile flags of every layer of a cell before reading
* walkability, and shadows are a layer of their own (`type 13`). If this is ever
* non-zero, dropping shadows from the union would have changed the map.
*/
readonly shadowBlockedSubtiles: number
/**
* References that only the type-agnostic fallback could satisfy.
*
* Should be 0: a non-zero value means a reference drew a tile of the wrong
* *type* (a wall where a floor belongs), which is exactly the defect
* `npm run verify:tiles` exists to catch.
*/
readonly looseRefs: number
/**
* Top-corner-right walls that got their left half drawn as well.
*
* The engine always adds the companion tile (`DRLGROOMTILE_InitWallTileData`), so
* this should equal the number of type-3 walls in the level.
*/
readonly cornerPairs: number
/** Top-corner-right walls whose left half could not be resolved (should be 0). */
readonly missingCornerPairs: number
/** Number of animated tiles in this scene. */
readonly animatedTileCount?: number | undefined
}
/**
* Composite a library tile's blocks into one indexed bitmap.
*
* The bitmap is the size Diablo II allocates for the tile: `width` by
* `|height|`. Blocks were already shifted into that box by the decoder.
*
* @param tile - the library tile.
* @returns the frame.
*/
function tileToFrame(tile: Dt1Tile): SpriteFrame {
const width = Math.max(tile.width, 1)
const height = Math.max(tile.bitmapHeight, 1)
const indices = new Uint8Array(width * height)
const mask = new Uint8Array(width * height)
for (const block of tile.blocks) {
for (let at = 0; at < indices.length && at < block.pixels.length; at += 1) {
const value = block.pixels[at]!
if (value === 0) continue
indices[at] = value
mask[at] = 1
}
}
return { width, height, indices, mask }
}
/** One tile of the merged pool. */
export interface PoolTile {
readonly library: number
readonly tile: number
/** Variant weight (`Dt1Tile.rarityFrameIndex`) used when picking per cell. */
readonly weight: number
/** Whether this tile is part of an animated sequence. */
readonly animated: boolean
/** 0-based animation frame index when animated is true. */
readonly frameIndex: number
}
/** The merged pool a DS1's references are resolved against. */
interface TilePool {
readonly exact: Map<string, PoolTile[]>
readonly loose: Map<string, PoolTile[]>
/** References that more than one tile could satisfy (the random variants). */
duplicates: number
}
/**
* Merge every library of a level into one lookup pool.
*
* The key insight — and the one that a single-library reader gets wrong — is
* that a DS1 cell's `style` matches the **tile's own `style` field inside a
* DT1**, not the position of the DT1 in the level type's file list. Act 1's
* `River.dt1` holds tiles with internal styles 2 and 3 while `Fence.dt1` holds
* only style 0, so treating `style` as a library index looks plausible (it
* resolves most of the town) and then misses every reference the real styles
* would have caught.
*
* @param libraries - the level type's libraries, in file-list order.
* @returns the pool.
*/
function mergeLibraries(libraries: readonly Dt1[]): TilePool {
const exact = new Map<string, PoolTile[]>()
const loose = new Map<string, PoolTile[]>()
let duplicates = 0
const push = (map: Map<string, PoolTile[]>, key: string, entry: PoolTile, countDuplicate: boolean): void => {
const bucket = map.get(key)
if (bucket === undefined) { map.set(key, [entry]); return }
if (countDuplicate) duplicates += 1
bucket.push(entry)
}
libraries.forEach((library, libraryIndex) => {
library.tiles.forEach((tile, tileIndex) => {
const animated = isAnimatedTile(tile)
const entry: PoolTile = {
library: libraryIndex,
tile: tileIndex,
weight: tile.rarityFrameIndex,
animated,
frameIndex: animated ? Math.max(0, tile.rarityFrameIndex) : 0,
}
// The third component is the DS1 wall `type` and it matches the DT1 tile's
// **`Type` field at offset +20**, not its `Direction` field at +0. `Direction`
// is the orientation/variant index (1..5 in practice) and never equals the
// semantic types (14 tree, 15 roof, ...); keying on it sent every roof/tree
// reference through the loose fallback, which drew a floor tile where a tent
// canopy belonged. Multiple tiles can share style:sequence:type — those are
// the real variants `pickVariant` draws among.
push(exact, `${String(tile.style)}:${String(tile.sequence)}:${String(tile.type)}`, entry, true)
push(loose, `${String(tile.style)}:${String(tile.sequence)}`, entry, false)
})
})
return { exact, loose, duplicates }
}
/**
* Pick one variant out of a `style:sequence` group, the way the engine does.
*
* A DT1 ships several tiles for the same `style`/`sequence` (different grass
* patches, stone patterns, torch variants), and the engine chooses **per cell**
* with a weighted random draw whose weight is the tile's `RarityFrameIndex`,
* seeded from the map seed and the cell's own `x`/`y`. This is the port of
* OpenDiablo2's `getRandomTile` (`d2mapengine/map_tile.go`):
*
* tileSeed = (seed + x) * y; tileSeed ^= tileSeed << 13; ^= >> 17; ^= << 5
* random = tileSeed % Σ weight; first tile whose running sum >= random wins
*
* The seed is a per-level constant (the DS1 member name hashed) rather than a
* random game seed, so the offline bake and the in-browser decode pick the *same*
* variant — that is what keeps `verify:packs` a byte comparison.
*
* @param candidates - the group, in library/file order.
* @param cellX - cell x, the way the engine seeds it.
* @param cellY - cell y.
* @param seed - per-level seed.
* @returns the chosen tile, or undefined for an empty group.
*/
export function pickVariant(
candidates: readonly PoolTile[] | undefined,
cellX: number,
cellY: number,
seed: number,
): PoolTile | undefined {
if (candidates === undefined || candidates.length === 0) return undefined
if (candidates.length === 1) return candidates[0]
const mask = (1n << 64n) - 1n
const x = BigInt(cellX >>> 0)
const y = BigInt(cellY >>> 0)
const s = BigInt(seed >>> 0)
let state = (s ^ (x * 0x9e3779b97f4a7c15n) ^ (y * 0xc6a4a7935bd1e995n)) & mask
state = (state + ((x << 32n) | y)) & mask
state = (state ^ (state >> 30n)) * 0xbf58476d1ce4e5b9n & mask
state = (state ^ (state >> 27n)) * 0x94d049bb133111ebn & mask
state = (state ^ (state >> 31n)) & mask
let total = 0
for (const candidate of candidates) total += Math.max(0, candidate.weight | 0)
if (total === 0) return candidates[0]
const roll = Number(state % BigInt(total))
let running = 0
for (const candidate of candidates) {
running += Math.max(0, candidate.weight | 0)
if (running > roll) return candidate
}
return candidates[candidates.length - 1]
}
/**
* Result of resolving a candidate group.
*/
interface ResolvedCandidates {
readonly tile: PoolTile | undefined
readonly animatedTiles?: readonly PoolTile[] | undefined
}
/**
* Resolve a candidate group from the pool:
* If the candidates are animated tiles, sort them by frameIndex (0..N-1) and return
* the base frame as `tile` and the sequence as `animatedTiles`.
* Otherwise, pick one variant per cell using weighted random draw.
*/
function resolveCandidates(
candidates: readonly PoolTile[] | undefined,
cellX: number,
cellY: number,
seed: number,
): ResolvedCandidates {
if (candidates === undefined || candidates.length === 0) {
return { tile: undefined }
}
const isAnim = candidates.some(c => c.animated)
if (isAnim) {
const sorted = [...candidates].sort((a, b) => a.frameIndex - b.frameIndex)
return {
tile: sorted[0],
animatedTiles: sorted.length > 1 ? sorted : undefined,
}
}
return {
tile: pickVariant(candidates, cellX, cellY, seed),
}
}
/**
* Resolve a DS1 reference against the pool.
*
* @param pool - the merged pool.
* @param style - DS1 style field.
* @param sequence - DS1 sequence field.
* @param type - DS1 wall type (the third field of the reference).
* @returns the resolved pool tile and animated sequence.
*/
function resolveExactType(
pool: TilePool,
style: number,
sequence: number,
type: number,
cellX: number,
cellY: number,
seed: number,
): ResolvedCandidates {
return resolveCandidates(pool.exact.get(`${String(style)}:${String(sequence)}:${String(type)}`), cellX, cellY, seed)
}
/**
* Resolve a DS1 reference against the pool.
*
* @param pool - the merged pool.
* @param style - DS1 style field.
* @param sequence - DS1 sequence field.
* @param type - DS1 wall type (the third field of the reference), or null for floors.
* @returns the pool tile, animated tiles if any, and whether it matched via loose fallback.
*/
function resolveWithSource(
pool: TilePool,
style: number,
sequence: number,
type: number,
cellX: number,
cellY: number,
seed: number,
): { tile: PoolTile | undefined; animatedTiles?: readonly PoolTile[] | undefined; viaLoose: boolean } {
const key = `${String(style)}:${String(sequence)}`
const exact = resolveExactType(pool, style, sequence, type, cellX, cellY, seed)
if (exact.tile !== undefined) return { tile: exact.tile, animatedTiles: exact.animatedTiles, viaLoose: false }
const loose = resolveCandidates(pool.loose.get(key), cellX, cellY, seed)
return { tile: loose.tile, animatedTiles: loose.animatedTiles, viaLoose: true }
}
/**
* The per-level variant seed for {@link buildIsoMapScene}.
*
* Derived from the DS1 member name so it is stable across processes: the packer
* hashes the name it read out of the archives, and the browser hashes the name it
* is about to fetch, so both draw the same tile variants.
*
* @param name - the DS1 member name, e.g. `data\global\tiles\Act1\Town\townN1.ds1`.
* @returns a 32-bit seed.
*/
export function levelSeed(name: string): number {
let hash = 0x811c9dc5
for (let index = 0; index < name.length; index += 1) {
hash ^= name.charCodeAt(index)
hash = Math.imul(hash, 0x01000193) >>> 0
}
return hash >>> 0
}
/**
* Build the renderable scene.
*
* @param level - the decoded DS1.
* @param libraries - the level type's DT1 libraries, in `File 1..N` order, so a
* cell's `style` can index straight into it.
* @param seed - per-level seed for the per-cell tile variant draw. Both the
* offline bake and the in-browser decode must pass the same value (the packer
* uses {@link levelSeed}); 0 keeps the old deterministic first-variant result.
* @returns the scene.
*/
export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0): IsoMapScene {
const pool = mergeLibraries(libraries)
const frames: SpriteFrame[] = []
/** `library:tile` → frame index, so repeated references share one frame. */
const frameOfTile = new Map<string, number>()
const frameFor = (libraryIndex: number, tileIndex: number): number => {
const key = `${String(libraryIndex)}:${String(tileIndex)}`
const existing = frameOfTile.get(key)
if (existing !== undefined) return existing
const index = frames.length
frames.push(tileToFrame(libraries[libraryIndex]!.tiles[tileIndex]!))
frameOfTile.set(key, index)
return index
}
const cellsX = level.width
const cellsY = level.height
const gridWidth = cellsX * SUB_TILES_PER_TILE
const gridHeight = cellsY * SUB_TILES_PER_TILE
const blocked = new Uint8Array(gridWidth * gridHeight)
const collisionMasks = new Uint16Array(gridWidth * gridHeight)
const rawFloors: IsoDraw[] = []
const rawWalls: IsoDraw[] = []
/** Roof draws (`wall.type` 15): kept apart so they can be painted last. */
const rawRoofs: IsoDraw[] = []
let missingTiles = 0
let clippedTiles = 0
const missingRefs: string[] = []
const noteMissing = (kind: string, style: number, sequence: number, direction: number | null, cellX: number, cellY: number): void => {
missingTiles += 1
if (missingRefs.length < 20) {
const dirText = direction === null ? '' : `:${String(direction)}`
missingRefs.push(`${kind} style=${String(style)} sequence=${String(sequence)}${dirText} @cell(${String(cellX)},${String(cellY)})`)
}
}
// The engine ORs the sub-tile flags of *every* layer of a cell (floor, wall,
// shadow) and then reads walkability off the combined flags — OpenDiablo2's
// `SubTileFlags.Combine` is a plain `||` of every bit (`d2dt1/subtile.go`),
// and D2MOO `sub_6FD411F0` (`D2Collision.cpp`) bitwise-ORs each DT1 subtile
// byte directly into the 16-bit `pCollisionMask` array.
let shadowBlocked = 0
let looseRefs = 0
let cornerPairs = 0
let missingCornerPairs = 0
const stamp = (cellX: number, cellY: number, tile: Dt1Tile, isDoorWall = false): void => {
for (let subY = 0; subY < SUB_TILES_PER_TILE; subY += 1) {
for (let subX = 0; subX < SUB_TILES_PER_TILE; subX += 1) {
const flags = tile.subTileFlags[subY * SUB_TILES_PER_TILE + subX]
if (flags === undefined) continue
const gx = cellX * SUB_TILES_PER_TILE + subX
const gy = cellY * SUB_TILES_PER_TILE + subY
if (gx < 0 || gx >= gridWidth || gy < 0 || gy >= gridHeight) continue
const idx = gy * gridWidth + gx
if (flags.raw !== 0) {
let maskBits = flags.raw & 0xffff
if (isDoorWall && (flags.blockWalk || flags.blockPlayerWalk)) {
maskBits |= COLLIDE_DOOR
}
collisionMasks[idx] = (collisionMasks[idx] ?? 0) | maskBits
}
if (!flags.blockWalk && !flags.blockPlayerWalk) continue
blocked[idx] = 1
}
}
}
for (let cellY = 0; cellY < cellsY; cellY += 1) {
for (let cellX = 0; cellX < cellsX; cellX += 1) {
const cell = level.cells[cellY]?.[cellX]
const isLavaOnlyFloor =
level.floorLayers >= 2
&& cell !== undefined
&& cell.floors[0] !== undefined
&& !cell.floors[0].hidden
&& cell.floors[0].prop1 !== 0
&& cell.floors[0].style === 20
&& cell.floors.slice(1).every(f => f.hidden || f.prop1 === 0)
const hasNoValidFloor =
cell === undefined
|| cell.floors.length === 0
|| cell.floors.every(f => f.hidden || f.prop1 === 0)
|| isLavaOnlyFloor
if (hasNoValidFloor) {
for (let subY = 0; subY < SUB_TILES_PER_TILE; subY += 1) {
for (let subX = 0; subX < SUB_TILES_PER_TILE; subX += 1) {
const gx = cellX * SUB_TILES_PER_TILE + subX
const gy = cellY * SUB_TILES_PER_TILE + subY
const idx = gy * gridWidth + gx
blocked[idx] = 1
collisionMasks[idx] = (collisionMasks[idx] ?? 0) | COLLIDE_MASK_INVALID
}
}
}
if (cell === undefined) continue
const orthoX = (cellX - cellY) * ORTHO_CELL_WIDTH
const orthoY = (cellX + cellY) * ORTHO_CELL_HEIGHT
for (const floor of cell.floors) {
// A zero `prop1` marks an unused slot, which most wall slots are.
if (floor.hidden || floor.prop1 === 0) continue
// A DS1 floor record has no `type` field: the engine reads it as DT1
// **type 0**. Passing `null` here (before this fix) sent every floor through
// the type-agnostic fallback, whose pool mixes floors, walls, pillars,
// shadows, trees and roofs — so a weighted-random draw could put a dark wall
// tile in a floor slot, drawn with the floor's offset (no `minBlockY + 80`),
// which is the "misplaced black block on the ground" defect.
const picked = resolveWithSource(pool, floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY, seed)
if (picked.viaLoose) looseRefs += 1
const found = picked.tile
if (found === undefined) { noteMissing('floor', floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY); continue }
const tile = libraries[found.library]!.tiles[found.tile]!
const animatedFrames = picked.animatedTiles?.map(t => frameFor(t.library, t.tile))
rawFloors.push({
frameIndex: frameFor(found.library, found.tile),
...(animatedFrames && animatedFrames.length > 1 ? { animatedFrames } : {}),
x: orthoX + TILE_ANCHOR_X,
y: orthoY,
cellX, cellY, library: found.library, tile: found.tile,
})
stamp(cellX, cellY, tile)
}
// Shadows are a collision-only layer here: they are resolved exactly like the
// engine resolves them (`type 13`) and folded into the grid, but nothing is
// drawn from them.
for (const shadow of cell.shadows) {
if (shadow.prop1 === 0) continue
// Shadows must come from a **type 13** tile specifically: the loose
// fallback ignores the type field and would happily return a wall tile,
// stamping its blocking flags as if they were a shadow's.
const found = resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed).tile
if (found === undefined) continue
const tile = libraries[found.library]!.tiles[found.tile]!
for (let sub = 0; sub < SUB_TILES_PER_TILE * SUB_TILES_PER_TILE; sub += 1) {
const flags = tile.subTileFlags[sub]
if (flags === undefined) continue
const gx = cellX * SUB_TILES_PER_TILE + (sub % SUB_TILES_PER_TILE)
const gy = cellY * SUB_TILES_PER_TILE + Math.floor(sub / SUB_TILES_PER_TILE)
if (gx < 0 || gx >= gridWidth || gy < 0 || gy >= gridHeight) continue
const idx = gy * gridWidth + gx
if (flags.raw !== 0) {
collisionMasks[idx] = (collisionMasks[idx] ?? 0) | (flags.raw & 0xffff)
}
if (!flags.blockWalk && !flags.blockPlayerWalk) continue
if (blocked[idx] === 0) shadowBlocked += 1
blocked[idx] = 1
}
}
for (const wall of cell.walls) {
if (wall.hidden || wall.prop1 === 0) continue
const pickedWall = resolveWithSource(pool, wall.style, wall.sequence, wall.type, cellX, cellY, seed)
if (pickedWall.viaLoose) looseRefs += 1
const found = pickedWall.tile
if (found === undefined) { noteMissing('wall', wall.style, wall.sequence, wall.type, cellX, cellY); continue }
const tile = libraries[found.library]!.tiles[found.tile]!
if (tile.bitmapHeight > Math.abs(tile.height)) clippedTiles += 1
const animatedFrames = pickedWall.animatedTiles?.map(t => frameFor(t.library, t.tile))
const draw = {
frameIndex: frameFor(found.library, found.tile),
...(animatedFrames && animatedFrames.length > 1 ? { animatedFrames } : {}),
x: orthoX + TILE_ANCHOR_X,
// A wall's art extends above its cell, so it is pushed back down by
// exactly the block shift the decoder applied, plus one cell height.
y: orthoY + tile.minBlockY + WALL_SURFACE_HEIGHT,
cellX, cellY, library: found.library, tile: found.tile,
}
if (wall.type === ROOF_WALL_TYPE) {
// Roofs use the engine's own roof offset and are painted after
// everything else, so they leave the wall painter order entirely.
rawRoofs.push({ ...draw, y: orthoY - tile.roofHeight, roofHeight: tile.roofHeight })
} else {
rawWalls.push(draw)
}
const isDoorWall = wall.type === LEFT_DOOR_WALL_TYPE || wall.type === RIGHT_DOOR_WALL_TYPE
stamp(cellX, cellY, tile, isDoorWall)
// A top-corner-right wall drags its left half along (see
// {@link TOP_CORNER_RIGHT_TYPE}): same cell, same style/sequence, resolved as
// a type-4 tile, drawn with its own block shift like any other wall. The
// engine's DS1s never list the left half themselves, so this is the only way
// the corner gets both faces — 411 of them across the 35 preset levels.
if (tile.type === TOP_CORNER_RIGHT_TYPE) {
const companionPicked = resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed)
const companion = companionPicked.tile
if (companion === undefined) {
missingCornerPairs += 1
noteMissing('corner-left', wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY)
} else {
const leftTile = libraries[companion.library]!.tiles[companion.tile]!
const companionAnim = companionPicked.animatedTiles?.map(t => frameFor(t.library, t.tile))
rawWalls.push({
frameIndex: frameFor(companion.library, companion.tile),
...(companionAnim && companionAnim.length > 1 ? { animatedFrames: companionAnim } : {}),
x: orthoX + TILE_ANCHOR_X,
y: orthoY + leftTile.minBlockY + WALL_SURFACE_HEIGHT,
cellX, cellY, library: companion.library, tile: companion.tile,
})
cornerPairs += 1
stamp(cellX, cellY, leftTile)
}
}
}
}
}
// Isometric painter's order: depth grows along the diagonal, so walls sort by
// (cellX + cellY) and then row, which keeps a nearer wall in front of the one
// behind it without any depth buffer.
rawWalls.sort((a, b) => (a.cellY + a.cellX) - (b.cellY + b.cellX) || a.cellY - b.cellY || a.x - b.x)
rawRoofs.sort((a, b) => (a.cellY + a.cellX) - (b.cellY + b.cellX) || a.cellY - b.cellY || a.x - b.x)
// Shift everything positive: `(cellX - cellY)` is negative on half the map.
let minX = 0
let minY = 0
let maxX = 0
let maxY = 0
for (const draw of [...rawFloors, ...rawWalls, ...rawRoofs]) {
const frame = frames[draw.frameIndex]!
minX = Math.min(minX, draw.x)
minY = Math.min(minY, draw.y)
maxX = Math.max(maxX, draw.x + frame.width)
maxY = Math.max(maxY, draw.y + frame.height)
}
const originX = -minX
const originY = -minY
const shift = (draw: (typeof rawFloors)[number]): IsoDraw => ({ ...draw, x: draw.x + originX, y: draw.y + originY })
const floors = rawFloors.map(shift)
const walls = rawWalls.map(shift)
const roofs = rawRoofs.map(shift)
const animatedTileCount = [...floors, ...walls, ...roofs].filter(
d => d.animatedFrames && d.animatedFrames.length > 1
).length
return {
frames,
sheet: { groups: [{ frames }], width: null },
cellsX,
cellsY,
widthPx: maxX - minX,
heightPx: maxY - minY,
originX,
originY,
floors,
walls,
roofs,
blocked,
collisionMasks,
gridWidth,
gridHeight,
missingTiles,
missingRefs,
clippedTiles,
shadowBlockedSubtiles: shadowBlocked,
looseRefs,
cornerPairs,
missingCornerPairs,
duplicateRefs: pool.duplicates,
animatedTileCount,
}
}
/**
* The parts of a scene that collision and projection need.
*
* A packed map and a freshly decoded one share these fields but not their
* storage (one has an atlas, the other PNG pages), so the helpers below ask for
* this shape instead of the whole scene.
*/
export interface CollisionGrid {
/** Screen x added to every coordinate so the minimum is 0. */
readonly originX: number
/** Screen y added to every coordinate so the minimum is 0. */
readonly originY: number
/** Cells horizontally. */
readonly cellsX: number
/** Cells vertically. */
readonly cellsY: number
/** Collision grid, 1 = blocked. */
readonly blocked: Uint8Array
/** Collision grid width in sub-tiles. */
readonly gridWidth: number
/** Optional 16-bit collision mask array (Blizzard D2 1.13c D2C_CollisionMaskFlags). */
readonly collisionMasks?: Uint16Array | undefined
}
/**
* The cell containing a scene-space point.
*
* Inverts the isometric projection: `cx = (x/80 + y/40) / 2`, `cy = (y/40 -
* x/80) / 2`.
*
* @param scene - the scene.
* @param x - scene-space x.
* @param y - scene-space y.
* @returns cell coordinates, unrounded.
*/
export function cellAt(scene: CollisionGrid, x: number, y: number): { x: number; y: number } {
const px = x - scene.originX
const py = y - scene.originY
return {
x: (px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2,
y: (py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2,
}
}
/**
* Convert a scene-space point to sub-tile grid coordinates (`0 .. cellsX*5 - 1`).
*
* @param scene - the scene.
* @param x - scene-space x.
* @param y - scene-space y.
* @returns integer sub-tile coordinates and whether they fall within the map bounds.
*/
export function subTileAt(
scene: CollisionGrid,
x: number,
y: number,
): { subX: number; subY: number; inBounds: boolean } {
const cell = cellAt(scene, x, y)
const cellX = Math.floor(cell.x)
const cellY = Math.floor(cell.y)
const inBounds = cellX >= 0 && cellY >= 0 && cellX < scene.cellsX && cellY < scene.cellsY
const localSubX = Math.min(Math.max(0, Math.floor((cell.x - cellX) * SUB_TILES_PER_TILE)), SUB_TILES_PER_TILE - 1)
const localSubY = Math.min(Math.max(0, Math.floor((cell.y - cellY) * SUB_TILES_PER_TILE)), SUB_TILES_PER_TILE - 1)
const subX = cellX * SUB_TILES_PER_TILE + localSubX
const subY = cellY * SUB_TILES_PER_TILE + localSubY
return { subX, subY, inBounds }
}
/**
* Convert sub-tile grid coordinates (`subX, subY`) back to scene-space centre coordinates.
*
* @param scene - the scene.
* @param subX - sub-tile column (`0 .. cellsX*5 - 1`).
* @param subY - sub-tile row (`0 .. cellsY*5 - 1`).
* @returns scene-space (x, y) centre of the sub-tile diamond.
*/
export function subTileCentre(
scene: CollisionGrid,
subX: number,
subY: number,
): { x: number; y: number } {
const cx = (subX + 0.5) / SUB_TILES_PER_TILE
const cy = (subY + 0.5) / SUB_TILES_PER_TILE
return {
x: (cx - cy) * ORTHO_CELL_WIDTH + scene.originX,
y: (cx + cy) * ORTHO_CELL_HEIGHT + scene.originY,
}
}
/**
* Retrieve the 16-bit `D2C_CollisionMaskFlags` value at sub-tile coordinates `(subX, subY)`.
* Out-of-bounds returns `COLLIDE_MASK_INVALID` (`0x0027`).
* If `scene.collisionMasks` is absent (e.g. legacy test stub), synthesizes from `scene.blocked`.
*/
export function getCollisionMask(scene: CollisionGrid, subX: number, subY: number): number {
const gridHeight = scene.cellsY * SUB_TILES_PER_TILE
if (subX < 0 || subY < 0 || subX >= scene.gridWidth || subY >= gridHeight) {
return COLLIDE_MASK_INVALID
}
const idx = subY * scene.gridWidth + subX
if (scene.collisionMasks !== undefined) {
return scene.collisionMasks[idx] ?? COLLIDE_NONE
}
return scene.blocked[idx] === 1
? (COLLIDE_WALL | COLLIDE_VISIBLE | COLLIDE_MISSILE_BARRIER)
: COLLIDE_NONE
}
/**
* Retrieve the 16-bit `D2C_CollisionMaskFlags` value at a scene-space point `(x, y)`.
*/
export function getCollisionMaskAt(scene: CollisionGrid, x: number, y: number): number {
const { subX, subY, inBounds } = subTileAt(scene, x, y)
if (!inBounds) return COLLIDE_MASK_INVALID
return getCollisionMask(scene, subX, subY)
}
/**
* Blizzard D2 1.13c `COLLISION_CheckMask` (D2Common.dll Ordinal 10118).
* Tests whether the sub-tile at `(subX, subY)` has any bit in `mask` set.
*/
export function collisionCheckMask(
scene: CollisionGrid,
subX: number,
subY: number,
mask: number,
): boolean {
return (getCollisionMask(scene, subX, subY) & mask) !== 0
}
/**
* Blizzard D2 1.13c `COLLISION_SetMask` (D2Common.dll Ordinal 10127).
* Sets `mask` bits on a sub-tile footprint `(subX, subY)` of size `widthSubTiles × heightSubTiles`,
* and synchronizes `scene.blocked` if any `COLLIDE_MASK_PLAYER_PATH` bit is set.
*/
export function collisionSetMask(
scene: CollisionGrid,
subX: number,
subY: number,
mask: number,
widthSubTiles = 1,
heightSubTiles = 1,
): void {
const gridHeight = scene.cellsY * SUB_TILES_PER_TILE
for (let dy = 0; dy < heightSubTiles; dy += 1) {
const gy = subY + dy
if (gy < 0 || gy >= gridHeight) continue
for (let dx = 0; dx < widthSubTiles; dx += 1) {
const gx = subX + dx
if (gx < 0 || gx >= scene.gridWidth) continue
const idx = gy * scene.gridWidth + gx
if (scene.collisionMasks !== undefined) {
scene.collisionMasks[idx] = (scene.collisionMasks[idx] ?? 0) | (mask & 0xffff)
}
if ((mask & COLLIDE_MASK_PLAYER_PATH) !== 0) {
scene.blocked[idx] = 1
}
}
}
}
/**
* Blizzard D2 1.13c `COLLISION_ResetMask` (D2Common.dll Ordinal 10123).
* Clears `mask` bits on a sub-tile footprint `(subX, subY)` (e.g., when a closed door is opened),
* and updates `scene.blocked` according to whether any `COLLIDE_MASK_PLAYER_PATH` bit remains.
*/
export function collisionResetMask(
scene: CollisionGrid,
subX: number,
subY: number,
mask: number,
widthSubTiles = 1,
heightSubTiles = 1,
): void {
const gridHeight = scene.cellsY * SUB_TILES_PER_TILE
for (let dy = 0; dy < heightSubTiles; dy += 1) {
const gy = subY + dy
if (gy < 0 || gy >= gridHeight) continue
for (let dx = 0; dx < widthSubTiles; dx += 1) {
const gx = subX + dx
if (gx < 0 || gx >= scene.gridWidth) continue
const idx = gy * scene.gridWidth + gx
if (scene.collisionMasks !== undefined) {
const nextMask = (scene.collisionMasks[idx] ?? 0) & ~(mask & 0xffff)
scene.collisionMasks[idx] = nextMask
scene.blocked[idx] = (nextMask & COLLIDE_MASK_PLAYER_PATH) !== 0 ? 1 : 0
} else if ((mask & COLLIDE_MASK_PLAYER_PATH) !== 0) {
scene.blocked[idx] = 0
}
}
}
}
/**
* Blizzard D2 1.13c `COLLISION_RayTrace` (D2Common.dll Ordinal 11263).
*
* Performs a Bresenham sub-tile line trace from `(startSubX, startSubY)` to `(endSubX, endSubY)`,
* testing each sub-tile against `mask` (e.g. `COLLIDE_MASK_MISSILE` or `COLLIDE_MASK_LOS`).
* If any visited sub-tile matches `mask`, terminates early and reports the hit coordinate.
*/
export function collisionRayTrace(
scene: CollisionGrid,
startSubX: number,
startSubY: number,
endSubX: number,
endSubY: number,
mask: number = COLLIDE_MASK_MISSILE,
): RayTraceResult {
let x0 = Math.floor(startSubX)
let y0 = Math.floor(startSubY)
const x1 = Math.floor(endSubX)
const y1 = Math.floor(endSubY)
const dx = Math.abs(x1 - x0)
const dy = Math.abs(y1 - y0)
const sx = x0 < x1 ? 1 : -1
const sy = y0 < y1 ? 1 : -1
let err = dx - dy
let steps = 0
while (true) {
// Check collision on current sub-tile (skipping the shooter's exact starting sub-tile at step 0
// so a unit standing at the edge of an obstacle can still fire outward, unless start === end).
if ((steps > 0 || (x0 === x1 && y0 === y1)) && collisionCheckMask(scene, x0, y0, mask)) {
const pt = subTileCentre(scene, x0, y0)
return { hit: true, hitSubX: x0, hitSubY: y0, hitX: pt.x, hitY: pt.y, steps }
}
if (x0 === x1 && y0 === y1) {
break
}
const e2 = 2 * err
if (e2 > -dy) {
err -= dy
x0 += sx
}
if (e2 < dx) {
err += dx
y0 += sy
}
steps += 1
}
const pt = subTileCentre(scene, x1, y1)
return { hit: false, hitSubX: x1, hitSubY: y1, hitX: pt.x, hitY: pt.y, steps }
}
/**
* Perform a 1.13c sub-tile ray trace between two scene-space coordinates `(startX, startY)` and `(endX, endY)`.
*/
export function rayTraceScene(
scene: CollisionGrid,
startX: number,
startY: number,
endX: number,
endY: number,
mask: number = COLLIDE_MASK_MISSILE,
): RayTraceResult {
const start = subTileAt(scene, startX, startY)
const end = subTileAt(scene, endX, endY)
return collisionRayTrace(scene, start.subX, start.subY, end.subX, end.subY, mask)
}
/**
* Whether a scene-space point lies on a blocked sub-tile.
*
* When `mask` is omitted, checks `scene.blocked === 1` (`COLLIDE_MASK_PLAYER_PATH`).
* When `mask` is provided, checks the 16-bit `collisionMasks` against `mask`.
*
* @param scene - the scene.
* @param x - scene-space x.
* @param y - scene-space y.
* @param mask - optional 16-bit collision mask (`D2C_CollisionMaskFlags`).
* @returns true when blocked for the given mask.
*/
export function isBlockedAt(scene: CollisionGrid, x: number, y: number, mask?: number): boolean {
const cell = cellAt(scene, x, y)
const cellX = Math.floor(cell.x)
const cellY = Math.floor(cell.y)
if (cellX < 0 || cellY < 0 || cellX >= scene.cellsX || cellY >= scene.cellsY) return true
const subX = Math.floor((cell.x - cellX) * SUB_TILES_PER_TILE)
const subY = Math.floor((cell.y - cellY) * SUB_TILES_PER_TILE)
const gx = cellX * SUB_TILES_PER_TILE + Math.min(subX, SUB_TILES_PER_TILE - 1)
const gy = cellY * SUB_TILES_PER_TILE + Math.min(subY, SUB_TILES_PER_TILE - 1)
if (mask !== undefined) {
return collisionCheckMask(scene, gx, gy, mask)
}
return scene.blocked[gy * scene.gridWidth + gx] === 1
}
/**
* Whether a projectile / missile is blocked at scene-space `(x, y)` (`COLLIDE_MASK_MISSILE`).
* Water, lava, rivers, and low cliffs (`COLLIDE_WALL` without `COLLIDE_MISSILE_BARRIER`) return `false`,
* allowing projectiles to fly across them.
*/
export function isMissileBlockedAt(scene: CollisionGrid, x: number, y: number): boolean {
return isBlockedAt(scene, x, y, COLLIDE_MASK_MISSILE)
}
/**
* Whether Line-of-Sight (LoS) is blocked at scene-space `(x, y)` (`COLLIDE_MASK_LOS`).
*/
export function isLosBlockedAt(scene: CollisionGrid, x: number, y: number): boolean {
return isBlockedAt(scene, x, y, COLLIDE_MASK_LOS)
}
/**
* Scene-space centre of a cell.
*
* @param scene - the scene.
* @param cellX - cell column.
* @param cellY - cell row.
* @returns the centre point.
*/
export function cellCentre(scene: CollisionGrid, cellX: number, cellY: number): { x: number; y: number } {
return {
x: (cellX - cellY) * ORTHO_CELL_WIDTH + scene.originX,
y: (cellX + cellY) * ORTHO_CELL_HEIGHT + scene.originY + ORTHO_CELL_HEIGHT / 2,
}
}
export {
findIsoSpawn,
type IsoSpawnOptions,
type IsoSpawnTarget,
} from './level-links.ts'