/** * 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 } /** * 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(url: string): Promise { 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 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 { let index: PackIndex try { index = await fetchJson(`${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(`${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() 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 { 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 { 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('#view')! const status = document.querySelector('#status')! const hud = document.querySelector('#hud')! const actSelect = document.querySelector('#act')! const variantSelect = document.querySelector('#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 { const actSelect = document.querySelector('#act') const sceneSelect = document.querySelector('#scene') const variantSelect = document.querySelector('#variant') if (actSelect === null || sceneSelect === null || variantSelect === null) return let index: PackIndex try { index = await fetchJson(`${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() 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('#act') const sceneSelect = document.querySelector('#scene') const variantSelect = document.querySelector('#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 { const canvas = document.querySelector('#view')! const status = document.querySelector('#status')! const actSelect = document.querySelector('#act')! const sceneSelect = document.querySelector('#scene')! const variantSelect = document.querySelector('#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()