diablo2-web/src/game/d2map.ts

696 lines
29 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 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
/** One tile to draw, in screen pixels. */
export interface IsoDraw {
/** Index into {@link IsoMapScene.frames}. */
readonly frameIndex: number
/** 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
}
/** 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
/** 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
}
/**
* 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. */
interface PoolTile {
readonly library: number
readonly tile: number
/** Variant weight (`Dt1Tile.rarityFrameIndex`) used when picking per cell. */
readonly weight: 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 entry = { library: libraryIndex, tile: tileIndex, weight: tile.rarityFrameIndex }
// 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.
*/
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]
let state = (BigInt(seed >>> 0) + BigInt(cellX >>> 0)) * BigInt(cellY >>> 0)
const mask = (1n << 64n) - 1n
state &= mask
state = (state ^ (state << 13n)) & mask
state = (state ^ (state >> 17n)) & mask
state = (state ^ (state << 5n)) & 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]
}
/**
* 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 pool tile, or undefined when nothing matches.
*/
function resolveExactType(
pool: TilePool,
style: number,
sequence: number,
type: number,
cellX: number,
cellY: number,
seed: number,
): PoolTile | undefined {
return pickVariant(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, or undefined when nothing matches.
*/
function resolveWithSource(
pool: TilePool,
style: number,
sequence: number,
type: number,
cellX: number,
cellY: number,
seed: number,
): { tile: PoolTile | undefined; viaLoose: boolean } {
const key = `${String(style)}:${String(sequence)}`
const exact = pickVariant(pool.exact.get(`${key}:${String(type)}`), cellX, cellY, seed)
if (exact !== undefined) return { tile: exact, viaLoose: false }
return { tile: pickVariant(pool.loose.get(key), cellX, cellY, seed), 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 rawFloors: { frameIndex: number; x: number; y: number; cellX: number; cellY: number; library: number; tile: number }[] = []
const rawWalls: typeof rawFloors = []
/** Roof draws (`wall.type` 15): kept apart so they can be painted last. */
const rawRoofs: typeof rawFloors = []
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`).
// Stamping each layer into the same grid is the same union for walkability, but
// shadows must be included: they are a distinct DS1 layer (`type 13` tiles) and
// skipping them would silently drop blocking sub-tiles.
let shadowBlocked = 0
let looseRefs = 0
let cornerPairs = 0
let missingCornerPairs = 0
const stamp = (cellX: number, cellY: number, tile: Dt1Tile): 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 || (!flags.blockWalk && !flags.blockPlayerWalk)) 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
blocked[gy * gridWidth + gx] = 1
}
}
}
for (let cellY = 0; cellY < cellsY; cellY += 1) {
for (let cellX = 0; cellX < cellsX; cellX += 1) {
const cell = level.cells[cellY]?.[cellX]
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]!
rawFloors.push({
frameIndex: frameFor(found.library, found.tile),
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)
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 || (!flags.blockWalk && !flags.blockPlayerWalk)) 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
if (blocked[gy * gridWidth + gx] === 0) shadowBlocked += 1
blocked[gy * gridWidth + gx] = 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 draw = {
frameIndex: frameFor(found.library, found.tile),
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 })
} else {
rawWalls.push(draw)
}
stamp(cellX, cellY, tile)
// 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 companion = resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed)
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]!
rawWalls.push({
frameIndex: frameFor(companion.library, companion.tile),
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 })
return {
frames,
sheet: { groups: [{ frames }], width: null },
cellsX,
cellsY,
widthPx: maxX - minX,
heightPx: maxY - minY,
originX,
originY,
floors: rawFloors.map(shift),
walls: rawWalls.map(shift),
roofs: rawRoofs.map(shift),
blocked,
gridWidth,
gridHeight,
missingTiles,
missingRefs,
clippedTiles,
shadowBlockedSubtiles: shadowBlocked,
looseRefs,
cornerPairs,
missingCornerPairs,
duplicateRefs: pool.duplicates,
}
}
/**
* 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
}
/**
* 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,
}
}
/**
* Whether a scene-space point lies on a blocked sub-tile.
*
* @param scene - the scene.
* @param x - scene-space x.
* @param y - scene-space y.
* @returns true when walking is blocked there.
*/
export function isBlockedAt(scene: CollisionGrid, x: number, y: 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)
return scene.blocked[gy * scene.gridWidth + gx] === 1
}
/**
* 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,
}
}
/**
* Find a walkable spawn near the middle of the map.
*
* @param scene - the scene.
* @returns a scene-space point, or null when nothing is walkable.
*/
export function findIsoSpawn(scene: CollisionGrid & { cellsX: number; cellsY: number }): { x: number; y: number } | null {
const centreX = Math.floor(scene.cellsX / 2)
const centreY = Math.floor(scene.cellsY / 2)
for (let radius = 0; radius < Math.max(scene.cellsX, scene.cellsY); radius += 1) {
for (let dy = -radius; dy <= radius; dy += 1) {
for (let dx = -radius; dx <= radius; dx += 1) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
const cellX = centreX + dx
const cellY = centreY + dy
if (cellX < 0 || cellY < 0 || cellX >= scene.cellsX || cellY >= scene.cellsY) continue
const point = cellCentre(scene, cellX, cellY)
if (!isBlockedAt(scene, point.x, point.y)) return point
}
}
}
return null
}