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