361 lines
12 KiB
TypeScript
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)
|
|
})
|
|
}
|