diablo2-web/src/scene/act-scene.ts

1151 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Act map scene: real Diablo II levels, walked by the player.
*
* Two data paths feed the same loop, and both stay because they answer different
* questions:
*
* - **pack** (default): a prebaked `scene.json` plus indexed PNG pages produced
* by `scripts/pack-act-assets.ts`. Nothing is decoded — the browser turns a PNG
* into a texture, and the JSON already carries painter's order and the
* collision grid. Pages load by need: the ones covering the spawn viewport
* first, the rest in the background while the player is already walking.
* - **live**: the archives themselves over HTTP ranges, decoded in the browser
* (DS1 + DT1 + PL2). Slower, needs no bake step, and works for any level the
* pack does not cover. `?live=1` forces it.
*
* No monsters are loaded on either path: this page exists to walk real maps.
*/
import { MpqArchive } from '../mpq/archive.ts'
import { httpRangeSource } from '../mpq/source.ts'
import { MountedArchives } from '../mpq/mount.ts'
import { loadActTables, resolveActTown, resolveLevel } from '../game/acts.ts'
import type { LevelInfo } from '../game/acts.ts'
import { decodeDs1 } from '../formats/ds1.ts'
import { decodeDt1 } from '../formats/dt1.ts'
import { decodePl2 } from '../formats/pl2.ts'
import type { Palette } from '../formats/pal.ts'
import type { SpriteSheet } from '../formats/sprite.ts'
import {
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, buildIsoMapScene, findIsoSpawn, isBlockedAt, levelSeed,
} from '../game/d2map.ts'
import type { CollisionGrid } from '../game/d2map.ts'
import { depthInsertIndex } from '../game/map.ts'
import { buildAtlas } from '../render/atlas.ts'
import type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts'
import { SpriteRenderer } from '../render/renderer.ts'
import type { AtlasHandle } from '../render/renderer.ts'
import { loadCharacterSheet, facingToDirection } from '../game/character.ts'
import { ACT_NAMES_ZH, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts'
import { GameLoop } from '../sim/loop.ts'
import { KeyboardInput } from '../sim/input.ts'
/** Archives the live path mounts, in load order. */
const DATA_ARCHIVES = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
/** Character archive: optional, and the map itself does not need it. */
const CHARACTER_ARCHIVE = 'd2char.mpq'
/** Archive locations tried in order when `?base=` is absent. */
const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
/** Pack locations tried in order when `?pack=` is absent. */
const DEFAULT_PACKS = ['samples/d2-packs', '/diablo2/packs']
/** Act count the page offers. */
const ACT_COUNT = 5
/** Walk speed in scene pixels per second (cells are 80×40). */
const WALK_SPEED = 170
/** Character collision box at the feet, in scene pixels. */
const FEET_WIDTH = 20
const FEET_HEIGHT = 10
/** Atlas pages fetched at once while filling in the background. */
const PAGE_CONCURRENCY = 3
/** Placeholder marker size, in scene pixels. */
const MARKER_WIDTH = 16
const MARKER_HEIGHT = 26
/** Live scene state, also published for automated checks. */
export interface ActSceneState {
ready: boolean
/** Where the map came from: a prebaked pack or the archives. */
source: 'pack' | 'live' | ''
act: number
level: string
quadrant: string
quadrants: string[]
/** The archive/pack location actually used. */
base: string
cellsX: number
cellsY: number
widthPx: number
heightPx: number
floors: number
walls: number
frames: number
/** Atlas pages loaded and total. */
pagesLoaded: number
pagesTotal: number
/** Pages that had arrived when the first frame was drawn. */
pagesAtFirstFrame: number
/** Milliseconds from boot to the first drawn frame. */
firstFrameMs: number
/** Page indices the spawn viewport needed, for diagnostics. */
priorityPages: number[]
/** Draws skipped because their page had not arrived yet. */
skippedDraws: number
/** Objects the pack places (their art still needs the DCC/COF decoder). */
objects: number
/** Roof draws, painted in their own pass after the character. */
roofs: number
/** Whether the composited Sorceress sprite is in use (else the marker). */
character: boolean
/** DCC members that went into the character sprite. */
characterMembers: number
/** Frames the character path has actually drawn; 0 while the marker is in use. */
characterFrames: number
/** Last group the character path selected (walk or stand, times 8 directions). */
characterGroup: number
/** Milliseconds from boot to the first rendered frame. */
loadMs: number
missing: number
walkable: number
x: number
y: number
facing: number
tick: number
tickRate: number
drawCalls: number
/** Current zoom factor and the canvas backing-store size. */
zoom: number
canvasWidth: number
canvasHeight: number
error: string | null
}
declare global {
interface Window {
/** Act scene state for automation and debugging. */
__d2webAct?: ActSceneState
}
}
const state: ActSceneState = {
ready: false, source: '', act: 1, level: '', quadrant: '', quadrants: [], base: '', roofs: 0,
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, walls: 0, frames: 0,
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
skippedDraws: 0, objects: 0, loadMs: 0,
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0,
drawCalls: 0, zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
}
window.__d2webAct = state
/** One tile to draw. */
interface Drawable {
readonly frame: AtlasFrame
readonly x: number
readonly y: number
readonly cellX: number
readonly cellY: number
/** Atlas page index this frame lives on. */
readonly page: number
}
/** Everything the render loop needs, from either data path. */
interface MapRuntime {
readonly source: 'pack' | 'live'
readonly base: string
readonly level: string
readonly act: number
readonly quadrants: readonly string[]
/**
* Which query parameter names the currently shown map block.
*
* The pack path selects a prebaked map by its entry label (`?level=`), the live
* path selects a DS1 file inside one level (`?quadrant=`). Crossing the two
* silently falls back to the act's town, so each runtime declares its own.
*/
readonly quadrantParam: 'level' | 'quadrant'
readonly quadrant: string
readonly cellsX: number
readonly cellsY: number
readonly widthPx: number
readonly heightPx: number
readonly grid: CollisionGrid
readonly floors: readonly Drawable[]
readonly walls: readonly Drawable[]
/** Roof draws: painted after the character, so a roof covers what walks under it. */
readonly roofs: readonly Drawable[]
readonly spawn: { x: number; y: number }
readonly objects: number
readonly frames: number
/** Whether the real character art is in use. */
readonly notes: readonly string[]
/** Handles by page index; a hole means "not loaded yet". */
readonly pages: (AtlasHandle | null)[]
/** Archive directories to try for the character archive, in order. */
readonly charBases: readonly string[]
/** Act palette for the character atlas, when the pack carries one. */
readonly palette: Palette | null
/** Pages the first frame needs (spawn viewport). */
readonly priorityPages: readonly number[]
/** Priority pages first, then everything else. */
readonly loadOrder: readonly number[]
/** Upload specific pages, a few in flight at a time. */
loadPages(renderer: SpriteRenderer, indices: readonly number[], onProgress: () => void): Promise<void>
}
/**
* Match the canvas backing store to its on-screen size.
*
* The canvas fills the window, so its pixel size has to follow the window *and*
* the device pixel ratio, or the browser scales a small buffer up and every tile
* edge goes soft. The backing store is capped because a 4K retina window would
* otherwise ask for a 30-megapixel framebuffer the software GL path cannot keep
* up with.
*
* @param canvas - the canvas element.
* @returns the CSS size the camera should treat as the viewport.
*/
function syncCanvasSize(canvas: HTMLCanvasElement): { width: number; height: number } {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const cssWidth = Math.max(320, canvas.clientWidth || window.innerWidth)
const cssHeight = Math.max(240, canvas.clientHeight || window.innerHeight)
const maxPixels = 1920 * 1200
const scale = Math.min(dpr, Math.sqrt(maxPixels / (cssWidth * cssHeight)))
const width = Math.max(1, Math.round(cssWidth * scale))
const height = Math.max(1, Math.round(cssHeight * scale))
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
}
return { width: cssWidth, height: cssHeight }
}
/**
* The zoom that makes a map cover the viewport.
*
* "Fill the screen" means the map should not sit in the middle with empty space
* around it, so this is the *cover* scale (`max`), not the fit scale (`min`): a
* map wider than the viewport is zoomed until it covers, and a small map is
* zoomed in until it does. The result is clamped so a tiny 17×20 level does not
* turn into a handful of giant pixels.
*
* @param mapWidth - map width in scene pixels.
* @param mapHeight - map height in scene pixels.
* @param viewport - viewport size in CSS pixels.
* @returns the zoom factor.
*/
function coverZoom(mapWidth: number, mapHeight: number, viewport: { width: number; height: number }): number {
if (mapWidth <= 0 || mapHeight <= 0) return 1.6
const cover = Math.max(viewport.width / mapWidth, viewport.height / mapHeight)
return Math.min(6, Math.max(0.9, cover))
}
/**
* Read a URL parameter.
*
* @param key - parameter name.
* @param fallback - value when absent.
* @returns the value.
*/
function param(key: string, fallback: string): string {
return new URLSearchParams(location.search).get(key) ?? fallback
}
/**
* Fetch JSON, with the HTTP status in the error when it fails.
*
* @param url - location.
* @returns the parsed JSON.
*/
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) throw new Error(`${url}: HTTP ${String(response.status)}`)
return await response.json() as T
}
/** A pack's index file. */
interface PackIndex {
readonly palettes?: Record<string, number[]>
readonly levels: readonly {
readonly act: number
readonly slug: string
readonly label: string
readonly levelName: string
readonly path: string
readonly pages: number
}[]
}
/** A pack's per-map scene file. */
interface PackSceneJson {
readonly act: number
readonly levelName: string
readonly ds1: string
readonly cellsX: number
readonly cellsY: number
readonly originX: number
readonly originY: number
readonly widthPx: number
readonly heightPx: number
readonly pages: readonly { readonly file: string; readonly width: number; readonly height: number }[]
readonly frames: readonly (readonly number[])[]
readonly framePlacement: readonly (readonly number[])[]
readonly floors: readonly (readonly number[])[]
readonly walls: readonly (readonly number[])[]
/** Roof draws, painted after everything else (absent in older packs). */
readonly roofs?: readonly (readonly number[])[]
readonly objects: readonly unknown[]
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
readonly spawn: readonly number[] | null
readonly stats: { readonly missingTiles: number; readonly walkable: number }
}
/**
* Build the runtime for a prebaked pack.
*
* @param packBase - directory holding `index.json`.
* @param act - act to open.
* @param wantedLevel - `?level=` label, when the caller picked one.
* @returns the runtime, or null when no pack covers this act.
*/
async function loadPackRuntime(
packBase: string,
act: number,
wantedLevel: string,
viewport: { width: number; height: number },
charBases: readonly string[],
): Promise<MapRuntime | null> {
let index: PackIndex
try {
index = await fetchJson<PackIndex>(`${packBase}/index.json`)
} catch {
return null
}
const forAct = index.levels.filter(entry => entry.act === act)
if (forAct.length === 0) return null
const entry = (wantedLevel === ''
? undefined
: forAct.find(candidate => candidate.label === wantedLevel)
?? forAct.find(candidate => candidate.slug === wantedLevel))
?? forAct.find(candidate => candidate.slug === 'town')
?? forAct[0]!
const scene = await fetchJson<PackSceneJson>(`${packBase}/${entry.path}/scene.json`)
const frames: AtlasFrame[] = scene.framePlacement.map(place => ({
x: place[1] ?? 0, y: place[2] ?? 0, width: place[3] ?? 1, height: place[4] ?? 1,
}))
const toDrawable = (row: readonly number[]): Drawable => {
const frameIndex = row[0] ?? 0
return {
frame: frames[frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
x: row[1] ?? 0,
y: row[2] ?? 0,
cellX: row[3] ?? 0,
cellY: row[4] ?? 0,
page: scene.framePlacement[frameIndex]?.[0] ?? 0,
}
}
const floors = scene.floors.map(toDrawable)
const walls = scene.walls.map(toDrawable)
const roofs = (scene.roofs ?? []).map(toDrawable)
// Collision arrives as runs; expanding it here keeps the JSON small and yields
// exactly the grid the live path would have produced.
const blocked = new Uint8Array(scene.collision.width * scene.collision.height)
let at = 0
for (const run of scene.collision.runs) {
const value = run[0] ?? 0
const length = run[1] ?? 0
if (value !== 0) blocked.fill(value, at, Math.min(at + length, blocked.length))
at += length
}
const grid: CollisionGrid = {
originX: scene.originX,
originY: scene.originY,
cellsX: scene.cellsX,
cellsY: scene.cellsY,
blocked,
gridWidth: scene.collision.width,
}
const spawn = scene.spawn === null || scene.spawn === undefined
? (findIsoSpawn(grid) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 })
: { x: scene.spawn[0] ?? 0, y: scene.spawn[1] ?? 0 }
// The pack carries the act palette, which the character atlas needs; without it
// the character simply stays a marker rather than rendering in wrong colours.
const paletteRgb = index.palettes?.[`act${String(scene.act)}`]
const packPalette: Palette | null = paletteRgb === undefined
? null
: { rgb: Uint8Array.from(paletteRgb), size: 256 }
const pages: (AtlasHandle | null)[] = scene.pages.map(() => null)
const priority = pagesNeededForViewport(floors, walls, spawn, viewport, coverZoom(scene.widthPx, scene.heightPx, viewport))
const loadOrder = [...priority, ...pages.map((_, index) => index).filter(index => !priority.includes(index))]
return {
source: 'pack',
base: packBase,
charBases,
palette: packPalette,
level: scene.levelName,
act: scene.act,
quadrants: forAct.map(candidate => candidate.label),
quadrantParam: 'level',
quadrant: entry.label,
cellsX: scene.cellsX,
cellsY: scene.cellsY,
widthPx: scene.widthPx,
heightPx: scene.heightPx,
grid,
floors,
walls,
roofs,
spawn,
objects: scene.objects.length,
frames: scene.frames.length,
notes: [`首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`],
pages,
priorityPages: priority,
loadOrder,
async loadPages(renderer, indices, onProgress) {
const queue = indices.filter(index => pages[index] === null)
let cursor = 0
const workers = Array.from({ length: Math.min(PAGE_CONCURRENCY, queue.length) }, async () => {
for (;;) {
const index = queue[cursor]
cursor += 1
if (index === undefined) return
const page = scene.pages[index]!
const response = await fetch(`${packBase}/${entry.path}/${page.file}`)
if (!response.ok) throw new Error(`${page.file}: HTTP ${String(response.status)}`)
const bitmap = await createImageBitmap(await response.blob())
try {
pages[index] = renderer.addAtlas(bitmap, page.width, page.height)
} finally {
bitmap.close()
}
onProgress()
}
})
await Promise.all(workers)
},
}
}
/**
* Which pages the first frame needs.
*
* Only the pages whose tiles intersect a box around the spawn point: later pages
* can be megabytes, and none of their tiles are on screen yet.
*
* @param floors - floor draws.
* @param walls - wall draws.
* @param spawn - spawn point in scene pixels.
* @returns page indices, ascending.
*/
function pagesNeededForViewport(
floors: readonly Drawable[],
walls: readonly Drawable[],
spawn: { x: number; y: number },
viewport: { width: number; height: number },
zoom: number,
): number[] {
// The first frame must cover what is *actually* on screen: a full-screen
// canvas shows far more world than the fixed 960×560 it used to assume, and a
// page that is not loaded draws nothing at all.
const halfWidth = viewport.width / zoom / 2 + 96
const halfHeight = viewport.height / zoom / 2 + 96
const left = spawn.x - halfWidth
const right = spawn.x + halfWidth
const top = spawn.y - halfHeight
const bottom = spawn.y + halfHeight
const needed = new Set<number>()
for (const draw of [...floors, ...walls]) {
if (draw.x + draw.frame.width < left || draw.x > right) continue
if (draw.y + draw.frame.height < top || draw.y > bottom) continue
needed.add(draw.page)
}
if (needed.size === 0) needed.add(0)
return [...needed].sort((a, b) => a - b)
}
/**
* Build the runtime by reading and decoding the archives.
*
* @param base - directory holding the archives.
* @param act - act to open.
* @param renderer - renderer that receives the atlas.
* @param wantedQuadrant - `?quadrant=` member name, when given.
* @returns the runtime.
*/
async function loadLiveRuntime(
base: string,
act: number,
renderer: SpriteRenderer,
wantedQuadrant: string,
): Promise<MapRuntime> {
const archives = new MountedArchives()
const notes: string[] = []
for (const name of [...DATA_ARCHIVES, CHARACTER_ARCHIVE]) {
try {
const source = await httpRangeSource(`${base}/${name}`, name)
archives.add(name, await MpqArchive.open(source))
} catch (err) {
notes.push(`${name}: ${(err as Error).message}`)
}
}
if (archives.size === 0) throw new Error(`no archive could be mounted from ${base}`)
const tables = await loadActTables(archives)
const wantedLevel = param('level', '')
const town: LevelInfo = wantedLevel === ''
? resolveActTown(tables, act)
: resolveLevel(tables, Number(wantedLevel), act)
const quadrants = town.ds1Names.map(name => name.split('\\').pop() ?? name)
const wanted = wantedQuadrant === '' ? (quadrants[0] ?? '') : wantedQuadrant
const index = Math.max(0, quadrants.indexOf(wanted))
const level = decodeDs1(await archives.read(town.ds1Names[index]!))
const libraries = []
for (const name of town.dt1Names) libraries.push(decodeDt1(await archives.read(name)))
const pl2 = decodePl2(await archives.read(town.paletteName))
const palette: Palette = { rgb: pl2.rgb, size: 256 }
const scene = buildIsoMapScene(level, libraries, levelSeed(town.ds1Names[index] ?? town.levelName))
const atlas = buildAtlasThatFits(renderer, scene.sheet, palette)
const frames = atlas.groups[0] ?? []
const toDrawable = (row: { frameIndex: number; x: number; y: number; cellX: number; cellY: number }): Drawable => ({
frame: frames[row.frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
x: row.x,
y: row.y,
cellX: row.cellX,
cellY: row.cellY,
page: 0,
})
return {
source: 'live',
base,
charBases: [base],
palette,
level: town.levelName,
act,
quadrants,
quadrantParam: 'quadrant',
quadrant: quadrants[index] ?? '',
cellsX: scene.cellsX,
cellsY: scene.cellsY,
widthPx: scene.widthPx,
heightPx: scene.heightPx,
grid: scene,
floors: scene.floors.map(toDrawable),
walls: scene.walls.map(toDrawable),
roofs: scene.roofs.map(toDrawable),
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
objects: level.objects.length,
frames: scene.frames.length,
notes,
pages: [renderer.defaultAtlasHandle],
priorityPages: [0],
loadOrder: [0],
loadPages: async () => {},
}
}
/**
* Build the tile atlas at the first width the renderer accepts.
*
* @param renderer - the sprite renderer, used as the size oracle.
* @param sheet - the scene's tile sheet.
* @param palette - the act palette.
* @returns the uploaded atlas.
*/
function buildAtlasThatFits(renderer: SpriteRenderer, sheet: SpriteSheet, palette: Palette): SpriteAtlas {
let lastError: unknown = null
for (const width of [4096, 8192, 16384]) {
const atlas = buildAtlas(sheet, palette, undefined, { width })
try {
renderer.setAtlas(atlas)
return atlas
} catch (err) {
lastError = err
console.warn(`atlas ${String(atlas.width)}x${String(atlas.height)} rejected: ${(err as Error).message}`)
}
}
throw lastError instanceof Error ? lastError : new Error('no atlas size the renderer accepts')
}
/**
* Fill in the remaining pages without blocking the first frames.
*
* @param runtime - the runtime.
* @param renderer - renderer to upload into.
*/
function preloadRemaining(runtime: MapRuntime, renderer: SpriteRenderer): void {
const rest = runtime.loadOrder.slice(1)
if (rest.length === 0) return
void runtime.loadPages(renderer, rest, () => {
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
}).catch((err: unknown) => {
console.warn(`page preload: ${(err as Error).message}`)
})
}
/** A composited character, uploaded as its own atlas page. */
interface LoadedCharacter {
readonly handle: AtlasHandle
/** `atlas.groups[direction][frame]`, ready to draw. */
readonly groups: readonly (readonly AtlasFrame[])[]
readonly directions: number
readonly walk: number
readonly stand: number
readonly members: number
}
/**
* Load the player's art from the character archive.
*
* The map comes from the pack, but a character is not map data: it is eight DCC
* layers plus a COF, ~1–3 MB that the archive serves on demand. Loading it after
* the first frame means the map is walkable while the Sorceress is still
* arriving, and if the archive is unreachable the page keeps the marker.
*
* @param base - archive directory.
* @param palette - the act palette (from the pack index).
* @param renderer - renderer to upload into.
* @returns the character, or null when it could not be loaded.
*/
async function loadCharacterArt(
bases: readonly string[],
palette: Palette,
renderer: SpriteRenderer,
): Promise<LoadedCharacter | null> {
try {
const archives = new MountedArchives()
let mounted = false
for (const base of bases) {
try {
archives.add(CHARACTER_ARCHIVE, await MpqArchive.open(await httpRangeSource(`${base}/${CHARACTER_ARCHIVE}`, CHARACTER_ARCHIVE)))
mounted = true
break
} catch (err) {
console.warn(`${base}/${CHARACTER_ARCHIVE}: ${(err as Error).message}`)
}
}
if (!mounted) return null
const walk = await loadCharacterSheet(archives, 'so', 'wl', 'hth')
const stand = await loadCharacterSheet(archives, 'so', 'nu', 'hth')
const joined: SpriteSheet = { groups: [...walk.sheet.groups, ...stand.sheet.groups], width: null }
const atlas = buildAtlas(joined, palette, undefined, { width: 1024 })
const handle = renderer.addAtlas({ pixels: atlas.pixels, width: atlas.width, height: atlas.height })
const walkCount = walk.sheet.groups.length
return {
handle,
groups: atlas.groups,
directions: walk.directions,
walk: 0,
stand: walkCount,
members: walk.members.length + stand.members.length,
}
} catch (err) {
console.warn(`character art unavailable: ${(err as Error).message}`)
return null
}
}
/**
* Run the loop for a runtime.
*
* @param runtime - the map.
* @param renderer - the renderer (already holding the atlas or the first pages).
* @param started - `performance.now()` when boot began.
*/
function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number): void {
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
const status = document.querySelector<HTMLElement>('#status')!
const hud = document.querySelector<HTMLElement>('#hud')!
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
const player = { x: runtime.spawn.x, y: runtime.spawn.y, facing: 0, walkFrame: 0, moving: false }
const input = new KeyboardInput()
input.attach()
let skippedDraws = 0
// Zoom: default to "cover the window", with manual override for looking around.
let character: LoadedCharacter | null = null
let zoom = coverZoom(runtime.widthPx, runtime.heightPx, syncCanvasSize(canvas))
let manualZoom = false
const setZoom = (value: number): void => {
manualZoom = true
zoom = Math.min(8, Math.max(0.4, value))
}
const onWheel = (event: WheelEvent): void => {
event.preventDefault()
setZoom(zoom * (event.deltaY < 0 ? 1.12 : 1 / 1.12))
}
const onKeyZoom = (event: KeyboardEvent): void => {
if (event.code === 'Equal' || event.code === 'NumpadAdd') setZoom(zoom * 1.15)
if (event.code === 'Minus' || event.code === 'NumpadSubtract') setZoom(zoom / 1.15)
if (event.code === 'Digit0') { manualZoom = false; zoom = coverZoom(runtime.widthPx, runtime.heightPx, syncCanvasSize(canvas)) }
}
const onResize = (): void => {
const size = syncCanvasSize(canvas)
if (!manualZoom) zoom = coverZoom(runtime.widthPx, runtime.heightPx, size)
state.zoom = Number(zoom.toFixed(2))
}
canvas.addEventListener('wheel', onWheel, { passive: false })
window.addEventListener('keydown', onKeyZoom)
window.addEventListener('resize', onResize)
const loop = new GameLoop({
tickRate: 25,
onTick: () => {
const movement = input.movement()
player.moving = movement.x !== 0 || movement.y !== 0
if (player.moving) {
// Input is screen-space: the diamond lattice is rotated 45° from the
// screen axes, so "north" walks along a cell diagonal.
const length = Math.hypot(movement.x, movement.y)
const step = (WALK_SPEED / 25) / length
if (!collides(runtime.grid, player.x + movement.x * step, player.y)) player.x += movement.x * step
if (!collides(runtime.grid, player.x, player.y + movement.y * step)) player.y += movement.y * step
player.facing = facingOf(movement.x, movement.y)
player.walkFrame = (player.walkFrame + 1) % 24
} else {
player.walkFrame = 0
}
state.tick = loop.tick
state.x = player.x
state.y = player.y
state.facing = player.facing
},
onRender: () => {
if (state.pagesAtFirstFrame < 0) {
state.pagesAtFirstFrame = runtime.pages.filter(page => page !== null).length
state.firstFrameMs = Math.round(performance.now() - started)
}
renderer.begin({ x: player.x, y: player.y - 16, zoom }, [0.03, 0.03, 0.04])
skippedDraws = 0
const drawTiles = (list: readonly Drawable[], from: number, to: number): void => {
for (let index = from; index < to; index += 1) {
const draw = list[index]!
const page = runtime.pages[draw.page]
if (page === null || page === undefined) { skippedDraws += 1; continue }
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page })
}
}
drawTiles(runtime.floors, 0, runtime.floors.length)
const cell = cellOf(runtime.grid, player.x, player.y)
const insertAt = depthInsertIndex(runtime.walls, cell.x, cell.y)
drawTiles(runtime.walls, 0, insertAt)
if (character === null) {
// Placeholder until the character archive has been read.
renderer.drawSolid(player.x - MARKER_WIDTH / 2, player.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.85, 0.75, 0.45, 1])
renderer.drawSolid(player.x - MARKER_WIDTH / 2, player.y - 6, MARKER_WIDTH, 6, [0.35, 0.25, 0.15, 1])
} else {
const direction = facingToDirection(player.facing, character.directions)
const groupIndex = (player.moving ? character.walk : character.stand) + direction
const group = character.groups[groupIndex] ?? character.groups[character.walk + direction]
const frameIndex = player.moving
? Math.floor(player.walkFrame / 3) % Math.max(1, group?.length ?? 1)
: 0
const frame = group?.[frameIndex]
if (frame !== undefined) {
// Feet at the player's position: the composited frame is anchored by its
// bottom edge, which is where the COF puts the sprite's origin.
renderer.draw(frame, player.x - frame.width / 2, player.y - frame.height + FEET_HEIGHT / 2, { atlas: character.handle })
state.characterFrames += 1
state.characterGroup = groupIndex
}
}
drawTiles(runtime.walls, insertAt, runtime.walls.length)
// Roofs last, in their own pass: the engine paints them after every other
// layer so they cover the floor, the walls and anything walking under them.
drawTiles(runtime.roofs, 0, runtime.roofs.length)
renderer.flush()
state.drawCalls = 1
state.tickRate = loop.tickRate
state.zoom = Number(zoom.toFixed(2))
state.canvasWidth = canvas.width
state.canvasHeight = canvas.height
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
state.skippedDraws = skippedDraws
hud.textContent = `${runtime.level} · ${runtime.quadrant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
+ ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙`
+ ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
+ ` · 对象 ${String(runtime.objects)} · ${loop.tickRate.toFixed(1)} tps · pos ${player.x.toFixed(0)},${player.y.toFixed(0)}`
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
},
})
loop.start()
state.ready = true
state.loadMs = Math.round(performance.now() - started)
// Only the live path fills the selectors here: the pack path already built them from
// the index (`populatePackSelectors`), and overwriting them with the single level this
// runtime knows would collapse the three-level hierarchy back into one flat list.
if (runtime.source === 'live') populateLiveSelectors(runtime)
void variantSelect
status.textContent = 'WASD / 方向键移动。'
+ (runtime.source === 'pack'
? `资源包:首屏 ${String(state.pagesLoaded)}/${String(state.pagesTotal)} 页,${String(state.loadMs)} ms 出画面,其余页后台补。`
: '本次直接读归档并解码(较慢):资源包不可用时会走这条路。')
+ (state.character
? ` 角色为真·女法师(DCC+COF 合成,${String(state.characterMembers)} 层)。`
: ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。')
+ ' 可破坏物品的位置与美术成员已进包,静态帧待接。'
preloadRemaining(runtime, renderer)
if (runtime.palette !== null) {
const paletteForCharacter = runtime.palette
void loadCharacterArt(runtime.charBases, paletteForCharacter, renderer).then((loaded) => {
character = loaded
state.character = loaded !== null
if (loaded !== null) state.characterMembers = loaded.members
// The status line is written before this promise settles, so refresh it: leaving
// "占位棋子" on screen while the real sprite is already drawing is a lie the user
// actually sees (it showed up in a screenshot review).
status.textContent = status.textContent.replace(
loaded === null
? ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。'
: / 角色[^。]*。/u,
loaded === null
? ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。'
: ` 角色为真·女法师(DCC+COF 合成,${String(loaded.members)} 层)。`,
)
})
}
window.addEventListener('beforeunload', () => {
loop.stop()
input.detach()
if (character?.handle) renderer.deleteAtlas(character.handle)
for (const page of runtime.pages) {
if (page !== null) renderer.deleteAtlas(page)
}
renderer.dispose()
})
}
/**
* Whether a feet box at a point overlaps blocked sub-tiles.
*
* @param grid - the collision grid.
* @param x - feet centre x.
* @param y - feet centre y.
* @returns true when blocked.
*/
function collides(grid: CollisionGrid, x: number, y: number): boolean {
if (isBlockedAt(grid, x, y)) return true
for (const [dx, dy] of [[-1, -1], [1, -1], [-1, 1], [1, 1], [0, -1], [0, 1], [-1, 0], [1, 0]] as const) {
if (isBlockedAt(grid, x + (dx * FEET_WIDTH) / 2, y + (dy * FEET_HEIGHT) / 2)) return true
}
return false
}
/**
* The cell containing a scene point.
*
* @param grid - the collision grid.
* @param x - scene x.
* @param y - scene y.
* @returns rounded-down cell coordinates.
*/
function cellOf(grid: CollisionGrid, x: number, y: number): { x: number; y: number } {
const px = x - grid.originX
const py = y - grid.originY
return {
x: Math.floor((px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2),
y: Math.floor((py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2),
}
}
/**
* The facing index for a screen-space direction.
*
* @param x - screen x direction, -1..1.
* @param y - screen y direction, -1..1 (down positive).
* @returns the facing index, 0 = south, turning clockwise (west).
*/
function facingOf(x: number, y: number): number {
const angle = Math.atan2(-x, y)
return Math.round((angle / (Math.PI / 4)) + 8) % 8
}
/**
* Copy a runtime's description into the published state.
*
* @param runtime - the runtime.
* @param base - location the data came from.
*/
function publish(runtime: MapRuntime, base: string): void {
state.source = runtime.source
state.base = base
state.act = runtime.act
state.level = runtime.level
state.quadrant = runtime.quadrant
state.quadrants = [...runtime.quadrants]
state.cellsX = runtime.cellsX
state.cellsY = runtime.cellsY
state.widthPx = runtime.widthPx
state.heightPx = runtime.heightPx
state.floors = runtime.floors.length
state.walls = runtime.walls.length
state.frames = runtime.frames
state.pagesTotal = runtime.pages.length
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
state.priorityPages = [...runtime.priorityPages]
state.objects = runtime.objects
state.roofs = runtime.roofs.length
state.missing = 0
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
}
/**
* Fill the level picker from a pack index and wire it to navigation.
*
* Every baked map is one entry, grouped by act. Picking one reloads the page with
* `?act=` and `?level=`, which is the same query the loader already understands,
* so the picker needs no second loading path.
*
* @param packBase - directory holding `index.json`.
* @param current - the act and the entry label currently displayed (`runtime.quadrant`),
* so the list marks exactly the map that is on screen.
*/
async function populatePackSelectors(packBase: string, current: { act: number; label: string }): Promise<void> {
const actSelect = document.querySelector<HTMLSelectElement>('#act')
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')
if (actSelect === null || sceneSelect === null || variantSelect === null) return
let index: PackIndex
try {
index = await fetchJson<PackIndex>(`${packBase}/index.json`)
} catch {
return
}
/** A scene type: one `Levels.txt` level, with every baked DS1 variant under it. */
interface Scene {
readonly slug: string
readonly name: string
readonly variants: readonly PackIndex['levels'][number][]
}
const scenesByAct = new Map<number, Scene[]>()
for (const entry of index.levels) {
let scenes = scenesByAct.get(entry.act)
if (scenes === undefined) { scenes = []; scenesByAct.set(entry.act, scenes) }
let scene = scenes.find(candidate => candidate.slug === entry.slug)
if (scene === undefined) {
scene = { slug: entry.slug, name: sceneNameZh(entry.slug, entry.levelName), variants: [] }
scenes.push(scene)
}
;(scene.variants as PackIndex['levels'][number][]).push(entry)
}
/** The suffix after the level slug, which is the DS1's own name fragment. */
const suffixOf = (entry: PackIndex['levels'][number]): string =>
entry.label.startsWith(`${entry.slug}-`) ? entry.label.slice(entry.slug.length + 1) : entry.label
const optionFor = (value: string, text: string, selected: boolean): HTMLOptionElement => {
const option = document.createElement('option')
option.value = value
option.textContent = text
option.selected = selected
return option
}
const acts = [...scenesByAct.keys()].sort((left, right) => left - right)
actSelect.replaceChildren(...acts.map(act => optionFor(
String(act),
`${ACT_NAMES_ZH[act] ?? `第 ${String(act)} 章`}`,
act === current.act,
)))
actSelect.disabled = acts.length === 0
const scenes = scenesByAct.get(current.act) ?? []
const currentEntry = index.levels.find(entry => entry.act === current.act && entry.label === current.label)
const currentSceneSlug = currentEntry?.slug ?? scenes[0]?.slug ?? ''
sceneSelect.replaceChildren(...scenes.map(scene => optionFor(
scene.slug,
scene.name,
scene.slug === currentSceneSlug,
)))
sceneSelect.disabled = scenes.length === 0
const populateVariants = (slug: string, selectedLabel: string): void => {
const scene = scenes.find(candidate => candidate.slug === slug)
const variants = scene?.variants ?? []
// A single-variant scene still shows its own DS1 so the third selector is never
// an empty box; the option text repeats the scene name only when it is the only one.
variantSelect.replaceChildren(...variants.map(entry => {
const suffix = suffixOf(entry)
const label = variants.length > 1 ? variantLabelZh(suffix) : `${scene?.name ?? entry.levelName}(${suffix})`
return optionFor(entry.label, label, entry.label === selectedLabel)
}))
variantSelect.disabled = variants.length === 0
}
populateVariants(currentSceneSlug, current.label)
variantSelect.dataset.scene = currentSceneSlug
const go = (params: URLSearchParams): void => { location.search = params.toString() }
const navigateToLabel = (label: string, act: number): void => {
const params = new URLSearchParams(location.search)
params.set('act', String(act))
params.set('level', label)
go(params)
}
actSelect.onchange = () => {
const act = Number(actSelect.value)
const first = scenesByAct.get(act)?.[0]
// Keep the address bar and the map in sync: an act with no baked scene yet (there
// are none today) simply leaves the current map alone.
if (first === undefined) return
navigateToLabel(first.variants[0]?.label ?? '', act)
}
sceneSelect.onchange = () => {
const scene = scenes.find(candidate => candidate.slug === sceneSelect.value)
if (scene === undefined) return
populateVariants(scene.slug, '')
navigateToLabel(scene.variants[0]?.label ?? '', current.act)
}
variantSelect.onchange = () => { navigateToLabel(variantSelect.value, current.act) }
}
/**
* Build the three selectors for the live (no pack) path.
*
* Without a pack index there is no scene catalog to offer: the live loader only knows
* the act's town, so `场景` shows that one level and `细分场景` lists the DS1 files the
* level actually contains — which is the same distinction the pack path draws between
* a scene and its variants.
*
* @param runtime - the freshly loaded runtime.
*/
function populateLiveSelectors(runtime: MapRuntime): void {
const actSelect = document.querySelector<HTMLSelectElement>('#act')
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')
if (actSelect === null || sceneSelect === null || variantSelect === null) return
actSelect.value = String(runtime.act)
actSelect.disabled = false
const option = document.createElement('option')
option.value = runtime.level
option.textContent = runtime.level
option.selected = true
sceneSelect.replaceChildren(option)
sceneSelect.disabled = false
variantSelect.replaceChildren(...runtime.quadrants.map(name => {
const item = document.createElement('option')
item.value = name
item.textContent = name
item.selected = name === runtime.quadrant
return item
}))
variantSelect.disabled = runtime.quadrants.length === 0
actSelect.onchange = () => {
const params = new URLSearchParams(location.search)
params.set('act', actSelect.value)
params.delete('level')
params.delete('quadrant')
location.search = params.toString()
}
sceneSelect.onchange = () => { /* one scene only in live mode */ }
variantSelect.onchange = () => {
const params = new URLSearchParams(location.search)
params.set('act', String(runtime.act))
params.set(runtime.quadrantParam, variantSelect.value)
params.delete(runtime.quadrantParam === 'level' ? 'quadrant' : 'level')
location.search = params.toString()
}
}
/**
* Boot the page: pack first, archives second.
*/
async function boot(): Promise<void> {
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
const status = document.querySelector<HTMLElement>('#status')!
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')!
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
actSelect.disabled = false
sceneSelect.disabled = true
variantSelect.disabled = true
const started = performance.now()
const act = Math.min(ACT_COUNT, Math.max(1, Number(param('act', '1'))))
state.act = act
actSelect.value = String(act)
actSelect.addEventListener('change', () => {
const params = new URLSearchParams(location.search)
params.set('act', actSelect.value)
params.delete('level')
location.search = params.toString()
})
// The canvas fills the window, so its size decides how much world the first
// frame needs and therefore which atlas pages must load before it.
const viewport = syncCanvasSize(canvas)
try {
const forcedLive = param('live', '') === '1'
if (!forcedLive) {
const packParam = param('pack', '')
const packBases = packParam === '' ? DEFAULT_PACKS : [packParam]
for (const packBase of packBases) {
status.textContent = `正在读取资源包 ${packBase}/index.json …`
const baseParamForChar = param('base', '')
const charBases = baseParamForChar === '' ? DEFAULT_BASES : [baseParamForChar]
const runtime = await loadPackRuntime(packBase, act, param('level', ''), viewport, charBases)
if (runtime === null) continue
publish(runtime, packBase)
void populatePackSelectors(packBase, { act: runtime.act, label: runtime.quadrant })
const renderer = new SpriteRenderer(canvas, {
onContextLost: () => {
status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
},
onContextRestored: () => {
status.textContent = 'WebGL 上下文已恢复,正在重新加载…'
location.reload()
},
})
// The first frame waits only for the spawn-area pages.
await runtime.loadPages(renderer, runtime.priorityPages, () => {
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
})
runScene(runtime, renderer, started)
return
}
}
const baseParam = param('base', '')
const bases = baseParam === '' ? DEFAULT_BASES : [baseParam]
let lastError: unknown = null
for (const base of bases) {
try {
status.textContent = `正在读取归档 ${base}(HTTP range)并解码 …`
const renderer = new SpriteRenderer(canvas, {
onContextLost: () => {
status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
},
onContextRestored: () => {
status.textContent = 'WebGL 上下文已恢复,正在重新加载…'
location.reload()
},
})
const runtime = await loadLiveRuntime(base, act, renderer, param('quadrant', ''))
publish(runtime, base)
runScene(runtime, renderer, started)
return
} catch (err) {
lastError = err
console.warn(`${base}: ${(err as Error).message}`)
}
}
throw lastError instanceof Error ? lastError : new Error('no data location worked')
} catch (err) {
state.error = (err as Error).message
status.textContent = `启动失败:${state.error}`
actSelect.disabled = false
sceneSelect.disabled = false
variantSelect.disabled = false
}
}
void boot()