696 lines
29 KiB
TypeScript
696 lines
29 KiB
TypeScript
/**
|
||
* 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
|
||
}
|