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

1844 lines
69 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, buildIsoMapScene, findIsoSpawn, levelSeed,
} from '../game/d2map.ts'
import type { CollisionGrid } from '../game/d2map.ts'
import { createIsoTerrain } from '../game/iso-terrain.ts'
import type { MonsterPack, MonsterStats } from '../game/combat.ts'
import { depthInsertIndex } from '../game/map.ts'
import { GameEngine, syncEngineState } from "../game/engine.ts"
import type { NpcEntity } from "../game/engine.ts"
import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts"
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
import type { NpcDef } from '../game/quests.ts'
import { buildAtlas, buildIndexedAtlas } from '../render/atlas.ts'
import type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts'
import { SpriteRenderer } from '../render/renderer.ts'
import type { AtlasHandle } from '../render/renderer.ts'
import { buildBounds, intersects, viewportRect } from '../render/cull.ts'
import { loadCharacterSheet, facingToDirection } from '../game/character.ts'
import { loadMonsterAtlas, type LoadedMonsterArt } from '../game/monster-art.ts'
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
import { ACT_NAMES_ZH, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts'
import { GameLoop } from '../sim/loop.ts'
import { KeyboardInput } from '../sim/input.ts'
/** Archives the live path mounts, in load order. */
const DATA_ARCHIVES = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
/** Character archive: optional, and the map itself does not need it. */
const CHARACTER_ARCHIVE = 'd2char.mpq'
/** Archive locations tried in order when `?base=` is absent. */
const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
/** Pack locations tried in order when `?pack=` is absent. */
const DEFAULT_PACKS = ['samples/d2-packs', '/diablo2/packs']
/** Act count the page offers. */
const ACT_COUNT = 5
/** Walk speed in scene pixels per second (cells are 80×40). */
const WALK_SPEED = 170
/** Character collision box at the feet, in scene pixels. */
const FEET_WIDTH = 20
const FEET_HEIGHT = 10
/** Atlas pages fetched at once while filling in the background. */
const PAGE_CONCURRENCY = 3
/** Placeholder marker size, in scene pixels. */
const MARKER_WIDTH = 16
const MARKER_HEIGHT = 26
/**
* Monsters placed per level, until `Levels.txt` supplies the real number.
*
* `MonDen` (density) and `NumMon` (how many kinds) are both real columns that
* #22 will read; 12 is only large enough that a walk across a level runs into
* something, which is what M6 needs to demonstrate that the level → spawn path
* works at all.
*/
const PLACEHOLDER_MONSTER_COUNT = 12
/**
* `localStorage` key holding the single save slot.
*
* Deliberately the same key the retired `map.html` sandbox used, so a save made
* there still loads here — the snapshot format never differed, only the scene
* that drove it.
*/
const SAVE_KEY = 'd2web.save'
/**
* How often the HUD text is rewritten, in milliseconds.
*
* The overlay reports averages, so refreshing it at frame rate only buys string
* churn: four `toFixed` calls and ten concatenations that the reader cannot
* follow anyway.
*/
const HUD_INTERVAL_MS = 250
/** Live scene state, also published for automated checks. */
export interface ActSceneState {
ready: boolean
/** Where the map came from: a prebaked pack or the archives. */
source: 'pack' | 'live' | ''
act: number
level: string
quadrant: string
quadrants: string[]
/** The archive/pack location actually used. */
base: string
cellsX: number
cellsY: number
widthPx: number
heightPx: number
floors: number
walls: number
frames: number
/** Atlas pages loaded and total. */
pagesLoaded: number
pagesTotal: number
/** Pages that had arrived when the first frame was drawn. */
pagesAtFirstFrame: number
/** Milliseconds from boot to the first drawn frame. */
firstFrameMs: number
/** Page indices the spawn viewport needed, for diagnostics. */
priorityPages: number[]
/** Draws skipped because their page had not arrived yet. */
skippedDraws: number
/** Objects the pack places (their art still needs the DCC/COF decoder). */
objects: number
/** Roof draws, painted in their own pass after the character. */
roofs: number
/** Whether the composited Sorceress sprite is in use (else the marker). */
character: boolean
/** DCC members that went into the character sprite. */
characterMembers: number
/** Frames the character path has actually drawn; 0 while the marker is in use. */
characterFrames: number
/** Last group the character path selected (walk or stand, times 8 directions). */
characterGroup: number
/** Milliseconds from boot to the first rendered frame. */
loadMs: number
missing: number
walkable: number
x: number
y: number
facing: number
tick: number
tickRate: number
/**
* GPU draw calls the last frame actually issued.
*
* This used to be hardcoded to 1, which hid the fact that switching atlas
* pages mid-list was costing up to 3 077 calls per frame. It now reads the
* renderer's own counter.
*/
drawCalls: number
/** Quads the last frame submitted, i.e. what survived culling. */
quadsDrawn: number
/** Draws rejected last frame because their bounds fell outside the viewport. */
culledDraws: number
/** Wall-clock milliseconds the last `onRender` took. */
renderMs: number
/** Current zoom factor and the canvas backing-store size. */
zoom: number
canvasWidth: number
canvasHeight: number
error: string | null
dialog: string[]
npcs: number
npcsNear: NpcEntity[]
}
declare global {
interface Window {
/** Act scene state for automation and debugging. */
__d2webAct?: ActSceneState
}
}
const state: ActSceneState = {
ready: false, source: '', act: 1, level: '', quadrant: '', quadrants: [], base: '', roofs: 0,
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, walls: 0, frames: 0,
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
skippedDraws: 0, objects: 0, loadMs: 0,
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0,
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
dialog: [], npcs: 0, npcsNear: [],
}
window.__d2webAct = state
/** One tile to draw. */
interface Drawable {
readonly frame: AtlasFrame
readonly x: number
readonly y: number
readonly cellX: number
readonly cellY: number
/** Atlas page index this frame lives on. */
readonly page: number
}
/** 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
readonly quadrants: readonly string[]
/**
* Which query parameter names the currently shown map block.
*
* The pack path selects a prebaked map by its entry label (`?level=`), the live
* path selects a DS1 file inside one level (`?quadrant=`). Crossing the two
* silently falls back to the act's town, so each runtime declares its own.
*/
readonly quadrantParam: 'level' | 'quadrant'
readonly quadrant: string
readonly cellsX: number
readonly cellsY: number
readonly widthPx: number
readonly heightPx: number
readonly grid: CollisionGrid
readonly floors: readonly Drawable[]
readonly walls: readonly Drawable[]
/** Roof draws: painted after the character, so a roof covers what walks under it. */
readonly roofs: readonly Drawable[]
/** Object draws: interleaved with walls in painter's order. */
readonly objectDrawables: readonly ObjectDrawable[]
readonly spawn: { x: number; y: number }
readonly npcs: readonly PackObject[]
/**
* The monsters this level actually holds, planned from `Levels.txt` and
* `MonStats.txt`.
*
* Empty when the level has none — a town — and empty for a source that has
* not planned one, which is what {@link ActRuntime.monsterStats} below is
* still there for.
*/
readonly monsterPacks: readonly MonsterPack[]
/**
* The level's monster types, by `MonStats.txt` id, for the status line.
*/
readonly monsterTypes: readonly string[]
/**
* Fallback monster definitions, used only when the level produced no packs.
*
* A level with no `Levels.txt` row — a hand-built fixture, or a pack baked
* before the monster columns were read — would otherwise be empty. Spawning a
* few placeholders there keeps the page useful to poke at, and the status
* line says which of the two happened.
*/
readonly monsterStats: readonly MonsterStats[]
/** How many of {@link ActRuntime.monsterStats} to place in that fallback. */
readonly monsterCount: number
readonly objects: number
readonly frames: number
/** Whether the real character art is in use. */
readonly notes: readonly string[]
/** Handles by page index; a hole means "not loaded yet". */
readonly pages: (AtlasHandle | null)[]
/** Object atlas page handles. */
readonly objectPages: (AtlasHandle | null)[]
/** Archive directories to try for the character archive, in order. */
readonly charBases: readonly string[]
/** Act palette for the character atlas, when the pack carries one. */
readonly palette: Palette | null
/** Pages the first frame needs (spawn viewport). */
readonly priorityPages: readonly number[]
/** Priority pages first, then everything else. */
readonly loadOrder: readonly number[]
/** Upload specific pages, a few in flight at a time. */
loadPages(renderer: SpriteRenderer, indices: readonly number[], onProgress: () => void): Promise<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 {
readonly act: number
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
readonly levelName: string
readonly ds1: string
readonly cellsX: number
readonly cellsY: number
readonly originX: number
readonly originY: number
readonly widthPx: number
readonly heightPx: number
readonly pages: readonly { readonly file: string; readonly width: number; readonly height: number }[]
readonly objectPages?: readonly { readonly file: string; readonly width: number; readonly height: number }[]
readonly frames: readonly (readonly number[])[]
readonly framePlacement: readonly (readonly number[])[]
readonly floors: readonly (readonly number[])[]
readonly walls: readonly (readonly number[])[]
/** Roof draws, painted after everything else (absent in older packs). */
readonly roofs?: readonly (readonly number[])[]
readonly objects: readonly PackObject[]
readonly npcs?: readonly PackObject[]
/**
* The level's monsters, planned at bake time (absent in packs baked before
* the monster columns were read).
*
* Planned at bake time rather than in the browser because the browser has no
* `Levels.txt` or `MonStats.txt` on the pack path — the whole point of a pack
* is that it carries what the level needs and nothing else. The same
* `planLevelMonsters` produces both this and the live path's population, so
* the two agree by construction.
*/
readonly monsters?: {
readonly types: readonly string[]
readonly budget: number
readonly packs: readonly { readonly members: readonly MonsterStats[] }[]
}
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
readonly spawn: readonly number[] | null
readonly stats: { readonly missingTiles: number; readonly walkable: number }
}
/**
* Build the runtime for a prebaked pack.
*
* @param packBase - directory holding `index.json`.
* @param act - act to open.
* @param wantedLevel - `?level=` label, when the caller picked one.
* @returns the runtime, or null when no pack covers this act.
*/
async function loadPackRuntime(
packBase: string,
act: number,
wantedLevel: string,
viewport: { width: number; height: number },
charBases: readonly string[],
): Promise<MapRuntime | null> {
let index: PackIndex
try {
index = await fetchJson<PackIndex>(`${packBase}/index.json`)
} catch {
return null
}
const forAct = index.levels.filter(entry => entry.act === act)
if (forAct.length === 0) return null
const entry = (wantedLevel === ''
? undefined
: forAct.find(candidate => candidate.label === wantedLevel)
?? forAct.find(candidate => candidate.slug === wantedLevel))
?? forAct.find(candidate => candidate.slug === 'town')
?? forAct[0]!
const scene = await fetchJson<PackSceneJson>(`${packBase}/${entry.path}/scene.json`)
const frames: AtlasFrame[] = scene.framePlacement.map(place => ({
x: place[1] ?? 0, y: place[2] ?? 0, width: place[3] ?? 1, height: place[4] ?? 1,
}))
const toDrawable = (row: readonly number[]): Drawable => {
const frameIndex = row[0] ?? 0
return {
frame: frames[frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
x: row[1] ?? 0,
y: row[2] ?? 0,
cellX: row[3] ?? 0,
cellY: row[4] ?? 0,
page: scene.framePlacement[frameIndex]?.[0] ?? 0,
}
}
const floors = scene.floors.map(toDrawable)
const walls = scene.walls.map(toDrawable)
const roofs = (scene.roofs ?? []).map(toDrawable)
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,
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,
objectDrawables,
spawn,
npcs: scene.npcs ?? [],
monsterPacks: scene.monsters?.packs ?? [],
monsterTypes: scene.monsters?.types ?? [],
monsterStats: DEMO_MONSTERS,
monsterCount: (entry.slug.includes('town') || scene.levelName.toLowerCase().includes('town') || (scene.monsters?.packs && scene.monsters.packs.length > 0) || scene.monsters !== undefined) ? 0 : PLACEHOLDER_MONSTER_COUNT,
objects: scene.objects.length,
frames: scene.frames.length,
notes: [
`首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`,
...(scene.monsters?.packs && scene.monsters.packs.length > 0
? [`怪物:${String(scene.monsters.budget)} 只 / ${String(scene.monsters.packs.length)} 群(${scene.monsters.types.join('、')})`]
: []),
],
pages,
objectPages,
priorityPages: priority,
loadOrder,
async loadPages(renderer, indices, onProgress) {
if (objectPages.some(page => page === null) && scene.objectPages && scene.objectPages.length > 0) {
await Promise.all(scene.objectPages.map(async (page, index) => {
if (objectPages[index] !== null) return
try {
const response = await fetch(`${packBase}/${entry.path}/${page.file}`)
if (!response.ok) return
const bitmap = await createImageBitmap(await response.blob())
try {
objectPages[index] = renderer.addAtlas(bitmap, page.width, page.height)
} finally {
bitmap.close()
}
} catch (err) {
console.warn(`object page ${page.file}: ${(err as Error).message}`)
}
}))
}
const queue = indices.filter(index => pages[index] === null)
let cursor = 0
const workers = Array.from({ length: Math.min(PAGE_CONCURRENCY, queue.length) }, async () => {
for (;;) {
const index = queue[cursor]
cursor += 1
if (index === undefined) return
const page = scene.pages[index]!
const response = await fetch(`${packBase}/${entry.path}/${page.file}`)
if (!response.ok) throw new Error(`${page.file}: HTTP ${String(response.status)}`)
const bitmap = await createImageBitmap(await response.blob())
try {
pages[index] = renderer.addAtlas(bitmap, page.width, page.height)
} finally {
bitmap.close()
}
onProgress()
}
})
await Promise.all(workers)
},
}
}
/**
* Which pages the first frame needs.
*
* Only the pages whose tiles intersect a box around the spawn point: later pages
* can be megabytes, and none of their tiles are on screen yet.
*
* @param floors - floor draws.
* @param walls - wall draws.
* @param spawn - spawn point in scene pixels.
* @returns page indices, ascending.
*/
function pagesNeededForViewport(
floors: readonly Drawable[],
walls: readonly Drawable[],
spawn: { x: number; y: number },
viewport: { width: number; height: number },
zoom: number,
): number[] {
// The first frame must cover what is *actually* on screen: a full-screen
// canvas shows far more world than the fixed 960×560 it used to assume, and a
// page that is not loaded draws nothing at all.
const halfWidth = viewport.width / zoom / 2 + 96
const halfHeight = viewport.height / zoom / 2 + 96
const left = spawn.x - halfWidth
const right = spawn.x + halfWidth
const top = spawn.y - halfHeight
const bottom = spawn.y + halfHeight
const needed = new Set<number>()
for (const draw of [...floors, ...walls]) {
if (draw.x + draw.frame.width < left || draw.x > right) continue
if (draw.y + draw.frame.height < top || draw.y > bottom) continue
needed.add(draw.page)
}
if (needed.size === 0) needed.add(0)
return [...needed].sort((a, b) => a - b)
}
/**
* Build the runtime by reading and decoding the archives.
*
* @param base - directory holding the archives.
* @param act - act to open.
* @param renderer - renderer that receives the atlas.
* @param wantedQuadrant - `?quadrant=` member name, when given.
* @returns the runtime.
*/
async function loadLiveRuntime(
base: string,
act: number,
renderer: SpriteRenderer,
wantedQuadrant: string,
): Promise<MapRuntime> {
const archives = new MountedArchives()
const notes: string[] = []
for (const name of [...DATA_ARCHIVES, CHARACTER_ARCHIVE]) {
try {
const source = await httpRangeSource(`${base}/${name}`, name)
archives.add(name, await MpqArchive.open(source))
} catch (err) {
notes.push(`${name}: ${(err as Error).message}`)
}
}
if (archives.size === 0) throw new Error(`no archive could be mounted from ${base}`)
const tables = await loadActTables(archives)
const wantedLevel = param('level', '')
const town: LevelInfo = wantedLevel === ''
? resolveActTown(tables, act)
: resolveLevel(tables, Number(wantedLevel), act)
const quadrants = town.ds1Names.map(name => name.split('\\').pop() ?? name)
const wanted = wantedQuadrant === '' ? (quadrants[0] ?? '') : wantedQuadrant
const index = Math.max(0, quadrants.indexOf(wanted))
const level = decodeDs1(await archives.read(town.ds1Names[index]!))
const libraries = []
for (const name of town.dt1Names) libraries.push(decodeDt1(await archives.read(name)))
const pl2 = decodePl2(await archives.read(town.paletteName))
const palette: Palette = { rgb: pl2.rgb, size: 256 }
const scene = buildIsoMapScene(level, libraries, levelSeed(town.ds1Names[index] ?? town.levelName))
const atlas = buildAtlasThatFits(renderer, scene.sheet, palette)
const frames = atlas.groups[0] ?? []
const toDrawable = (row: { frameIndex: number; x: number; y: number; cellX: number; cellY: number }): Drawable => ({
frame: frames[row.frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
x: row.x,
y: row.y,
cellX: row.cellX,
cellY: row.cellY,
page: 0,
})
// 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,
quadrants,
quadrantParam: 'quadrant',
quadrant: quadrants[index] ?? '',
cellsX: scene.cellsX,
cellsY: scene.cellsY,
widthPx: scene.widthPx,
heightPx: scene.heightPx,
grid: scene,
floors: scene.floors.map(toDrawable),
walls: scene.walls.map(toDrawable),
roofs: scene.roofs.map(toDrawable),
objectDrawables: [],
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
npcs: [],
monsterPacks: population.packs,
monsterTypes: population.types,
monsterStats: DEMO_MONSTERS,
monsterCount: (town.levelName.toLowerCase().includes('town') || population.packs.length > 0 || population.budget === 0) ? 0 : PLACEHOLDER_MONSTER_COUNT,
objects: level.objects.length,
frames: scene.frames.length,
notes,
pages: [renderer.defaultAtlasHandle],
objectPages: [],
priorityPages: [0],
loadOrder: [0],
loadPages: async () => {},
}
}
/**
* Build the tile atlas at the first width the renderer accepts.
*
* @param renderer - the sprite renderer, used as the size oracle.
* @param sheet - the scene's tile sheet.
* @param palette - the act palette.
* @returns the uploaded atlas.
*/
function buildAtlasThatFits(renderer: SpriteRenderer, sheet: SpriteSheet, palette: Palette): SpriteAtlas {
let lastError: unknown = null
for (const width of [4096, 8192, 16384]) {
const atlas = buildAtlas(sheet, palette, undefined, { width })
try {
renderer.setAtlas(atlas)
return atlas
} catch (err) {
lastError = err
console.warn(`atlas ${String(atlas.width)}x${String(atlas.height)} rejected: ${(err as Error).message}`)
}
}
throw lastError instanceof Error ? lastError : new Error('no atlas size the renderer accepts')
}
/**
* Fill in the remaining pages without blocking the first frames.
*
* @param runtime - the runtime.
* @param renderer - renderer to upload into.
*/
function preloadRemaining(runtime: MapRuntime, renderer: SpriteRenderer): void {
const rest = runtime.loadOrder.slice(1)
if (rest.length === 0) return
void runtime.loadPages(renderer, rest, () => {
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
}).catch((err: unknown) => {
console.warn(`page preload: ${(err as Error).message}`)
})
}
/** A composited character, uploaded as its own atlas page. */
interface LoadedCharacter {
readonly handle: AtlasHandle
/** `atlas.groups[direction][frame]`, ready to draw. */
readonly groups: readonly (readonly AtlasFrame[])[]
readonly directions: number
readonly walk: number
readonly stand: number
readonly members: number
}
/**
* Load the player's art from the character archive.
*
* The map comes from the pack, but a character is not map data: it is eight DCC
* layers plus a COF, ~1–3 MB that the archive serves on demand. Loading it after
* the first frame means the map is walkable while the Sorceress is still
* arriving, and if the archive is unreachable the page keeps the marker.
*
* @param base - archive directory.
* @param palette - the act palette (from the pack index).
* @param renderer - renderer to upload into.
* @returns the character, or null when it could not be loaded.
*/
/** Cache of opened MPQ archives by URL to avoid re-opening and re-parsing index tables. */
const archivePromiseCache = new Map<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])[][]
}
/**
* 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<Map<string, LoadedMonsterArt>> {
const map = new Map<string, LoadedMonsterArt>()
if (monsterIds.length === 0) return map
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,
})
return
}
} catch {
// fall through to per-act PNG fallback
}
// 2. Fallback to per-act PNG if available
if (actEntitiesUrl !== undefined) {
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${actEntitiesUrl}/${slug}.json`),
fetch(`${actEntitiesUrl}/${slug}.png`),
])
if (jsonResp.ok && pngResp.ok) {
const meta = (await jsonResp.json()) as PackedMonsterJson
const bitmap = await createImageBitmap(await pngResp.blob())
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const groups: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height]) => ({ x, y, width, height }))
)
loadedBySpecKey.set(key, {
token: spec.token,
handle,
groups,
directions: meta.directions,
walkOffset: meta.walkOffset,
standOffset: meta.standOffset,
walkFrames: meta.walkFrames,
standFrames: meta.standFrames,
})
return
}
} catch {
// fall back to MPQ loading below
}
}
fallbackSpecs.push([key, spec])
})
)
} else {
fallbackSpecs.push(...tokenSpecs.entries())
}
// Load any remaining specs via runtime MPQ archives
if (fallbackSpecs.length > 0) {
const archives = await getMountedDataArchives(bases)
if (archives) {
await Promise.all(
fallbackSpecs.map(async ([key, spec]) => {
try {
const loaded = await loadMonsterAtlas(archives, spec.token, spec.weapon, palette, renderer)
loadedBySpecKey.set(key, loaded)
} catch (err) {
console.warn(`monster art ${spec.token} (${spec.weapon}) unavailable: ${(err as Error).message}`)
}
})
)
}
}
// Map monster id to loaded art
for (const id of monsterIds) {
const spec = resolveMonsterArtSpec(id)
if (spec !== null) {
const key = `${spec.token}-${spec.weapon}`
const loaded = loadedBySpecKey.get(key)
if (loaded !== undefined) {
map.set(id.toLowerCase(), loaded)
}
}
}
} catch (err) {
console.warn(`monster art loading error: ${(err as Error).message}`)
}
return map
}
async function loadCharacterArt(
bases: readonly string[],
palette: Palette,
renderer: SpriteRenderer,
packBase?: string,
act?: number,
): Promise<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
}
}
/**
* Run the loop for a runtime.
*
* @param runtime - the map.
* @param renderer - the renderer (already holding the atlas or the first pages).
* @param started - `performance.now()` when boot began.
*/
function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number): void {
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
const status = document.querySelector<HTMLElement>('#status')!
const hud = document.querySelector<HTMLElement>('#hud')!
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
const 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, built once: the draw lists are immutable for the life of the
// runtime, so the only per-frame cost is the rejection test itself.
const floorBounds = buildBounds(runtime.floors)
const wallBounds = buildBounds(runtime.walls)
const roofBounds = buildBounds(runtime.roofs)
const objectBounds = buildBounds(runtime.objectDrawables)
// `runtime.pages` is filled in by background loaders, so the count has to be
// re-read every frame. A loop is used rather than `filter().length` because
// the latter allocates a throwaway array 60 times a second.
const countPages = (): number => {
let loaded = 0
for (const page of runtime.pages) if (page !== null && page !== undefined) loaded += 1
return loaded
}
// NPC name plates. The container and the elements outlive a frame so the
// render loop only has to move them.
const labelsContainer = document.querySelector<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 engine = new GameEngine(
createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, {
width: FEET_WIDTH,
height: FEET_HEIGHT,
}),
{
spawn: runtime.spawn,
stats: runtime.monsterStats,
xpTable: DEMO_EXPERIENCE,
itemBases: DEMO_BASES,
prefixAffixes: DEMO_AFFIXES,
suffixAffixes: DEMO_AFFIXES,
skills: DEMO_SKILLS,
npcDefs,
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: WALK_SPEED,
playerReach: 52,
playerCooldownTicks: 12,
playerDamage: 7,
playerManaPerAttack: 2,
respawnTicks: 75,
disableMonsterAggro: true,
},
lootSeed: 0x5eed,
talkRadius: 80,
pickupRadius: 64,
inventoryCols: 10,
inventoryRows: 4,
monsterCount: runtime.monsterCount,
monsterPacks: runtime.monsterPacks,
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
// 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) })
}
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)
const dialogPanel = document.querySelector<HTMLElement>('#dialog')
if (dialogPanel !== null) {
dialogPanel.hidden = state.dialog.length === 0
dialogPanel.textContent = state.dialog.join('\n')
}
},
onRender: () => {
const renderStarted = performance.now()
if (state.pagesAtFirstFrame < 0) {
state.pagesAtFirstFrame = countPages()
state.firstFrameMs = Math.round(performance.now() - started)
}
const camX = engine.world.player.x
const camY = engine.world.player.y - 16
renderer.begin({ x: camX, y: camY, zoom: camera.zoom }, [0.03, 0.03, 0.04])
skippedDraws = 0
let culledDraws = 0
const view = viewportRect(camX, camY, canvas.width, canvas.height, camera.zoom)
const visible = (bounds: Float32Array, index: number): boolean => intersects(bounds, index, view)
const drawTiles = (list: readonly Drawable[], bounds: Float32Array): void => {
for (let index = 0; index < list.length; index += 1) {
if (!visible(bounds, index)) { culledDraws += 1; continue }
const draw = list[index]!
const page = runtime.pages[draw.page]
if (page === null || page === undefined) { skippedDraws += 1; continue }
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page })
}
}
drawTiles(runtime.floors, floorBounds)
const cell = cellOf(runtime.grid, engine.world.player.x, engine.world.player.y)
const playerDepth = cell.x + cell.y
let wallIdx = 0
let objIdx = 0
const walls = runtime.walls
const objs = runtime.objectDrawables
const drawUpToDepth = (maxDepth: number): void => {
while (wallIdx < walls.length || objIdx < objs.length) {
const nextWall = wallIdx < walls.length ? walls[wallIdx]! : null
const nextObj = objIdx < objs.length ? objs[objIdx]! : null
const wallDepth = nextWall !== null ? nextWall.cellX + nextWall.cellY : Infinity
const objDepth = nextObj !== null ? nextObj.depth : Infinity
if (wallDepth > maxDepth && objDepth > maxDepth) break
// Culling only skips the `draw`; the cursors still advance, because the
// two lists are merged by depth and a stalled cursor would reorder
// everything behind it.
if (wallDepth <= objDepth) {
if (!visible(wallBounds, wallIdx)) {
culledDraws += 1
} else {
const page = runtime.pages[nextWall!.page]
if (page === null || page === undefined) {
skippedDraws += 1
} else {
renderer.draw(nextWall!.frame, nextWall!.x, nextWall!.y, { atlas: page })
}
}
wallIdx += 1
} else {
if (!visible(objectBounds, objIdx)) {
culledDraws += 1
} else {
const page = runtime.objectPages[nextObj!.page]
if (page === null || page === undefined) {
skippedDraws += 1
} else {
renderer.draw(nextObj!.frame, nextObj!.x, nextObj!.y, { atlas: page })
}
}
objIdx += 1
}
}
}
interface EntityDraw {
x: number
y: number
depth: number
draw: () => void
}
const entities: EntityDraw[] = []
const pushEntity = (x: number, y: number, draw: () => void) => {
const c = cellOf(runtime.grid, x, y)
entities.push({ x, y, depth: c.x + c.y, draw })
}
pushEntity(engine.world.player.x, engine.world.player.y, () => {
if (character === null) {
renderer.drawSolid(engine.world.player.x - MARKER_WIDTH / 2, engine.world.player.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.85, 0.75, 0.45, 1])
renderer.drawSolid(engine.world.player.x - MARKER_WIDTH / 2, engine.world.player.y - 6, MARKER_WIDTH, 6, [0.35, 0.25, 0.15, 1])
} else {
const direction = facingToDirection(engine.world.player.facing, character.directions)
const groupIndex = (playerMoving ? character.walk : character.stand) + direction
const group = character.groups[groupIndex] ?? character.groups[character.walk + direction]
const frameIndex = playerMoving
? Math.floor(playerWalkFrame / 3) % Math.max(1, group?.length ?? 1)
: 0
const frame = group?.[frameIndex]
if (frame !== undefined) {
renderer.draw(frame, engine.world.player.x - frame.width / 2, engine.world.player.y - frame.height + FEET_HEIGHT / 2, { atlas: character.handle })
state.characterFrames += 1
}
}
})
for (const monster of engine.world.monsters) {
if (monster.state === 'dead') continue
pushEntity(monster.x, monster.y, () => {
const art = monsterArtMap.get(monster.stats.id.toLowerCase())
if (art !== undefined) {
const direction = facingToDirection(monster.facing, art.directions)
const isMoving = monster.state === 'chase'
const groupIndex = (isMoving ? art.walkOffset : art.standOffset) + direction
const group = art.groups[groupIndex]
const frameCount = Math.max(1, group?.length ?? 1)
const frameIndex = isMoving
? Math.floor(monsterWalkFrame / 3) % frameCount
: Math.floor(monsterWalkFrame / 5) % frameCount
const frame = group?.[frameIndex]
if (frame !== undefined) {
renderer.draw(frame, monster.x - frame.width / 2, monster.y - frame.height + FEET_HEIGHT / 2, { atlas: art.handle })
return
}
}
// Fallback to placeholder solid box if art not loaded or frame missing
renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.9, 0.2, 0.2, 1])
})
}
for (const npc of engine.npcEntities) {
// Sprite-backed NPCs are already painted by the object layer; the box
// is only here so an art-less NPC still shows up and stays clickable.
// The name label below is drawn for both kinds.
if (npc.hasSprite) continue
pushEntity(npc.x, npc.y, () => {
renderer.drawSolid(npc.x - MARKER_WIDTH / 2, npc.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.2, 0.9, 0.2, 1])
})
}
for (const shot of engine.projectiles) {
pushEntity(shot.x, shot.y, () => {
renderer.drawSolid(shot.x - 4, shot.y - 4, 8, 8, [0.9, 0.9, 0.2, 1])
})
}
for (const entry of engine.ground) {
pushEntity(entry.x, entry.y, () => {
const size = 6
renderer.drawSolid(entry.x - size / 2, entry.y - size, size, size, [0.8, 0.8, 0.8, 1])
})
}
entities.sort((a, b) => a.depth - b.depth)
for (const entity of entities) {
drawUpToDepth(entity.depth)
entity.draw()
}
drawUpToDepth(Infinity)
// Roofs last, in their own pass: the engine paints them after every other
// layer so they cover the floor, the walls and anything walking under them.
drawTiles(runtime.roofs, roofBounds)
renderer.flush()
// The labels are recycled rather than rebuilt: `innerHTML = ''` plus one
// `createElement` per NPC per frame forced a full style recalculation and
// made the DOM, not the GPU, the slowest part of some town frames.
if (labelsContainer !== null) {
let labelCount = 0
for (const npc of engine.npcEntities) {
const screenX = (npc.x - camX) * camera.zoom + canvas.width / 2
const screenY = (npc.y - camY) * camera.zoom + canvas.height / 2
if (screenX <= -100 || screenX >= canvas.width + 100) continue
if (screenY <= -100 || screenY >= canvas.height + 100) continue
let label = labelPool[labelCount]
if (label === undefined) {
label = document.createElement('div')
label.style.position = 'absolute'
label.style.transform = 'translate(-50%, -100%)'
label.style.color = '#c8a15a'
label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
labelPool.push(label)
labelsContainer.appendChild(label)
}
if (label.textContent !== npc.def.name) label.textContent = npc.def.name
label.style.left = `${screenX}px`
label.style.top = `${screenY - 60 * camera.zoom}px`
label.style.fontSize = `${Math.max(10, 14 * camera.zoom)}px`
label.hidden = false
labelCount += 1
}
for (let index = labelCount; index < labelPool.length; index += 1) labelPool[index]!.hidden = true
}
state.drawCalls = renderer.drawCalls
state.quadsDrawn = renderer.quadsSubmitted
state.culledDraws = culledDraws
state.tickRate = loop.tickRate
state.zoom = Number(camera.zoom.toFixed(2))
state.canvasWidth = canvas.width
state.canvasHeight = canvas.height
state.pagesLoaded = countPages()
state.skippedDraws = skippedDraws
state.renderMs = performance.now() - renderStarted
// The HUD is a single text node, but rebuilding it means ten string
// concatenations and four `toFixed` calls; at 4 Hz that is invisible to the
// reader and stops the overlay from competing with the frame it describes.
if (renderStarted >= hudNextMs) {
hudNextMs = renderStarted + HUD_INTERVAL_MS
hud.textContent = `${runtime.level} · ${runtime.quadrant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
+ ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙`
+ ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
+ ` · 对象 ${String(runtime.objects)} · ${loop.tickRate.toFixed(1)} tps · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
+ ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})· ${state.renderMs.toFixed(1)} ms`
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
}
},
})
loop.start()
state.ready = true
state.loadMs = Math.round(performance.now() - started)
// Only the live path fills the selectors here: the pack path already built them from
// the index (`populatePackSelectors`), and overwriting them with the single level this
// runtime knows would collapse the three-level hierarchy back into one flat list.
if (runtime.source === 'live') populateLiveSelectors(runtime)
void variantSelect
status.textContent = 'WASD / 方向键移动。'
+ (runtime.source === 'pack'
? `资源包:首屏 ${String(state.pagesLoaded)}/${String(state.pagesTotal)} 页,${String(state.loadMs)} ms 出画面,其余页后台补。`
: '本次直接读归档并解码(较慢):资源包不可用时会走这条路。')
+ (state.character
? ` 角色为真·女法师(DCC+COF 合成,${String(state.characterMembers)} 层)。`
: ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。')
+ (runtime.objectDrawables.length > 0
? ` 对象层已绘制(火把/箱子/传送点等 ${String(runtime.objectDrawables.length)} 处)。`
: ' 可破坏物品的位置与美术成员已进包,静态帧待接。')
preloadRemaining(runtime, renderer)
if (runtime.palette !== null) {
renderer.setPalette(runtime.palette.rgb)
const paletteForEntities = runtime.palette
const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
// Collect monster types to load
const activeMonsterIds = runtime.monsterTypes.length > 0
? runtime.monsterTypes
: (runtime.monsterCount > 0 ? runtime.monsterStats.map(s => s.id) : [])
if (activeMonsterIds.length > 0) {
void loadMonsterArtMap(
DEFAULT_BASES,
activeMonsterIds,
paletteForEntities,
renderer,
packEntityBase,
packEntityAct,
).then((loadedMap) => {
monsterArtMap = loadedMap
})
}
void loadCharacterArt(
runtime.charBases,
paletteForEntities,
renderer,
packEntityBase,
packEntityAct,
).then((loaded) => {
character = loaded
state.character = loaded !== null
if (loaded !== null) state.characterMembers = loaded.members
// The status line is written before this promise settles, so refresh it: leaving
// "占位棋子" on screen while the real sprite is already drawing is a lie the user
// actually sees (it showed up in a screenshot review).
status.textContent = status.textContent.replace(
loaded === null
? ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。'
: / 角色[^。]*。/u,
loaded === null
? ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。'
: ` 角色为真·女法师(${runtime.source === 'pack' ? '预烘焙图集' : 'DCC+COF 合成'},${String(loaded.members)} 层)。`,
)
})
}
window.addEventListener('beforeunload', () => {
loop.stop()
input.detach()
if (character?.handle) renderer.deleteAtlas(character.handle)
for (const art of monsterArtMap.values()) {
renderer.deleteAtlas(art.handle)
}
for (const page of runtime.pages) {
if (page !== null) renderer.deleteAtlas(page)
}
for (const page of runtime.objectPages) {
if (page !== null) renderer.deleteAtlas(page)
}
renderer.dispose()
})
}
/**
* The cell containing a scene point.
*
* @param grid - the collision grid.
* @param x - scene x.
* @param y - scene y.
* @returns rounded-down cell coordinates.
*/
function cellOf(grid: CollisionGrid, x: number, y: number): { x: number; y: number } {
const px = x - grid.originX
const py = y - grid.originY
return {
x: Math.floor((px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2),
y: Math.floor((py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2),
}
}
/**
* The facing index for a screen-space direction.
*
* @param x - screen x direction, -1..1.
* @param y - screen y direction, -1..1 (down positive).
* @returns the facing index, 0 = south, turning clockwise (west).
*/
function facingOf(x: number, y: number): number {
const angle = Math.atan2(-x, y)
return Math.round((angle / (Math.PI / 4)) + 8) % 8
}
/**
* Copy a runtime's description into the published state.
*
* @param runtime - the runtime.
* @param base - location the data came from.
*/
function publish(runtime: MapRuntime, base: string): void {
state.source = runtime.source
state.base = base
state.act = runtime.act
state.level = runtime.level
state.quadrant = runtime.quadrant
state.quadrants = [...runtime.quadrants]
state.cellsX = runtime.cellsX
state.cellsY = runtime.cellsY
state.widthPx = runtime.widthPx
state.heightPx = runtime.heightPx
state.floors = runtime.floors.length
state.walls = runtime.walls.length
state.frames = runtime.frames
state.pagesTotal = runtime.pages.length
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
state.priorityPages = [...runtime.priorityPages]
state.objects = runtime.objects
state.roofs = runtime.roofs.length
state.missing = 0
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
}
/**
* Fill the level picker from a pack index and wire it to navigation.
*
* Every baked map is one entry, grouped by act. Picking one reloads the page with
* `?act=` and `?level=`, which is the same query the loader already understands,
* so the picker needs no second loading path.
*
* @param packBase - directory holding `index.json`.
* @param current - the act and the entry label currently displayed (`runtime.quadrant`),
* so the list marks exactly the map that is on screen.
*/
async function populatePackSelectors(packBase: string, current: { act: number; label: string }): Promise<void> {
const actSelect = document.querySelector<HTMLSelectElement>('#act')
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')
if (actSelect === null || sceneSelect === null || variantSelect === null) return
let index: PackIndex
try {
index = await fetchJson<PackIndex>(`${packBase}/index.json`)
} catch {
return
}
/** A scene type: one `Levels.txt` level, with every baked DS1 variant under it. */
interface Scene {
readonly slug: string
readonly name: string
readonly variants: readonly PackIndex['levels'][number][]
}
const scenesByAct = new Map<number, Scene[]>()
for (const entry of index.levels) {
let scenes = scenesByAct.get(entry.act)
if (scenes === undefined) { scenes = []; scenesByAct.set(entry.act, scenes) }
let scene = scenes.find(candidate => candidate.slug === entry.slug)
if (scene === undefined) {
scene = { slug: entry.slug, name: sceneNameZh(entry.slug, entry.levelName), variants: [] }
scenes.push(scene)
}
;(scene.variants as PackIndex['levels'][number][]).push(entry)
}
/** The suffix after the level slug, which is the DS1's own name fragment. */
const suffixOf = (entry: PackIndex['levels'][number]): string =>
entry.label.startsWith(`${entry.slug}-`) ? entry.label.slice(entry.slug.length + 1) : entry.label
const optionFor = (value: string, text: string, selected: boolean): HTMLOptionElement => {
const option = document.createElement('option')
option.value = value
option.textContent = text
option.selected = selected
return option
}
const acts = [...scenesByAct.keys()].sort((left, right) => left - right)
actSelect.replaceChildren(...acts.map(act => optionFor(
String(act),
`${ACT_NAMES_ZH[act] ?? `第 ${String(act)} 章`}`,
act === current.act,
)))
actSelect.disabled = acts.length === 0
const scenes = scenesByAct.get(current.act) ?? []
const currentEntry = index.levels.find(entry => entry.act === current.act && entry.label === current.label)
const currentSceneSlug = currentEntry?.slug ?? scenes[0]?.slug ?? ''
sceneSelect.replaceChildren(...scenes.map(scene => optionFor(
scene.slug,
scene.name,
scene.slug === currentSceneSlug,
)))
sceneSelect.disabled = scenes.length === 0
const populateVariants = (slug: string, selectedLabel: string): void => {
const scene = scenes.find(candidate => candidate.slug === slug)
const variants = scene?.variants ?? []
// A single-variant scene still shows its own DS1 so the third selector is never
// an empty box; the option text repeats the scene name only when it is the only one.
variantSelect.replaceChildren(...variants.map(entry => {
const suffix = suffixOf(entry)
const label = variants.length > 1 ? variantLabelZh(suffix) : `${scene?.name ?? entry.levelName}(${suffix})`
return optionFor(entry.label, label, entry.label === selectedLabel)
}))
variantSelect.disabled = variants.length === 0
}
populateVariants(currentSceneSlug, current.label)
variantSelect.dataset.scene = currentSceneSlug
const go = (params: URLSearchParams): void => { location.search = params.toString() }
const navigateToLabel = (label: string, act: number): void => {
const params = new URLSearchParams(location.search)
params.set('act', String(act))
params.set('level', label)
go(params)
}
actSelect.onchange = () => {
const act = Number(actSelect.value)
const first = scenesByAct.get(act)?.[0]
// Keep the address bar and the map in sync: an act with no baked scene yet (there
// are none today) simply leaves the current map alone.
if (first === undefined) return
navigateToLabel(first.variants[0]?.label ?? '', act)
}
sceneSelect.onchange = () => {
const scene = scenes.find(candidate => candidate.slug === sceneSelect.value)
if (scene === undefined) return
populateVariants(scene.slug, '')
navigateToLabel(scene.variants[0]?.label ?? '', current.act)
}
variantSelect.onchange = () => { navigateToLabel(variantSelect.value, current.act) }
}
/**
* Build the three selectors for the live (no pack) path.
*
* Without a pack index there is no scene catalog to offer: the live loader only knows
* the act's town, so `场景` shows that one level and `细分场景` lists the DS1 files the
* level actually contains — which is the same distinction the pack path draws between
* a scene and its variants.
*
* @param runtime - the freshly loaded runtime.
*/
function populateLiveSelectors(runtime: MapRuntime): void {
const actSelect = document.querySelector<HTMLSelectElement>('#act')
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')
if (actSelect === null || sceneSelect === null || variantSelect === null) return
actSelect.value = String(runtime.act)
actSelect.disabled = false
const option = document.createElement('option')
option.value = runtime.level
option.textContent = runtime.level
option.selected = true
sceneSelect.replaceChildren(option)
sceneSelect.disabled = false
variantSelect.replaceChildren(...runtime.quadrants.map(name => {
const item = document.createElement('option')
item.value = name
item.textContent = name
item.selected = name === runtime.quadrant
return item
}))
variantSelect.disabled = runtime.quadrants.length === 0
actSelect.onchange = () => {
const params = new URLSearchParams(location.search)
params.set('act', actSelect.value)
params.delete('level')
params.delete('quadrant')
location.search = params.toString()
}
sceneSelect.onchange = () => { /* one scene only in live mode */ }
variantSelect.onchange = () => {
const params = new URLSearchParams(location.search)
params.set('act', String(runtime.act))
params.set(runtime.quadrantParam, variantSelect.value)
params.delete(runtime.quadrantParam === 'level' ? 'quadrant' : 'level')
location.search = params.toString()
}
}
/**
* Boot the page: pack first, archives second.
*/
async function boot(): Promise<void> {
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
const status = document.querySelector<HTMLElement>('#status')!
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')!
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
actSelect.disabled = false
sceneSelect.disabled = true
variantSelect.disabled = true
const started = performance.now()
const act = Math.min(ACT_COUNT, Math.max(1, Number(param('act', '1'))))
state.act = act
actSelect.value = String(act)
actSelect.addEventListener('change', () => {
const params = new URLSearchParams(location.search)
params.set('act', actSelect.value)
params.delete('level')
location.search = params.toString()
})
// The canvas fills the window, so its size decides how much world the first
// frame needs and therefore which atlas pages must load before it.
const viewport = ViewportCamera.syncCanvasSize(canvas)
try {
const forcedLive = param('live', '') === '1'
if (!forcedLive) {
const packParam = param('pack', '')
const packBases = packParam === '' ? DEFAULT_PACKS : [packParam]
for (const packBase of packBases) {
status.textContent = `正在读取资源包 ${packBase}/index.json …`
const baseParamForChar = param('base', '')
const charBases = baseParamForChar === '' ? DEFAULT_BASES : [baseParamForChar]
const runtime = await loadPackRuntime(packBase, act, param('level', ''), viewport, charBases)
if (runtime === null) continue
publish(runtime, packBase)
void populatePackSelectors(packBase, { act: runtime.act, label: runtime.quadrant })
const renderer = new SpriteRenderer(canvas, {
onContextLost: () => {
status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
},
onContextRestored: () => {
status.textContent = 'WebGL 上下文已恢复,正在重新加载…'
location.reload()
},
})
// The first frame waits only for the spawn-area pages.
await runtime.loadPages(renderer, runtime.priorityPages, () => {
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
})
runScene(runtime, renderer, started)
return
}
}
const baseParam = param('base', '')
const bases = baseParam === '' ? DEFAULT_BASES : [baseParam]
let lastError: unknown = null
for (const base of bases) {
try {
status.textContent = `正在读取归档 ${base}(HTTP range)并解码 …`
const renderer = new SpriteRenderer(canvas, {
onContextLost: () => {
status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
},
onContextRestored: () => {
status.textContent = 'WebGL 上下文已恢复,正在重新加载…'
location.reload()
},
})
const runtime = await loadLiveRuntime(base, act, renderer, param('quadrant', ''))
publish(runtime, base)
runScene(runtime, renderer, started)
return
} catch (err) {
lastError = err
console.warn(`${base}: ${(err as Error).message}`)
}
}
throw lastError instanceof Error ? lastError : new Error('no data location worked')
} catch (err) {
state.error = (err as Error).message
status.textContent = `启动失败:${state.error}`
actSelect.disabled = false
sceneSelect.disabled = false
variantSelect.disabled = false
}
}
void boot()