diablo2-web/src/client/map/map-worker.ts

361 lines
12 KiB
TypeScript

/**
* DRLG Map Generation Worker & Engine (`src/client/map/map-worker.ts`, Feature F12).
*
* Runs `createDrlgActSession`, `drlgLevelInputFromSession`, `buildDrlgLevelMap`, and
* `buildLevelView` off the main thread (in a Web Worker) or in-process via
* `MapWorkerEngine`. Supports:
* - Loading from the pre-packed `drlg-bundle.json` + `drlg-bundle.bin` + `index.json`
* (zero MPQ dependency at runtime)
* - Loading directly from an existing `DrlgEnv` + `DrlgMapContext`
* - Full-level mode (`revealMode: 'all'`) and progressive room-reveal mode
* (`revealMode: 'revealed-only'`) driven by `0x03 LoadAct`, `0x07 AddRoomData`,
* and `0x08 RemoveRoomData`.
*/
import {
createDrlgActSession,
type DrlgActSession,
type DrlgMapContext,
type DrlgSessionRoom,
} from '../../common/drlg/drlg-map.ts'
import {
DRLG_TABLE_NAMES,
createDrlgEnv,
memoryDrlgSource,
normalizeDrlgPath,
} from '../../common/drlg/drlg-source.ts'
import { loadDrlgTables, type DrlgTableName } from '../../common/drlg/drlg-tables.ts'
import type { DrlgEnv } from '../../common/drlg/drlg-types.ts'
import type { PackedDrlgBundleManifest, PackedTileCatalogIndex } from '../../common/pack-contract/index.ts'
import { buildLevelViewFromSession, type LevelRoomView, type LevelView } from './level-view.ts'
import { TileAtlas } from './tile-atlas.ts'
export type MapWorkerRequest =
| {
readonly id: number
readonly type: 'initFromBundle'
readonly manifest: PackedDrlgBundleManifest
readonly bundleBytes: Uint8Array
readonly catalogIndex: PackedTileCatalogIndex
}
| {
readonly id: number
readonly type: 'loadAct'
readonly act: number
readonly seed: number
readonly difficulty?: 0 | 1 | 2
readonly areaId?: number
readonly revealMode?: 'all' | 'revealed-only'
}
| {
readonly id: number
readonly type: 'getLevelView'
readonly areaId: number
readonly revealMode?: 'all' | 'revealed-only'
}
| {
readonly id: number
readonly type: 'revealRoom'
readonly areaId: number
readonly roomX: number
readonly roomY: number
readonly rebuildView?: boolean
readonly revealMode?: 'all' | 'revealed-only'
}
| {
readonly id: number
readonly type: 'hideRoom'
readonly areaId: number
readonly roomX: number
readonly roomY: number
readonly rebuildView?: boolean
readonly revealMode?: 'all' | 'revealed-only'
}
export type MapWorkerResponse =
| {
readonly id: number
readonly ok: true
readonly type: 'initFromBundle'
}
| {
readonly id: number
readonly ok: true
readonly type: 'loadAct'
readonly view: LevelView | null
}
| {
readonly id: number
readonly ok: true
readonly type: 'getLevelView'
readonly view: LevelView
}
| {
readonly id: number
readonly ok: true
readonly type: 'revealRoom'
readonly room: LevelRoomView | null
readonly view: LevelView | null
}
| {
readonly id: number
readonly ok: true
readonly type: 'hideRoom'
readonly room: LevelRoomView | null
readonly view: LevelView | null
}
| {
readonly id: number
readonly ok: false
readonly error: string
}
function toRoomView(r: DrlgSessionRoom | null): LevelRoomView | null {
if (!r) return null
return {
index: r.index,
areaId: r.areaId,
tileX: r.tileX,
tileY: r.tileY,
tileW: r.tileW,
tileH: r.tileH,
localTileX: r.localTileX,
localTileY: r.localTileY,
subX: r.subX,
subY: r.subY,
subW: r.subW,
subH: r.subH,
worldSubX: r.worldSubX,
worldSubY: r.worldSubY,
revealed: r.revealed,
}
}
/**
* Reconstruct a `DrlgEnv`, `TileAtlas`, and `DrlgMapContext` from a packed
* `drlg-bundle.json` + `drlg-bundle.bin` + `index.json`.
*/
export function createEnvFromPackedBundle(
manifest: PackedDrlgBundleManifest,
bundleBytes: Uint8Array,
catalogIndex: PackedTileCatalogIndex,
): {
readonly env: DrlgEnv
readonly tileAtlas: TileAtlas
readonly ctx: DrlgMapContext
} {
const tableMap = new Map<DrlgTableName, Uint8Array>()
for (const name of DRLG_TABLE_NAMES) {
const slice = manifest.tables[name]
if (!slice) {
throw new Error(`createEnvFromPackedBundle: missing table ${name} in manifest`)
}
tableMap.set(name, bundleBytes.subarray(slice.offset, slice.offset + slice.length))
}
const fileMap = new Map<string, Uint8Array>()
for (const [key, slice] of Object.entries(manifest.ds1Files)) {
fileMap.set(normalizeDrlgPath(key), bundleBytes.subarray(slice.offset, slice.offset + slice.length))
}
for (const [key, slice] of Object.entries(manifest.dt1HeaderFiles)) {
fileMap.set(normalizeDrlgPath(key), bundleBytes.subarray(slice.offset, slice.offset + slice.length))
}
const source = memoryDrlgSource(tableMap, fileMap)
const tables = loadDrlgTables(source)
const env = createDrlgEnv(source, tables)
const tileAtlas = TileAtlas.fromCatalogIndex(catalogIndex)
const ctx: DrlgMapContext = {
tables,
dt1: tileAtlas.toDt1Map(),
superUniqueIds: manifest.superUniqueIds,
}
return { env, tileAtlas, ctx }
}
export class MapWorkerEngine {
private env: DrlgEnv | null = null
private ctx: DrlgMapContext | null = null
private tileAtlas: TileAtlas | null = null
private session: DrlgActSession | null = null
private readonly viewCache = new Map<string, LevelView>()
initWithEnv(env: DrlgEnv, ctx: DrlgMapContext, tileAtlas?: TileAtlas): void {
this.env = env
this.ctx = ctx
this.tileAtlas = tileAtlas ?? TileAtlas.fromDt1Map(ctx.dt1)
this.session = null
this.viewCache.clear()
}
initFromBundle(
manifest: PackedDrlgBundleManifest,
bundleBytes: Uint8Array,
catalogIndex: PackedTileCatalogIndex,
): void {
const { env, ctx, tileAtlas } = createEnvFromPackedBundle(manifest, bundleBytes, catalogIndex)
this.initWithEnv(env, ctx, tileAtlas)
}
getTileAtlas(): TileAtlas | null {
return this.tileAtlas
}
getSession(): DrlgActSession | null {
return this.session
}
loadAct(options: {
readonly act: number
readonly seed: number
readonly difficulty?: 0 | 1 | 2
readonly areaId?: number
readonly revealMode?: 'all' | 'revealed-only'
}): LevelView | null {
if (!this.env || !this.ctx) {
throw new Error('MapWorkerEngine.loadAct: engine not initialized')
}
const difficulty = options.difficulty ?? 0
const normalizedSeed = options.seed >>> 0
if (
!this.session ||
this.session.seed !== normalizedSeed ||
this.session.difficulty !== difficulty
) {
this.session = createDrlgActSession(this.env, {
act: options.act,
seed: normalizedSeed,
difficulty,
})
this.viewCache.clear()
}
if (options.areaId !== undefined && options.areaId > 0) {
return this.getLevelView(options.areaId, options.revealMode ?? 'all')
}
return null
}
getLevelView(areaId: number, revealMode: 'all' | 'revealed-only' = 'all', forceRebuild = false): LevelView {
if (!this.env || !this.ctx) {
throw new Error('MapWorkerEngine.getLevelView: engine not initialized')
}
if (!this.session) {
throw new Error('MapWorkerEngine.getLevelView: no active DRLG session (call loadAct first)')
}
const cacheKey = `${String(this.session.seed)}:${String(this.session.difficulty)}:${String(areaId)}:${revealMode}`
if (!forceRebuild && revealMode === 'all') {
const cached = this.viewCache.get(cacheKey)
if (cached) return cached
}
const view = buildLevelViewFromSession(this.session, areaId, this.ctx, {
...(this.tileAtlas ? { tileAtlas: this.tileAtlas } : {}),
revealMode,
})
if (revealMode === 'all') {
this.viewCache.set(cacheKey, view)
}
return view
}
revealRoom(
areaId: number,
roomX: number,
roomY: number,
options: { readonly rebuildView?: boolean; readonly revealMode?: 'all' | 'revealed-only' } = {},
): { readonly room: LevelRoomView | null; readonly view: LevelView | null } {
if (!this.session) {
throw new Error('MapWorkerEngine.revealRoom: no active DRLG session')
}
const room = this.session.revealRoom(areaId, roomX, roomY)
const view =
options.rebuildView === true
? this.getLevelView(areaId, options.revealMode ?? 'revealed-only', true)
: null
return { room: toRoomView(room), view }
}
hideRoom(
areaId: number,
roomX: number,
roomY: number,
options: { readonly rebuildView?: boolean; readonly revealMode?: 'all' | 'revealed-only' } = {},
): { readonly room: LevelRoomView | null; readonly view: LevelView | null } {
if (!this.session) {
throw new Error('MapWorkerEngine.hideRoom: no active DRLG session')
}
const room = this.session.hideRoom(areaId, roomX, roomY)
const view =
options.rebuildView === true
? this.getLevelView(areaId, options.revealMode ?? 'revealed-only', true)
: null
return { room: toRoomView(room), view }
}
handleRequest(req: MapWorkerRequest): MapWorkerResponse {
try {
switch (req.type) {
case 'initFromBundle': {
this.initFromBundle(req.manifest, req.bundleBytes, req.catalogIndex)
return { id: req.id, ok: true, type: 'initFromBundle' }
}
case 'loadAct': {
const view = this.loadAct({
act: req.act,
seed: req.seed,
...(req.difficulty !== undefined ? { difficulty: req.difficulty } : {}),
...(req.areaId !== undefined ? { areaId: req.areaId } : {}),
...(req.revealMode !== undefined ? { revealMode: req.revealMode } : {}),
})
return { id: req.id, ok: true, type: 'loadAct', view }
}
case 'getLevelView': {
const view = this.getLevelView(req.areaId, req.revealMode ?? 'all')
return { id: req.id, ok: true, type: 'getLevelView', view }
}
case 'revealRoom': {
const { room, view } = this.revealRoom(req.areaId, req.roomX, req.roomY, {
...(req.rebuildView !== undefined ? { rebuildView: req.rebuildView } : {}),
...(req.revealMode !== undefined ? { revealMode: req.revealMode } : {}),
})
return { id: req.id, ok: true, type: 'revealRoom', room, view }
}
case 'hideRoom': {
const { room, view } = this.hideRoom(req.areaId, req.roomX, req.roomY, {
...(req.rebuildView !== undefined ? { rebuildView: req.rebuildView } : {}),
...(req.revealMode !== undefined ? { revealMode: req.revealMode } : {}),
})
return { id: req.id, ok: true, type: 'hideRoom', room, view }
}
}
} catch (err) {
return {
id: req.id,
ok: false,
error: err instanceof Error ? err.message : String(err),
}
}
}
}
// Auto-bind message listener when executed inside a Web Worker context
const globalScope = globalThis as unknown as {
readonly document?: unknown
readonly postMessage?: (message: MapWorkerResponse) => void
readonly addEventListener?: (
type: 'message',
listener: (event: { readonly data: MapWorkerRequest }) => void,
) => void
}
if (
typeof globalScope.addEventListener === 'function' &&
typeof globalScope.postMessage === 'function' &&
globalScope.document === undefined
) {
const engine = new MapWorkerEngine()
globalScope.addEventListener('message', event => {
const res = engine.handleRequest(event.data)
globalScope.postMessage!(res)
})
}