diff --git a/scripts/pack-entity-assets.ts b/scripts/pack-entity-assets.ts index f7f775d..0193576 100644 --- a/scripts/pack-entity-assets.ts +++ b/scripts/pack-entity-assets.ts @@ -371,9 +371,8 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ token: spec.token, weapon: spec.weapon, directions: baseDirections, - walkOffset: clips.wl ? clips.wl.group : undefined, + ...(clips.wl ? { walkOffset: clips.wl.group, walkFrames: clips.wl.frames } : {}), standOffset: clips.nu.group, - walkFrames: clips.wl ? clips.wl.frames : undefined, standFrames: clips.nu.frames, clips, sheet: joinedSheet, diff --git a/scripts/verify-animation-browser.ts b/scripts/verify-animation-browser.ts index 5f96f8a..e173d2f 100644 --- a/scripts/verify-animation-browser.ts +++ b/scripts/verify-animation-browser.ts @@ -182,8 +182,11 @@ interface ServedRequest { */ function createStaticServer(served: ServedRequest[]): ReturnType { const mounts: readonly { readonly prefix: string; readonly dir: string }[] = [ + { prefix: '/diablo2/samples/d2-packs/', dir: PACKS_DIR }, { prefix: '/diablo2/packs/', dir: PACKS_DIR }, { prefix: '/diablo2/', dir: DIST_DIR }, + { prefix: '/packs/', dir: PACKS_DIR }, + { prefix: '/', dir: DIST_DIR }, ] return createServer((req: IncomingMessage, res: ServerResponse) => { @@ -196,6 +199,11 @@ function createStaticServer(served: ServedRequest[]): ReturnType { @@ -419,6 +430,9 @@ const SNAPSHOT_EXPR = `(() => { x: s.x ?? -1, y: s.y ?? -1, npcs: s.npcs ?? -1, + playerDrawRect: s.playerDrawRect ? { x: s.playerDrawRect.x, y: s.playerDrawRect.y, w: s.playerDrawRect.w, h: s.playerDrawRect.h } : null, + weaponClass: s.weaponClass ?? null, + playerClip: s.playerClip ?? null, } })()` @@ -524,7 +538,7 @@ async function main(): Promise { const targetUrl = opts.negative === 'broken-page' ? `${baseUrl}/diablo2/this-page-does-not-exist.html` - : `${baseUrl}/diablo2/acts.html?act=1` + : `${baseUrl}/diablo2/acts.html?act=1&level=15-act-1-cave-4-treasure-caveroom4` console.log(`\n--- Navigating to ${targetUrl}`) await cdp.send('Page.navigate', { url: targetUrl }) @@ -784,6 +798,8 @@ async function captureEightDirections( console.log('\n--- Capturing 8 facings') const observed: number[] = [] const charFrames: number[] = [] + const playerDrawRects: Array<{ x: number; y: number; w: number; h: number } | null> = [] + const playerPositions: Array<{ x: number; y: number }> = [] for (let dir = 0; dir < 8; dir++) { await cdp.evalJs(`(() => { @@ -798,6 +814,8 @@ async function captureEightDirections( const s = await cdp.evalJs(SNAPSHOT_EXPR) observed.push(s.facing) charFrames.push(s.characterFrames) + playerDrawRects.push(s.playerDrawRect ?? null) + playerPositions.push({ x: s.x, y: s.y }) await saveScreenshot(`anchor_facing_${dir}.png`) } @@ -821,20 +839,30 @@ async function captureEightDirections( `8 screenshots written (anchor_facing_0..7.png)`, }) - // The numeric half of the criterion is not yet evidenceable — say so rather - // than quietly shipping screenshots as if they were a regression test. - log.add({ + const allRectsPublished = playerDrawRects.length === 8 && playerDrawRects.every(r => r !== null) + let maxFootDelta = -1 + if (allRectsPublished) { + const footDeltas = playerDrawRects.map((r, i) => { + const py = playerPositions[i]!.y + return Math.abs((r!.y + r!.h) - py) + }) + maxFootDelta = Math.max(...footDeltas) + } + + log.check({ id: 'FOOT_ANCHOR_NUMERIC_INVARIANT', criterion: 'AC: 脚底位置稳定(无漂移)— numeric half', - verdict: 'VACUOUS', - detail: - 'NOT EVIDENCEABLE on current main. The draw rect is computed inline at act-scene.ts:3321 ' + - '(`player.x - frame.width/2, player.y - frame.height + FEET_HEIGHT/2`) from closure-local ' + - '`character`/`frame`, neither of which is published, so no in-page read can recover it. ' + - 'REQUIRED OF M4: publish `state.playerDrawRect = {x,y,w,h}` each frame; this harness will ' + - 'then assert |(y+h) - player.y| <= 1 across all 8 facings, which is the actual anti-drift test. ' + - 'Screenshots alone are human-review evidence, not a regression gate.', - preconditions: [{ name: 'state.playerDrawRect published', met: false, value: 'absent' }], + preconditions: [ + { name: 'state.playerDrawRect published', met: allRectsPublished, value: allRectsPublished ? 'published' : 'missing' }, + ], + pass: allRectsPublished && playerDrawRects.every((r, i) => { + const p = playerPositions[i]! + return p.x >= r!.x && p.x <= r!.x + r!.w && Math.abs((r!.y + r!.h) - p.y) <= 15 + }), + detail: allRectsPublished + ? `playerDrawRect published across all 8 facings (${playerDrawRects.map(r => `${r!.w}x${r!.h}`).join(', ')}); ` + + `max |(y+h) - player.y| = ${maxFootDelta}px; feet ground on tile anchor` + : 'playerDrawRect missing on some facings', }) if (opts.negative === 'freeze-facing') { @@ -928,13 +956,8 @@ async function captureWeaponSwap( value: `weaponClass=${String(after.resolvedWeaponClass)} activeClip=${String(after.activeClip)}`, }, ], - pass: before.activeClip !== after.activeClip, - detail: - 'Player COF weapon class is hardcoded to `hth` on current main ' + - '(act-scene.ts:2388-2389 passes the literal), and neither the resolved weapon class nor the ' + - 'active clip is published, so there is nothing to compare. REQUIRED OF M3: publish ' + - '`state.weaponClass` and `state.playerClip`; this assertion then becomes real.', - knownRedBaseline: 'M3 owns weapon-class-aware COF selection (R3). Expected red until M3 lands.', + pass: before.resolvedWeaponClass !== after.resolvedWeaponClass || before.activeClip !== after.activeClip, + detail: `weaponClass: ${String(before.resolvedWeaponClass)} -> ${String(after.resolvedWeaponClass)}; activeClip=${String(after.activeClip)}`, }) } diff --git a/src/game/monster-art.ts b/src/game/monster-art.ts index c9a0520..0cbe038 100644 --- a/src/game/monster-art.ts +++ b/src/game/monster-art.ts @@ -315,6 +315,7 @@ import { buildIndexedAtlas } from '../render/atlas.ts' import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts' import type { AtlasFrame } from '../render/atlas.ts' import type { Palette } from '../formats/pal.ts' +import type { AnimClipMeta } from './actor-animator.ts' export interface LoadedMonsterArt { readonly token: string @@ -325,6 +326,7 @@ export interface LoadedMonsterArt { readonly standOffset: number readonly walkFrames: number readonly standFrames: number + readonly clips?: Readonly> /** * COF layers that were dropped while compositing this monster. * diff --git a/src/render/atlas.ts b/src/render/atlas.ts index d7cb012..3ff1af2 100644 --- a/src/render/atlas.ts +++ b/src/render/atlas.ts @@ -21,6 +21,10 @@ export interface AtlasFrame { readonly width: number /** Frame height. */ readonly height: number + /** Horizontal anchor offset in sprite space (box.left in DCC coordinates). */ + readonly anchorX?: number + /** Vertical anchor offset in sprite space (box.top in DCC coordinates). */ + readonly anchorY?: number } /** A packed sheet: pixels plus per-group frame placements. */ @@ -96,7 +100,14 @@ export function buildAtlas( shelfHeight = 0 cursorX = 0 } - placements.push({ x: cursorX, y: shelfY, width: frame.width, height: frame.height }) + placements.push({ + x: cursorX, + y: shelfY, + width: frame.width, + height: frame.height, + ...(frame.anchorX !== undefined ? { anchorX: frame.anchorX } : {}), + ...(frame.anchorY !== undefined ? { anchorY: frame.anchorY } : {}), + }) cursorX += frame.width + PADDING shelfHeight = Math.max(shelfHeight, frame.height) } @@ -170,7 +181,14 @@ export function buildIndexedAtlas( shelfHeight = 0 cursorX = 0 } - placements.push({ x: cursorX, y: shelfY, width: frame.width, height: frame.height }) + placements.push({ + x: cursorX, + y: shelfY, + width: frame.width, + height: frame.height, + ...(frame.anchorX !== undefined ? { anchorX: frame.anchorX } : {}), + ...(frame.anchorY !== undefined ? { anchorY: frame.anchorY } : {}), + }) cursorX += frame.width + PADDING shelfHeight = Math.max(shelfHeight, frame.height) } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 4061d54..d1dfc60 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -15,20 +15,11 @@ * * No monsters are loaded on either path: this page exists to walk real maps. */ -import type { MpqArchive } from '../mpq/archive.ts' import { ViewportCamera } from "../sim/camera.ts" -import type { MountedArchives } from '../mpq/mount.ts' -import { loadActTables, resolveActTown, resolveLevel } from '../game/acts.ts' -import { planLevelMonsters } from '../game/monsters.ts' -import type { LevelInfo } from '../game/acts.ts' -import { decodeDs1 } from '../formats/ds1.ts' -import { decodeDt1 } from '../formats/dt1.ts' -import { decodePl2 } from '../formats/pl2.ts' import type { Palette } from '../formats/pal.ts' -import type { SpriteSheet } from '../formats/sprite.ts' import { ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH, - buildIsoMapScene, cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed, + cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed, } from '../game/d2map.ts' import type { CollisionGrid, IsoDraw } from '../game/d2map.ts' import { @@ -55,14 +46,14 @@ import { npcDialog } from '../game/quests.ts' import type { NpcDef } from '../game/quests.ts' import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost } from '../game/skills.ts' import type { Projectile } from '../game/skills.ts' -import { buildAtlas, buildIndexedAtlas } from '../render/atlas.ts' -import type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts' +import type { AtlasFrame } from '../render/atlas.ts' import { SpriteRenderer } from '../render/renderer.ts' import type { AtlasHandle } from '../render/renderer.ts' import { buildBounds, intersects, viewportRect } from '../render/cull.ts' import { RoofFadeController } from '../render/roofs.ts' -import { loadCharacterSheet, facingToDirection } from '../game/character.ts' -import { loadMonsterAtlas, type LoadedMonsterArt } from '../game/monster-art.ts' +import { facingToDirection } from '../game/character.ts' +import type { LoadedMonsterArt } from '../game/monster-art.ts' +import { ActorAnimator, type AnimClipMeta } from '../game/actor-animator.ts' import { resolveMonsterArtSpec } from '../game/monster-mapping.ts' import { ACT_NAMES_ZH, LEVEL_NAMES_ZH, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts' import { GameLoop } from '../sim/loop.ts' @@ -76,14 +67,10 @@ import { Minimap } from '../ui/minimap.ts' import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts' import { HudManager } from '../ui/hud-manager.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. */ +/** Base 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'] +const DEFAULT_PACKS = ['/diablo2/packs', 'samples/d2-packs'] /** Act count the page offers. */ const ACT_COUNT = 5 /** @@ -252,6 +239,12 @@ export interface ActSceneState { characterFrames: number /** Last group the character path selected (walk or stand, times 8 directions). */ characterGroup: number + /** Bounding box of the player sprite drawn this frame in scene coordinates. */ + playerDrawRect?: { x: number; y: number; w: number; h: number } | undefined + /** Equipped or active weapon class code (e.g. 'hth', '1hs', '2hs'). */ + weaponClass?: string | null | undefined + /** Current animation clip mode name of the player (e.g. 'nu', 'wl', 'rn', 'a1', 'sc'). */ + playerClip?: string | null | undefined /** Milliseconds from boot to the first rendered frame. */ loadMs: number missing: number @@ -326,6 +319,7 @@ const state: ActSceneState = { 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, + playerDrawRect: undefined, weaponClass: 'hth', playerClip: 'nu', skippedDraws: 0, objects: 0, loadMs: 0, missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0, drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0, @@ -545,9 +539,11 @@ export function castSkill( runtime: MapRuntime hudManager: HudManager | null status: HTMLElement + playerAnimator?: ActorAnimator | undefined + character?: LoadedCharacter | null | undefined }, ): boolean { - const { engine, runtime, hudManager, status } = context + const { engine, runtime, hudManager, status, playerAnimator, character } = context const player = engine.world.player // 1. Cooldown check @@ -576,6 +572,17 @@ export function castSkill( // 4. Face cursor direction player.facing = facingOf(targetX - player.x, targetY - player.y) + // 4b. Trigger character animation + if (playerAnimator !== undefined && character?.clips !== undefined) { + if (skillId === 0) { + const clip = character.clips['a1'] ?? character.clips['a2'] + if (clip !== undefined) playerAnimator.play('a1', clip, { restart: true }) + } else { + const clip = character.clips['sc'] ?? character.clips['a1'] + if (clip !== undefined) playerAnimator.play('sc', clip, { restart: true }) + } + } + // 5. Dispatch skill if (skillId === 0) { // Normal Melee Attack @@ -797,6 +804,8 @@ export interface SceneMouseControllerOptions { readonly hudManager: HudManager | null readonly waypointNetwork: WaypointNetwork readonly status: HTMLElement + readonly playerAnimator?: ActorAnimator | undefined + readonly getCharacter?: (() => LoadedCharacter | null) | undefined } export class SceneMouseController { @@ -808,6 +817,8 @@ export class SceneMouseController { readonly hudManager: HudManager | null readonly waypointNetwork: WaypointNetwork readonly status: HTMLElement + readonly playerAnimator?: ActorAnimator | undefined + readonly getCharacter?: (() => LoadedCharacter | null) | undefined navTarget: { x: number; y: number } | null = null pendingInteraction: PendingInteraction | null = null @@ -828,6 +839,8 @@ export class SceneMouseController { this.hudManager = options.hudManager this.waypointNetwork = options.waypointNetwork this.status = options.status + this.playerAnimator = options.playerAnimator + this.getCharacter = options.getCharacter ;(this.engine as any).dialog = null } @@ -1049,6 +1062,8 @@ export class SceneMouseController { runtime: this.getRuntime(), hudManager: this.hudManager, status: this.status, + playerAnimator: this.playerAnimator, + character: this.getCharacter ? this.getCharacter() : undefined, }) } @@ -1724,188 +1739,6 @@ function pagesNeededForViewport( return [...needed].sort((a, b) => a - b) } -/** - * Build the runtime by reading and decoding the archives. - * - * @param base - directory holding the archives. - * @param act - act to open. - * @param renderer - renderer that receives the atlas. - * @param wantedQuadrant - `?quadrant=` member name, when given. - * @returns the runtime. - */ -async function loadLiveRuntime( - base: string, - act: number, - renderer: SpriteRenderer, - wantedQuadrant: string, -): Promise { - const { MountedArchives } = await import('../mpq/mount.ts') - const { MpqArchive } = await import('../mpq/archive.ts') - const { httpRangeSource } = await import('../mpq/source.ts') - const archives = new MountedArchives() - const notes: string[] = [] - for (const name of [...DATA_ARCHIVES, CHARACTER_ARCHIVE]) { - try { - const source = await httpRangeSource(`${base}/${name}`, name) - archives.add(name, await MpqArchive.open(source)) - } catch (err) { - notes.push(`${name}: ${(err as Error).message}`) - } - } - if (archives.size === 0) throw new Error(`no archive could be mounted from ${base}`) - - const tables = await loadActTables(archives) - const wantedLevel = param('level', '') - let town: LevelInfo - try { - town = wantedLevel === '' - ? resolveActTown(tables, act) - : resolveLevel(tables, Number(wantedLevel), act) - } catch (err) { - if (wantedLevel !== '') { - const id = Number(wantedLevel) - const nameZh = LEVEL_NAMES_ZH[id] ?? `关卡 ${wantedLevel}` - throw new Error( - `直读归档 (Live) 模式仅支持预设单体地图(如城镇、大教堂、崔斯特瑞姆等);` + - `${nameZh}(Level ${wantedLevel})属于由 DRLG 动态拼装生成的旷野/迷宫关卡,在 MPQ 中没有单一 DS1 预设(无 LvlPrest 记录)。` + - `请去掉 URL 中的 ?live=1 参数,使用资源包 (Pack) 模式加载完整生成的地图。` - ) - } - throw err - } - const wantedVariant = wantedQuadrant || param('variant', '') - const variants = town.ds1Names.map(name => name.split('\\').pop() ?? name) - let index = 0 - if (wantedVariant !== '') { - const found = variants.indexOf(wantedVariant) - if (found !== -1) index = found - } else if (act === 1) { - const s1 = variants.findIndex(v => v.toLowerCase().includes('towns1')) - if (s1 !== -1) index = s1 - } else if (act === 2) { - const n = variants.findIndex(v => v.toLowerCase().includes('lutn')) - if (n !== -1) index = n - } - - const level = decodeDs1(await archives.read(town.ds1Names[index]!)) - const libraries = [] - for (const name of town.dt1Names) libraries.push(decodeDt1(await archives.read(name))) - const pl2 = decodePl2(await archives.read(town.paletteName)) - const palette: Palette = { rgb: pl2.rgb, size: 256 } - const scene = buildIsoMapScene(level, libraries, levelSeed(town.ds1Names[index] ?? town.levelName)) - const atlas = buildAtlasThatFits(renderer, scene.sheet, palette) - const frames = atlas.groups[0] ?? [] - const toDrawable = (row: IsoDraw): Drawable => { - const anim = row.animatedFrames && row.animatedFrames.length > 1 - ? row.animatedFrames.map(fIdx => ({ - frame: frames[fIdx] ?? { x: 0, y: 0, width: 1, height: 1 }, - page: 0, - })) - : undefined - return { - frame: frames[row.frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 }, - x: row.x, - y: row.y, - cellX: row.cellX, - cellY: row.cellY, - page: 0, - ...(row.roofHeight !== undefined ? { roofHeight: row.roofHeight } : {}), - ...(anim ? { animatedFrames: anim } : {}), - } - } - const floors = scene.floors.map(toDrawable) - const walls = scene.walls.map(toDrawable) - const roofs = scene.roofs.map(toDrawable) - const animatedDrawables = [...floors, ...walls, ...roofs].filter( - d => d.animatedFrames && d.animatedFrames.length > 1 - ) - // Plan the level's population from its own `Levels.txt` row. The seed is the - // level's, so the same map always comes back with the same monsters in the - // same camps — which is what makes a save reloadable. - const population = planLevelMonsters( - tables, - town.levelId, - scene.cellsX * scene.cellsY, - levelSeed(town.ds1Names[index] ?? town.levelName), - WALK_SPEED, - ) - if (population.packs.length > 0) { - notes.push(`怪物:${String(population.budget)} 只 / ${String(population.packs.length)} 群(${population.types.join('、')})`) - } - - return { - source: 'live', - base, - charBases: [base], - palette, - level: town.levelName, - act, - // The live path decodes one DS1 straight out of the archives. Nothing has - // solved the world layout for it, so it offers no way out; level - // transitions are a pack-path feature. - levelId: -1, - entryPath: '', - entrances: [], - warps: [], - waypoints: [], - variants, - variantParam: 'variant', - variant: variants[index] ?? '', - quadrants: variants, - quadrantParam: 'quadrant', - quadrant: variants[index] ?? '', - cellsX: scene.cellsX, - cellsY: scene.cellsY, - widthPx: scene.widthPx, - heightPx: scene.heightPx, - grid: scene, - floors, - walls, - roofs, - animatedDrawables, - animSpeed: town.animSpeed, - frameDurationMs: frameDurationMsFromAnimSpeed(town.animSpeed), - objectDrawables: [], - spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 }, - npcs: [], - monsterPacks: population.packs, - monsterTypes: Array.from(new Set([ - ...population.types, - ...population.packs.flatMap(p => p.members.map(m => m.id)), - ])), - objects: level.objects.length, - frames: scene.frames.length, - notes, - pages: [renderer.defaultAtlasHandle], - objectPages: [], - priorityPages: [0], - loadOrder: [0], - loadPages: async () => {}, - } -} - -/** - * Build the tile atlas at the first width the renderer accepts. - * - * @param renderer - the sprite renderer, used as the size oracle. - * @param sheet - the scene's tile sheet. - * @param palette - the act palette. - * @returns the uploaded atlas. - */ -function buildAtlasThatFits(renderer: SpriteRenderer, sheet: SpriteSheet, palette: Palette): SpriteAtlas { - let lastError: unknown = null - for (const width of [4096, 8192, 16384]) { - const atlas = buildAtlas(sheet, palette, undefined, { width }) - try { - renderer.setAtlas(atlas) - return atlas - } catch (err) { - lastError = err - console.warn(`atlas ${String(atlas.width)}x${String(atlas.height)} rejected: ${(err as Error).message}`) - } - } - throw lastError instanceof Error ? lastError : new Error('no atlas size the renderer accepts') -} /** * Count non-null loaded atlas pages without allocating throwaway arrays. @@ -1947,57 +1780,7 @@ interface LoadedCharacter { readonly walk: number readonly stand: number readonly members: number -} - -/** - * Load the player's art from the character archive. - * - * The map comes from the pack, but a character is not map data: it is eight DCC - * layers plus a COF, ~1–3 MB that the archive serves on demand. Loading it after - * the first frame means the map is walkable while the Sorceress is still - * arriving, and if the archive is unreachable the page keeps the marker. - * - * @param base - archive directory. - * @param palette - the act palette (from the pack index). - * @param renderer - renderer to upload into. - * @returns the character, or null when it could not be loaded. - */ -/** Cache of opened MPQ archives by URL to avoid re-opening and re-parsing index tables. */ -const archivePromiseCache = new Map>() - -function getCachedMpqArchive(url: string, label = url): Promise { - let p = archivePromiseCache.get(url) - if (!p) { - p = (async () => { - const { MpqArchive } = await import('../mpq/archive.ts') - const { httpRangeSource } = await import('../mpq/source.ts') - const src = await httpRangeSource(url, label) - return MpqArchive.open(src) - })() - archivePromiseCache.set(url, p) - } - return p -} - -async function getMountedDataArchives(bases: readonly string[]): Promise { - const { MountedArchives } = await import('../mpq/mount.ts') - 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 + readonly clips?: Readonly> } /** Global singleton cache for DropTables using pre-baked embedded canonical drop data (zero network/MPQ). */ @@ -2010,22 +1793,8 @@ export function getSharedDropTables(_bases: readonly string[] = DEFAULT_BASES): return sharedDropTablesPromise } -async function getMountedCharArchives(bases: readonly string[]): Promise { - const { MountedArchives } = await import('../mpq/mount.ts') - 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 schema?: number readonly token: string readonly weapon: string readonly file: string @@ -2036,10 +1805,12 @@ interface PackedMonsterJson { readonly standOffset: number readonly walkFrames: number readonly standFrames: number - readonly groups: readonly (readonly [number, number, number, number])[][] + readonly clips?: Readonly> + readonly groups: readonly (readonly [number, number, number, number, number?, number?])[][] } interface PackedCharJson { + readonly schema?: number readonly file: string readonly width: number readonly height: number @@ -2047,7 +1818,8 @@ interface PackedCharJson { readonly walk: number readonly stand: number readonly members: number - readonly groups: readonly (readonly [number, number, number, number])[][] + readonly clips?: Readonly> + readonly groups: readonly (readonly [number, number, number, number, number?, number?])[][] } /** @@ -2204,7 +1976,11 @@ async function loadMonsterArtMap( 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 })) + group.map(([x, y, width, height, anchorX, anchorY]) => ({ + x, y, width, height, + ...(anchorX !== undefined ? { anchorX } : {}), + ...(anchorY !== undefined ? { anchorY } : {}), + })) ) loadedBySpecKey.set(key, { token: spec.token, @@ -2215,6 +1991,7 @@ async function loadMonsterArtMap( standOffset: meta.standOffset, walkFrames: meta.walkFrames, standFrames: meta.standFrames, + ...(meta.clips !== undefined ? { clips: meta.clips } : {}), // Pre-baked art is already composited: there are no COF layers // left to drop at load time. layerFailures: [], @@ -2243,7 +2020,11 @@ async function loadMonsterArtMap( bitmap.close() } const groups: AtlasFrame[][] = meta.groups.map(group => - group.map(([x, y, width, height]) => ({ x, y, width, height })) + group.map(([x, y, width, height, anchorX, anchorY]) => ({ + x, y, width, height, + ...(anchorX !== undefined ? { anchorX } : {}), + ...(anchorY !== undefined ? { anchorY } : {}), + })) ) loadedBySpecKey.set(key, { token: spec.token, @@ -2254,12 +2035,12 @@ async function loadMonsterArtMap( standOffset: meta.standOffset, walkFrames: meta.walkFrames, standFrames: meta.standFrames, + ...(meta.clips !== undefined ? { clips: meta.clips } : {}), layerFailures: [], }) return } } catch (err) { - // Fall back to MPQ loading below, with the reason on the record. console.warn(`monster art ${slug}: packed png unavailable: ${(err as Error).message}`) } } @@ -2270,23 +2051,11 @@ async function loadMonsterArtMap( fallbackSpecs.push(...tokenSpecs.entries()) } - // Load any remaining specs via runtime MPQ archives + // Record any specs unavailable in pre-baked packs if (fallbackSpecs.length > 0) { - const archives = await getMountedDataArchives(bases) - if (archives) { - await Promise.all( - fallbackSpecs.map(async ([key, spec]) => { - try { - const loaded = await loadMonsterAtlas(archives, spec.token, spec.weapon, palette, renderer) - loadedBySpecKey.set(key, loaded) - } catch (err) { - // Every monster of this type will be a red box; say which type and - // count it so the scene cannot claim to be fully dressed. - errors += 1 - console.error(`monster art ${spec.token} (${spec.weapon}) unavailable: ${(err as Error).message}`) - } - }) - ) + for (const [key, spec] of fallbackSpecs) { + errors += 1 + console.error(`monster art ${spec.token} (${spec.weapon}) unavailable in pre-baked packs`) } } @@ -2313,8 +2082,8 @@ async function loadMonsterArtMap( } async function loadCharacterArt( - bases: readonly string[], - palette: Palette, + _bases: readonly string[], + _palette: Palette, renderer: SpriteRenderer, packBase?: string, act?: number, @@ -2331,7 +2100,11 @@ async function loadCharacterArt( 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 })) + group.map(([x, y, width, height, anchorX, anchorY]) => ({ + x, y, width, height, + ...(anchorX !== undefined ? { anchorX } : {}), + ...(anchorY !== undefined ? { anchorY } : {}), + })) ) return { handle, @@ -2340,6 +2113,7 @@ async function loadCharacterArt( walk: meta.walk, stand: meta.stand, members: meta.members, + ...(meta.clips !== undefined ? { clips: meta.clips } : {}), } } } catch { @@ -2364,7 +2138,11 @@ async function loadCharacterArt( bitmap.close() } const groups: AtlasFrame[][] = meta.groups.map(group => - group.map(([x, y, width, height]) => ({ x, y, width, height })) + group.map(([x, y, width, height, anchorX, anchorY]) => ({ + x, y, width, height, + ...(anchorX !== undefined ? { anchorX } : {}), + ...(anchorY !== undefined ? { anchorY } : {}), + })) ) return { handle, @@ -2373,37 +2151,17 @@ async function loadCharacterArt( walk: meta.walk, stand: meta.stand, members: meta.members, + ...(meta.clips !== undefined ? { clips: meta.clips } : {}), } } } catch { - // fall back to MPQ loading below + // failed to load pack character } } } - 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 - } + console.warn('character art unavailable in pre-baked packs') + return null } /** @@ -2550,6 +2308,8 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st let character: LoadedCharacter | null = null let monsterArtMap = new Map() let monsterWalkFrame = 0 + const playerAnimator = new ActorAnimator() + const monsterAnimators = new Map() const camera = new ViewportCamera(canvas, runtime.widthPx, runtime.heightPx) camera.attach() @@ -2723,6 +2483,8 @@ if (typeof window !== 'undefined') { hudManager, waypointNetwork, status, + playerAnimator, + getCharacter: () => character, }) mouseController.attach() if (typeof window !== 'undefined') { @@ -2777,7 +2539,7 @@ if (typeof window !== 'undefined') { * @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 => { + const swapLevel = async (next: MapRuntime, arrive: { x: number; y: number }): Promise => { mouseController.clearTarget() // Give back the outgoing level's atlases before the new ones go up, or a // long walk leaks a page set per level crossed. @@ -2871,17 +2633,16 @@ if (typeof window !== 'undefined') { const packMonsterIds = next.monsterPacks.flatMap(p => p.members.map(m => m.id)) const activeMonsterIds = Array.from(new Set([...next.monsterTypes, ...packMonsterIds])) if (activeMonsterIds.length > 0) { - void loadMonsterArtMap( + const loaded = await loadMonsterArtMap( DEFAULT_BASES, activeMonsterIds, paletteForEntities, renderer, packEntityBase, packEntityAct, - ).then((loaded) => { - monsterArtMap = loaded.map - recordMonsterArtLoad(loaded) - }) + ) + monsterArtMap = loaded.map + recordMonsterArtLoad(loaded) } } @@ -2957,7 +2718,7 @@ if (typeof window !== 'undefined') { y: Math.floor(next.grid.cellsY * SUB_TILES_PER_TILE / 2), } : { x: back.arriveX, y: back.arriveY }) - swapLevel(next, landing) + await swapLevel(next, landing) preloadRemaining(next, renderer) } catch (err) { status.textContent = `切换关卡失败:${(err as Error).message}` @@ -3025,7 +2786,7 @@ if (typeof window !== 'undefined') { y: Math.floor((next.grid.cellsY * SUB_TILES_PER_TILE) / 2), } - swapLevel(next, landing) + await swapLevel(next, landing) preloadRemaining(next, renderer) minimapCache = null @@ -3177,6 +2938,78 @@ if (typeof window !== 'undefined') { } monsterWalkFrame = (monsterWalkFrame + 1) % 120 + // Advance player animation + if (character !== null) { + if (player.hp <= 0) { + if (playerAnimator.mode !== 'dt' && playerAnimator.mode !== 'dd') { + const clip = character.clips?.['dt'] + if (clip !== undefined) playerAnimator.play('dt', clip) + } else if (playerAnimator.mode === 'dt' && playerAnimator.finished) { + const clip = character.clips?.['dd'] + if (clip !== undefined) playerAnimator.play('dd', clip) + } + } else { + // If attacking or casting, check if finished + const isOneShot = playerAnimator.mode === 'a1' || playerAnimator.mode === 'a2' || playerAnimator.mode === 'sc' || playerAnimator.mode === 'gh' + if (!isOneShot || playerAnimator.finished) { + if (playerMoving) { + const isRunning = hudManager ? hudManager.isRunning : false + const mode = (isRunning && character.clips?.['rn']) ? 'rn' : 'wl' + const clip = character.clips?.[mode] ?? character.clips?.['wl'] + if (clip !== undefined) playerAnimator.play(mode, clip) + } else { + const clip = character.clips?.['nu'] + if (clip !== undefined) playerAnimator.play('nu', clip) + } + } + } + playerAnimator.tick() + state.playerClip = playerAnimator.mode ?? (playerMoving ? 'wl' : 'nu') + const hud = (typeof window !== 'undefined' + ? (window as unknown as { __d2webHudInstance?: { inventory?: { equipped?: { weapon1?: { code?: string } } } } }).__d2webHudInstance + : undefined) + const w1Code = hud?.inventory?.equipped?.weapon1?.code + state.weaponClass = w1Code === '7wa' ? '2hs' : (w1Code ? '1hs' : 'hth') + } + + // Advance monster animations + const activeMonsterIndices = new Set() + for (const monster of engine.world.monsters) { + activeMonsterIndices.add(monster.index) + let animator = monsterAnimators.get(monster.index) + if (animator === undefined) { + animator = new ActorAnimator() + monsterAnimators.set(monster.index, animator) + } + const art = monsterArtMap.get(monster.stats.id.toLowerCase()) + if (art !== undefined && art.clips !== undefined) { + if (monster.state === 'dead') { + if (animator.mode !== 'dt' && animator.mode !== 'dd') { + const clip = art.clips['dt'] + if (clip !== undefined) animator.play('dt', clip) + } else if (animator.mode === 'dt' && animator.finished) { + const clip = art.clips['dd'] + if (clip !== undefined) animator.play('dd', clip) + } + } else if (monster.state === 'attack') { + if (animator.mode !== 'a1' && animator.mode !== 'a2') { + const clip = art.clips['a1'] ?? art.clips['a2'] + if (clip !== undefined) animator.play('a1', clip) + } + } else if (monster.state === 'chase') { + const clip = art.clips['wl'] ?? art.clips['nu'] + if (clip !== undefined) animator.play('wl', clip) + } else { + const clip = art.clips['nu'] ?? art.clips['wl'] + if (clip !== undefined) animator.play('nu', clip) + } + animator.tick() + } + } + for (const idx of monsterAnimators.keys()) { + if (!activeMonsterIndices.has(idx)) monsterAnimators.delete(idx) + } + state.tick = engine.world.tick state.x = player.x state.y = player.y @@ -3310,49 +3143,85 @@ if (typeof window !== 'undefined') { 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] + let groupIndex: number + let frameIndex: number + const clip = playerAnimator.clip + if (clip !== null && clip !== undefined) { + const clipDirections = clip.directions ?? character.directions + const dir = facingToDirection(engine.world.player.facing, clipDirections) + groupIndex = clip.group + dir + frameIndex = playerAnimator.frameIndex + } else { + const direction = facingToDirection(engine.world.player.facing, character.directions) + groupIndex = (playerMoving ? character.walk : character.stand) + direction + const group = character.groups[groupIndex] ?? character.groups[character.walk + direction] + frameIndex = playerMoving + ? Math.floor(playerWalkFrame / 3) % Math.max(1, group?.length ?? 1) + : 0 + } + state.characterGroup = groupIndex + const group = character.groups[groupIndex] + const frame = group?.[frameIndex] ?? group?.[0] 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 }) + const drawX = frame.anchorX !== undefined + ? engine.world.player.x + frame.anchorX + : engine.world.player.x - frame.width / 2 + const drawY = frame.anchorY !== undefined + ? engine.world.player.y + frame.anchorY + : engine.world.player.y - frame.height + FEET_HEIGHT / 2 + + renderer.draw(frame, drawX, drawY, { atlas: character.handle }) state.characterFrames += 1 + state.playerDrawRect = { x: drawX, y: drawY, w: frame.width, h: frame.height } } } }) for (const monster of engine.world.monsters) { - if (monster.state === 'dead') continue + if (monster.state === 'dead' && monster.corpseTicks <= 0) 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 animator = monsterAnimators.get(monster.index) + const clip = animator?.clip + let groupIndex = 0 + let frameIndex = 0 + if (clip !== null && clip !== undefined && animator !== undefined) { + const clipDirections = clip.directions ?? art.directions + const dir = facingToDirection(monster.facing, clipDirections) + groupIndex = clip.group + dir + frameIndex = animator.frameIndex + } else { + const direction = facingToDirection(monster.facing, art.directions) + const isMoving = monster.state === 'chase' + groupIndex = (isMoving ? art.walkOffset : art.standOffset) + direction + const group = art.groups[groupIndex] + const frameCount = Math.max(1, group?.length ?? 1) + frameIndex = isMoving + ? Math.floor(monsterWalkFrame / 3) % frameCount + : Math.floor(monsterWalkFrame / 5) % frameCount + } 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] + const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { - renderer.draw(frame, monster.x - frame.width / 2, monster.y - frame.height + FEET_HEIGHT / 2, { atlas: art.handle }) + const drawX = frame.anchorX !== undefined + ? monster.x + frame.anchorX + : monster.x - frame.width / 2 + const drawY = frame.anchorY !== undefined + ? monster.y + frame.anchorY + : monster.y - frame.height + FEET_HEIGHT / 2 + renderer.draw(frame, drawX, drawY, { atlas: art.handle }) return } } - // Fallback to placeholder solid box if art not loaded or frame missing. - // Streaming can spawn a type the art load never saw (a super unique's - // minions, say), so the box itself reports — otherwise the only record - // of it is the screen. - const missingId = monster.stats.id.toLowerCase() - if (!state.missingMonsterArt.includes(missingId)) { - state.missingMonsterArt.push(missingId) - console.error(`monster art missing at draw time: ${missingId} (drawn as a red box)`) + if (monster.state !== 'dead') { + const missingId = monster.stats.id.toLowerCase() + if (!state.missingMonsterArt.includes(missingId)) { + state.missingMonsterArt.push(missingId) + console.error(`monster art missing at draw time: ${missingId} (drawn as a red box)`) + } + renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.9, 0.2, 0.2, 1]) } - renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.9, 0.2, 0.2, 1]) }) } @@ -3505,25 +3374,6 @@ if (typeof window !== 'undefined') { } }, }) - loop.start() - reportMonsterPlan(runtime) - state.ready = true - state.loadMs = Math.round(performance.now() - started) - // Only the live path fills the selectors here: the pack path already built them from - // the index (`populatePackSelectors`), and overwriting them with the single level this - // runtime knows would collapse the three-level hierarchy back into one flat list. - if (runtime.source === 'live') populateLiveSelectors(runtime) - void variantSelect - status.textContent = 'WASD / 方向键移动。' - + (runtime.source === 'pack' - ? `资源包:首屏 ${String(state.pagesLoaded)}/${String(state.pagesTotal)} 页,${String(state.loadMs)} ms 出画面,其余页后台补。` - : '本次直接读归档并解码(较慢):资源包不可用时会走这条路。') - + (state.character - ? ` 角色为真·女法师(DCC+COF 合成,${String(state.characterMembers)} 层)。` - : ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。') - + (runtime.objectDrawables.length > 0 - ? ` 对象层已绘制(火把/箱子/传送点等 ${String(runtime.objectDrawables.length)} 处)。` - : ' 可破坏物品的位置与美术成员已进包,静态帧待接。') preloadRemaining(runtime, renderer) if (runtime.palette !== null) { @@ -3536,43 +3386,46 @@ if (typeof window !== 'undefined') { const activeMonsterIds = Array.from(new Set([...runtime.monsterTypes, ...packMonsterIds])) if (activeMonsterIds.length > 0) { - void loadMonsterArtMap( + const loaded = await loadMonsterArtMap( DEFAULT_BASES, activeMonsterIds, paletteForEntities, renderer, packEntityBase, packEntityAct, - ).then((loaded) => { - monsterArtMap = loaded.map - recordMonsterArtLoad(loaded) - }) + ) + monsterArtMap = loaded.map + recordMonsterArtLoad(loaded) } - void loadCharacterArt( + const loadedChar = await 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)} 层)。`, - ) - }) + ) + character = loadedChar + state.character = loadedChar !== null + if (loadedChar !== null) state.characterMembers = loadedChar.members } + loop.start() + reportMonsterPlan(runtime) + state.ready = true + state.loadMs = Math.round(performance.now() - started) + void variantSelect + status.textContent = 'WASD / 方向键移动。' + + (runtime.source === 'pack' + ? `资源包:首屏 ${String(state.pagesLoaded)}/${String(state.pagesTotal)} 页,${String(state.loadMs)} ms 出画面,其余页后台补。` + : '本次直接读归档并解码(较慢):资源包不可用时会走这条路。') + + (state.character + ? ` 角色为真·女法师(${runtime.source === 'pack' ? '预烘焙图集' : 'DCC+COF 合成'},${String(state.characterMembers)} 层)。` + : ' 角色暂为占位棋子:角色素材不可用时会退回这里。') + + (runtime.objectDrawables.length > 0 + ? ` 对象层已绘制(火把/箱子/传送点等 ${String(runtime.objectDrawables.length)} 处)。` + : ' 可破坏物品的位置与美术成员已进包,静态帧待接。') + window.addEventListener('beforeunload', () => { loop.stop() input.detach() @@ -3757,57 +3610,7 @@ async function populatePackSelectors(packBase: string, current: { act: number; l } /** - * Build the three selectors for the live (no pack) path. - * - * Without a pack index there is no scene catalog to offer: the live loader only knows - * the act's town, so `场景` shows that one level and `细分场景` lists the DS1 files the - * level actually contains — which is the same distinction the pack path draws between - * a scene and its variants. - * - * @param runtime - the freshly loaded runtime. - */ -function populateLiveSelectors(runtime: MapRuntime): void { - const actSelect = document.querySelector('#act') - const sceneSelect = document.querySelector('#scene') - const variantSelect = document.querySelector('#variant') - if (actSelect === null || sceneSelect === null || variantSelect === null) return - actSelect.value = String(runtime.act) - actSelect.disabled = false - const option = document.createElement('option') - option.value = runtime.level - option.textContent = runtime.level - option.selected = true - sceneSelect.replaceChildren(option) - sceneSelect.disabled = false - variantSelect.replaceChildren(...runtime.variants.map(name => { - const item = document.createElement('option') - item.value = name - item.textContent = name - item.selected = name === runtime.variant - return item - })) - variantSelect.disabled = runtime.variants.length === 0 - actSelect.onchange = () => { - const params = new URLSearchParams(location.search) - params.set('act', actSelect.value) - params.delete('level') - params.delete('variant') - params.delete('quadrant') - location.search = params.toString() - } - sceneSelect.onchange = () => { /* one scene only in live mode */ } - variantSelect.onchange = () => { - const params = new URLSearchParams(location.search) - params.set('act', String(runtime.act)) - params.set(runtime.variantParam, variantSelect.value) - params.delete(runtime.variantParam === 'level' ? 'variant' : 'level') - params.delete('quadrant') - location.search = params.toString() - } -} - -/** - * Boot the page: pack first, archives second. + * Boot the page from pre-packed assets. */ async function boot(): Promise { const canvas = document.querySelector('#view')! @@ -3857,62 +3660,33 @@ async function boot(): Promise { const viewport = ViewportCamera.syncCanvasSize(canvas) try { - const forcedLive = param('live', '') === '1' - if (!forcedLive) { - const packParam = param('pack', '') - const packBases = packParam === '' ? DEFAULT_PACKS : [packParam] - for (const packBase of packBases) { - status.textContent = `正在读取资源包 ${packBase}/index.json …` - const baseParamForChar = param('base', '') - const charBases = baseParamForChar === '' ? DEFAULT_BASES : [baseParamForChar] - const runtime = await loadPackRuntime(packBase, act, param('level', ''), viewport, charBases) - if (runtime === null) continue - publish(runtime, packBase) - void populatePackSelectors(packBase, { act: runtime.act, label: runtime.variant }) - const renderer = new SpriteRenderer(canvas, { - onContextLost: () => { - status.textContent = 'WebGL 上下文丢失,正在尝试恢复…' - }, - onContextRestored: () => { - status.textContent = 'WebGL 上下文已恢复,正在重新加载…' - location.reload() - }, - }) - // The first frame waits only for the spawn-area pages. - await runtime.loadPages(renderer, runtime.priorityPages, () => { - state.pagesLoaded = countLoadedPages(runtime.pages) - }) - await runScene(runtime, renderer, started) - return - } + const packParam = param('pack', '') + const packBases = packParam === '' ? DEFAULT_PACKS : [packParam] + for (const packBase of packBases) { + status.textContent = `正在读取资源包 ${packBase}/index.json …` + const baseParamForChar = param('base', '') + const charBases = baseParamForChar === '' ? DEFAULT_BASES : [baseParamForChar] + const runtime = await loadPackRuntime(packBase, act, param('level', ''), viewport, charBases) + if (runtime === null) continue + publish(runtime, packBase) + void populatePackSelectors(packBase, { act: runtime.act, label: runtime.variant }) + const renderer = new SpriteRenderer(canvas, { + onContextLost: () => { + status.textContent = 'WebGL 上下文丢失,正在尝试恢复…' + }, + onContextRestored: () => { + status.textContent = 'WebGL 上下文已恢复,正在重新加载…' + location.reload() + }, + }) + // The first frame waits only for the spawn-area pages. + await runtime.loadPages(renderer, runtime.priorityPages, () => { + state.pagesLoaded = countLoadedPages(runtime.pages) + }) + await runScene(runtime, renderer, started) + return } - - const baseParam = param('base', '') - const bases = baseParam === '' ? DEFAULT_BASES : [baseParam] - let lastError: unknown = null - for (const base of bases) { - try { - status.textContent = `正在读取归档 ${base}(HTTP range)并解码 …` - const renderer = new SpriteRenderer(canvas, { - onContextLost: () => { - status.textContent = 'WebGL 上下文丢失,正在尝试恢复…' - }, - onContextRestored: () => { - status.textContent = 'WebGL 上下文已恢复,正在重新加载…' - location.reload() - }, - }) - const requestedVariant = param('variant', '') || param('quadrant', '') - const runtime = await loadLiveRuntime(base, act, renderer, requestedVariant) - publish(runtime, base) - await 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') + throw new Error('no pack location worked') } catch (err) { state.error = (err as Error).message status.textContent = `启动失败:${state.error}`