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:
parent
97787ff99c
commit
79bf8f4cda
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue