perf(scene): 解决首屏阻塞式 166 次 MPQ Range 请求导致的地图载入卡顿 (Issue #117)

- 将 DropTables 移出首屏临界路径,消除 runScene 启动时 166 次 MPQ HTTP Range 阻塞
- 引入 getSharedDropTables 全局单例记忆化缓存,避免跨地图切关卡时重复挂载与重读 MPQ
- 在 boot() 初始阶段提早非阻塞式预热,并在 GameEngine 创建后异步注入 dropTables
- 在 GameEngine 中添加 setDropTables 方法支持动态掉落表注入
- 新增 tests/scene-drop-preload.test.ts 覆盖单例缓存与延迟注入行为验证

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-19 03:03:28 +00:00
parent 97787ff99c
commit 79bf8f4cda
3 changed files with 115 additions and 8 deletions

View File

@ -244,6 +244,14 @@ export class GameEngine {
}
}
/**
* Dynamically assign or update drop tables (e.g. when loaded asynchronously in the background).
*/
setDropTables(dropTables: DropTables): void {
this.opts.dropTables = dropTables
this.opts.monsterKinds = dropTables.monsterKinds
}
tick(input: EngineInput): void {
const movement = input.movement
const player = this.world.player

View File

@ -49,7 +49,7 @@ import { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts'
import { GameEngine, syncEngineState } from "../game/engine.ts"
import type { NpcEntity } from "../game/engine.ts"
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts"
import { loadDropTables } from '../game/drop-pipeline.ts'
import { loadDropTables, type DropTables } from '../game/drop-pipeline.ts'
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
import type { NpcDef } from '../game/quests.ts'
import { buildAtlas, buildIndexedAtlas } from '../render/atlas.ts'
@ -1149,6 +1149,25 @@ async function getMountedDataArchives(bases: readonly string[]): Promise<Mounted
return null
}
/** Global singleton cache for DropTables so MPQ data tables are parsed only once per session. */
let sharedDropTablesPromise: Promise<DropTables | null> | null = null
export function getSharedDropTables(bases: readonly string[] = DEFAULT_BASES): Promise<DropTables | null> {
if (!sharedDropTablesPromise) {
sharedDropTablesPromise = (async () => {
try {
const archives = await getMountedDataArchives(bases)
if (!archives) return null
return await loadDropTables(archives)
} catch (err) {
console.warn('Failed to load DropTables in background:', err)
return null
}
})()
}
return sharedDropTablesPromise
}
async function getMountedCharArchives(bases: readonly string[]): Promise<MountedArchives | null> {
for (const base of bases) {
try {
@ -1736,11 +1755,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
return pack
})
const streamingRooms = buildStreamingRooms(runtime, resolvedPacks, safeZones)
const dropArchives = await getMountedDataArchives(runtime.charBases.length > 0 ? runtime.charBases : DEFAULT_BASES)
if (!dropArchives) {
throw new Error('Failed to mount MPQ archives for DropTables: data archives unavailable')
}
const dropTables = await loadDropTables(dropArchives)
// Drop tables are loaded asynchronously in the background and injected when ready,
// preventing 160+ HTTP Range requests from blocking first-frame map rendering.
const dropTablesPromise = getSharedDropTables(runtime.charBases.length > 0 ? runtime.charBases : DEFAULT_BASES)
const engine = new GameEngine(
createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, {
@ -1753,9 +1770,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
// back on, so the engine's frame-0 spawner has nothing to draw from.
stats: [],
xpTable: DEMO_EXPERIENCE,
dropTables,
difficulty: 'normal',
monsterKinds: dropTables.monsterKinds,
skills: DEMO_SKILLS,
npcDefs,
questDefs: DEMO_QUESTS,
@ -1802,6 +1817,12 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
}
)
void dropTablesPromise.then(dropTables => {
if (dropTables) {
engine.setDropTables(dropTables)
}
})
// `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) {
@ -2797,6 +2818,11 @@ async function boot(): Promise<void> {
sceneSelect.disabled = true
variantSelect.disabled = true
const started = performance.now()
// Pre-warm DropTables asynchronously in the background so it loads concurrently with scene indices
const baseParamForChar = param('base', '')
const charBases = baseParamForChar === '' ? DEFAULT_BASES : [baseParamForChar]
void getSharedDropTables(charBases)
const act = Math.min(ACT_COUNT, Math.max(1, Number(param('act', '1'))))
state.act = act
actSelect.value = String(act)

View File

@ -0,0 +1,73 @@
import { describe, it, expect, beforeAll } from 'vitest'
import * as fs from 'fs'
import { getSharedDropTables } from '../src/scene/act-scene.ts'
import { GameEngine } from '../src/game/engine.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from '../src/game/demo-data.ts'
import { loadDropTables, type DropTables } from '../src/game/drop-pipeline.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
describe('Scene DropTables Preload & Decoupling (Issue #117)', () => {
let dropTables: DropTables
beforeAll(async () => {
if (!hasD2) return
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
}
dropTables = await loadDropTables(archives)
})
it('getSharedDropTables caches promise as a singleton to prevent repeated MPQ mounting', () => {
const p1 = getSharedDropTables(['samples/d2'])
const p2 = getSharedDropTables(['samples/d2'])
expect(p1).toBe(p2)
})
it('GameEngine allows starting with dropTables undefined on frame 0 and injecting later via setDropTables', () => {
const dummyTerrain = {
overlap: () => 0,
raycast: () => null,
findPath: () => [],
}
// Initialize GameEngine without dropTables (simulating instant frame-0 map display)
const engine = new GameEngine(dummyTerrain, {
spawn: { x: 100, y: 100 },
stats: [],
xpTable: DEMO_EXPERIENCE,
difficulty: 'normal',
skills: DEMO_SKILLS,
npcDefs: DEMO_NPCS,
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 10,
playerManaPerAttack: 1,
respawnTicks: 100,
disableMonsterAggro: true,
},
talkRadius: 50,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4,
monsterCount: 0,
})
expect(engine.opts.dropTables).toBeUndefined()
expect(engine.opts.monsterKinds).toBeUndefined()
// Simulate background promise resolution
if (dropTables) {
engine.setDropTables(dropTables)
expect(engine.opts.dropTables).toBe(dropTables)
expect(engine.opts.monsterKinds).toBe(dropTables.monsterKinds)
}
})
})