From 79bf8f4cda8c2573de4ee6c734e11d1f44141ae8 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 19 Sep 2026 03:03:28 +0000 Subject: [PATCH] =?UTF-8?q?perf(scene):=20=E8=A7=A3=E5=86=B3=E9=A6=96?= =?UTF-8?q?=E5=B1=8F=E9=98=BB=E5=A1=9E=E5=BC=8F=20166=20=E6=AC=A1=20MPQ=20?= =?UTF-8?q?Range=20=E8=AF=B7=E6=B1=82=E5=AF=BC=E8=87=B4=E7=9A=84=E5=9C=B0?= =?UTF-8?q?=E5=9B=BE=E8=BD=BD=E5=85=A5=E5=8D=A1=E9=A1=BF=20(Issue=20#117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 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 --- src/game/engine.ts | 8 ++++ src/scene/act-scene.ts | 42 ++++++++++++++---- tests/scene-drop-preload.test.ts | 73 ++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 tests/scene-drop-preload.test.ts diff --git a/src/game/engine.ts b/src/game/engine.ts index a1f78f6..f730d42 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -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 diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index a90e076..ba1f892 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 | null = null + +export function getSharedDropTables(bases: readonly string[] = DEFAULT_BASES): Promise { + 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 { 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 { 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) diff --git a/tests/scene-drop-preload.test.ts b/tests/scene-drop-preload.test.ts new file mode 100644 index 0000000..349e278 --- /dev/null +++ b/tests/scene-drop-preload.test.ts @@ -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) + } + }) +})