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

424 lines
13 KiB
TypeScript

/**
* Client Map Service (`src/client/map/map-service.ts`, Feature F12).
*
* High-level runtime facade for:
* - Initializing DRLG & TileAtlas from packed assets (`public/act-packs/tiles`)
* or an existing `DrlgEnv`
* - Handling server `0x03 LoadAct`, `0x07 AddRoomData`, and `0x08 RemoveRoomData`
* packets to generate/update `LevelView`
* - Querying sub-tile collision and walkability in world or local coordinates
* - Dispatching requests either in-process (`MapWorkerEngine`) or via a Web Worker.
*/
import type { DrlgMapContext } from '../../game/drlg/drlg-map.ts'
import type { DrlgEnv } from '../../game/drlg/drlg-types.ts'
import type { PackedDrlgBundleManifest, PackedTileCatalogIndex } from '../../../scripts/pack-tiles.ts'
import {
getCollisionLocalSubTile,
getCollisionWorldSubTile,
isWalkableLocalSubTile,
isWalkableWorldSubTile,
localSubTileToOrtho,
localToWorldSubTile,
orthoToLocalSubTile,
worldToLocalSubTile,
type LevelRoomView,
type LevelView,
type LevelViewOptions,
} from './level-view.ts'
import {
MapWorkerEngine,
createEnvFromPackedBundle,
type MapWorkerRequest,
type MapWorkerResponse,
} from './map-worker.ts'
import { TileAtlas } from './tile-atlas.ts'
export {
TileAtlas,
MapWorkerEngine,
createEnvFromPackedBundle,
worldToLocalSubTile,
localToWorldSubTile,
localSubTileToOrtho,
orthoToLocalSubTile,
isWalkableLocalSubTile,
isWalkableWorldSubTile,
getCollisionLocalSubTile,
getCollisionWorldSubTile,
type LevelView,
type LevelRoomView,
type LevelViewOptions,
type MapWorkerRequest,
type MapWorkerResponse,
}
export interface WorkerLike {
postMessage(message: MapWorkerRequest): void
addEventListener(
type: 'message',
listener: (event: { readonly data: MapWorkerResponse }) => void,
): void
removeEventListener?(
type: 'message',
listener: (event: { readonly data: MapWorkerResponse }) => void,
): void
terminate?(): void
}
export interface LoadActPacketLike {
readonly act: number
readonly mapSeed: number
readonly areaId: number
readonly unk?: number
}
export interface RoomDataPacketLike {
readonly roomX: number
readonly roomY: number
readonly areaId: number
}
export class MapService {
private readonly engine: MapWorkerEngine | null
private readonly worker: WorkerLike | null
private tileAtlas: TileAtlas | null
private activeAct: number | null = null
private activeSeed: number | null = null
private activeDifficulty: 0 | 1 | 2 = 0
private activeAreaId: number | null = null
private activeView: LevelView | null = null
private nextRequestId = 1
private readonly pendingWorkerRequests = new Map<
number,
{
readonly resolve: (res: Extract<MapWorkerResponse, { readonly ok: true }>) => void
readonly reject: (err: Error) => void
}
>()
private constructor(options: {
readonly engine?: MapWorkerEngine
readonly worker?: WorkerLike
readonly tileAtlas?: TileAtlas | null
}) {
this.engine = options.engine ?? null
this.worker = options.worker ?? null
this.tileAtlas = options.tileAtlas ?? this.engine?.getTileAtlas() ?? null
if (this.worker) {
this.worker.addEventListener('message', event => {
const res = event.data
const pending = this.pendingWorkerRequests.get(res.id)
if (!pending) return
this.pendingWorkerRequests.delete(res.id)
if (!res.ok) {
pending.reject(new Error(res.error))
} else {
pending.resolve(res)
}
})
}
}
/**
* Create a `MapService` backed by an in-process `MapWorkerEngine` initialized from
* an existing `DrlgEnv` and `DrlgMapContext`.
*/
static fromEnv(env: DrlgEnv, ctx: DrlgMapContext, tileAtlas?: TileAtlas): MapService {
const engine = new MapWorkerEngine()
engine.initWithEnv(env, ctx, tileAtlas)
return new MapService({ engine, tileAtlas: engine.getTileAtlas() })
}
/**
* Create a `MapService` from in-memory packed bundle artifacts.
*/
static fromPackedBundle(
manifest: PackedDrlgBundleManifest,
bundleBytes: Uint8Array,
catalogIndex: PackedTileCatalogIndex,
): MapService {
const engine = new MapWorkerEngine()
engine.initFromBundle(manifest, bundleBytes, catalogIndex)
return new MapService({ engine, tileAtlas: engine.getTileAtlas() })
}
/**
* Create a `MapService` in Node/test environments by reading `public/act-packs/tiles`
* directly from disk.
*/
static async fromPackedDir(dirPath = 'public/act-packs/tiles'): Promise<MapService> {
const fsSpec = `node:${'fs'}`
const pathSpec = `node:${'path'}`
const fs = (await import(/* @vite-ignore */ fsSpec)) as typeof import('node:fs')
const path = (await import(/* @vite-ignore */ pathSpec)) as typeof import('node:path')
const resolvedDir = path.resolve(dirPath)
const indexJsonPath = path.join(resolvedDir, 'index.json')
const manifestPath = path.join(resolvedDir, 'drlg-bundle.json')
const bundleBinPath = path.join(resolvedDir, 'drlg-bundle.bin')
const catalogIndex = JSON.parse(
fs.readFileSync(indexJsonPath, 'utf8'),
) as PackedTileCatalogIndex
const manifest = JSON.parse(
fs.readFileSync(manifestPath, 'utf8'),
) as PackedDrlgBundleManifest
const bundleBuf = fs.readFileSync(bundleBinPath)
const bundleBytes = new Uint8Array(bundleBuf.buffer, bundleBuf.byteOffset, bundleBuf.byteLength)
return MapService.fromPackedBundle(manifest, bundleBytes, catalogIndex)
}
/**
* Create a `MapService` in browser environments by fetching `index.json`,
* `drlg-bundle.json`, and `drlg-bundle.bin` from `baseUrl`.
*/
static async fromPackedUrl(baseUrl = '/act-packs/tiles'): Promise<MapService> {
const cleanBase = baseUrl.replace(/\/+$/, '')
const [indexRes, manifestRes, bundleRes] = await Promise.all([
fetch(`${cleanBase}/index.json`),
fetch(`${cleanBase}/drlg-bundle.json`),
fetch(`${cleanBase}/drlg-bundle.bin`),
])
if (!indexRes.ok) {
throw new Error(`MapService.fromPackedUrl: failed to fetch index.json (${String(indexRes.status)})`)
}
if (!manifestRes.ok) {
throw new Error(
`MapService.fromPackedUrl: failed to fetch drlg-bundle.json (${String(manifestRes.status)})`,
)
}
if (!bundleRes.ok) {
throw new Error(
`MapService.fromPackedUrl: failed to fetch drlg-bundle.bin (${String(bundleRes.status)})`,
)
}
const [catalogIndex, manifest, bundleBuffer] = await Promise.all([
indexRes.json() as Promise<PackedTileCatalogIndex>,
manifestRes.json() as Promise<PackedDrlgBundleManifest>,
bundleRes.arrayBuffer(),
])
return MapService.fromPackedBundle(manifest, new Uint8Array(bundleBuffer), catalogIndex)
}
/**
* Create a `MapService` backed by a Web Worker (`WorkerLike`).
*/
static fromWorker(worker: WorkerLike, tileAtlas?: TileAtlas): MapService {
return new MapService({ worker, tileAtlas: tileAtlas ?? null })
}
getTileAtlas(): TileAtlas | null {
return this.tileAtlas
}
getActiveAct(): number | null {
return this.activeAct
}
getActiveSeed(): number | null {
return this.activeSeed
}
getActiveDifficulty(): 0 | 1 | 2 {
return this.activeDifficulty
}
getActiveAreaId(): number | null {
return this.activeAreaId
}
getActiveLevelView(): LevelView | null {
return this.activeView
}
/**
* Synchronous `loadAct` (requires in-process `MapWorkerEngine`).
*/
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.engine) {
throw new Error('MapService.loadAct: synchronous call requires in-process engine (use loadActAsync for worker)')
}
const difficulty = options.difficulty ?? this.activeDifficulty
this.activeAct = options.act
this.activeSeed = options.seed >>> 0
this.activeDifficulty = difficulty
if (options.areaId !== undefined && options.areaId > 0) {
this.activeAreaId = options.areaId
}
const view = this.engine.loadAct(options)
if (view) {
this.activeView = view
this.activeAreaId = view.id
}
return view
}
/**
* Asynchronous `loadAct` (works with both in-process engine and Web Worker).
*/
async loadActAsync(options: {
readonly act: number
readonly seed: number
readonly difficulty?: 0 | 1 | 2
readonly areaId?: number
readonly revealMode?: 'all' | 'revealed-only'
}): Promise<LevelView | null> {
if (this.engine) {
return this.loadAct(options)
}
const id = this.nextRequestId++
const res = await this.sendWorkerRequest({
id,
type: 'loadAct',
act: options.act,
seed: options.seed >>> 0,
...(options.difficulty !== undefined ? { difficulty: options.difficulty } : {}),
...(options.areaId !== undefined ? { areaId: options.areaId } : {}),
...(options.revealMode !== undefined ? { revealMode: options.revealMode } : {}),
})
if (res.type !== 'loadAct') {
throw new Error(`Unexpected worker response type: ${res.type}`)
}
this.activeAct = options.act
this.activeSeed = options.seed >>> 0
if (options.difficulty !== undefined) {
this.activeDifficulty = options.difficulty
}
if (res.view) {
this.activeView = res.view
this.activeAreaId = res.view.id
}
return res.view
}
/**
* Synchronous `getLevelView` for `areaId`.
*/
getLevelView(
areaId: number,
revealMode: 'all' | 'revealed-only' = 'all',
forceRebuild = false,
): LevelView {
if (!this.engine) {
throw new Error('MapService.getLevelView: synchronous call requires in-process engine')
}
const view = this.engine.getLevelView(areaId, revealMode, forceRebuild)
this.activeView = view
this.activeAreaId = areaId
return view
}
/**
* Asynchronous `getLevelView` for `areaId`.
*/
async getLevelViewAsync(
areaId: number,
revealMode: 'all' | 'revealed-only' = 'all',
): Promise<LevelView> {
if (this.engine) {
return this.getLevelView(areaId, revealMode)
}
const id = this.nextRequestId++
const res = await this.sendWorkerRequest({
id,
type: 'getLevelView',
areaId,
revealMode,
})
if (res.type !== 'getLevelView') {
throw new Error(`Unexpected worker response type: ${res.type}`)
}
this.activeView = res.view
this.activeAreaId = areaId
return res.view
}
/**
* Handle server S2C `0x03 LoadAct` packet (`{ act, mapSeed, areaId, unk }`).
*/
onLoadActPacket(
packet: LoadActPacketLike,
options: { readonly difficulty?: 0 | 1 | 2; readonly revealMode?: 'all' | 'revealed-only' } = {},
): LevelView | null {
return this.loadAct({
act: packet.act,
seed: packet.mapSeed >>> 0,
...(options.difficulty !== undefined ? { difficulty: options.difficulty } : {}),
...(packet.areaId > 0 ? { areaId: packet.areaId } : {}),
...(options.revealMode !== undefined ? { revealMode: options.revealMode } : {}),
})
}
/**
* Handle server S2C `0x07 AddRoomData` packet (`{ roomX, roomY, areaId }`).
*/
onAddRoomDataPacket(
packet: RoomDataPacketLike,
options: { readonly rebuildView?: boolean; readonly revealMode?: 'all' | 'revealed-only' } = {},
): { readonly room: LevelRoomView | null; readonly view: LevelView | null } {
if (!this.engine) {
throw new Error('MapService.onAddRoomDataPacket: synchronous call requires in-process engine')
}
const res = this.engine.revealRoom(packet.areaId, packet.roomX, packet.roomY, options)
if (res.view) {
this.activeView = res.view
this.activeAreaId = res.view.id
}
return res
}
/**
* Handle server S2C `0x08 RemoveRoomData` packet (`{ roomX, roomY, areaId }`).
*/
onRemoveRoomDataPacket(
packet: RoomDataPacketLike,
options: { readonly rebuildView?: boolean; readonly revealMode?: 'all' | 'revealed-only' } = {},
): { readonly room: LevelRoomView | null; readonly view: LevelView | null } {
if (!this.engine) {
throw new Error('MapService.onRemoveRoomDataPacket: synchronous call requires in-process engine')
}
const res = this.engine.hideRoom(packet.areaId, packet.roomX, packet.roomY, options)
if (res.view) {
this.activeView = res.view
this.activeAreaId = res.view.id
}
return res
}
/**
* Query whether world sub-tile `(worldSubX, worldSubY)` is walkable in the active `LevelView`.
*/
isWalkableWorldSubTile(worldSubX: number, worldSubY: number): boolean {
if (!this.activeView) return false
return isWalkableWorldSubTile(this.activeView, worldSubX, worldSubY)
}
/**
* Query the 16-bit collision mask at world sub-tile `(worldSubX, worldSubY)` in the active `LevelView`.
*/
getCollisionWorldSubTile(worldSubX: number, worldSubY: number): number {
if (!this.activeView) return 0x1c09
return getCollisionWorldSubTile(this.activeView, worldSubX, worldSubY)
}
private sendWorkerRequest(
req: MapWorkerRequest,
): Promise<Extract<MapWorkerResponse, { readonly ok: true }>> {
if (!this.worker) {
return Promise.reject(new Error('MapService: no worker configured'))
}
return new Promise<Extract<MapWorkerResponse, { readonly ok: true }>>((resolve, reject) => {
this.pendingWorkerRequests.set(req.id, { resolve, reject })
this.worker!.postMessage(req)
})
}
}