2275 lines
86 KiB
TypeScript
2275 lines
86 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 { ViewportCamera } from "../sim/camera.ts"
|
||
import { MountedArchives } from '../mpq/mount.ts'
|
||
import { loadActTables, resolveActTown, resolveLevel } from '../game/acts.ts'
|
||
import { planLevelMonsters } from '../game/monsters.ts'
|
||
import type { LevelInfo } from '../game/acts.ts'
|
||
import { decodeDs1 } from '../formats/ds1.ts'
|
||
import { decodeDt1 } from '../formats/dt1.ts'
|
||
import { decodePl2 } from '../formats/pl2.ts'
|
||
import type { Palette } from '../formats/pal.ts'
|
||
import type { SpriteSheet } from '../formats/sprite.ts'
|
||
import {
|
||
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
|
||
buildIsoMapScene, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
|
||
} from '../game/d2map.ts'
|
||
import type { CollisionGrid } from '../game/d2map.ts'
|
||
import { createIsoTerrain } from '../game/iso-terrain.ts'
|
||
import type { MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts'
|
||
import { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts'
|
||
import { GameEngine, syncEngineState } from "../game/engine.ts"
|
||
import type { NpcEntity } from "../game/engine.ts"
|
||
import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts"
|
||
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
|
||
import type { NpcDef } from '../game/quests.ts'
|
||
import { buildAtlas, buildIndexedAtlas } from '../render/atlas.ts'
|
||
import type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts'
|
||
import { SpriteRenderer } from '../render/renderer.ts'
|
||
import type { AtlasHandle } from '../render/renderer.ts'
|
||
import { buildBounds, intersects, viewportRect } from '../render/cull.ts'
|
||
import { loadCharacterSheet, facingToDirection } from '../game/character.ts'
|
||
import { loadMonsterAtlas, type LoadedMonsterArt } from '../game/monster-art.ts'
|
||
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
|
||
import { ACT_NAMES_ZH, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts'
|
||
import { GameLoop } from '../sim/loop.ts'
|
||
import { KeyboardInput } from '../sim/input.ts'
|
||
import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-links.ts'
|
||
import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../game/level-links.ts'
|
||
import { FadeOverlay, SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from './transition.ts'
|
||
import { TownPortalSlot, WaypointNetwork, townLevelForAct } from '../game/portal.ts'
|
||
import { Minimap } from '../ui/minimap.ts'
|
||
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
|
||
|
||
/** Archives the live path mounts, in load order. */
|
||
const DATA_ARCHIVES = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
||
/** Character archive: optional, and the map itself does not need it. */
|
||
const CHARACTER_ARCHIVE = 'd2char.mpq'
|
||
/** Archive locations tried in order when `?base=` is absent. */
|
||
const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
|
||
/** Pack locations tried in order when `?pack=` is absent. */
|
||
const DEFAULT_PACKS = ['samples/d2-packs', '/diablo2/packs']
|
||
/** Act count the page offers. */
|
||
const ACT_COUNT = 5
|
||
/**
|
||
* Picks which baked variant of a generated level this world instance uses.
|
||
*
|
||
* Each generated level is baked three times and the world graph names none of
|
||
* them, so one has to be chosen at load time. It must be chosen from the level
|
||
* alone: keying it on the pair of levels, as an earlier version did, gave Cold
|
||
* Plains a different layout depending on whether you walked in from the Blood
|
||
* Moor or back from Stony Field, and left waypoints and town portals pointing
|
||
* at coordinates from a map that was no longer loaded. One variant per level
|
||
* per world is what the design calls for.
|
||
*
|
||
* A real game would derive this from the game seed; until there is one, a
|
||
* constant keeps every session identical, which is also what the verification
|
||
* scripts assume.
|
||
*/
|
||
const WORLD_VARIANT_SEED = 0x5eed_3000
|
||
/** Walk speed in scene pixels per second (cells are 80×40). */
|
||
const WALK_SPEED = 170
|
||
/** Character collision box at the feet, in scene pixels. */
|
||
const FEET_WIDTH = 20
|
||
const FEET_HEIGHT = 10
|
||
/** Atlas pages fetched at once while filling in the background. */
|
||
const PAGE_CONCURRENCY = 3
|
||
/** Placeholder marker size, in scene pixels. */
|
||
const MARKER_WIDTH = 16
|
||
const MARKER_HEIGHT = 26
|
||
/**
|
||
* Monsters placed per level, until `Levels.txt` supplies the real number.
|
||
*
|
||
* `MonDen` (density) and `NumMon` (how many kinds) are both real columns that
|
||
* #22 will read; 12 is only large enough that a walk across a level runs into
|
||
* something, which is what M6 needs to demonstrate that the level → spawn path
|
||
* works at all.
|
||
*/
|
||
const PLACEHOLDER_MONSTER_COUNT = 12
|
||
/**
|
||
* `localStorage` key holding the single save slot.
|
||
*
|
||
* Deliberately the same key the retired `map.html` sandbox used, so a save made
|
||
* there still loads here — the snapshot format never differed, only the scene
|
||
* that drove it.
|
||
*/
|
||
const SAVE_KEY = 'd2web.save'
|
||
/**
|
||
* How often the HUD text is rewritten, in milliseconds.
|
||
*
|
||
* The overlay reports averages, so refreshing it at frame rate only buys string
|
||
* churn: four `toFixed` calls and ten concatenations that the reader cannot
|
||
* follow anyway.
|
||
*/
|
||
const HUD_INTERVAL_MS = 250
|
||
|
||
/** Live scene state, also published for automated checks. */
|
||
export interface ActSceneState {
|
||
ready: boolean
|
||
/** Where the map came from: a prebaked pack or the archives. */
|
||
source: 'pack' | 'live' | ''
|
||
act: number
|
||
level: string
|
||
quadrant: string
|
||
quadrants: string[]
|
||
/** The archive/pack location actually used. */
|
||
base: string
|
||
cellsX: number
|
||
cellsY: number
|
||
widthPx: number
|
||
heightPx: number
|
||
floors: number
|
||
walls: number
|
||
frames: number
|
||
/** Atlas pages loaded and total. */
|
||
pagesLoaded: number
|
||
pagesTotal: number
|
||
/** Pages that had arrived when the first frame was drawn. */
|
||
pagesAtFirstFrame: number
|
||
/** Milliseconds from boot to the first drawn frame. */
|
||
firstFrameMs: number
|
||
/** Page indices the spawn viewport needed, for diagnostics. */
|
||
priorityPages: number[]
|
||
/** Draws skipped because their page had not arrived yet. */
|
||
skippedDraws: number
|
||
/** Objects the pack places (their art still needs the DCC/COF decoder). */
|
||
objects: number
|
||
/** Roof draws, painted in their own pass after the character. */
|
||
roofs: number
|
||
/** Whether the composited Sorceress sprite is in use (else the marker). */
|
||
character: boolean
|
||
/** DCC members that went into the character sprite. */
|
||
characterMembers: number
|
||
/** Frames the character path has actually drawn; 0 while the marker is in use. */
|
||
characterFrames: number
|
||
/** Last group the character path selected (walk or stand, times 8 directions). */
|
||
characterGroup: number
|
||
/** Milliseconds from boot to the first rendered frame. */
|
||
loadMs: number
|
||
missing: number
|
||
walkable: number
|
||
x: number
|
||
y: number
|
||
facing: number
|
||
tick: number
|
||
tickRate: number
|
||
/**
|
||
* GPU draw calls the last frame actually issued.
|
||
*
|
||
* This used to be hardcoded to 1, which hid the fact that switching atlas
|
||
* pages mid-list was costing up to 3 077 calls per frame. It now reads the
|
||
* renderer's own counter.
|
||
*/
|
||
drawCalls: number
|
||
/** Quads the last frame submitted, i.e. what survived culling. */
|
||
quadsDrawn: number
|
||
/** Draws rejected last frame because their bounds fell outside the viewport. */
|
||
culledDraws: number
|
||
/** Wall-clock milliseconds the last `onRender` took. */
|
||
renderMs: number
|
||
/** Current zoom factor and the canvas backing-store size. */
|
||
zoom: number
|
||
canvasWidth: number
|
||
canvasHeight: number
|
||
error: string | null
|
||
dialog: string[]
|
||
npcs: number
|
||
npcsNear: NpcEntity[]
|
||
}
|
||
|
||
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
|
||
/**
|
||
* `Levels.txt` `Id`, or -1 when the source cannot say.
|
||
*
|
||
* The world graph speaks in level ids; the pack speaks in labels. This is
|
||
* where the two meet.
|
||
*/
|
||
readonly levelId: number
|
||
/** The pack directory this map came from, for loading its neighbours. */
|
||
readonly entryPath: string
|
||
/** Walk-through seams out of this level, in sub-tiles. */
|
||
readonly entrances: readonly SceneEntrance[]
|
||
/** Clickable stairs and cave mouths, in sub-tiles. */
|
||
readonly warps: readonly SceneWarp[]
|
||
/** The waypoint pedestal, when this level has one. */
|
||
readonly waypoints: readonly SceneWaypoint[]
|
||
readonly quadrants: readonly string[]
|
||
/**
|
||
* Which query parameter names the currently shown map block.
|
||
*
|
||
* The pack path selects a prebaked map by its entry label (`?level=`), the live
|
||
* path selects a DS1 file inside one level (`?quadrant=`). Crossing the two
|
||
* silently falls back to the act's town, so each runtime declares its own.
|
||
*/
|
||
readonly quadrantParam: 'level' | 'quadrant'
|
||
readonly quadrant: string
|
||
readonly cellsX: number
|
||
readonly cellsY: number
|
||
readonly widthPx: number
|
||
readonly heightPx: number
|
||
readonly grid: CollisionGrid
|
||
readonly floors: readonly Drawable[]
|
||
readonly walls: readonly Drawable[]
|
||
/** Roof draws: painted after the character, so a roof covers what walks under it. */
|
||
readonly roofs: readonly Drawable[]
|
||
/** 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 PackIndexEntry[]
|
||
}
|
||
|
||
/**
|
||
* One baked map in the index.
|
||
*
|
||
* `levelId` and `kind` have been written by the packer since the first bake;
|
||
* they were simply not declared here, because until now nothing needed to find
|
||
* a map by anything other than the label in the URL. Level transitions do: an
|
||
* edge of the world graph names a destination level, not a pack label, and a
|
||
* generated level has three labels for the one id.
|
||
*/
|
||
interface PackIndexEntry {
|
||
readonly act: number
|
||
readonly levelId?: number
|
||
readonly kind?: 'preset' | 'maze' | 'wilderness'
|
||
readonly slug: string
|
||
readonly label: string
|
||
readonly levelName: string
|
||
readonly path: string
|
||
readonly pages: number
|
||
}
|
||
|
||
/** Object frame inside an object atlas page. */
|
||
interface PackObjectFrame {
|
||
readonly page: number
|
||
readonly x: number
|
||
readonly y: number
|
||
readonly width: number
|
||
readonly height: number
|
||
readonly offsetX: number
|
||
readonly offsetY: number
|
||
}
|
||
|
||
/** Object instance serialized in scene.json. */
|
||
interface PackObject {
|
||
readonly id: number
|
||
readonly type: number
|
||
readonly name?: string | null
|
||
readonly token?: string
|
||
readonly mode?: string
|
||
readonly objectsTxtId?: number
|
||
readonly hp?: number
|
||
readonly member?: string | null
|
||
readonly alternatives?: number
|
||
readonly x: number
|
||
readonly y: number
|
||
readonly depth?: number
|
||
readonly frame?: PackObjectFrame | null
|
||
}
|
||
|
||
/** A pack's per-map scene file. */
|
||
interface PackSceneJson {
|
||
readonly act: number
|
||
/** `Levels.txt` `Id`. Written by every bake; only now read. */
|
||
readonly levelId?: number
|
||
readonly levelName: string
|
||
readonly ds1: string
|
||
readonly cellsX: number
|
||
readonly cellsY: number
|
||
readonly originX: number
|
||
readonly originY: number
|
||
readonly widthPx: number
|
||
readonly heightPx: number
|
||
readonly pages: readonly { readonly file: string; readonly width: number; readonly height: number }[]
|
||
readonly objectPages?: readonly { readonly file: string; readonly width: number; readonly height: number }[]
|
||
readonly frames: readonly (readonly number[])[]
|
||
readonly framePlacement: readonly (readonly number[])[]
|
||
readonly floors: readonly (readonly number[])[]
|
||
readonly walls: readonly (readonly number[])[]
|
||
/** Roof draws, painted after everything else (absent in older packs). */
|
||
readonly roofs?: readonly (readonly number[])[]
|
||
readonly objects: readonly PackObject[]
|
||
readonly npcs?: readonly PackObject[]
|
||
/**
|
||
* The level's monsters, planned at bake time (absent in packs baked before
|
||
* the monster columns were read).
|
||
*
|
||
* Planned at bake time rather than in the browser because the browser has no
|
||
* `Levels.txt` or `MonStats.txt` on the pack path — the whole point of a pack
|
||
* is that it carries what the level needs and nothing else. The same
|
||
* `planLevelMonsters` produces both this and the live path's population, so
|
||
* the two agree by construction.
|
||
*/
|
||
readonly monsters?: {
|
||
readonly types: readonly string[]
|
||
readonly budget: number
|
||
readonly packs: readonly { readonly members: readonly MonsterStats[] }[]
|
||
}
|
||
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
|
||
readonly spawn: readonly number[] | null
|
||
/** Ways out, in sub-tiles. Absent in packs baked before world connectivity. */
|
||
readonly entrances?: readonly SceneEntrance[]
|
||
readonly warps?: readonly SceneWarp[]
|
||
readonly waypoints?: readonly SceneWaypoint[]
|
||
readonly stats: { readonly missingTiles: number; readonly walkable: number }
|
||
}
|
||
|
||
/**
|
||
* The pack index, fetched once.
|
||
*
|
||
* A level transition needs the index to find its destination, and refetching it
|
||
* mid-walk would put a network round trip inside the fade.
|
||
*/
|
||
const packIndexCache = new Map<string, Promise<PackIndex>>()
|
||
|
||
/**
|
||
* Read a pack's index, reusing the fetch.
|
||
*
|
||
* @param packBase - directory holding `index.json`.
|
||
* @returns the index, or null when there is no pack there.
|
||
*/
|
||
async function getPackIndex(packBase: string): Promise<PackIndex | null> {
|
||
let pending = packIndexCache.get(packBase)
|
||
if (pending === undefined) {
|
||
pending = fetchJson<PackIndex>(`${packBase}/index.json`)
|
||
packIndexCache.set(packBase, pending)
|
||
}
|
||
try {
|
||
return await pending
|
||
} catch {
|
||
packIndexCache.delete(packBase)
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build the runtime for a prebaked pack.
|
||
*
|
||
* @param packBase - directory holding `index.json`.
|
||
* @param act - act to open.
|
||
* @param wantedLevel - `?level=` label, when the caller picked one.
|
||
* @returns the runtime, or null when no pack covers this act.
|
||
*/
|
||
async function loadPackRuntime(
|
||
packBase: string,
|
||
act: number,
|
||
wantedLevel: string,
|
||
viewport: { width: number; height: number },
|
||
charBases: readonly string[],
|
||
): Promise<MapRuntime | null> {
|
||
const index = await getPackIndex(packBase)
|
||
if (index === null) return null
|
||
const forAct = index.levels.filter(entry => entry.act === act)
|
||
if (forAct.length === 0) return null
|
||
const entry = (wantedLevel === ''
|
||
? undefined
|
||
: forAct.find(candidate => candidate.label === wantedLevel)
|
||
?? forAct.find(candidate => candidate.slug === wantedLevel))
|
||
?? forAct.find(candidate => candidate.slug === 'town')
|
||
?? forAct[0]!
|
||
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
||
}
|
||
|
||
/**
|
||
* Build the runtime for one level, named by id rather than by label.
|
||
*
|
||
* Generated levels have three baked variants and the world graph names none of
|
||
* them, so one is picked here. Which one does not matter for connectivity: the
|
||
* act layout solve gives all three the same seams on the same edges, so any
|
||
* variant docks with any neighbour. What does matter is that the choice depends
|
||
* only on the level, so that the same level is the same map every time it is
|
||
* entered — see {@link WORLD_VARIANT_SEED}.
|
||
*
|
||
* @param packBase - directory holding `index.json`.
|
||
* @param levelId - `Levels.txt` `Id` of the wanted level.
|
||
* @param variantSeed - picks between the variants deterministically.
|
||
* @returns the runtime, or null when the pack has no such level.
|
||
*/
|
||
async function loadRuntimeForLevel(
|
||
packBase: string,
|
||
levelId: number,
|
||
variantSeed: number,
|
||
viewport: { width: number; height: number },
|
||
charBases: readonly string[],
|
||
): Promise<MapRuntime | null> {
|
||
const index = await getPackIndex(packBase)
|
||
if (index === null) return null
|
||
const candidates = index.levels.filter(entry => entry.levelId === levelId)
|
||
const entry = candidates[Math.abs(variantSeed) % Math.max(1, candidates.length)]
|
||
if (entry === undefined) return null
|
||
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
||
}
|
||
|
||
/**
|
||
* Turn one index entry into a runtime.
|
||
*
|
||
* @param packBase - directory holding `index.json`.
|
||
* @param index - the already-fetched index.
|
||
* @param entry - the map to load.
|
||
* @returns the runtime.
|
||
*/
|
||
async function buildPackRuntime(
|
||
packBase: string,
|
||
index: PackIndex,
|
||
entry: PackIndexEntry,
|
||
viewport: { width: number; height: number },
|
||
charBases: readonly string[],
|
||
): Promise<MapRuntime> {
|
||
const forAct = index.levels.filter(candidate => candidate.act === entry.act)
|
||
const scene = await fetchJson<PackSceneJson>(`${packBase}/${entry.path}/scene.json`)
|
||
|
||
const frames: AtlasFrame[] = scene.framePlacement.map(place => ({
|
||
x: place[1] ?? 0, y: place[2] ?? 0, width: place[3] ?? 1, height: place[4] ?? 1,
|
||
}))
|
||
const toDrawable = (row: readonly number[]): Drawable => {
|
||
const frameIndex = row[0] ?? 0
|
||
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,
|
||
levelId: scene.levelId ?? entry.levelId ?? -1,
|
||
entryPath: entry.path,
|
||
entrances: scene.entrances ?? [],
|
||
warps: scene.warps ?? [],
|
||
waypoints: scene.waypoints ?? [],
|
||
quadrants: forAct.map(candidate => candidate.label),
|
||
quadrantParam: 'level',
|
||
quadrant: entry.label,
|
||
cellsX: scene.cellsX,
|
||
cellsY: scene.cellsY,
|
||
widthPx: scene.widthPx,
|
||
heightPx: scene.heightPx,
|
||
grid,
|
||
floors,
|
||
walls,
|
||
roofs,
|
||
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,
|
||
// The live path decodes one DS1 straight out of the archives. Nothing has
|
||
// solved the world layout for it, so it offers no way out; level
|
||
// transitions are a pack-path feature.
|
||
levelId: -1,
|
||
entryPath: '',
|
||
entrances: [],
|
||
warps: [],
|
||
waypoints: [],
|
||
quadrants,
|
||
quadrantParam: 'quadrant',
|
||
quadrant: quadrants[index] ?? '',
|
||
cellsX: scene.cellsX,
|
||
cellsY: scene.cellsY,
|
||
widthPx: scene.widthPx,
|
||
heightPx: scene.heightPx,
|
||
grid: scene,
|
||
floors: 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 initialRuntime - the map to start in. The binding inside is mutable:
|
||
* a level transition swaps the map under the running loop rather than
|
||
* rebuilding the scene, because rebuilding would take the character's bag,
|
||
* quests and experience with it.
|
||
* @param renderer - the renderer (already holding the atlas or the first pages).
|
||
* @param started - `performance.now()` when boot began.
|
||
*/
|
||
function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: number): void {
|
||
let runtime = initialRuntime
|
||
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
|
||
const status = document.querySelector<HTMLElement>('#status')!
|
||
const hud = document.querySelector<HTMLElement>('#hud')!
|
||
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
|
||
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
|
||
|
||
const input = new KeyboardInput()
|
||
input.attach()
|
||
let skippedDraws = 0
|
||
|
||
let character: LoadedCharacter | null = null
|
||
let monsterArtMap = new Map<string, LoadedMonsterArt>()
|
||
let monsterWalkFrame = 0
|
||
const camera = new ViewportCamera(canvas, runtime.widthPx, runtime.heightPx)
|
||
camera.attach()
|
||
|
||
// Culling bounds, 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 safeZones: SafeZone[] = [
|
||
{ x: runtime.spawn.x, y: runtime.spawn.y, radius: 180 },
|
||
]
|
||
for (const wp of runtime.waypoints) {
|
||
const pos = subTileToScene(wp.x, wp.y, runtime.grid)
|
||
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
|
||
}
|
||
for (const ent of runtime.entrances) {
|
||
const pos = subTileToScene(ent.x, ent.y, runtime.grid)
|
||
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
|
||
}
|
||
const resolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => {
|
||
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
|
||
return {
|
||
...pack,
|
||
fixedPosition: cellCentre(runtime.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY),
|
||
}
|
||
}
|
||
return pack
|
||
})
|
||
const engine = new GameEngine(
|
||
createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, {
|
||
width: FEET_WIDTH,
|
||
height: FEET_HEIGHT,
|
||
}),
|
||
{
|
||
spawn: runtime.spawn,
|
||
stats: runtime.monsterStats,
|
||
xpTable: DEMO_EXPERIENCE,
|
||
itemBases: DEMO_BASES,
|
||
prefixAffixes: DEMO_AFFIXES,
|
||
suffixAffixes: DEMO_AFFIXES,
|
||
skills: DEMO_SKILLS,
|
||
npcDefs,
|
||
questDefs: DEMO_QUESTS,
|
||
combatOptions: {
|
||
playerSpeed: WALK_SPEED,
|
||
playerReach: 52,
|
||
playerCooldownTicks: 12,
|
||
playerDamage: 7,
|
||
playerManaPerAttack: 2,
|
||
respawnTicks: 75,
|
||
disableMonsterAggro: true,
|
||
},
|
||
lootSeed: 0x5eed,
|
||
talkRadius: 80,
|
||
pickupRadius: 64,
|
||
inventoryCols: 10,
|
||
inventoryRows: 4,
|
||
monsterCount: runtime.monsterCount,
|
||
monsterPacks: resolvedPacks,
|
||
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
|
||
safeZones,
|
||
// This scene knows where its NPCs stand — the DS1 carries their
|
||
// coordinates — so the engine must not build a ring we would discard.
|
||
npcRingFallback: false,
|
||
// The engine decides when to save; the browser only says where. Reads are
|
||
// guarded because private-browsing windows throw on access, not on use.
|
||
snapshotStore: {
|
||
read: () => localStorage.getItem(SAVE_KEY),
|
||
write: (data: string) => { localStorage.setItem(SAVE_KEY, data) },
|
||
},
|
||
}
|
||
)
|
||
|
||
// `npcRingFallback: false` above means the engine left this empty, so we are
|
||
// filling it rather than replacing a synthetic ring.
|
||
for (const n of runtime.npcs) {
|
||
const def = npcDefs.find(d => d.id === `npc-${String(n.id)}`)!
|
||
// Same predicate the sprite layer gated on above, so the box and the art
|
||
// can never both be drawn for one NPC.
|
||
engine.npcEntities.push({ def, x: n.x, y: n.y, hasSprite: hasPackedSprite(n) })
|
||
}
|
||
|
||
/* ----------------------------------------------------------------------- *
|
||
* World connectivity
|
||
* ----------------------------------------------------------------------- */
|
||
|
||
const fade = new FadeOverlay(document.body)
|
||
const minimap = new Minimap()
|
||
const waypointNetwork = new WaypointNetwork()
|
||
const portalSlot = new TownPortalSlot()
|
||
/** True while a transition is in flight, so nothing triggers a second one. */
|
||
let travelling = false
|
||
|
||
/**
|
||
* Tell the waypoint network about whatever level is loaded.
|
||
*
|
||
* @param map - the runtime just loaded.
|
||
*/
|
||
const registerWaypoints = (map: MapRuntime): void => {
|
||
for (const waypoint of map.waypoints) {
|
||
waypointNetwork.register({
|
||
waypointId: waypoint.waypointId,
|
||
levelId: map.levelId,
|
||
act: map.act,
|
||
name: map.level,
|
||
x: waypoint.arriveX,
|
||
y: waypoint.arriveY,
|
||
})
|
||
}
|
||
}
|
||
registerWaypoints(runtime)
|
||
|
||
/**
|
||
* Where the player is, in sub-tiles.
|
||
*
|
||
* The inverse of the isometric projection: scene pixels back to the grid the
|
||
* links are expressed in.
|
||
*
|
||
* @returns the player's sub-tile.
|
||
*/
|
||
const playerSubTile = (): { x: number; y: number } => {
|
||
const dx = (engine.world.player.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
|
||
const dy = (engine.world.player.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
||
return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) }
|
||
}
|
||
|
||
/**
|
||
* Replace the level under the running loop.
|
||
*
|
||
* Everything about the character survives: `engine.world.player` is the same
|
||
* object before and after, so the bag, the quest flags and the experience go
|
||
* with it. Only the terrain, the collision closure and the crowd change.
|
||
*
|
||
* @param next - the level to move into.
|
||
* @param arrive - where to put the player, in sub-tiles.
|
||
*/
|
||
const swapLevel = (next: MapRuntime, arrive: { x: number; y: number }): void => {
|
||
// Give back the outgoing level's atlases before the new ones go up, or a
|
||
// long walk leaks a page set per level crossed.
|
||
for (const page of runtime.pages) if (page !== null) renderer.deleteAtlas(page)
|
||
for (const page of runtime.objectPages) if (page !== null) renderer.deleteAtlas(page)
|
||
|
||
runtime = next
|
||
camera.mapWidthPx = next.widthPx
|
||
camera.mapHeightPx = next.heightPx
|
||
engine.terrain = createIsoTerrain(next.grid, next.widthPx, next.heightPx, {
|
||
width: FEET_WIDTH,
|
||
height: FEET_HEIGHT,
|
||
})
|
||
|
||
const landing = subTileToScene(arrive.x, arrive.y, next.grid)
|
||
engine.world.player.x = landing.x
|
||
engine.world.player.y = landing.y
|
||
|
||
const defs = next.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? ''))
|
||
engine.npcEntities.length = 0
|
||
for (const n of next.npcs) {
|
||
const def = defs.find(d => d.id === `npc-${String(n.id)}`)
|
||
if (def === undefined) continue
|
||
engine.npcEntities.push({ def, x: n.x, y: n.y, hasSprite: hasPackedSprite(n) })
|
||
}
|
||
|
||
registerWaypoints(next)
|
||
state.level = next.level
|
||
state.act = next.act
|
||
state.cellsX = next.cellsX
|
||
state.cellsY = next.cellsY
|
||
state.pagesTotal = next.pages.length
|
||
state.pagesLoaded = 0
|
||
}
|
||
|
||
/**
|
||
* Walk the player through a link.
|
||
*
|
||
* The destination decides where the player lands, not the source: level B
|
||
* knows where its own opening back to A is, and using it means the two ends
|
||
* of a seam can never disagree.
|
||
*
|
||
* @param toLevelId - the level to move to.
|
||
* @param fadeMs - how long to black out for.
|
||
* @param override - an explicit landing sub-tile, for waypoints and portals.
|
||
*/
|
||
const travel = async (
|
||
toLevelId: number,
|
||
fadeMs: number,
|
||
override?: { x: number; y: number },
|
||
): Promise<void> => {
|
||
if (travelling || runtime.source !== 'pack') return
|
||
travelling = true
|
||
const fromLevelId = runtime.levelId
|
||
try {
|
||
await fade.out(fadeMs)
|
||
const next = await loadRuntimeForLevel(
|
||
runtime.base,
|
||
toLevelId,
|
||
// Per level, not per pair: see WORLD_VARIANT_SEED.
|
||
WORLD_VARIANT_SEED + toLevelId,
|
||
{ width: canvas.width, height: canvas.height },
|
||
runtime.charBases,
|
||
)
|
||
if (next === null) {
|
||
status.textContent = `资源包里没有关卡 ${String(toLevelId)}。`
|
||
return
|
||
}
|
||
await next.loadPages(renderer, next.priorityPages, () => {
|
||
state.pagesLoaded = next.pages.filter(page => page !== null).length
|
||
})
|
||
// The destination's own opening back to where we came from is the landing
|
||
// spot; an override wins, because a waypoint or a portal lands somewhere
|
||
// that no edge describes.
|
||
const back = next.entrances.find(entrance => entrance.toLevelId === fromLevelId)
|
||
?? next.warps.find(warp => warp.toLevelId === fromLevelId)
|
||
const landing = override
|
||
?? (back === undefined
|
||
? {
|
||
x: Math.floor(next.grid.cellsX * SUB_TILES_PER_TILE / 2),
|
||
y: Math.floor(next.grid.cellsY * SUB_TILES_PER_TILE / 2),
|
||
}
|
||
: { x: back.arriveX, y: back.arriveY })
|
||
swapLevel(next, landing)
|
||
preloadRemaining(next, renderer)
|
||
} catch (err) {
|
||
status.textContent = `切换关卡失败:${(err as Error).message}`
|
||
} finally {
|
||
await fade.in(fadeMs)
|
||
travelling = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Act on whatever the player is standing on.
|
||
*
|
||
* Seams fire on contact — the player walked off the edge of the map, and
|
||
* asking them to press a key for that would be strange. Warps, waypoints and
|
||
* portals need the use key, because they sit in the middle of walkable ground
|
||
* and stepping on one by accident is normal.
|
||
*/
|
||
const checkLinks = (): void => {
|
||
if (travelling || runtime.source !== 'pack') return
|
||
const here = playerSubTile()
|
||
const near = (x: number, y: number, radius: number): boolean =>
|
||
Math.abs(here.x - x) <= radius && Math.abs(here.y - y) <= radius
|
||
|
||
for (const entrance of runtime.entrances) {
|
||
if (!near(entrance.x, entrance.y, SEAM_TRIGGER_SUBTILES)) continue
|
||
void travel(entrance.toLevelId, SEAM_FADE_MS)
|
||
return
|
||
}
|
||
|
||
const wantsUse = input.takeUse()
|
||
if (wantsUse) {
|
||
const portalTarget = portalSlot.otherEnd(runtime.levelId)
|
||
for (const warp of runtime.warps) {
|
||
if (!near(warp.x, warp.y, WARP_TRIGGER_SUBTILES)) continue
|
||
void travel(warp.toLevelId, WARP_FADE_MS)
|
||
return
|
||
}
|
||
if (portalTarget !== null) {
|
||
void travel(portalTarget.levelId, WARP_FADE_MS, { x: portalTarget.x, y: portalTarget.y })
|
||
return
|
||
}
|
||
}
|
||
|
||
for (const waypoint of runtime.waypoints) {
|
||
if (!near(waypoint.x, waypoint.y, WARP_TRIGGER_SUBTILES)) continue
|
||
if (waypointNetwork.activate(waypoint.waypointId)) {
|
||
status.textContent = `传送点已激活:${runtime.level}`
|
||
}
|
||
if (input.takeWaypoint()) {
|
||
// No destination picker yet, so the waypoint does the one thing a
|
||
// player always wants from it: go back to town.
|
||
const town = waypointNetwork.destinations()
|
||
.find(site => site.levelId === townLevelForAct(runtime.act))
|
||
if (town !== undefined && town.levelId !== runtime.levelId) {
|
||
void travel(town.levelId, WARP_FADE_MS, { x: town.x, y: town.y })
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
if (input.takePortal()) {
|
||
const townLevelId = townLevelForAct(runtime.act)
|
||
if (runtime.levelId !== townLevelId) {
|
||
const townSite = waypointNetwork.destinations().find(site => site.levelId === townLevelId)
|
||
portalSlot.cast({
|
||
fromLevelId: runtime.levelId,
|
||
fromX: here.x,
|
||
fromY: here.y,
|
||
townLevelId,
|
||
townX: townSite?.x ?? here.x,
|
||
townY: townSite?.y ?? here.y,
|
||
})
|
||
status.textContent = `已打开回城门(${runtime.level})。走到原地按 G 返回。`
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The automap's view of the level under the player.
|
||
*
|
||
* Rebuilt only when the level changes: the marker list is a copy of three
|
||
* arrays, and the render loop asks for this every frame.
|
||
*/
|
||
let minimapCache: { key: string; level: MinimapLevel } | null = null
|
||
const minimapLevel = (): MinimapLevel => {
|
||
// Live runtimes have no entry path, so fall back to the level name; either
|
||
// way the key only has to change when the level does.
|
||
const key = runtime.entryPath === '' ? `live:${runtime.level}` : runtime.entryPath
|
||
if (minimapCache !== null && minimapCache.key === key) return minimapCache.level
|
||
const marker = (x: number, y: number, kind: MinimapMarker['kind']): MinimapMarker => ({
|
||
cellX: Math.floor(x / SUB_TILES_PER_TILE),
|
||
cellY: Math.floor(y / SUB_TILES_PER_TILE),
|
||
kind,
|
||
})
|
||
const level: MinimapLevel = {
|
||
key,
|
||
cellsX: runtime.grid.cellsX,
|
||
cellsY: runtime.grid.cellsY,
|
||
blocked: runtime.grid.blocked,
|
||
gridWidth: runtime.grid.gridWidth,
|
||
markers: [
|
||
...runtime.entrances.map(entrance => marker(entrance.x, entrance.y, 'exit')),
|
||
...runtime.warps.map(warp => marker(warp.x, warp.y, 'exit')),
|
||
...runtime.waypoints.map(waypoint => marker(waypoint.x, waypoint.y, 'waypoint')),
|
||
],
|
||
}
|
||
minimapCache = { key, level }
|
||
return level
|
||
}
|
||
|
||
const loop = new GameLoop({
|
||
tickRate: 25,
|
||
onTick: () => {
|
||
engine.tick({
|
||
movement: input.movement(),
|
||
attacking: input.attacking,
|
||
pickingUp: input.pickingUp,
|
||
talking: input.talking,
|
||
digits: input.digits,
|
||
saving: input.saving,
|
||
loading: input.loading,
|
||
})
|
||
|
||
const player = engine.world.player
|
||
const movement = input.movement()
|
||
// Assign, do not redeclare: `onRender` reads the outer `playerMoving` to
|
||
// pick the walk animation, and a shadowing `let` here left it stuck false.
|
||
playerMoving = movement.x !== 0 || movement.y !== 0
|
||
if (playerMoving) {
|
||
playerWalkFrame = (playerWalkFrame + 1) % 24
|
||
} else {
|
||
playerWalkFrame = 0
|
||
}
|
||
monsterWalkFrame = (monsterWalkFrame + 1) % 120
|
||
|
||
state.tick = engine.world.tick
|
||
state.x = player.x
|
||
state.y = player.y
|
||
state.facing = player.facing
|
||
|
||
syncEngineState(engine, state)
|
||
|
||
// The automap is a view, but revealing is state: it has to advance with
|
||
// the simulation, not with however many frames the machine can draw.
|
||
if (input.takeMapToggle()) minimap.visible = !minimap.visible
|
||
checkLinks()
|
||
const walked = cellOf(runtime.grid, player.x, player.y)
|
||
minimap.reveal(minimapLevel(), walked.x, walked.y)
|
||
const dialogPanel = document.querySelector<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)
|
||
// Last in the batch, so the panel sits over every world layer.
|
||
minimap.draw(
|
||
renderer,
|
||
minimapLevel(),
|
||
cell.x,
|
||
cell.y,
|
||
{ x: engine.world.player.x, y: engine.world.player.y - 16, zoom: camera.zoom },
|
||
{ width: canvas.width, height: canvas.height },
|
||
)
|
||
renderer.flush()
|
||
|
||
// The labels are recycled rather than rebuilt: `innerHTML = ''` plus one
|
||
// `createElement` per NPC per frame forced a full style recalculation and
|
||
// made the DOM, not the GPU, the slowest part of some town frames.
|
||
if (labelsContainer !== null) {
|
||
let labelCount = 0
|
||
for (const npc of engine.npcEntities) {
|
||
const screenX = (npc.x - camX) * camera.zoom + canvas.width / 2
|
||
const screenY = (npc.y - camY) * camera.zoom + canvas.height / 2
|
||
if (screenX <= -100 || screenX >= canvas.width + 100) continue
|
||
if (screenY <= -100 || screenY >= canvas.height + 100) continue
|
||
let label = labelPool[labelCount]
|
||
if (label === undefined) {
|
||
label = document.createElement('div')
|
||
label.style.position = 'absolute'
|
||
label.style.transform = 'translate(-50%, -100%)'
|
||
label.style.color = '#c8a15a'
|
||
label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
|
||
labelPool.push(label)
|
||
labelsContainer.appendChild(label)
|
||
}
|
||
if (label.textContent !== npc.def.name) label.textContent = npc.def.name
|
||
label.style.left = `${screenX}px`
|
||
label.style.top = `${screenY - 60 * camera.zoom}px`
|
||
label.style.fontSize = `${Math.max(10, 14 * camera.zoom)}px`
|
||
label.hidden = false
|
||
labelCount += 1
|
||
}
|
||
for (let index = labelCount; index < labelPool.length; index += 1) labelPool[index]!.hidden = true
|
||
}
|
||
|
||
state.drawCalls = renderer.drawCalls
|
||
state.quadsDrawn = renderer.quadsSubmitted
|
||
state.culledDraws = culledDraws
|
||
state.tickRate = loop.tickRate
|
||
state.zoom = Number(camera.zoom.toFixed(2))
|
||
state.canvasWidth = canvas.width
|
||
state.canvasHeight = canvas.height
|
||
state.pagesLoaded = countPages()
|
||
state.skippedDraws = skippedDraws
|
||
state.renderMs = performance.now() - renderStarted
|
||
|
||
// The HUD is a single text node, but rebuilding it means ten string
|
||
// concatenations and four `toFixed` calls; at 4 Hz that is invisible to the
|
||
// reader and stops the overlay from competing with the frame it describes.
|
||
if (renderStarted >= hudNextMs) {
|
||
hudNextMs = renderStarted + HUD_INTERVAL_MS
|
||
hud.textContent = `${runtime.level} · ${runtime.quadrant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
|
||
+ ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙`
|
||
+ ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
|
||
+ ` · 对象 ${String(runtime.objects)} · ${loop.tickRate.toFixed(1)} tps · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
|
||
+ ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})· ${state.renderMs.toFixed(1)} ms`
|
||
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
|
||
}
|
||
},
|
||
})
|
||
loop.start()
|
||
state.ready = true
|
||
state.loadMs = Math.round(performance.now() - started)
|
||
// Only the live path fills the selectors here: the pack path already built them from
|
||
// the index (`populatePackSelectors`), and overwriting them with the single level this
|
||
// runtime knows would collapse the three-level hierarchy back into one flat list.
|
||
if (runtime.source === 'live') populateLiveSelectors(runtime)
|
||
void variantSelect
|
||
status.textContent = 'WASD / 方向键移动。'
|
||
+ (runtime.source === 'pack'
|
||
? `资源包:首屏 ${String(state.pagesLoaded)}/${String(state.pagesTotal)} 页,${String(state.loadMs)} ms 出画面,其余页后台补。`
|
||
: '本次直接读归档并解码(较慢):资源包不可用时会走这条路。')
|
||
+ (state.character
|
||
? ` 角色为真·女法师(DCC+COF 合成,${String(state.characterMembers)} 层)。`
|
||
: ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。')
|
||
+ (runtime.objectDrawables.length > 0
|
||
? ` 对象层已绘制(火把/箱子/传送点等 ${String(runtime.objectDrawables.length)} 处)。`
|
||
: ' 可破坏物品的位置与美术成员已进包,静态帧待接。')
|
||
|
||
preloadRemaining(runtime, renderer)
|
||
if (runtime.palette !== null) {
|
||
renderer.setPalette(runtime.palette.rgb)
|
||
const paletteForEntities = runtime.palette
|
||
const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
|
||
const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
|
||
// Collect monster types to load
|
||
const activeMonsterIds = runtime.monsterTypes.length > 0
|
||
? runtime.monsterTypes
|
||
: (runtime.monsterCount > 0 ? runtime.monsterStats.map(s => s.id) : [])
|
||
|
||
if (activeMonsterIds.length > 0) {
|
||
void loadMonsterArtMap(
|
||
DEFAULT_BASES,
|
||
activeMonsterIds,
|
||
paletteForEntities,
|
||
renderer,
|
||
packEntityBase,
|
||
packEntityAct,
|
||
).then((loadedMap) => {
|
||
monsterArtMap = loadedMap
|
||
})
|
||
}
|
||
|
||
void loadCharacterArt(
|
||
runtime.charBases,
|
||
paletteForEntities,
|
||
renderer,
|
||
packEntityBase,
|
||
packEntityAct,
|
||
).then((loaded) => {
|
||
character = loaded
|
||
state.character = loaded !== null
|
||
if (loaded !== null) state.characterMembers = loaded.members
|
||
// The status line is written before this promise settles, so refresh it: leaving
|
||
// "占位棋子" on screen while the real sprite is already drawing is a lie the user
|
||
// actually sees (it showed up in a screenshot review).
|
||
status.textContent = status.textContent.replace(
|
||
loaded === null
|
||
? ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。'
|
||
: / 角色[^。]*。/u,
|
||
loaded === null
|
||
? ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。'
|
||
: ` 角色为真·女法师(${runtime.source === 'pack' ? '预烘焙图集' : 'DCC+COF 合成'},${String(loaded.members)} 层)。`,
|
||
)
|
||
})
|
||
}
|
||
|
||
window.addEventListener('beforeunload', () => {
|
||
loop.stop()
|
||
input.detach()
|
||
if (character?.handle) renderer.deleteAtlas(character.handle)
|
||
for (const art of monsterArtMap.values()) {
|
||
renderer.deleteAtlas(art.handle)
|
||
}
|
||
for (const page of runtime.pages) {
|
||
if (page !== null) renderer.deleteAtlas(page)
|
||
}
|
||
for (const page of runtime.objectPages) {
|
||
if (page !== null) renderer.deleteAtlas(page)
|
||
}
|
||
renderer.dispose()
|
||
})
|
||
}
|
||
|
||
|
||
/**
|
||
* The cell containing a scene point.
|
||
*
|
||
* @param grid - the collision grid.
|
||
* @param x - scene x.
|
||
* @param y - scene y.
|
||
* @returns rounded-down cell coordinates.
|
||
*/
|
||
function cellOf(grid: CollisionGrid, x: number, y: number): { x: number; y: number } {
|
||
const px = x - grid.originX
|
||
const py = y - grid.originY
|
||
return {
|
||
x: Math.floor((px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2),
|
||
y: Math.floor((py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The facing index for a screen-space direction.
|
||
*
|
||
* @param x - screen x direction, -1..1.
|
||
* @param y - screen y direction, -1..1 (down positive).
|
||
* @returns the facing index, 0 = south, turning clockwise (west).
|
||
*/
|
||
function facingOf(x: number, y: number): number {
|
||
const angle = Math.atan2(-x, y)
|
||
return Math.round((angle / (Math.PI / 4)) + 8) % 8
|
||
}
|
||
|
||
/**
|
||
* Copy a runtime's description into the published state.
|
||
*
|
||
* @param runtime - the runtime.
|
||
* @param base - location the data came from.
|
||
*/
|
||
function publish(runtime: MapRuntime, base: string): void {
|
||
state.source = runtime.source
|
||
state.base = base
|
||
state.act = runtime.act
|
||
state.level = runtime.level
|
||
state.quadrant = runtime.quadrant
|
||
state.quadrants = [...runtime.quadrants]
|
||
state.cellsX = runtime.cellsX
|
||
state.cellsY = runtime.cellsY
|
||
state.widthPx = runtime.widthPx
|
||
state.heightPx = runtime.heightPx
|
||
state.floors = runtime.floors.length
|
||
state.walls = runtime.walls.length
|
||
state.frames = runtime.frames
|
||
state.pagesTotal = runtime.pages.length
|
||
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
|
||
state.priorityPages = [...runtime.priorityPages]
|
||
state.objects = runtime.objects
|
||
state.roofs = runtime.roofs.length
|
||
state.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()
|