424 lines
13 KiB
TypeScript
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)
|
|
})
|
|
}
|
|
}
|