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

2873 lines
111 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Act map scene: real Diablo II levels, walked by the player.
*
* Two data paths feed the same loop, and both stay because they answer different
* questions:
*
* - **pack** (default): a prebaked `scene.json` plus indexed PNG pages produced
* by `scripts/pack-act-assets.ts`. Nothing is decoded — the browser turns a PNG
* into a texture, and the JSON already carries painter's order and the
* collision grid. Pages load by need: the ones covering the spawn viewport
* first, the rest in the background while the player is already walking.
* - **live**: the archives themselves over HTTP ranges, decoded in the browser
* (DS1 + DT1 + PL2). Slower, needs no bake step, and works for any level the
* pack does not cover. `?live=1` forces it.
*
* No monsters are loaded on either path: this page exists to walk real maps.
*/
import { MpqArchive } from '../mpq/archive.ts'
import { httpRangeSource } from '../mpq/source.ts'
import { 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, cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
} from '../game/d2map.ts'
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
import {
updateAnimatableTiles,
frameDurationMsFromAnimSpeed,
DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
} from '../game/animated-tiles.ts'
import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts'
import { createIsoTerrain } from '../game/iso-terrain.ts'
import { spawnMonsterPacks } from '../game/combat.ts'
import type { MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts'
import { MonsterStreamingManager } from '../game/monster-streaming.ts'
import type { StreamingRoomDef } from '../game/monster-streaming.ts'
import { allocatePacksToRooms } from '../game/monster-rooms.ts'
import type { MapRoom } from '../game/monster-rooms.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_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 { RoofFadeController } from '../render/roofs.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, LEVEL_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 { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side } from '../game/variants.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
/**
* Side of a streaming room, in cells.
*
* `D2Common`'s DRLG cuts a wilderness level into rooms of a handful of tiles
* each; a generated level here is 8 cells per generator block, so 24 cells is
* three blocks — about a screen and a half, which is the granularity at which
* populating a room is cheap enough to do inside one frame.
*/
const ROOM_CELL_SIZE = 24
/**
* Distance at which a room is populated and woken, in scene pixels.
*
* The viewport is ~1280×720, and the isometric projection makes a screen
* roughly 1300px wide in scene space, so 1600 is "just past the edge of the
* screen": monsters exist before they can be seen, never in front of the
* player's eyes.
*/
const ROOM_ACTIVATION_RADIUS = 1600
/**
* Distance at which a room goes back to sleep, in scene pixels.
*
* Deliberately larger than the activation radius. The gap is hysteresis: a
* player pacing across a room boundary would otherwise toggle a room's AI on
* and off every few frames.
*/
const ROOM_DEACTIVATION_RADIUS = 2400
/**
* `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
variant: string
variants: string[]
/** @deprecated Use `variant` instead. */
quadrant: string
/** @deprecated Use `variants` instead. */
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
/** Roof opacity alpha (0.0..1.0), faded dynamically when player enters shelter. */
roofAlpha: 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
simTps: number
renderFps: number
fps: 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
/** Animation speed from LvlPrest.txt `Animate` column (or undefined if not specified). */
animSpeed?: number | undefined
/** Per-frame duration in ms computed from animSpeed. */
frameDurationMs?: number | undefined
/**
* Monster ids the art loader could not dress, lower case.
*
* Each one renders as a red placeholder box. The scene stays `ready` — it can
* still be walked — but it is not showing the level it claims to.
*/
missingMonsterArt: string[]
/** Monster-art loads that threw: a whole level, or one monster type. */
monsterArtErrors: number
/** COF layers dropped inside art that did load: monsters missing body parts. */
monsterArtLayerFailures: number
/**
* Monsters the level's own plan produced.
*
* Zero is a real answer — a town has none — and it is now the *only* answer
* for a level nothing planned for. The scene used to drop 12 hand-written
* `DEMO_MONSTERS` into that case, which also rendered as red boxes because
* their ids are in no art table.
*/
monstersPlanned: number
}
declare global {
interface Window {
/** Act scene state for automation and debugging. */
__d2webAct?: ActSceneState
}
}
const state: ActSceneState = {
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1,
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, simTps: 0, renderFps: 0, fps: 0,
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0,
animSpeed: undefined, frameDurationMs: DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0,
}
if (typeof window !== 'undefined') 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
readonly roofHeight?: number
}
/** 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 variants: readonly string[]
readonly variantParam: 'level' | 'variant'
readonly variant: string
/** @deprecated Kept for backward compatibility. Use `variants` instead. */
readonly quadrants: readonly string[]
/** @deprecated Kept for backward compatibility. Use `variantParam` instead. */
readonly quadrantParam: 'level' | 'quadrant'
/** @deprecated Kept for backward compatibility. Use `variant` instead. */
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
/** LvlPrest.txt `Animate` column speed. */
readonly animSpeed?: number | undefined
/** Per-frame duration in ms computed from animSpeed. */
readonly frameDurationMs?: number | 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. There is no fallback: a level nothing planned for stays
* empty and says so, rather than being filled with invented monsters.
*/
readonly monsterPacks: readonly MonsterPack[]
/**
* The level's monster types, by `MonStats.txt` id, for the status line.
*/
readonly monsterTypes: readonly string[]
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<void>
}
/**
* Read a URL parameter.
*
* @param key - parameter name.
* @param fallback - value when absent.
* @returns the value.
*/
function param(key: string, fallback: string): string {
return new URLSearchParams(location.search).get(key) ?? fallback
}
/**
* Fetch JSON, with the HTTP status in the error when it fails.
*
* @param url - location.
* @returns the parsed JSON.
*/
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) throw new Error(`${url}: HTTP ${String(response.status)}`)
return await response.json() as T
}
/** A pack's index file. */
interface PackIndex {
readonly palettes?: Record<string, number[]>
readonly levels: readonly 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[]
/** LvlPrest.txt `Animate` column speed. */
readonly animSpeed?: number | undefined
/** Per-frame duration in ms computed from animSpeed. */
readonly frameDurationMs?: number | undefined
/**
* 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<string, Promise<PackIndex>>()
/**
* 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<PackIndex | null> {
let pending = packIndexCache.get(packBase)
if (pending === undefined) {
pending = fetchJson<PackIndex>(`${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<MapRuntime | null> {
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))
?? getDefaultTownVariant(forAct, act)
?? forAct.find(candidate => candidate.slug.includes('town'))
?? forAct[0]!
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
}
/**
* Build the runtime for one level, named by id rather than by label.
*
* When a level has multiple baked variants (such as Rogue Encampment TownN1/E1/S1/W1,
* Lut Gholein LutN/LutW, or Cloister Courtyard N/E/W), the variant is selected based
* on the connecting border orientation (`fromSide`) so that the gate and road
* seamlessly align across the level boundary. If no directional requirement exists,
* it picks deterministically via `variantSeed`.
*
* @param packBase - directory holding `index.json`.
* @param levelId - `Levels.txt` `Id` of the wanted level.
* @param variantSeed - picks between the variants deterministically.
* @param viewport - current viewport dimensions.
* @param charBases - character assets bases.
* @param fromSide - the compass edge of the source level the player walked off of.
* @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[],
fromSide?: Side,
): Promise<MapRuntime | null> {
const index = await getPackIndex(packBase)
if (index === null) return null
const candidates = index.levels.filter(entry => entry.levelId === levelId)
if (candidates.length === 0) return null
const requiredSide = fromSide ? oppositeSide(fromSide) : undefined
const entry = findMatchingVariant(candidates, requiredSide, variantSeed)
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<MapRuntime> {
const forAct = index.levels.filter(candidate => candidate.act === entry.act)
const scene = await fetchJson<PackSceneJson>(`${packBase}/${entry.path}/scene.json`)
const frames: AtlasFrame[] = scene.framePlacement.map(place => ({
x: place[1] ?? 0, y: place[2] ?? 0, width: place[3] ?? 1, height: place[4] ?? 1,
}))
const toDrawable = (row: readonly number[]): Drawable => {
const frameIndex = row[0] ?? 0
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
const cellX = row[3] ?? 0
const cellY = row[4] ?? 0
const y = row[2] ?? 0
const orthoY = (cellX + cellY) * 40
const roofHeight = orthoY > y ? orthoY - y : undefined
return {
frame: frames[frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
x: row[1] ?? 0,
y,
cellX,
cellY,
page: scene.framePlacement[frameIndex]?.[0] ?? 0,
...(roofHeight !== undefined ? { roofHeight } : {}),
...(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<number, number>()
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 ?? [],
variants: forAct.map(candidate => candidate.label),
variantParam: 'level',
variant: entry.label,
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
),
animSpeed: scene.animSpeed,
frameDurationMs: scene.frameDurationMs ?? (scene.animSpeed !== undefined ? frameDurationMsFromAnimSpeed(scene.animSpeed) : DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS),
objectDrawables,
spawn,
npcs: scene.npcs ?? [],
monsterPacks: scene.monsters?.packs ?? [],
monsterTypes: Array.from(new Set([
...(scene.monsters?.types ?? []),
...(scene.monsters?.packs ?? []).flatMap(p => p.members.map(m => m.id)),
])),
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)} 群(${Array.from(new Set([...(scene.monsters.types ?? []), ...(scene.monsters.packs ?? []).flatMap(p => p.members.map(m => m.id))])).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<number>()
for (const draw of [...floors, ...walls]) {
if (draw.x + draw.frame.width < left || draw.x > right) continue
if (draw.y + draw.frame.height < top || draw.y > bottom) continue
needed.add(draw.page)
}
if (needed.size === 0) needed.add(0)
return [...needed].sort((a, b) => a - b)
}
/**
* Build the runtime by reading and decoding the archives.
*
* @param base - directory holding the archives.
* @param act - act to open.
* @param renderer - renderer that receives the atlas.
* @param wantedQuadrant - `?quadrant=` member name, when given.
* @returns the runtime.
*/
async function loadLiveRuntime(
base: string,
act: number,
renderer: SpriteRenderer,
wantedQuadrant: string,
): Promise<MapRuntime> {
const archives = new MountedArchives()
const notes: string[] = []
for (const name of [...DATA_ARCHIVES, CHARACTER_ARCHIVE]) {
try {
const source = await httpRangeSource(`${base}/${name}`, name)
archives.add(name, await MpqArchive.open(source))
} catch (err) {
notes.push(`${name}: ${(err as Error).message}`)
}
}
if (archives.size === 0) throw new Error(`no archive could be mounted from ${base}`)
const tables = await loadActTables(archives)
const wantedLevel = param('level', '')
let town: LevelInfo
try {
town = wantedLevel === ''
? resolveActTown(tables, act)
: resolveLevel(tables, Number(wantedLevel), act)
} catch (err) {
if (wantedLevel !== '') {
const id = Number(wantedLevel)
const nameZh = LEVEL_NAMES_ZH[id] ?? `关卡 ${wantedLevel}`
throw new Error(
`直读归档 (Live) 模式仅支持预设单体地图(如城镇、大教堂、崔斯特瑞姆等);` +
`${nameZh}(Level ${wantedLevel})属于由 DRLG 动态拼装生成的旷野/迷宫关卡,在 MPQ 中没有单一 DS1 预设(无 LvlPrest 记录)。` +
`请去掉 URL 中的 ?live=1 参数,使用资源包 (Pack) 模式加载完整生成的地图。`
)
}
throw err
}
const wantedVariant = wantedQuadrant || param('variant', '')
const variants = town.ds1Names.map(name => name.split('\\').pop() ?? name)
let index = 0
if (wantedVariant !== '') {
const found = variants.indexOf(wantedVariant)
if (found !== -1) index = found
} else if (act === 1) {
const s1 = variants.findIndex(v => v.toLowerCase().includes('towns1'))
if (s1 !== -1) index = s1
} else if (act === 2) {
const n = variants.findIndex(v => v.toLowerCase().includes('lutn'))
if (n !== -1) index = n
}
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,
...(row.roofHeight !== undefined ? { roofHeight: row.roofHeight } : {}),
...(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: [],
variants,
variantParam: 'variant',
variant: variants[index] ?? '',
quadrants: variants,
quadrantParam: 'quadrant',
quadrant: variants[index] ?? '',
cellsX: scene.cellsX,
cellsY: scene.cellsY,
widthPx: scene.widthPx,
heightPx: scene.heightPx,
grid: scene,
floors,
walls,
roofs,
animatedDrawables,
animSpeed: town.animSpeed,
frameDurationMs: frameDurationMsFromAnimSpeed(town.animSpeed),
objectDrawables: [],
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
npcs: [],
monsterPacks: population.packs,
monsterTypes: Array.from(new Set([
...population.types,
...population.packs.flatMap(p => p.members.map(m => m.id)),
])),
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')
}
/**
* Count non-null loaded atlas pages without allocating throwaway arrays.
*
* @param pages - array of page handles.
* @returns number of loaded pages.
*/
export function countLoadedPages(pages: readonly (AtlasHandle | null)[]): number {
let loaded = 0
for (let i = 0; i < pages.length; i += 1) {
const page = pages[i]
if (page !== null && page !== undefined) loaded += 1
}
return loaded
}
/**
* 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 = countLoadedPages(runtime.pages)
}).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<string, Promise<MpqArchive>>()
function getCachedMpqArchive(url: string, label = url): Promise<MpqArchive> {
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<MountedArchives | null> {
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<MountedArchives | null> {
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])[][]
}
/**
* What a monster-art load produced, including what it failed to produce.
*
* The three failure channels used to be a `console.warn` and an empty `Map`,
* which the render loop turns into a red box per monster while the scene still
* reports `ready`. They are counted here so {@link ActSceneState} — and through
* it the HUD — can say so.
*/
export interface MonsterArtLoad {
/** Monster id (lower case) to its atlas. */
readonly map: Map<string, LoadedMonsterArt>
/** Monster ids left with no atlas: every one of these draws as a red box. */
readonly missing: string[]
/** Loads that threw — a per-spec failure, or the whole function. */
readonly errors: number
/** COF layers dropped inside the art that *did* load: monsters missing limbs. */
readonly layerFailures: number
}
/**
* The monster ids that got no atlas.
*
* @param monsterIds - the ids the level asked for.
* @param loaded - the ids that loaded, lower case.
* @returns the missing ids, lower case and deduplicated, in request order.
*/
export function missingMonsterArtIds(
monsterIds: readonly string[],
loaded: ReadonlySet<string>,
): string[] {
const missing: string[] = []
for (const id of monsterIds) {
const key = id.toLowerCase()
if (!loaded.has(key) && !missing.includes(key)) missing.push(key)
}
return missing
}
/**
* The HUD fragment describing a monster-art load, empty when it was clean.
*
* @param load - the counters, from {@link ActSceneState} or {@link MonsterArtLoad}.
* @returns the fragment, ready to append to the HUD line.
*/
export function monsterArtStatusText(load: {
readonly missing: readonly string[]
readonly errors: number
readonly layerFailures: number
}): string {
const parts: string[] = []
if (load.missing.length > 0) {
const shown = load.missing.slice(0, 3).join('、')
const more = load.missing.length > 3 ? `…+${String(load.missing.length - 3)}` : ''
parts.push(`怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`)
}
if (load.errors > 0) parts.push(`加载错误 ${String(load.errors)}`)
if (load.layerFailures > 0) parts.push(`缺层 ${String(load.layerFailures)}`)
return parts.length === 0 ? '' : ` · ${parts.join(' · ')}`
}
/**
* The HUD fragment describing a level's planned population.
*
* @param planned - how many monsters the plan produced.
* @returns the fragment, ready to append to the HUD line.
*/
export function monsterPlanStatusText(planned: number): string {
return planned === 0 ? ' · 怪物 0(本关未规划到怪物)' : ` · 怪物 ${String(planned)}`
}
/**
* Publish a level's planned population into {@link ActSceneState}.
*
* @param runtime - the level being entered.
*/
function reportMonsterPlan(runtime: MapRuntime): void {
const planned = runtime.monsterPacks.reduce((sum, pack) => sum + pack.members.length, 0)
state.monstersPlanned = planned
if (planned === 0) {
console.info(`level ${runtime.level}: no monsters planned; the level stays empty`)
}
}
/**
* Publish a load's failures into {@link ActSceneState}.
*
* Both the boot path and the level-change path call the loader, and both used to
* drop everything but the `Map` on the floor.
*
* @param load - what the loader produced.
*/
function recordMonsterArtLoad(load: MonsterArtLoad): void {
state.missingMonsterArt = [...load.missing]
state.monsterArtErrors = load.errors
state.monsterArtLayerFailures = load.layerFailures
if (load.missing.length > 0 || load.errors > 0 || load.layerFailures > 0) {
console.error(
`monster art incomplete: ${String(load.missing.length)} type(s) without art `
+ `(${load.missing.join(', ')}), ${String(load.errors)} load error(s), `
+ `${String(load.layerFailures)} dropped layer(s)`,
)
}
}
/**
* 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<MonsterArtLoad> {
const map = new Map<string, LoadedMonsterArt>()
let errors = 0
let layerFailures = 0
if (monsterIds.length === 0) return { map, missing: [], errors, layerFailures }
try {
// Resolve unique (token, weapon) specs needed
const tokenSpecs = new Map<string, { token: string; weapon: string }>()
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<string, LoadedMonsterArt>()
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,
// Pre-baked art is already composited: there are no COF layers
// left to drop at load time.
layerFailures: [],
})
return
}
} catch (err) {
// Fall through to the per-act PNG, but do not pretend it did not happen.
console.warn(`monster art ${slug}: packed r8 unavailable: ${(err as Error).message}`)
}
// 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,
layerFailures: [],
})
return
}
} catch (err) {
// Fall back to MPQ loading below, with the reason on the record.
console.warn(`monster art ${slug}: packed png unavailable: ${(err as Error).message}`)
}
}
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) {
// Every monster of this type will be a red box; say which type and
// count it so the scene cannot claim to be fully dressed.
errors += 1
console.error(`monster art ${spec.token} (${spec.weapon}) unavailable: ${(err as Error).message}`)
}
})
)
}
}
for (const loaded of loadedBySpecKey.values()) layerFailures += loaded.layerFailures.length
// 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) {
// This `catch` wraps the whole function, so landing here means *no* monster
// in the level has art. That is a broken scene, not a warning.
errors += 1
console.error(`monster art loading error: ${(err as Error).message}`)
}
return { map, missing: missingMonsterArtIds(monsterIds, new Set(map.keys())), errors, layerFailures }
}
async function loadCharacterArt(
bases: readonly string[],
palette: Palette,
renderer: SpriteRenderer,
packBase?: string,
act?: number,
): Promise<LoadedCharacter | null> {
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
}
}
/**
* Whether any walkable sub-tile lies inside a scene-pixel rectangle.
*
* A level's pixel extent is the bounding box of an isometric diamond, so
* roughly half of it is off-map. Rooms carved out of that half can never hold a
* monster — `spawnPacksInRoom` would search their whole area, find nothing and
* silently drop the pack — so they are discarded before packs are handed out.
*
* @param grid - the level's collision grid.
* @param minX - rectangle left edge, scene pixels.
* @param minY - rectangle top edge, scene pixels.
* @param maxX - rectangle right edge, scene pixels.
* @param maxY - rectangle bottom edge, scene pixels.
* @returns true when at least one sampled point is walkable.
*/
function roomHasWalkableGround(
grid: CollisionGrid,
minX: number,
minY: number,
maxX: number,
maxY: number,
): boolean {
const samples = 9
for (let iy = 1; iy < samples; iy += 1) {
const y = minY + (maxY - minY) * (iy / samples)
for (let ix = 1; ix < samples; ix += 1) {
const x = minX + (maxX - minX) * (ix / samples)
if (!isBlockedAt(grid, x, y)) return true
}
}
return false
}
/**
* Cuts a level into streaming rooms and allocates its monster packs to them.
*
* This is the level-generation half of `D2Game.dll`'s lazy population: the DRLG
* hands the game a room list, each room remembers *what* should live in it, and
* nothing is instantiated until the player walks close enough. `GameEngine`
* owns the other half (see `MonsterStreamingManager`).
*
* Rooms are laid out over **scene-pixel space**, not cell space. A rectangle of
* cells projects to a diamond, and the axis-aligned boxes of neighbouring cell
* rectangles overlap almost completely — which would make every room in the
* level "near" the player at once and defeat the streaming entirely.
*
* @param runtime - the level being entered.
* @param packs - the level's packs, with landmark tiles already resolved to
* scene positions.
* @param safeZones - waypoints, entrances and the landing spot; rooms touching
* one are left empty so a player never arrives inside a camp.
* @returns the rooms that hold at least one pack, in load order.
*/
function buildStreamingRooms(
runtime: MapRuntime,
packs: readonly MonsterPack[],
safeZones: readonly SafeZone[],
): StreamingRoomDef[] {
if (packs.length === 0) return []
const grid = runtime.grid
const roomWidth = ROOM_CELL_SIZE * ORTHO_CELL_WIDTH / 2
const roomHeight = ROOM_CELL_SIZE * ORTHO_CELL_HEIGHT / 2
const cellsPerRoomX = Math.max(1, Math.round(roomWidth / ORTHO_CELL_WIDTH))
const cellsPerRoomY = Math.max(1, Math.round(roomHeight / ORTHO_CELL_HEIGHT))
const rooms: MapRoom[] = []
let id = 0
for (let y = 0; y < runtime.heightPx; y += roomHeight) {
for (let x = 0; x < runtime.widthPx; x += roomWidth) {
const maxX = Math.min(x + roomWidth, runtime.widthPx)
const maxY = Math.min(y + roomHeight, runtime.heightPx)
if (maxX - x < 32 || maxY - y < 32) continue
if (!roomHasWalkableGround(grid, x, y, maxX, maxY)) continue
// A safe zone is a circle; the cheap box-vs-box test is deliberate. An
// over-eager match only costs one empty room, while a missed one puts a
// pack on top of a waypoint.
const isSafeZone = safeZones.some(zone =>
zone.x + zone.radius > x && zone.x - zone.radius < maxX
&& zone.y + zone.radius > y && zone.y - zone.radius < maxY)
// Cell coordinates are bookkeeping for `allocatePacksToRooms`' area
// weighting; every room is the same size, so the value only has to be
// consistent, not exact.
const cell = cellAt(grid, x + (maxX - x) / 2, y + (maxY - y) / 2)
rooms.push({
id: id,
x,
y,
width: maxX - x,
height: maxY - y,
cellX: Math.floor(cell.x - cellsPerRoomX / 2),
cellY: Math.floor(cell.y - cellsPerRoomY / 2),
cellsX: cellsPerRoomX,
cellsY: cellsPerRoomY,
isSafeZone,
})
id += 1
}
}
if (rooms.length === 0) return []
const allocation = allocatePacksToRooms(packs, rooms, levelSeed(runtime.level))
const defs: StreamingRoomDef[] = []
for (const room of rooms) {
const roomPacks = allocation.get(room)
if (roomPacks === undefined || roomPacks.length === 0) continue
defs.push({
id: room.id,
bounds: { minX: room.x, minY: room.y, maxX: room.x + room.width, maxY: room.y + room.height },
packs: roomPacks,
room,
})
}
return defs
}
/**
* 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<HTMLCanvasElement>('#view')!
const status = document.querySelector<HTMLElement>('#status')!
const hud = document.querySelector<HTMLElement>('#hud')!
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
const input = new KeyboardInput()
input.attach()
let skippedDraws = 0
let character: LoadedCharacter | null = null
let monsterArtMap = new Map<string, LoadedMonsterArt>()
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)
let roofController = new RoofFadeController(runtime.roofs)
// `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 => countLoadedPages(runtime.pages)
// NPC name plates. The container and the elements outlive a frame so the
// render loop only has to move them.
const labelsContainer = document.querySelector<HTMLElement>('#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 streamingRooms = buildStreamingRooms(runtime, resolvedPacks, safeZones)
const engine = new GameEngine(
createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, {
width: FEET_WIDTH,
height: FEET_HEIGHT,
}),
{
spawn: runtime.spawn,
// The scene plans its own population; there is no synthetic roster to fall
// back on, so the engine's frame-0 spawner has nothing to draw from.
stats: [],
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,
// Aggro is safe again now that rooms stream: a monster the player has
// not walked up to is either unspawned or asleep, so "chase the player"
// can no longer drag the whole level into a single conga line. It was
// only ever disabled because every pack on the map was awake at once.
disableMonsterAggro: false,
},
lootSeed: 0x5eed,
talkRadius: 80,
pickupRadius: 64,
inventoryCols: 10,
inventoryRows: 4,
// With rooms present the engine skips frame-0 spawning entirely and lets
// the streaming manager do it room by room; with none it places the
// planned packs at once. A level that planned nothing gets nothing.
monsterCount: 0,
monsterPacks: resolvedPacks,
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
roomPacks: streamingRooms,
streamingOptions: {
activationRadius: ROOM_ACTIVATION_RADIUS,
deactivationRadius: ROOM_DEACTIVATION_RADIUS,
safeZones,
},
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)
roofController = new RoofFadeController(next.roofs)
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
// A level change means a new room list. `engine.terrain` was already
// swapped above, which matters: the manager captures it and uses it for
// every later collision test, so building it any earlier would place the
// destination's monsters against the departed level's walls.
const nextRooms = buildStreamingRooms(next, nextResolvedPacks, nextSafeZones)
if (nextRooms.length > 0) {
engine.streamingManager = new MonsterStreamingManager(
engine.world,
engine.terrain,
nextRooms,
{
activationRadius: ROOM_ACTIVATION_RADIUS,
deactivationRadius: ROOM_DEACTIVATION_RADIUS,
safeZones: nextSafeZones,
},
)
} else {
// Nothing to stream: the planned packs go down at once. A level the baker
// gave no plan for stays empty — and the status line says so, rather than
// 12 invented monsters standing in for the data that is missing.
engine.streamingManager = undefined
if (nextResolvedPacks.length > 0) {
spawnMonsterPacks(engine.world, nextResolvedPacks, landing, spread, engine.terrain, nextSafeZones)
}
}
reportMonsterPlan(next)
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 packMonsterIds = next.monsterPacks.flatMap(p => p.members.map(m => m.id))
const activeMonsterIds = Array.from(new Set([...next.monsterTypes, ...packMonsterIds]))
if (activeMonsterIds.length > 0) {
void loadMonsterArtMap(
DEFAULT_BASES,
activeMonsterIds,
paletteForEntities,
renderer,
packEntityBase,
packEntityAct,
).then((loaded) => {
monsterArtMap = loaded.map
recordMonsterArtLoad(loaded)
})
}
}
if (next.source === 'pack') {
history.replaceState(null, '', `?act=${String(next.act)}&level=${encodeURIComponent(next.variant)}`)
void populatePackSelectors(next.base, { act: next.act, label: next.variant })
}
registerWaypoints(next)
state.level = next.level
state.act = next.act
state.variant = next.variant
state.variants = [...next.variants]
state.quadrant = next.variant
state.quadrants = [...next.variants]
state.cellsX = next.cellsX
state.cellsY = next.cellsY
state.pagesTotal = next.pages.length
state.pagesLoaded = 0
state.animatedTiles = next.animatedDrawables?.length ?? 0
state.animSpeed = next.animSpeed
state.frameDurationMs = next.frameDurationMs
}
/**
* 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.
* @param fromSide - the compass edge left by the player on the source level.
*/
const travel = async (
toLevelId: number,
fadeMs: number,
override?: { x: number; y: number },
fromSide?: Side,
): Promise<void> => {
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,
fromSide,
)
if (next === null) {
status.textContent = `资源包里没有关卡 ${String(toLevelId)}。`
return
}
await next.loadPages(renderer, next.priorityPages, () => {
state.pagesLoaded = countLoadedPages(next.pages)
})
// 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, undefined, entrance.side)
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<HTMLElement>('#dialog')
if (dialogPanel !== null) {
dialogPanel.hidden = state.dialog.length === 0
dialogPanel.textContent = state.dialog.join('\n')
}
},
onRender: (_alpha: number, frameMs: number) => {
const renderStarted = performance.now()
if (runtime.animatedDrawables && runtime.animatedDrawables.length > 0) {
updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time', runtime.frameDurationMs)
}
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 })
}
}
const drawRoofs = (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 }
const alpha = roofController.getAlpha(index)
if (alpha < 0.999) {
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page, tint: [1, 1, 1, alpha] })
} else {
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 deltaMs = Number.isFinite(frameMs) && frameMs > 0 ? Math.min(frameMs, 100) : 16
roofController.update(engine.world.player, deltaMs, cell)
state.roofAlpha = roofController.overallAlpha
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.
// Streaming can spawn a type the art load never saw (a super unique's
// minions, say), so the box itself reports — otherwise the only record
// of it is the screen.
const missingId = monster.stats.id.toLowerCase()
if (!state.missingMonsterArt.includes(missingId)) {
state.missingMonsterArt.push(missingId)
console.error(`monster art missing at draw time: ${missingId} (drawn as a red box)`)
}
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.
drawRoofs(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.simTps = loop.simTps
state.renderFps = loop.fps
state.fps = loop.fps
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
const simTpsText = loop.simTps.toFixed(1)
const renderFpsText = loop.fps.toFixed(0)
const renderMsText = (loop.frameMs > 0 ? loop.frameMs : state.renderMs).toFixed(1)
hud.textContent = `${runtime.level} · ${runtime.variant} · ${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)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
+ ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})`
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
// A level whose monsters are all red boxes used to look exactly like a
// healthy one here.
+ monsterPlanStatusText(state.monstersPlanned)
+ monsterArtStatusText({
missing: state.missingMonsterArt,
errors: state.monsterArtErrors,
layerFailures: state.monsterArtLayerFailures,
})
}
},
})
loop.start()
reportMonsterPlan(runtime)
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 packMonsterIds = runtime.monsterPacks.flatMap(p => p.members.map(m => m.id))
const activeMonsterIds = Array.from(new Set([...runtime.monsterTypes, ...packMonsterIds]))
if (activeMonsterIds.length > 0) {
void loadMonsterArtMap(
DEFAULT_BASES,
activeMonsterIds,
paletteForEntities,
renderer,
packEntityBase,
packEntityAct,
).then((loaded) => {
monsterArtMap = loaded.map
recordMonsterArtLoad(loaded)
})
}
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.variant = runtime.variant
state.variants = [...runtime.variants]
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 = countLoadedPages(runtime.pages)
state.priorityPages = [...runtime.priorityPages]
state.objects = runtime.objects
state.roofs = runtime.roofs.length
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
state.animSpeed = runtime.animSpeed
state.frameDurationMs = runtime.frameDurationMs
state.roofAlpha = 1
state.missing = 0
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
}
/**
* Fill the level picker from a pack index and wire it to navigation.
*
* Every baked map is one entry, grouped by act. Picking one reloads the page with
* `?act=` and `?level=`, which is the same query the loader already understands,
* so the picker needs no second loading path.
*
* @param packBase - directory holding `index.json`.
* @param current - the act and the entry label currently displayed (`runtime.quadrant`),
* so the list marks exactly the map that is on screen.
*/
async function populatePackSelectors(packBase: string, current: { act: number; label: string }): Promise<void> {
const actSelect = document.querySelector<HTMLSelectElement>('#act')
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')
if (actSelect === null || sceneSelect === null || variantSelect === null) return
let index: PackIndex
try {
index = await fetchJson<PackIndex>(`${packBase}/index.json`)
} catch {
return
}
/** A scene type: one `Levels.txt` level, with every baked DS1 variant under it. */
interface Scene {
readonly slug: string
readonly name: string
readonly variants: readonly PackIndex['levels'][number][]
}
const scenesByAct = new Map<number, Scene[]>()
for (const entry of index.levels) {
let scenes = scenesByAct.get(entry.act)
if (scenes === undefined) { scenes = []; scenesByAct.set(entry.act, scenes) }
let scene = scenes.find(candidate => candidate.slug === entry.slug)
if (scene === undefined) {
scene = { slug: entry.slug, name: sceneNameZh(entry.slug, entry.levelName), variants: [] }
scenes.push(scene)
}
;(scene.variants as PackIndex['levels'][number][]).push(entry)
}
/** The suffix after the level slug, which is the DS1's own name fragment. */
const suffixOf = (entry: PackIndex['levels'][number]): string =>
entry.label.startsWith(`${entry.slug}-`) ? entry.label.slice(entry.slug.length + 1) : entry.label
const optionFor = (value: string, text: string, selected: boolean): HTMLOptionElement => {
const option = document.createElement('option')
option.value = value
option.textContent = text
option.selected = selected
return option
}
const acts = [...scenesByAct.keys()].sort((left, right) => left - right)
actSelect.replaceChildren(...acts.map(act => optionFor(
String(act),
`${ACT_NAMES_ZH[act] ?? `第 ${String(act)} 章`}`,
act === current.act,
)))
actSelect.disabled = acts.length === 0
const scenes = scenesByAct.get(current.act) ?? []
const currentEntry = index.levels.find(entry => entry.act === current.act && entry.label === current.label)
const currentSceneSlug = currentEntry?.slug ?? scenes[0]?.slug ?? ''
sceneSelect.replaceChildren(...scenes.map(scene => optionFor(
scene.slug,
scene.name,
scene.slug === currentSceneSlug,
)))
sceneSelect.disabled = scenes.length === 0
const populateVariants = (slug: string, selectedLabel: string): void => {
const scene = scenes.find(candidate => candidate.slug === slug)
const variants = scene?.variants ?? []
// A single-variant scene still shows its own DS1 so the third selector is never
// an empty box; the option text repeats the scene name only when it is the only one.
variantSelect.replaceChildren(...variants.map(entry => {
const suffix = suffixOf(entry)
const label = variants.length > 1 ? variantLabelZh(suffix) : `${scene?.name ?? entry.levelName}(${suffix})`
return optionFor(entry.label, label, entry.label === selectedLabel)
}))
variantSelect.disabled = variants.length === 0
}
populateVariants(currentSceneSlug, current.label)
variantSelect.dataset.scene = currentSceneSlug
const go = (params: URLSearchParams): void => { location.search = params.toString() }
const navigateToLabel = (label: string, act: number): void => {
const params = new URLSearchParams(location.search)
params.set('act', String(act))
params.set('level', label)
go(params)
}
actSelect.onchange = () => {
const act = Number(actSelect.value)
const first = scenesByAct.get(act)?.[0]
// Keep the address bar and the map in sync: an act with no baked scene yet (there
// are none today) simply leaves the current map alone.
if (first === undefined) return
navigateToLabel(first.variants[0]?.label ?? '', act)
}
sceneSelect.onchange = () => {
const scene = scenes.find(candidate => candidate.slug === sceneSelect.value)
if (scene === undefined) return
populateVariants(scene.slug, '')
navigateToLabel(scene.variants[0]?.label ?? '', current.act)
}
variantSelect.onchange = () => { navigateToLabel(variantSelect.value, current.act) }
}
/**
* Build the three selectors for the live (no pack) path.
*
* Without a pack index there is no scene catalog to offer: the live loader only knows
* the act's town, so `场景` shows that one level and `细分场景` lists the DS1 files the
* level actually contains — which is the same distinction the pack path draws between
* a scene and its variants.
*
* @param runtime - the freshly loaded runtime.
*/
function populateLiveSelectors(runtime: MapRuntime): void {
const actSelect = document.querySelector<HTMLSelectElement>('#act')
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')
if (actSelect === null || sceneSelect === null || variantSelect === null) return
actSelect.value = String(runtime.act)
actSelect.disabled = false
const option = document.createElement('option')
option.value = runtime.level
option.textContent = runtime.level
option.selected = true
sceneSelect.replaceChildren(option)
sceneSelect.disabled = false
variantSelect.replaceChildren(...runtime.variants.map(name => {
const item = document.createElement('option')
item.value = name
item.textContent = name
item.selected = name === runtime.variant
return item
}))
variantSelect.disabled = runtime.variants.length === 0
actSelect.onchange = () => {
const params = new URLSearchParams(location.search)
params.set('act', actSelect.value)
params.delete('level')
params.delete('variant')
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.variantParam, variantSelect.value)
params.delete(runtime.variantParam === 'level' ? 'variant' : 'level')
params.delete('quadrant')
location.search = params.toString()
}
}
/**
* Boot the page: pack first, archives second.
*/
async function boot(): Promise<void> {
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
const status = document.querySelector<HTMLElement>('#status')!
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')!
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
actSelect.disabled = false
sceneSelect.disabled = true
variantSelect.disabled = true
const started = performance.now()
const act = Math.min(ACT_COUNT, Math.max(1, Number(param('act', '1'))))
state.act = act
actSelect.value = String(act)
actSelect.addEventListener('change', () => {
const params = new URLSearchParams(location.search)
params.set('act', actSelect.value)
params.delete('level')
location.search = params.toString()
})
// The canvas fills the window, so its size decides how much world the first
// frame needs and therefore which atlas pages must load before it.
const viewport = 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.variant })
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 = countLoadedPages(runtime.pages)
})
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 requestedVariant = param('variant', '') || param('quadrant', '')
const runtime = await loadLiveRuntime(base, act, renderer, requestedVariant)
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
}
}
if (typeof document !== 'undefined') void boot()