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) + } + }) +})