/** * 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 { ViewportCamera } from "../sim/camera.ts" import { MountedArchives } from '../mpq/mount.ts' import { loadActTables, resolveActTown, resolveLevel } from '../game/acts.ts' import { planLevelMonsters } from '../game/monsters.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, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH, buildIsoMapScene, cellCentre, findIsoSpawn, isBlockedAt, levelSeed, } from '../game/d2map.ts' import type { CollisionGrid, IsoDraw } from '../game/d2map.ts' import { updateAnimatableTiles } from '../game/animated-tiles.ts' import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts' import { createIsoTerrain } from '../game/iso-terrain.ts' import { spawnMonsterPacks, spawnMonsters } from '../game/combat.ts' import type { MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts' import { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts' import { GameEngine, syncEngineState } from "../game/engine.ts" import type { NpcEntity } from "../game/engine.ts" import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts" import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' import type { NpcDef } from '../game/quests.ts' import { buildAtlas, buildIndexedAtlas } 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 { buildBounds, intersects, viewportRect } from '../render/cull.ts' import { loadCharacterSheet, facingToDirection } from '../game/character.ts' import { loadMonsterAtlas, type LoadedMonsterArt } from '../game/monster-art.ts' import { resolveMonsterArtSpec } from '../game/monster-mapping.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' import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-links.ts' import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../game/level-links.ts' import { FadeOverlay, SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from './transition.ts' import { TownPortalSlot, WaypointNetwork, townLevelForAct } from '../game/portal.ts' import { Minimap } from '../ui/minimap.ts' import type { MinimapLevel, MinimapMarker } from '../ui/minimap.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 /** * Picks which baked variant of a generated level this world instance uses. * * Each generated level is baked three times and the world graph names none of * them, so one has to be chosen at load time. It must be chosen from the level * alone: keying it on the pair of levels, as an earlier version did, gave Cold * Plains a different layout depending on whether you walked in from the Blood * Moor or back from Stony Field, and left waypoints and town portals pointing * at coordinates from a map that was no longer loaded. One variant per level * per world is what the design calls for. * * A real game would derive this from the game seed; until there is one, a * constant keeps every session identical, which is also what the verification * scripts assume. */ const WORLD_VARIANT_SEED = 0x5eed_3000 /** 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 /** * Monsters placed per level, until `Levels.txt` supplies the real number. * * `MonDen` (density) and `NumMon` (how many kinds) are both real columns that * #22 will read; 12 is only large enough that a walk across a level runs into * something, which is what M6 needs to demonstrate that the level → spawn path * works at all. */ const PLACEHOLDER_MONSTER_COUNT = 12 /** * `localStorage` key holding the single save slot. * * Deliberately the same key the retired `map.html` sandbox used, so a save made * there still loads here — the snapshot format never differed, only the scene * that drove it. */ const SAVE_KEY = 'd2web.save' /** * How often the HUD text is rewritten, in milliseconds. * * The overlay reports averages, so refreshing it at frame rate only buys string * churn: four `toFixed` calls and ten concatenations that the reader cannot * follow anyway. */ const HUD_INTERVAL_MS = 250 /** 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 /** * GPU draw calls the last frame actually issued. * * This used to be hardcoded to 1, which hid the fact that switching atlas * pages mid-list was costing up to 3 077 calls per frame. It now reads the * renderer's own counter. */ drawCalls: number /** Quads the last frame submitted, i.e. what survived culling. */ quadsDrawn: number /** Draws rejected last frame because their bounds fell outside the viewport. */ culledDraws: number /** Wall-clock milliseconds the last `onRender` took. */ renderMs: number /** Current zoom factor and the canvas backing-store size. */ zoom: number canvasWidth: number canvasHeight: number error: string | null dialog: string[] npcs: number npcsNear: NpcEntity[] /** Count of animated tiles in the active map. */ animatedTiles: number } 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, quadsDrawn: 0, culledDraws: 0, renderMs: 0, zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null, dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0, } window.__d2webAct = state /** One tile to draw. */ interface Drawable extends AnimatableTile { frame: AtlasFrame readonly x: number readonly y: number readonly cellX: number readonly cellY: number /** Atlas page index this frame lives on. */ page: number readonly animatedFrames?: readonly AnimatedFrame[] | undefined } /** One object sprite to draw. */ interface ObjectDrawable { readonly frame: AtlasFrame readonly x: number readonly y: number readonly depth: number /** Object 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 /** * `Levels.txt` `Id`, or -1 when the source cannot say. * * The world graph speaks in level ids; the pack speaks in labels. This is * where the two meet. */ readonly levelId: number /** The pack directory this map came from, for loading its neighbours. */ readonly entryPath: string /** Walk-through seams out of this level, in sub-tiles. */ readonly entrances: readonly SceneEntrance[] /** Clickable stairs and cave mouths, in sub-tiles. */ readonly warps: readonly SceneWarp[] /** The waypoint pedestal, when this level has one. */ readonly waypoints: readonly SceneWaypoint[] 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[] /** Animated tile drawables whose frames cycle during rendering. */ readonly animatedDrawables?: readonly Drawable[] | undefined /** Object draws: interleaved with walls in painter's order. */ readonly objectDrawables: readonly ObjectDrawable[] readonly spawn: { x: number; y: number } readonly npcs: readonly PackObject[] /** * The monsters this level actually holds, planned from `Levels.txt` and * `MonStats.txt`. * * Empty when the level has none — a town — and empty for a source that has * not planned one, which is what {@link ActRuntime.monsterStats} below is * still there for. */ readonly monsterPacks: readonly MonsterPack[] /** * The level's monster types, by `MonStats.txt` id, for the status line. */ readonly monsterTypes: readonly string[] /** * Fallback monster definitions, used only when the level produced no packs. * * A level with no `Levels.txt` row — a hand-built fixture, or a pack baked * before the monster columns were read — would otherwise be empty. Spawning a * few placeholders there keeps the page useful to poke at, and the status * line says which of the two happened. */ readonly monsterStats: readonly MonsterStats[] /** How many of {@link ActRuntime.monsterStats} to place in that fallback. */ readonly monsterCount: 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)[] /** Object atlas page handles. */ readonly objectPages: (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 } /** * 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 PackIndexEntry[] } /** * One baked map in the index. * * `levelId` and `kind` have been written by the packer since the first bake; * they were simply not declared here, because until now nothing needed to find * a map by anything other than the label in the URL. Level transitions do: an * edge of the world graph names a destination level, not a pack label, and a * generated level has three labels for the one id. */ interface PackIndexEntry { readonly act: number readonly levelId?: number readonly kind?: 'preset' | 'maze' | 'wilderness' readonly slug: string readonly label: string readonly levelName: string readonly path: string readonly pages: number } /** Object frame inside an object atlas page. */ interface PackObjectFrame { readonly page: number readonly x: number readonly y: number readonly width: number readonly height: number readonly offsetX: number readonly offsetY: number } /** Object instance serialized in scene.json. */ interface PackObject { readonly id: number readonly type: number readonly name?: string | null readonly token?: string readonly mode?: string readonly objectsTxtId?: number readonly hp?: number readonly member?: string | null readonly alternatives?: number readonly x: number readonly y: number readonly depth?: number readonly frame?: PackObjectFrame | null } /** A pack's per-map scene file. */ interface PackSceneJson { readonly act: number /** `Levels.txt` `Id`. Written by every bake; only now read. */ readonly levelId?: 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 objectPages?: 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 PackObject[] readonly npcs?: readonly PackObject[] /** * The level's monsters, planned at bake time (absent in packs baked before * the monster columns were read). * * Planned at bake time rather than in the browser because the browser has no * `Levels.txt` or `MonStats.txt` on the pack path — the whole point of a pack * is that it carries what the level needs and nothing else. The same * `planLevelMonsters` produces both this and the live path's population, so * the two agree by construction. */ readonly monsters?: { readonly types: readonly string[] readonly budget: number readonly packs: readonly { readonly members: readonly MonsterStats[] }[] } readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] } readonly spawn: readonly number[] | null /** Ways out, in sub-tiles. Absent in packs baked before world connectivity. */ readonly entrances?: readonly SceneEntrance[] readonly warps?: readonly SceneWarp[] readonly waypoints?: readonly SceneWaypoint[] readonly stats: { readonly missingTiles: number; readonly walkable: number } } /** * The pack index, fetched once. * * A level transition needs the index to find its destination, and refetching it * mid-walk would put a network round trip inside the fade. */ const packIndexCache = new Map>() /** * Read a pack's index, reusing the fetch. * * @param packBase - directory holding `index.json`. * @returns the index, or null when there is no pack there. */ async function getPackIndex(packBase: string): Promise { let pending = packIndexCache.get(packBase) if (pending === undefined) { pending = fetchJson(`${packBase}/index.json`) packIndexCache.set(packBase, pending) } try { return await pending } catch { packIndexCache.delete(packBase) return null } } /** * 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 { const index = await getPackIndex(packBase) if (index === null) 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]! return await buildPackRuntime(packBase, index, entry, viewport, charBases) } /** * Build the runtime for one level, named by id rather than by label. * * Generated levels have three baked variants and the world graph names none of * them, so one is picked here. Which one does not matter for connectivity: the * act layout solve gives all three the same seams on the same edges, so any * variant docks with any neighbour. What does matter is that the choice depends * only on the level, so that the same level is the same map every time it is * entered — see {@link WORLD_VARIANT_SEED}. * * @param packBase - directory holding `index.json`. * @param levelId - `Levels.txt` `Id` of the wanted level. * @param variantSeed - picks between the variants deterministically. * @returns the runtime, or null when the pack has no such level. */ async function loadRuntimeForLevel( packBase: string, levelId: number, variantSeed: number, viewport: { width: number; height: number }, charBases: readonly string[], ): Promise { const index = await getPackIndex(packBase) if (index === null) return null const candidates = index.levels.filter(entry => entry.levelId === levelId) const entry = candidates[Math.abs(variantSeed) % Math.max(1, candidates.length)] if (entry === undefined) return null return await buildPackRuntime(packBase, index, entry, viewport, charBases) } /** * Turn one index entry into a runtime. * * @param packBase - directory holding `index.json`. * @param index - the already-fetched index. * @param entry - the map to load. * @returns the runtime. */ async function buildPackRuntime( packBase: string, index: PackIndex, entry: PackIndexEntry, viewport: { width: number; height: number }, charBases: readonly string[], ): Promise { const forAct = index.levels.filter(candidate => candidate.act === entry.act) 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 const animFrames = row.length > 5 ? row.slice(5) : undefined const anim = animFrames && animFrames.length > 1 ? animFrames.map(fIdx => ({ frame: frames[fIdx] ?? { x: 0, y: 0, width: 1, height: 1 }, page: scene.framePlacement[fIdx]?.[0] ?? 0, })) : undefined 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, ...(anim ? { animatedFrames: anim } : {}), } } const floors = scene.floors.map(toDrawable) const WILDERNESS_LEVEL_IDS = new Set([2, 3, 4, 5, 6, 7, 17, 41, 42, 43, 44, 45, 46, 76, 77, 78, 79, 80, 81, 82, 83, 104, 105, 106, 110, 111, 112, 117, 134]) if (entry.levelId !== undefined && WILDERNESS_LEVEL_IDS.has(entry.levelId) && floors.length > 0 && floors.length < scene.cellsX * scene.cellsY) { const covered = new Uint8Array(scene.cellsX * scene.cellsY) const freq = new Map() for (let i = 0; i < scene.floors.length; i += 1) { const row = scene.floors[i]! const cx = row[3] ?? 0 const cy = row[4] ?? 0 if (cx >= 0 && cx < scene.cellsX && cy >= 0 && cy < scene.cellsY) { covered[cy * scene.cellsX + cx] = 1 } const fIdx = row[0] ?? 0 freq.set(fIdx, (freq.get(fIdx) ?? 0) + 1) } let defaultFrameIndex = scene.floors[0]?.[0] ?? 0 let maxCount = -1 for (const [fIdx, count] of freq) { if (count > maxCount) { maxCount = count defaultFrameIndex = fIdx } } const defaultFrame = frames[defaultFrameIndex] ?? { x: 0, y: 0, width: 1, height: 1 } const defaultPage = scene.framePlacement[defaultFrameIndex]?.[0] ?? 0 for (let cy = 0; cy < scene.cellsY; cy += 1) { for (let cx = 0; cx < scene.cellsX; cx += 1) { if (covered[cy * scene.cellsX + cx] === 0) { floors.push({ frame: defaultFrame, x: scene.originX + (cx - cy) * ORTHO_CELL_WIDTH - 80, y: scene.originY + (cx + cy) * ORTHO_CELL_HEIGHT, cellX: cx, cellY: cy, page: defaultPage, }) } } } floors.sort((a, b) => (a.cellX + a.cellY) - (b.cellX + b.cellY) || a.cellY - b.cellY) } const walls = scene.walls.map(toDrawable) const roofs = (scene.roofs ?? []).map(toDrawable) const objectDrawables: ObjectDrawable[] = [] const packObjects = [...scene.objects, ...(scene.npcs ?? [])] for (const obj of packObjects) { if (hasPackedSprite(obj)) { objectDrawables.push({ frame: { x: obj.frame.x, y: obj.frame.y, width: obj.frame.width, height: obj.frame.height, }, x: obj.x + obj.frame.offsetX, y: obj.y + obj.frame.offsetY, depth: obj.depth ?? ((obj.x + obj.y) / 5), page: obj.frame.page, }) } } objectDrawables.sort((a, b) => a.depth - b.depth) const objectPages: (AtlasHandle | null)[] = (scene.objectPages ?? []).map(() => null) // 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, ViewportCamera.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, levelId: scene.levelId ?? entry.levelId ?? -1, entryPath: entry.path, entrances: scene.entrances ?? [], warps: scene.warps ?? [], waypoints: scene.waypoints ?? [], 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, animatedDrawables: [...floors, ...walls, ...roofs].filter( d => d.animatedFrames && d.animatedFrames.length > 1 ), objectDrawables, spawn, npcs: scene.npcs ?? [], monsterPacks: scene.monsters?.packs ?? [], monsterTypes: scene.monsters?.types ?? [], monsterStats: DEMO_MONSTERS, monsterCount: (entry.slug.includes('town') || scene.levelName.toLowerCase().includes('town') || (scene.monsters?.packs && scene.monsters.packs.length > 0) || scene.monsters !== undefined) ? 0 : PLACEHOLDER_MONSTER_COUNT, objects: scene.objects.length, frames: scene.frames.length, notes: [ `首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`, ...(scene.monsters?.packs && scene.monsters.packs.length > 0 ? [`怪物:${String(scene.monsters.budget)} 只 / ${String(scene.monsters.packs.length)} 群(${scene.monsters.types.join('、')})`] : []), ], pages, objectPages, priorityPages: priority, loadOrder, async loadPages(renderer, indices, onProgress) { if (objectPages.some(page => page === null) && scene.objectPages && scene.objectPages.length > 0) { await Promise.all(scene.objectPages.map(async (page, index) => { if (objectPages[index] !== null) return try { const response = await fetch(`${packBase}/${entry.path}/${page.file}`) if (!response.ok) return const bitmap = await createImageBitmap(await response.blob()) try { objectPages[index] = renderer.addAtlas(bitmap, page.width, page.height) } finally { bitmap.close() } } catch (err) { console.warn(`object page ${page.file}: ${(err as Error).message}`) } })) } 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: IsoDraw): Drawable => { const anim = row.animatedFrames && row.animatedFrames.length > 1 ? row.animatedFrames.map(fIdx => ({ frame: frames[fIdx] ?? { x: 0, y: 0, width: 1, height: 1 }, page: 0, })) : undefined return { 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, ...(anim ? { animatedFrames: anim } : {}), } } const floors = scene.floors.map(toDrawable) const walls = scene.walls.map(toDrawable) const roofs = scene.roofs.map(toDrawable) const animatedDrawables = [...floors, ...walls, ...roofs].filter( d => d.animatedFrames && d.animatedFrames.length > 1 ) // Plan the level's population from its own `Levels.txt` row. The seed is the // level's, so the same map always comes back with the same monsters in the // same camps — which is what makes a save reloadable. const population = planLevelMonsters( tables, town.levelId, scene.cellsX * scene.cellsY, levelSeed(town.ds1Names[index] ?? town.levelName), WALK_SPEED, ) if (population.packs.length > 0) { notes.push(`怪物:${String(population.budget)} 只 / ${String(population.packs.length)} 群(${population.types.join('、')})`) } return { source: 'live', base, charBases: [base], palette, level: town.levelName, act, // The live path decodes one DS1 straight out of the archives. Nothing has // solved the world layout for it, so it offers no way out; level // transitions are a pack-path feature. levelId: -1, entryPath: '', entrances: [], warps: [], waypoints: [], quadrants, quadrantParam: 'quadrant', quadrant: quadrants[index] ?? '', cellsX: scene.cellsX, cellsY: scene.cellsY, widthPx: scene.widthPx, heightPx: scene.heightPx, grid: scene, floors, walls, roofs, animatedDrawables, objectDrawables: [], spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 }, npcs: [], monsterPacks: population.packs, monsterTypes: population.types, monsterStats: DEMO_MONSTERS, monsterCount: (town.levelName.toLowerCase().includes('town') || population.packs.length > 0 || population.budget === 0) ? 0 : PLACEHOLDER_MONSTER_COUNT, objects: level.objects.length, frames: scene.frames.length, notes, pages: [renderer.defaultAtlasHandle], objectPages: [], 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. */ /** Cache of opened MPQ archives by URL to avoid re-opening and re-parsing index tables. */ const archivePromiseCache = new Map>() function getCachedMpqArchive(url: string, label = url): Promise { let p = archivePromiseCache.get(url) if (!p) { p = httpRangeSource(url, label).then(src => MpqArchive.open(src)) archivePromiseCache.set(url, p) } return p } async function getMountedDataArchives(bases: readonly string[]): Promise { for (const base of bases) { const archives = new MountedArchives() let mountedCount = 0 await Promise.all( DATA_ARCHIVES.map(async (name) => { try { const arc = await getCachedMpqArchive(`${base}/${name}`, name) archives.add(name, arc) mountedCount += 1 } catch { // ignore missing } }) ) if (mountedCount > 0) return archives } return null } async function getMountedCharArchives(bases: readonly string[]): Promise { for (const base of bases) { try { const arc = await getCachedMpqArchive(`${base}/${CHARACTER_ARCHIVE}`, CHARACTER_ARCHIVE) const archives = new MountedArchives() archives.add(CHARACTER_ARCHIVE, arc) return archives } catch (err) { console.warn(`${base}/${CHARACTER_ARCHIVE}: ${(err as Error).message}`) } } return null } interface PackedMonsterJson { readonly token: string readonly weapon: string readonly file: string readonly width: number readonly height: number readonly directions: number readonly walkOffset: number readonly standOffset: number readonly walkFrames: number readonly standFrames: number readonly groups: readonly (readonly [number, number, number, number])[][] } interface PackedCharJson { readonly file: string readonly width: number readonly height: number readonly directions: number readonly walk: number readonly stand: number readonly members: number readonly groups: readonly (readonly [number, number, number, number])[][] } /** * Load monster atlases on-demand for all monster types present in the scene. * Prefers pre-baked indexed PNG + JSON from the act pack when available (< 200ms), * falling back to runtime MPQ HTTP Range extraction only if pre-packed assets are missing. */ async function loadMonsterArtMap( bases: readonly string[], monsterIds: readonly string[], palette: Palette, renderer: SpriteRenderer, packBase?: string, act?: number, ): Promise> { const map = new Map() if (monsterIds.length === 0) return map try { // Resolve unique (token, weapon) specs needed const tokenSpecs = new Map() for (const id of monsterIds) { const spec = resolveMonsterArtSpec(id) if (spec !== null) { const key = `${spec.token}-${spec.weapon}` if (!tokenSpecs.has(key)) { tokenSpecs.set(key, spec) } } } const loadedBySpecKey = new Map() const fallbackSpecs: Array<[string, { token: string; weapon: string }]> = [] if (packBase !== undefined) { const globalEntitiesUrl = `${packBase}/entities` const actEntitiesUrl = act !== undefined ? `${packBase}/act${String(act)}/entities` : undefined await Promise.all( [...tokenSpecs.entries()].map(async ([key, spec]) => { const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}` // 1. Prefer global act-independent raw 8-bit (.r8) index texture try { const [jsonResp, r8Resp] = await Promise.all([ fetch(`${globalEntitiesUrl}/${slug}.json`), fetch(`${globalEntitiesUrl}/${slug}.r8`), ]) if (jsonResp.ok && r8Resp.ok) { const meta = (await jsonResp.json()) as PackedMonsterJson const indices = new Uint8Array(await r8Resp.arrayBuffer()) const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height) const groups: AtlasFrame[][] = meta.groups.map(group => group.map(([x, y, width, height]) => ({ x, y, width, height })) ) loadedBySpecKey.set(key, { token: spec.token, handle, groups, directions: meta.directions, walkOffset: meta.walkOffset, standOffset: meta.standOffset, walkFrames: meta.walkFrames, standFrames: meta.standFrames, }) return } } catch { // fall through to per-act PNG fallback } // 2. Fallback to per-act PNG if available if (actEntitiesUrl !== undefined) { try { const [jsonResp, pngResp] = await Promise.all([ fetch(`${actEntitiesUrl}/${slug}.json`), fetch(`${actEntitiesUrl}/${slug}.png`), ]) if (jsonResp.ok && pngResp.ok) { const meta = (await jsonResp.json()) as PackedMonsterJson const bitmap = await createImageBitmap(await pngResp.blob()) let handle: AtlasHandle try { handle = renderer.addAtlas(bitmap, meta.width, meta.height) } finally { bitmap.close() } const groups: AtlasFrame[][] = meta.groups.map(group => group.map(([x, y, width, height]) => ({ x, y, width, height })) ) loadedBySpecKey.set(key, { token: spec.token, handle, groups, directions: meta.directions, walkOffset: meta.walkOffset, standOffset: meta.standOffset, walkFrames: meta.walkFrames, standFrames: meta.standFrames, }) return } } catch { // fall back to MPQ loading below } } fallbackSpecs.push([key, spec]) }) ) } else { fallbackSpecs.push(...tokenSpecs.entries()) } // Load any remaining specs via runtime MPQ archives if (fallbackSpecs.length > 0) { const archives = await getMountedDataArchives(bases) if (archives) { await Promise.all( fallbackSpecs.map(async ([key, spec]) => { try { const loaded = await loadMonsterAtlas(archives, spec.token, spec.weapon, palette, renderer) loadedBySpecKey.set(key, loaded) } catch (err) { console.warn(`monster art ${spec.token} (${spec.weapon}) unavailable: ${(err as Error).message}`) } }) ) } } // Map monster id to loaded art for (const id of monsterIds) { const spec = resolveMonsterArtSpec(id) if (spec !== null) { const key = `${spec.token}-${spec.weapon}` const loaded = loadedBySpecKey.get(key) if (loaded !== undefined) { map.set(id.toLowerCase(), loaded) } } } } catch (err) { console.warn(`monster art loading error: ${(err as Error).message}`) } return map } async function loadCharacterArt( bases: readonly string[], palette: Palette, renderer: SpriteRenderer, packBase?: string, act?: number, ): Promise { if (packBase !== undefined) { // 1. Prefer global act-independent raw 8-bit (.r8) index texture try { const [jsonResp, r8Resp] = await Promise.all([ fetch(`${packBase}/entities/char-so.json`), fetch(`${packBase}/entities/char-so.r8`), ]) if (jsonResp.ok && r8Resp.ok) { const meta = (await jsonResp.json()) as PackedCharJson const indices = new Uint8Array(await r8Resp.arrayBuffer()) const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height) const groups: AtlasFrame[][] = meta.groups.map(group => group.map(([x, y, width, height]) => ({ x, y, width, height })) ) return { handle, groups, directions: meta.directions, walk: meta.walk, stand: meta.stand, members: meta.members, } } } catch { // fall through to per-act PNG fallback } // 2. Fallback to per-act PNG if available if (act !== undefined) { const entitiesUrl = `${packBase}/act${String(act)}/entities` try { const [jsonResp, pngResp] = await Promise.all([ fetch(`${entitiesUrl}/char-so.json`), fetch(`${entitiesUrl}/char-so.png`), ]) if (jsonResp.ok && pngResp.ok) { const meta = (await jsonResp.json()) as PackedCharJson const bitmap = await createImageBitmap(await pngResp.blob()) let handle: AtlasHandle try { handle = renderer.addAtlas(bitmap, meta.width, meta.height) } finally { bitmap.close() } const groups: AtlasFrame[][] = meta.groups.map(group => group.map(([x, y, width, height]) => ({ x, y, width, height })) ) return { handle, groups, directions: meta.directions, walk: meta.walk, stand: meta.stand, members: meta.members, } } } catch { // fall back to MPQ loading below } } } try { const archives = await getMountedCharArchives(bases) if (!archives) return null const [walk, stand] = await Promise.all([ loadCharacterSheet(archives, 'so', 'wl', 'hth'), loadCharacterSheet(archives, 'so', 'nu', 'hth'), ]) const joined: SpriteSheet = { groups: [...walk.sheet.groups, ...stand.sheet.groups], width: null } const atlas = buildIndexedAtlas(joined, 172, { width: 1024 }) const handle = renderer.addIndexedAtlas(atlas.r8, atlas.width, 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 initialRuntime - the map to start in. The binding inside is mutable: * a level transition swaps the map under the running loop rather than * rebuilding the scene, because rebuilding would take the character's bag, * quests and experience with it. * @param renderer - the renderer (already holding the atlas or the first pages). * @param started - `performance.now()` when boot began. */ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: number): void { let runtime = initialRuntime 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 input = new KeyboardInput() input.attach() let skippedDraws = 0 let character: LoadedCharacter | null = null let monsterArtMap = new Map() let monsterWalkFrame = 0 const camera = new ViewportCamera(canvas, runtime.widthPx, runtime.heightPx) camera.attach() // Culling bounds, rebuilt on level swap: the draw lists are immutable for the // life of a single runtime, so the per-frame cost is just the rejection test. let floorBounds = buildBounds(runtime.floors) let wallBounds = buildBounds(runtime.walls) let roofBounds = buildBounds(runtime.roofs) let objectBounds = buildBounds(runtime.objectDrawables) // `runtime.pages` is filled in by background loaders, so the count has to be // re-read every frame. A loop is used rather than `filter().length` because // the latter allocates a throwaway array 60 times a second. const countPages = (): number => { let loaded = 0 for (const page of runtime.pages) if (page !== null && page !== undefined) loaded += 1 return loaded } // NPC name plates. The container and the elements outlive a frame so the // render loop only has to move them. const labelsContainer = document.querySelector('#labels') const labelPool: HTMLDivElement[] = [] /** `performance.now()` at which the HUD text may be rewritten again. */ let hudNextMs = 0 let playerMoving = false let playerWalkFrame = 0 const npcDefs = runtime.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? '')) const safeZones: SafeZone[] = [ { x: runtime.spawn.x, y: runtime.spawn.y, radius: 180 }, ] for (const wp of runtime.waypoints) { const pos = subTileToScene(wp.x, wp.y, runtime.grid) safeZones.push({ x: pos.x, y: pos.y, radius: 160 }) } for (const ent of runtime.entrances) { const pos = subTileToScene(ent.x, ent.y, runtime.grid) safeZones.push({ x: pos.x, y: pos.y, radius: 160 }) } const resolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => { if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) { return { ...pack, fixedPosition: cellCentre(runtime.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY), } } return pack }) const engine = new GameEngine( createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, { width: FEET_WIDTH, height: FEET_HEIGHT, }), { spawn: runtime.spawn, stats: runtime.monsterStats, xpTable: DEMO_EXPERIENCE, itemBases: DEMO_BASES, prefixAffixes: DEMO_AFFIXES, suffixAffixes: DEMO_AFFIXES, skills: DEMO_SKILLS, npcDefs, questDefs: DEMO_QUESTS, combatOptions: { playerSpeed: WALK_SPEED, playerReach: 52, playerCooldownTicks: 12, playerDamage: 7, playerManaPerAttack: 2, respawnTicks: 75, disableMonsterAggro: true, }, lootSeed: 0x5eed, talkRadius: 80, pickupRadius: 64, inventoryCols: 10, inventoryRows: 4, monsterCount: runtime.monsterCount, monsterPacks: resolvedPacks, monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4, safeZones, // This scene knows where its NPCs stand — the DS1 carries their // coordinates — so the engine must not build a ring we would discard. npcRingFallback: false, // The engine decides when to save; the browser only says where. Reads are // guarded because private-browsing windows throw on access, not on use. snapshotStore: { read: () => localStorage.getItem(SAVE_KEY), write: (data: string) => { localStorage.setItem(SAVE_KEY, data) }, }, } ) // `npcRingFallback: false` above means the engine left this empty, so we are // filling it rather than replacing a synthetic ring. for (const n of runtime.npcs) { const def = npcDefs.find(d => d.id === `npc-${String(n.id)}`)! // Same predicate the sprite layer gated on above, so the box and the art // can never both be drawn for one NPC. engine.npcEntities.push({ def, x: n.x, y: n.y, hasSprite: hasPackedSprite(n) }) } /* ----------------------------------------------------------------------- * * World connectivity * ----------------------------------------------------------------------- */ const fade = new FadeOverlay(document.body) const minimap = new Minimap() const waypointNetwork = new WaypointNetwork() const portalSlot = new TownPortalSlot() /** True while a transition is in flight, so nothing triggers a second one. */ let travelling = false /** * Tell the waypoint network about whatever level is loaded. * * @param map - the runtime just loaded. */ const registerWaypoints = (map: MapRuntime): void => { for (const waypoint of map.waypoints) { waypointNetwork.register({ waypointId: waypoint.waypointId, levelId: map.levelId, act: map.act, name: map.level, x: waypoint.arriveX, y: waypoint.arriveY, }) } } registerWaypoints(runtime) /** * Where the player is, in sub-tiles. * * The inverse of the isometric projection: scene pixels back to the grid the * links are expressed in. * * @returns the player's sub-tile. */ const playerSubTile = (): { x: number; y: number } => { const dx = (engine.world.player.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH const dy = (engine.world.player.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) } } /** * Replace the level under the running loop. * * Everything about the character survives: `engine.world.player` is the same * object before and after, so the bag, the quest flags and the experience go * with it. Only the terrain, the collision closure and the crowd change. * * @param next - the level to move into. * @param arrive - where to put the player, in sub-tiles. */ const swapLevel = (next: MapRuntime, arrive: { x: number; y: number }): void => { // Give back the outgoing level's atlases before the new ones go up, or a // long walk leaks a page set per level crossed. for (const page of runtime.pages) if (page !== null) renderer.deleteAtlas(page) for (const page of runtime.objectPages) if (page !== null) renderer.deleteAtlas(page) for (const art of monsterArtMap.values()) renderer.deleteAtlas(art.handle) monsterArtMap = new Map() runtime = next floorBounds = buildBounds(next.floors) wallBounds = buildBounds(next.walls) roofBounds = buildBounds(next.roofs) objectBounds = buildBounds(next.objectDrawables) camera.mapWidthPx = next.widthPx camera.mapHeightPx = next.heightPx engine.terrain = createIsoTerrain(next.grid, next.widthPx, next.heightPx, { width: FEET_WIDTH, height: FEET_HEIGHT, }) const landing = subTileToScene(arrive.x, arrive.y, next.grid) engine.world.player.x = landing.x engine.world.player.y = landing.y const defs = next.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? '')) engine.npcEntities.length = 0 for (const n of next.npcs) { const def = defs.find(d => d.id === `npc-${String(n.id)}`) if (def === undefined) continue engine.npcEntities.push({ def, x: n.x, y: n.y, hasSprite: hasPackedSprite(n) }) } // Clear outgoing monsters and ground items, then populate the destination level engine.world.monsters.length = 0 engine.ground.length = 0 const nextSafeZones: SafeZone[] = [ { x: landing.x, y: landing.y, radius: 180 }, ] for (const wp of next.waypoints) { const pos = subTileToScene(wp.x, wp.y, next.grid) nextSafeZones.push({ x: pos.x, y: pos.y, radius: 160 }) } for (const ent of next.entrances) { const pos = subTileToScene(ent.x, ent.y, next.grid) nextSafeZones.push({ x: pos.x, y: pos.y, radius: 160 }) } const nextResolvedPacks: MonsterPack[] = next.monsterPacks.map(pack => { if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) { return { ...pack, fixedPosition: cellCentre(next.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY), } } return pack }) const spread = Math.max(next.widthPx, next.heightPx) / 4 if (nextResolvedPacks.length > 0) { spawnMonsterPacks(engine.world, nextResolvedPacks, landing, spread, engine.terrain, nextSafeZones) } else if (next.monsterCount > 0) { spawnMonsters(engine.world, next.monsterStats, next.monsterCount, landing, spread, engine.terrain) } if (next.palette !== null) { renderer.setPalette(next.palette.rgb) const paletteForEntities = next.palette const packEntityBase = next.source === 'pack' ? next.base : undefined const packEntityAct = next.source === 'pack' ? next.act : undefined const activeMonsterIds = next.monsterTypes.length > 0 ? next.monsterTypes : (next.monsterCount > 0 ? next.monsterStats.map(s => s.id) : []) if (activeMonsterIds.length > 0) { void loadMonsterArtMap( DEFAULT_BASES, activeMonsterIds, paletteForEntities, renderer, packEntityBase, packEntityAct, ).then((loadedMap) => { monsterArtMap = loadedMap }) } } if (next.source === 'pack') { history.replaceState(null, '', `?act=${String(next.act)}&level=${encodeURIComponent(next.quadrant)}`) void populatePackSelectors(next.base, { act: next.act, label: next.quadrant }) } registerWaypoints(next) state.level = next.level state.act = next.act state.cellsX = next.cellsX state.cellsY = next.cellsY state.pagesTotal = next.pages.length state.pagesLoaded = 0 state.animatedTiles = next.animatedDrawables?.length ?? 0 } /** * Walk the player through a link. * * The destination decides where the player lands, not the source: level B * knows where its own opening back to A is, and using it means the two ends * of a seam can never disagree. * * @param toLevelId - the level to move to. * @param fadeMs - how long to black out for. * @param override - an explicit landing sub-tile, for waypoints and portals. */ const travel = async ( toLevelId: number, fadeMs: number, override?: { x: number; y: number }, ): Promise => { if (travelling || runtime.source !== 'pack') return travelling = true const fromLevelId = runtime.levelId try { await fade.out(fadeMs) const next = await loadRuntimeForLevel( runtime.base, toLevelId, // Per level, not per pair: see WORLD_VARIANT_SEED. WORLD_VARIANT_SEED + toLevelId, { width: canvas.width, height: canvas.height }, runtime.charBases, ) if (next === null) { status.textContent = `资源包里没有关卡 ${String(toLevelId)}。` return } await next.loadPages(renderer, next.priorityPages, () => { state.pagesLoaded = next.pages.filter(page => page !== null).length }) // The destination's own opening back to where we came from is the landing // spot; an override wins, because a waypoint or a portal lands somewhere // that no edge describes. const back = next.entrances.find(entrance => entrance.toLevelId === fromLevelId) ?? next.warps.find(warp => warp.toLevelId === fromLevelId) const landing = override ?? (back === undefined ? { x: Math.floor(next.grid.cellsX * SUB_TILES_PER_TILE / 2), y: Math.floor(next.grid.cellsY * SUB_TILES_PER_TILE / 2), } : { x: back.arriveX, y: back.arriveY }) swapLevel(next, landing) preloadRemaining(next, renderer) } catch (err) { status.textContent = `切换关卡失败:${(err as Error).message}` } finally { await fade.in(fadeMs) travelling = false } } /** * Act on whatever the player is standing on. * * Seams fire on contact — the player walked off the edge of the map, and * asking them to press a key for that would be strange. Warps, waypoints and * portals need the use key, because they sit in the middle of walkable ground * and stepping on one by accident is normal. */ const checkLinks = (): void => { if (travelling || runtime.source !== 'pack') return const here = playerSubTile() const near = (x: number, y: number, radius: number): boolean => Math.abs(here.x - x) <= radius && Math.abs(here.y - y) <= radius for (const entrance of runtime.entrances) { if (!near(entrance.x, entrance.y, SEAM_TRIGGER_SUBTILES)) continue void travel(entrance.toLevelId, SEAM_FADE_MS) return } const wantsUse = input.takeUse() if (wantsUse) { const portalTarget = portalSlot.otherEnd(runtime.levelId) for (const warp of runtime.warps) { if (!near(warp.x, warp.y, WARP_TRIGGER_SUBTILES)) continue void travel(warp.toLevelId, WARP_FADE_MS) return } if (portalTarget !== null) { void travel(portalTarget.levelId, WARP_FADE_MS, { x: portalTarget.x, y: portalTarget.y }) return } } for (const waypoint of runtime.waypoints) { if (!near(waypoint.x, waypoint.y, WARP_TRIGGER_SUBTILES)) continue if (waypointNetwork.activate(waypoint.waypointId)) { status.textContent = `传送点已激活:${runtime.level}` } if (input.takeWaypoint()) { // No destination picker yet, so the waypoint does the one thing a // player always wants from it: go back to town. const town = waypointNetwork.destinations() .find(site => site.levelId === townLevelForAct(runtime.act)) if (town !== undefined && town.levelId !== runtime.levelId) { void travel(town.levelId, WARP_FADE_MS, { x: town.x, y: town.y }) } } return } if (input.takePortal()) { const townLevelId = townLevelForAct(runtime.act) if (runtime.levelId !== townLevelId) { const townSite = waypointNetwork.destinations().find(site => site.levelId === townLevelId) portalSlot.cast({ fromLevelId: runtime.levelId, fromX: here.x, fromY: here.y, townLevelId, townX: townSite?.x ?? here.x, townY: townSite?.y ?? here.y, }) status.textContent = `已打开回城门(${runtime.level})。走到原地按 G 返回。` } } } /** * The automap's view of the level under the player. * * Rebuilt only when the level changes: the marker list is a copy of three * arrays, and the render loop asks for this every frame. */ let minimapCache: { key: string; level: MinimapLevel } | null = null const minimapLevel = (): MinimapLevel => { // Live runtimes have no entry path, so fall back to the level name; either // way the key only has to change when the level does. const key = runtime.entryPath === '' ? `live:${runtime.level}` : runtime.entryPath if (minimapCache !== null && minimapCache.key === key) return minimapCache.level const marker = (x: number, y: number, kind: MinimapMarker['kind']): MinimapMarker => ({ cellX: Math.floor(x / SUB_TILES_PER_TILE), cellY: Math.floor(y / SUB_TILES_PER_TILE), kind, }) const level: MinimapLevel = { key, cellsX: runtime.grid.cellsX, cellsY: runtime.grid.cellsY, blocked: runtime.grid.blocked, gridWidth: runtime.grid.gridWidth, markers: [ ...runtime.entrances.map(entrance => marker(entrance.x, entrance.y, 'exit')), ...runtime.warps.map(warp => marker(warp.x, warp.y, 'exit')), ...runtime.waypoints.map(waypoint => marker(waypoint.x, waypoint.y, 'waypoint')), ], } minimapCache = { key, level } return level } const loop = new GameLoop({ tickRate: 25, onTick: () => { engine.tick({ movement: input.movement(), attacking: input.attacking, pickingUp: input.pickingUp, talking: input.talking, digits: input.digits, saving: input.saving, loading: input.loading, }) const player = engine.world.player const movement = input.movement() // Assign, do not redeclare: `onRender` reads the outer `playerMoving` to // pick the walk animation, and a shadowing `let` here left it stuck false. playerMoving = movement.x !== 0 || movement.y !== 0 if (playerMoving) { playerWalkFrame = (playerWalkFrame + 1) % 24 } else { playerWalkFrame = 0 } monsterWalkFrame = (monsterWalkFrame + 1) % 120 state.tick = engine.world.tick state.x = player.x state.y = player.y state.facing = player.facing syncEngineState(engine, state) // The automap is a view, but revealing is state: it has to advance with // the simulation, not with however many frames the machine can draw. if (input.takeMapToggle()) minimap.visible = !minimap.visible checkLinks() const walked = cellOf(runtime.grid, player.x, player.y) minimap.reveal(minimapLevel(), walked.x, walked.y) const dialogPanel = document.querySelector('#dialog') if (dialogPanel !== null) { dialogPanel.hidden = state.dialog.length === 0 dialogPanel.textContent = state.dialog.join('\n') } }, onRender: () => { const renderStarted = performance.now() if (runtime.animatedDrawables && runtime.animatedDrawables.length > 0) { updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time') } if (state.pagesAtFirstFrame < 0) { state.pagesAtFirstFrame = countPages() state.firstFrameMs = Math.round(performance.now() - started) } const camX = engine.world.player.x const camY = engine.world.player.y - 16 renderer.begin({ x: camX, y: camY, zoom: camera.zoom }, [0.03, 0.03, 0.04]) skippedDraws = 0 let culledDraws = 0 const view = viewportRect(camX, camY, canvas.width, canvas.height, camera.zoom) const visible = (bounds: Float32Array, index: number): boolean => intersects(bounds, index, view) const drawTiles = (list: readonly Drawable[], bounds: Float32Array): void => { for (let index = 0; index < list.length; index += 1) { if (!visible(bounds, index)) { culledDraws += 1; continue } 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, floorBounds) const cell = cellOf(runtime.grid, engine.world.player.x, engine.world.player.y) const playerDepth = cell.x + cell.y let wallIdx = 0 let objIdx = 0 const walls = runtime.walls const objs = runtime.objectDrawables const drawUpToDepth = (maxDepth: number): void => { while (wallIdx < walls.length || objIdx < objs.length) { const nextWall = wallIdx < walls.length ? walls[wallIdx]! : null const nextObj = objIdx < objs.length ? objs[objIdx]! : null const wallDepth = nextWall !== null ? nextWall.cellX + nextWall.cellY : Infinity const objDepth = nextObj !== null ? nextObj.depth : Infinity if (wallDepth > maxDepth && objDepth > maxDepth) break // Culling only skips the `draw`; the cursors still advance, because the // two lists are merged by depth and a stalled cursor would reorder // everything behind it. if (wallDepth <= objDepth) { if (!visible(wallBounds, wallIdx)) { culledDraws += 1 } else { const page = runtime.pages[nextWall!.page] if (page === null || page === undefined) { skippedDraws += 1 } else { renderer.draw(nextWall!.frame, nextWall!.x, nextWall!.y, { atlas: page }) } } wallIdx += 1 } else { if (!visible(objectBounds, objIdx)) { culledDraws += 1 } else { const page = runtime.objectPages[nextObj!.page] if (page === null || page === undefined) { skippedDraws += 1 } else { renderer.draw(nextObj!.frame, nextObj!.x, nextObj!.y, { atlas: page }) } } objIdx += 1 } } } interface EntityDraw { x: number y: number depth: number draw: () => void } const entities: EntityDraw[] = [] const pushEntity = (x: number, y: number, draw: () => void) => { const c = cellOf(runtime.grid, x, y) entities.push({ x, y, depth: c.x + c.y, draw }) } pushEntity(engine.world.player.x, engine.world.player.y, () => { if (character === null) { renderer.drawSolid(engine.world.player.x - MARKER_WIDTH / 2, engine.world.player.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.85, 0.75, 0.45, 1]) renderer.drawSolid(engine.world.player.x - MARKER_WIDTH / 2, engine.world.player.y - 6, MARKER_WIDTH, 6, [0.35, 0.25, 0.15, 1]) } else { const direction = facingToDirection(engine.world.player.facing, character.directions) const groupIndex = (playerMoving ? character.walk : character.stand) + direction const group = character.groups[groupIndex] ?? character.groups[character.walk + direction] const frameIndex = playerMoving ? Math.floor(playerWalkFrame / 3) % Math.max(1, group?.length ?? 1) : 0 const frame = group?.[frameIndex] if (frame !== undefined) { renderer.draw(frame, engine.world.player.x - frame.width / 2, engine.world.player.y - frame.height + FEET_HEIGHT / 2, { atlas: character.handle }) state.characterFrames += 1 } } }) for (const monster of engine.world.monsters) { if (monster.state === 'dead') continue pushEntity(monster.x, monster.y, () => { const art = monsterArtMap.get(monster.stats.id.toLowerCase()) if (art !== undefined) { const direction = facingToDirection(monster.facing, art.directions) const isMoving = monster.state === 'chase' const groupIndex = (isMoving ? art.walkOffset : art.standOffset) + direction const group = art.groups[groupIndex] const frameCount = Math.max(1, group?.length ?? 1) const frameIndex = isMoving ? Math.floor(monsterWalkFrame / 3) % frameCount : Math.floor(monsterWalkFrame / 5) % frameCount const frame = group?.[frameIndex] if (frame !== undefined) { renderer.draw(frame, monster.x - frame.width / 2, monster.y - frame.height + FEET_HEIGHT / 2, { atlas: art.handle }) return } } // Fallback to placeholder solid box if art not loaded or frame missing renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.9, 0.2, 0.2, 1]) }) } for (const npc of engine.npcEntities) { // Sprite-backed NPCs are already painted by the object layer; the box // is only here so an art-less NPC still shows up and stays clickable. // The name label below is drawn for both kinds. if (npc.hasSprite) continue pushEntity(npc.x, npc.y, () => { renderer.drawSolid(npc.x - MARKER_WIDTH / 2, npc.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.2, 0.9, 0.2, 1]) }) } for (const shot of engine.projectiles) { pushEntity(shot.x, shot.y, () => { renderer.drawSolid(shot.x - 4, shot.y - 4, 8, 8, [0.9, 0.9, 0.2, 1]) }) } for (const entry of engine.ground) { pushEntity(entry.x, entry.y, () => { const size = 6 renderer.drawSolid(entry.x - size / 2, entry.y - size, size, size, [0.8, 0.8, 0.8, 1]) }) } entities.sort((a, b) => a.depth - b.depth) for (const entity of entities) { drawUpToDepth(entity.depth) entity.draw() } drawUpToDepth(Infinity) // 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, roofBounds) // Last in the batch, so the panel sits over every world layer. minimap.draw( renderer, minimapLevel(), cell.x, cell.y, { x: engine.world.player.x, y: engine.world.player.y - 16, zoom: camera.zoom }, { width: canvas.width, height: canvas.height }, ) renderer.flush() // The labels are recycled rather than rebuilt: `innerHTML = ''` plus one // `createElement` per NPC per frame forced a full style recalculation and // made the DOM, not the GPU, the slowest part of some town frames. if (labelsContainer !== null) { let labelCount = 0 for (const npc of engine.npcEntities) { const screenX = (npc.x - camX) * camera.zoom + canvas.width / 2 const screenY = (npc.y - camY) * camera.zoom + canvas.height / 2 if (screenX <= -100 || screenX >= canvas.width + 100) continue if (screenY <= -100 || screenY >= canvas.height + 100) continue let label = labelPool[labelCount] if (label === undefined) { label = document.createElement('div') label.style.position = 'absolute' label.style.transform = 'translate(-50%, -100%)' label.style.color = '#c8a15a' label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000' labelPool.push(label) labelsContainer.appendChild(label) } if (label.textContent !== npc.def.name) label.textContent = npc.def.name label.style.left = `${screenX}px` label.style.top = `${screenY - 60 * camera.zoom}px` label.style.fontSize = `${Math.max(10, 14 * camera.zoom)}px` label.hidden = false labelCount += 1 } for (let index = labelCount; index < labelPool.length; index += 1) labelPool[index]!.hidden = true } state.drawCalls = renderer.drawCalls state.quadsDrawn = renderer.quadsSubmitted state.culledDraws = culledDraws state.tickRate = loop.tickRate state.zoom = Number(camera.zoom.toFixed(2)) state.canvasWidth = canvas.width state.canvasHeight = canvas.height state.pagesLoaded = countPages() state.skippedDraws = skippedDraws state.renderMs = performance.now() - renderStarted // The HUD is a single text node, but rebuilding it means ten string // concatenations and four `toFixed` calls; at 4 Hz that is invisible to the // reader and stops the overlay from competing with the frame it describes. if (renderStarted >= hudNextMs) { hudNextMs = renderStarted + HUD_INTERVAL_MS 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 ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}` + ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})· ${state.renderMs.toFixed(1)} ms` + (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 不可用时会退回这里。') + (runtime.objectDrawables.length > 0 ? ` 对象层已绘制(火把/箱子/传送点等 ${String(runtime.objectDrawables.length)} 处)。` : ' 可破坏物品的位置与美术成员已进包,静态帧待接。') preloadRemaining(runtime, renderer) if (runtime.palette !== null) { renderer.setPalette(runtime.palette.rgb) const paletteForEntities = runtime.palette const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined // Collect monster types to load const activeMonsterIds = runtime.monsterTypes.length > 0 ? runtime.monsterTypes : (runtime.monsterCount > 0 ? runtime.monsterStats.map(s => s.id) : []) if (activeMonsterIds.length > 0) { void loadMonsterArtMap( DEFAULT_BASES, activeMonsterIds, paletteForEntities, renderer, packEntityBase, packEntityAct, ).then((loadedMap) => { monsterArtMap = loadedMap }) } void loadCharacterArt( runtime.charBases, paletteForEntities, renderer, packEntityBase, packEntityAct, ).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 不可用时会退回这里。' : ` 角色为真·女法师(${runtime.source === 'pack' ? '预烘焙图集' : 'DCC+COF 合成'},${String(loaded.members)} 层)。`, ) }) } window.addEventListener('beforeunload', () => { loop.stop() input.detach() if (character?.handle) renderer.deleteAtlas(character.handle) for (const art of monsterArtMap.values()) { renderer.deleteAtlas(art.handle) } for (const page of runtime.pages) { if (page !== null) renderer.deleteAtlas(page) } for (const page of runtime.objectPages) { if (page !== null) renderer.deleteAtlas(page) } renderer.dispose() }) } /** * 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.animatedTiles = runtime.animatedDrawables?.length ?? 0 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 = ViewportCamera.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()