530 lines
17 KiB
TypeScript
530 lines
17 KiB
TypeScript
/**
|
||
* Seed-independent DT1 Tile Atlas (`src/client/map/tile-atlas.ts`, Feature F11 & F12).
|
||
*
|
||
* Indexes all DT1 libraries across Acts 1–5 by normalized library path, tile index,
|
||
* and `(style, sequence, type)`, supporting:
|
||
* - Direct DRLG-pinned tile lookup (`(dt1Library, dt1Tile)`)
|
||
* - Cell-masked (`dt1MaskAllows`) candidate resolution with SplitMix64 `pickVariant` fallback
|
||
* - Reconstruction of `Dt1` / `Dt1Tile` structures (including 25-byte `subTileFlags`,
|
||
* `roofHeight`, `minBlockY`, `bitmapHeight`, and optional R8 pixel slices) so
|
||
* `buildDrlgLevelMap`, `auditDrlgDt1Ambiguity`, and `buildIsoMapScene` run identically
|
||
* against packed atlases or raw MPQ DT1s.
|
||
*/
|
||
|
||
import {
|
||
decodeDt1,
|
||
isAnimatedTile,
|
||
subTileFlagsOf,
|
||
type Dt1,
|
||
type Dt1Block,
|
||
type Dt1Tile,
|
||
} from '../../formats/dt1.ts'
|
||
import { dt1MaskAllows, pickVariant, type PoolTile } from '../../game/d2map.ts'
|
||
import { DRLG_UNIVERSAL_DT1 } from '../../game/drlg/drlg-map.ts'
|
||
import { normalizeDrlgPath } from '../../game/drlg/drlg-source.ts'
|
||
import type {
|
||
PackedDt1LibraryEntry,
|
||
PackedDt1TileEntry,
|
||
PackedTileCatalogIndex,
|
||
PackedTilePlacement,
|
||
} from '../../../scripts/pack-tiles.ts'
|
||
|
||
export type {
|
||
PackedDt1LibraryEntry,
|
||
PackedDt1TileEntry,
|
||
PackedTileCatalogIndex,
|
||
PackedTilePlacement,
|
||
}
|
||
|
||
export interface TileAtlasFrameEntry {
|
||
readonly libraryPath: string
|
||
readonly index: number
|
||
readonly direction: number
|
||
readonly roofHeight: number
|
||
readonly materialFlags: number
|
||
readonly animated: number
|
||
readonly height: number
|
||
readonly width: number
|
||
readonly type: number
|
||
readonly style: number
|
||
readonly sequence: number
|
||
readonly rarityFrameIndex: number
|
||
readonly minBlockY: number
|
||
readonly bitmapHeight: number
|
||
readonly blockCount: number
|
||
/** 25 sub-tile flag bytes in normalized top-to-bottom order (`subY * 5 + subX`). */
|
||
readonly subTileFlags: readonly number[]
|
||
/** 25 sub-tile flag bytes in raw DT1 file bottom-to-top order (`(4 - subY) * 5 + subX`). */
|
||
readonly rawSubTileFlags: readonly number[]
|
||
readonly placement: PackedTilePlacement | null
|
||
}
|
||
|
||
export interface ResolvedTileAtlasCandidate {
|
||
readonly libraryIndex: number
|
||
readonly libraryPath: string
|
||
readonly dt1Path: string
|
||
readonly tileIndex: number
|
||
readonly entry: TileAtlasFrameEntry
|
||
readonly animatedEntries?: readonly TileAtlasFrameEntry[]
|
||
readonly pinned: boolean
|
||
readonly matchKind: 'pinned' | 'fallback'
|
||
}
|
||
|
||
export interface UniversalDt1Verification {
|
||
readonly blankFound: boolean
|
||
readonly invisWalFound: boolean
|
||
readonly invisWalFloor49_7Found: boolean
|
||
readonly warpFound: boolean
|
||
readonly allValid: boolean
|
||
}
|
||
|
||
export interface R8AtlasPage {
|
||
readonly act: number
|
||
readonly page: number
|
||
readonly width: number
|
||
readonly height: number
|
||
readonly r8: Uint8Array
|
||
}
|
||
|
||
export class TileAtlas {
|
||
private readonly librariesByPath = new Map<string, PackedDt1LibraryEntry>()
|
||
private readonly framesByPath = new Map<string, readonly TileAtlasFrameEntry[]>()
|
||
private readonly styleSeqTypeIndex = new Map<string, readonly TileAtlasFrameEntry[]>()
|
||
private readonly dt1Cache = new Map<string, Dt1>()
|
||
private readonly palettesByAct = new Map<number, Uint8Array>()
|
||
private readonly r8Pages = new Map<string, R8AtlasPage>()
|
||
|
||
constructor(libraries: Iterable<PackedDt1LibraryEntry>) {
|
||
for (const lib of libraries) {
|
||
const key = normalizeDrlgPath(lib.path)
|
||
this.librariesByPath.set(key, lib)
|
||
const entries: TileAtlasFrameEntry[] = lib.tiles.map(t => ({
|
||
libraryPath: key,
|
||
index: t.index,
|
||
direction: t.direction,
|
||
roofHeight: t.roofHeight,
|
||
materialFlags: t.materialFlags,
|
||
animated: t.animated,
|
||
height: t.height,
|
||
width: t.width,
|
||
type: t.type,
|
||
style: t.style,
|
||
sequence: t.sequence,
|
||
rarityFrameIndex: t.rarityFrameIndex,
|
||
minBlockY: t.minBlockY,
|
||
bitmapHeight: t.bitmapHeight,
|
||
blockCount: t.blockCount,
|
||
subTileFlags: t.subTileFlags,
|
||
rawSubTileFlags: t.rawSubTileFlags,
|
||
placement: t.placement,
|
||
}))
|
||
this.framesByPath.set(key, entries)
|
||
|
||
const byTuple = new Map<string, TileAtlasFrameEntry[]>()
|
||
for (const entry of entries) {
|
||
const tupleKey = `${key}|${String(entry.style)}:${String(entry.sequence)}:${String(entry.type)}`
|
||
let list = byTuple.get(tupleKey)
|
||
if (!list) {
|
||
list = []
|
||
byTuple.set(tupleKey, list)
|
||
}
|
||
list.push(entry)
|
||
}
|
||
for (const [tupleKey, list] of byTuple) {
|
||
this.styleSeqTypeIndex.set(tupleKey, list)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Construct a {@link TileAtlas} from a parsed `PackedTileCatalogIndex` (`public/act-packs/tiles/index.json`).
|
||
*/
|
||
static fromCatalogIndex(index: PackedTileCatalogIndex): TileAtlas {
|
||
return new TileAtlas(Object.values(index.libraries))
|
||
}
|
||
|
||
/**
|
||
* Construct a {@link TileAtlas} directly from decoded `Dt1` libraries (e.g. in tests or MPQ mode).
|
||
*/
|
||
static fromDt1Map(dt1ByPath: ReadonlyMap<string, Dt1>): TileAtlas {
|
||
const libs: PackedDt1LibraryEntry[] = []
|
||
for (const [rawPath, dt1] of dt1ByPath) {
|
||
const key = normalizeDrlgPath(rawPath)
|
||
const match = /[\\/]act([1-5])[\\/]/i.exec(key)
|
||
const ownerAct = match ? Number(match[1]) : 1
|
||
const tiles: PackedDt1TileEntry[] = dt1.tiles.map((t, index) => {
|
||
const subTileFlags = t.subTileFlags.map(f => f.raw)
|
||
const rawSubTileFlags = new Array<number>(25).fill(0)
|
||
for (let sy = 0; sy < 5; sy += 1) {
|
||
for (let sx = 0; sx < 5; sx += 1) {
|
||
rawSubTileFlags[(4 - sy) * 5 + sx] = subTileFlags[sy * 5 + sx] ?? 0
|
||
}
|
||
}
|
||
return {
|
||
index,
|
||
direction: t.direction,
|
||
roofHeight: t.roofHeight,
|
||
materialFlags: t.materialFlags,
|
||
animated: t.animated,
|
||
height: t.height,
|
||
width: t.width,
|
||
type: t.type,
|
||
style: t.style,
|
||
sequence: t.sequence,
|
||
rarityFrameIndex: t.rarityFrameIndex,
|
||
minBlockY: t.minBlockY,
|
||
bitmapHeight: t.bitmapHeight,
|
||
blockCount: t.blocks.length,
|
||
subTileFlags,
|
||
rawSubTileFlags,
|
||
placement:
|
||
t.blocks.length > 0 && t.width > 0 && t.bitmapHeight > 0
|
||
? {
|
||
act: ownerAct,
|
||
page: 0,
|
||
x: 0,
|
||
y: 0,
|
||
width: t.width,
|
||
height: t.bitmapHeight,
|
||
nonEmptyPixels: 1,
|
||
hash: '00000000',
|
||
}
|
||
: null,
|
||
}
|
||
})
|
||
libs.push({
|
||
path: key,
|
||
archivePath: rawPath,
|
||
ownerAct,
|
||
acts: [ownerAct],
|
||
versionMajor: dt1.versionMajor,
|
||
versionMinor: dt1.versionMinor,
|
||
tileCount: tiles.length,
|
||
tiles,
|
||
})
|
||
}
|
||
const atlas = new TileAtlas(libs)
|
||
for (const [rawPath, dt1] of dt1ByPath) {
|
||
atlas.dt1Cache.set(normalizeDrlgPath(rawPath), dt1)
|
||
}
|
||
return atlas
|
||
}
|
||
|
||
/**
|
||
* Load a {@link TileAtlas} over HTTP `fetch` from `/act-packs/tiles/index.json` (or custom base URL).
|
||
*/
|
||
static async fetch(baseUrl = '/act-packs/tiles'): Promise<TileAtlas> {
|
||
const cleanBase = baseUrl.replace(/\/+$/, '')
|
||
const res = await fetch(`${cleanBase}/index.json`)
|
||
if (!res.ok) {
|
||
throw new Error(`TileAtlas.fetch: HTTP ${String(res.status)} loading ${cleanBase}/index.json`)
|
||
}
|
||
const index = (await res.json()) as PackedTileCatalogIndex
|
||
return TileAtlas.fromCatalogIndex(index)
|
||
}
|
||
|
||
get libraryCount(): number {
|
||
return this.librariesByPath.size
|
||
}
|
||
|
||
hasLibrary(libraryPath: string): boolean {
|
||
return this.librariesByPath.has(normalizeDrlgPath(libraryPath))
|
||
}
|
||
|
||
getLibrary(libraryPath: string): PackedDt1LibraryEntry | null {
|
||
return this.librariesByPath.get(normalizeDrlgPath(libraryPath)) ?? null
|
||
}
|
||
|
||
getLibraryPaths(): string[] {
|
||
return [...this.librariesByPath.keys()]
|
||
}
|
||
|
||
/**
|
||
* Verify that the 3 mandatory universal DT1s (`Blank.dt1`, `InvisWal.dt1` with `style=49 sequence=7`,
|
||
* `Warp.dt1`) are present in this atlas.
|
||
*/
|
||
verifyUniversalDt1s(): UniversalDt1Verification {
|
||
const blankFound = this.hasLibrary(DRLG_UNIVERSAL_DT1[0]!)
|
||
const invisWalFound = this.hasLibrary(DRLG_UNIVERSAL_DT1[1]!)
|
||
const warpFound = this.hasLibrary(DRLG_UNIVERSAL_DT1[2]!)
|
||
const invisFloors = this.getFramesByStyleSeqType(
|
||
DRLG_UNIVERSAL_DT1[1]!,
|
||
49,
|
||
7,
|
||
0,
|
||
)
|
||
const invisWalFloor49_7Found = invisFloors.length > 0
|
||
return {
|
||
blankFound,
|
||
invisWalFound,
|
||
invisWalFloor49_7Found,
|
||
warpFound,
|
||
allValid: blankFound && invisWalFound && invisWalFloor49_7Found && warpFound,
|
||
}
|
||
}
|
||
|
||
setActPalette(act: number, rgb768: Uint8Array): void {
|
||
this.palettesByAct.set(act, rgb768)
|
||
}
|
||
|
||
getActPalette(act: number): Uint8Array | undefined {
|
||
return this.palettesByAct.get(act)
|
||
}
|
||
|
||
setActR8Page(page: R8AtlasPage): void {
|
||
this.r8Pages.set(`${String(page.act)}:${String(page.page)}`, page)
|
||
this.dt1Cache.clear()
|
||
}
|
||
|
||
getActR8Page(act: number, page: number): R8AtlasPage | undefined {
|
||
return this.r8Pages.get(`${String(act)}:${String(page)}`)
|
||
}
|
||
|
||
/**
|
||
* Look up a single tile entry by normalized DT1 library path and tile index (`0 .. N-1`).
|
||
*/
|
||
getFrameByLibraryTile(libraryPath: string, tileIndex: number): TileAtlasFrameEntry | undefined {
|
||
const entries = this.framesByPath.get(normalizeDrlgPath(libraryPath))
|
||
return entries?.[tileIndex]
|
||
}
|
||
|
||
/**
|
||
* Look up all tile entries in `libraryPath` matching `(style, sequence, type)`.
|
||
*/
|
||
getFramesByStyleSeqType(
|
||
libraryPath: string,
|
||
style: number,
|
||
sequence: number,
|
||
type: number,
|
||
): readonly TileAtlasFrameEntry[] {
|
||
const key = `${normalizeDrlgPath(libraryPath)}|${String(style)}:${String(sequence)}:${String(type)}`
|
||
return this.styleSeqTypeIndex.get(key) ?? []
|
||
}
|
||
|
||
/**
|
||
* Resolve a cell's tile using DRLG's exact pinned `(pinnedLibrary, pinnedTile)` or `(dt1Path, tileIndex)`
|
||
* when available, or falling back to cell-masked (`dt1MaskAllows`) candidate lookup + SplitMix64 `pickVariant`.
|
||
*/
|
||
resolveCellTile(options: {
|
||
readonly dt1Names?: readonly string[]
|
||
readonly dt1Path?: string
|
||
readonly tileIndex?: number
|
||
readonly dt1Mask?: number
|
||
readonly style: number
|
||
readonly sequence: number
|
||
readonly type?: number
|
||
readonly tileType?: number
|
||
readonly cellX?: number
|
||
readonly cellY?: number
|
||
readonly x?: number
|
||
readonly y?: number
|
||
readonly seed?: number
|
||
readonly pinnedLibrary?: number
|
||
readonly pinnedTile?: number
|
||
}): ResolvedTileAtlasCandidate | null {
|
||
const dt1Names = options.dt1Names ?? (options.dt1Path ? [options.dt1Path] : [])
|
||
const dt1Mask = options.dt1Mask
|
||
const style = options.style
|
||
const sequence = options.sequence
|
||
const type = options.type ?? options.tileType ?? 0
|
||
const cellX = options.cellX ?? options.x ?? 0
|
||
const cellY = options.cellY ?? options.y ?? 0
|
||
const seed = options.seed ?? 0
|
||
const pinnedLibrary = options.pinnedLibrary ?? (options.dt1Path !== undefined ? 0 : undefined)
|
||
const pinnedTile = options.pinnedTile ?? options.tileIndex
|
||
|
||
if (pinnedLibrary !== undefined && pinnedTile !== undefined) {
|
||
const libPath = dt1Names[pinnedLibrary]
|
||
if (libPath !== undefined) {
|
||
const entry = this.getFrameByLibraryTile(libPath, pinnedTile)
|
||
if (entry !== undefined) {
|
||
const normPath = normalizeDrlgPath(libPath)
|
||
const isAnim = isAnimatedTile(entry)
|
||
if (!isAnim) {
|
||
return {
|
||
libraryIndex: pinnedLibrary,
|
||
libraryPath: normPath,
|
||
dt1Path: normPath,
|
||
tileIndex: pinnedTile,
|
||
entry,
|
||
pinned: true,
|
||
matchKind: 'pinned',
|
||
}
|
||
}
|
||
const animCandidates = this.getFramesByStyleSeqType(libPath, style, sequence, type)
|
||
.filter(c => isAnimatedTile(c))
|
||
.slice()
|
||
.sort((a, b) => a.rarityFrameIndex - b.rarityFrameIndex || a.index - b.index)
|
||
return {
|
||
libraryIndex: pinnedLibrary,
|
||
libraryPath: normPath,
|
||
dt1Path: normPath,
|
||
tileIndex: pinnedTile,
|
||
entry,
|
||
...(animCandidates.length > 1 ? { animatedEntries: animCandidates } : {}),
|
||
pinned: true,
|
||
matchKind: 'pinned',
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback: gather candidates across dt1Names filtered by dt1MaskAllows
|
||
const allCandidates: { readonly poolTile: PoolTile; readonly entry: TileAtlasFrameEntry; readonly libPath: string }[] = []
|
||
for (let libIdx = 0; libIdx < dt1Names.length; libIdx += 1) {
|
||
const libPath = dt1Names[libIdx]!
|
||
const matches = this.getFramesByStyleSeqType(libPath, style, sequence, type)
|
||
for (const entry of matches) {
|
||
const animated = isAnimatedTile(entry)
|
||
allCandidates.push({
|
||
poolTile: {
|
||
library: libIdx,
|
||
tile: entry.index,
|
||
weight: entry.rarityFrameIndex,
|
||
animated,
|
||
frameIndex: animated ? Math.max(0, entry.rarityFrameIndex) : 0,
|
||
},
|
||
entry,
|
||
libPath: normalizeDrlgPath(libPath),
|
||
})
|
||
}
|
||
}
|
||
|
||
if (allCandidates.length === 0) return null
|
||
const masked =
|
||
dt1Mask !== undefined
|
||
? allCandidates.filter(c => dt1MaskAllows(dt1Mask, c.poolTile.library))
|
||
: allCandidates
|
||
const active = masked.length > 0 ? masked : allCandidates
|
||
|
||
if (active.some(c => c.poolTile.animated)) {
|
||
const sorted = [...active].sort(
|
||
(a, b) => a.poolTile.frameIndex - b.poolTile.frameIndex || a.poolTile.tile - b.poolTile.tile,
|
||
)
|
||
const first = sorted[0]!
|
||
return {
|
||
libraryIndex: first.poolTile.library,
|
||
libraryPath: first.libPath,
|
||
dt1Path: first.libPath,
|
||
tileIndex: first.poolTile.tile,
|
||
entry: first.entry,
|
||
...(sorted.length > 1 ? { animatedEntries: sorted.map(s => s.entry) } : {}),
|
||
pinned: false,
|
||
matchKind: 'fallback',
|
||
}
|
||
}
|
||
|
||
const pickedPool = pickVariant(
|
||
active.map(c => c.poolTile),
|
||
cellX,
|
||
cellY,
|
||
seed,
|
||
)
|
||
if (pickedPool === undefined) return null
|
||
const chosen = active.find(
|
||
c => c.poolTile.library === pickedPool.library && c.poolTile.tile === pickedPool.tile,
|
||
)!
|
||
return {
|
||
libraryIndex: chosen.poolTile.library,
|
||
libraryPath: chosen.libPath,
|
||
dt1Path: chosen.libPath,
|
||
tileIndex: chosen.poolTile.tile,
|
||
entry: chosen.entry,
|
||
pinned: false,
|
||
matchKind: 'fallback',
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reconstruct a `Dt1` object for `libraryPath` so `buildDrlgLevelMap`, `auditDrlgDt1Ambiguity`,
|
||
* and `buildIsoMapScene` can consume this atlas directly.
|
||
*/
|
||
toDt1(libraryPath: string): Dt1 {
|
||
const key = normalizeDrlgPath(libraryPath)
|
||
const cached = this.dt1Cache.get(key)
|
||
if (cached !== undefined) return cached
|
||
|
||
const lib = this.librariesByPath.get(key)
|
||
if (lib === undefined) {
|
||
throw new Error(`TileAtlas.toDt1: DT1 library "${key}" is not in the atlas`)
|
||
}
|
||
|
||
const tiles: Dt1Tile[] = lib.tiles.map(t => {
|
||
const subTileFlags = t.subTileFlags.map(raw => subTileFlagsOf(raw))
|
||
const blocks: Dt1Block[] = []
|
||
if (t.placement !== null) {
|
||
const page = this.getActR8Page(t.placement.act, t.placement.page)
|
||
const width = Math.max(t.width, 1)
|
||
const height = Math.max(t.bitmapHeight, 1)
|
||
const pixels = new Uint8Array(width * height)
|
||
if (page !== undefined) {
|
||
const copyW = Math.min(width, t.placement.width)
|
||
const copyH = Math.min(height, t.placement.height)
|
||
for (let row = 0; row < copyH; row += 1) {
|
||
const srcStart = (t.placement.y + row) * page.width + t.placement.x
|
||
const dstStart = row * width
|
||
pixels.set(page.r8.subarray(srcStart, srcStart + copyW), dstStart)
|
||
}
|
||
}
|
||
blocks.push({
|
||
x: 0,
|
||
y: 0,
|
||
gridX: 0,
|
||
gridY: 0,
|
||
format: 0,
|
||
pixels,
|
||
})
|
||
}
|
||
return {
|
||
direction: t.direction,
|
||
roofHeight: t.roofHeight,
|
||
materialFlags: t.materialFlags,
|
||
animated: t.animated,
|
||
height: t.height,
|
||
width: t.width,
|
||
type: t.type,
|
||
style: t.style,
|
||
sequence: t.sequence,
|
||
rarityFrameIndex: t.rarityFrameIndex,
|
||
subTileFlags,
|
||
minBlockY: t.minBlockY,
|
||
bitmapHeight: t.bitmapHeight,
|
||
blocks,
|
||
}
|
||
})
|
||
|
||
const dt1: Dt1 = {
|
||
warnings: [],
|
||
versionMajor: lib.versionMajor,
|
||
versionMinor: lib.versionMinor,
|
||
tiles,
|
||
}
|
||
this.dt1Cache.set(key, dt1)
|
||
return dt1
|
||
}
|
||
|
||
/**
|
||
* Build a `Map<string, Dt1>` for a subset (or all) of the libraries in this atlas,
|
||
* suitable for `DrlgMapContext.dt1`.
|
||
*/
|
||
toDt1Map(libraryPaths?: Iterable<string>): Map<string, Dt1> {
|
||
const out = new Map<string, Dt1>()
|
||
const paths = libraryPaths ?? this.librariesByPath.keys()
|
||
for (const p of paths) {
|
||
const key = normalizeDrlgPath(p)
|
||
out.set(key, this.toDt1(key))
|
||
}
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* Decode raw DT1 bytes and register them into this atlas.
|
||
*/
|
||
registerRawDt1(libraryPath: string, rawBytes: Uint8Array): Dt1 {
|
||
const key = normalizeDrlgPath(libraryPath)
|
||
const dt1 = decodeDt1(rawBytes)
|
||
this.dt1Cache.set(key, dt1)
|
||
return dt1
|
||
}
|
||
}
|