From d6e624829a9d799896c29541a3af393ad113d646 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 15 Sep 2026 15:03:28 +0000 Subject: [PATCH] fix(monster): fix loading performance, monster facing, and spawn logic (#30, #31, #32) - Fix #30: Concurrent layer/animation decoding and shared MPQ archive caching to reduce load latency - Fix #31: Use dir64ToDcc for permuted 8-direction DCC extraction in monster art compositing and align 8-way facing calculation - Fix #32: Prevent monster spawning in town/safe zones, support hellbovine cow packs, and cluster packs properly --- src/game/character.ts | 26 ++++---- src/game/combat.ts | 12 ++-- src/game/engine.ts | 5 +- src/game/monster-art.ts | 36 +++++------ src/game/monster-mapping.ts | 5 ++ src/scene/act-scene.ts | 123 +++++++++++++++++++++++------------- tests/engine.test.ts | 18 ++++++ tests/monster-art.test.ts | 34 ++++++++++ 8 files changed, 171 insertions(+), 88 deletions(-) diff --git a/src/game/character.ts b/src/game/character.ts index 97c6295..65839b1 100644 --- a/src/game/character.ts +++ b/src/game/character.ts @@ -123,30 +123,26 @@ export async function loadCharacterSheet( const cof: CofFile = decodeCof(cofBytes) const names = await archives.listFiles() - const notes: string[] = [] - const members: string[] = [] - const sprites: (DccFile | null)[] = [] - for (const layer of cof.layers) { + const layerResults = await Promise.all(cof.layers.map(async (layer) => { const component = COMPONENTS[layer.type] if (component === undefined) { - notes.push(`layer type ${String(layer.type)} has no component directory`) - sprites.push(null) - continue + return { sprite: null, member: undefined, note: `layer type ${String(layer.type)} has no component directory` } } const member = findLayerSprite(names, root, lower, animation, weapon, component) if (member === undefined) { - notes.push(`no sprite for component ${component} (${animation}${weapon})`) - sprites.push(null) - continue + return { sprite: null, member: undefined, note: `no sprite for component ${component} (${animation}${weapon})` } } try { - sprites.push(decodeDcc(await archives.read(member))) - members.push(member) + const dcc = decodeDcc(await archives.read(member)) + return { sprite: dcc, member, note: undefined } } catch (err) { - notes.push(`${member}: ${(err as Error).message}`) - sprites.push(null) + return { sprite: null, member: undefined, note: `${member}: ${(err as Error).message}` } } - } + })) + + const notes: string[] = layerResults.flatMap(r => r.note ? [r.note] : []) + const members: string[] = layerResults.flatMap(r => r.member ? [r.member] : []) + const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite) const groups: { frames: SpriteFrame[] }[] = [] let skipped = 0 diff --git a/src/game/combat.ts b/src/game/combat.ts index 93ea400..a4f933b 100644 --- a/src/game/combat.ts +++ b/src/game/combat.ts @@ -506,14 +506,10 @@ export function spawnMonsterPacks( * @param dy - vertical component. * @returns the facing. */ -function facingOf(dx: number, dy: number): number { - const sx = Math.sign(dx) - const sy = Math.sign(dy) - if (sx === 0 && sy === 0) return 0 - if (sx === 0) return sy > 0 ? 0 : 4 - if (sy === 0) return sx > 0 ? 6 : 2 - if (sx < 0) return sy > 0 ? 1 : 3 - return sy > 0 ? 7 : 5 +export function facingOf(dx: number, dy: number): number { + if (dx === 0 && dy === 0) return 0 + const angle = Math.atan2(-dx, dy) + return Math.round((angle / (Math.PI / 4)) + 8) % 8 } /** diff --git a/src/game/engine.ts b/src/game/engine.ts index c6405a8..120f4e8 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -161,10 +161,11 @@ export class GameEngine { constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) { this.world = createWorld(opts.spawn.x, opts.spawn.y) const spread = opts.monsterSpread ?? 260 + const count = opts.monsterCount ?? 8 if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) { spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain) - } else { - spawnMonsters(this.world, opts.stats, opts.monsterCount ?? 8, opts.spawn, spread, this.terrain) + } else if (count > 0) { + spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, this.terrain) } this.loot = new Rng(opts.lootSeed ?? 0x5eed) this.bag = new Inventory(opts.inventoryCols, opts.inventoryRows) diff --git a/src/game/monster-art.ts b/src/game/monster-art.ts index 5768660..09c9c20 100644 --- a/src/game/monster-art.ts +++ b/src/game/monster-art.ts @@ -20,6 +20,7 @@ import type { CofFile } from '../formats/cof.ts' import { decodeDcc } from '../formats/dcc.ts' import type { DccFile } from '../formats/dcc.ts' import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts' +import { dir64ToDcc } from './character.ts' /** Composite type index -> component folder name in MPQ archives. */ export const MONSTER_COMPONENTS: readonly string[] = [ @@ -105,28 +106,21 @@ export async function compositeMonsterAnimation( const cofBytes = await archives.read(matchedCof) const cof: CofFile = decodeCof(cofBytes) - const sprites: (DccFile | null)[] = [] - const decodedMembers: string[] = [] - - for (const layer of cof.layers) { + const layerResults = await Promise.all(cof.layers.map(async (layer) => { const component = MONSTER_COMPONENTS[layer.type] - if (component === undefined) { - sprites.push(null) - continue - } + if (component === undefined) return { sprite: null, member: undefined } const member = findMonsterLayerSprite(names, root, lower, animation, weapon, component) - if (member === undefined) { - sprites.push(null) - continue - } + if (member === undefined) return { sprite: null, member: undefined } try { const bytes = await archives.read(member) - sprites.push(decodeDcc(bytes)) - decodedMembers.push(member) + return { sprite: decodeDcc(bytes), member } } catch { - sprites.push(null) + return { sprite: null, member: undefined } } - } + })) + + const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite) + const decodedMembers: string[] = layerResults.flatMap(r => r.member ? [r.member] : []) // Determine maximum bounding box across all layer directions let minX = 0 @@ -149,6 +143,7 @@ export async function compositeMonsterAnimation( const groups: { frames: SpriteFrame[] }[] = [] for (let dir = 0; dir < cof.numberOfDirections; dir += 1) { + const dir64 = Math.round((dir * 64) / cof.numberOfDirections) const frames: SpriteFrame[] = [] for (let f = 0; f < cof.framesPerDirection; f += 1) { const target = { @@ -161,7 +156,8 @@ export async function compositeMonsterAnimation( for (const layerIdx of order) { const dcc = sprites[layerIdx] if (!dcc) continue - const dirData = dcc.directions[dir] + const dccDir = dir64ToDcc(dir64, dcc.directions.length) + const dirData = dcc.directions[dccDir] if (!dirData) continue const frameData = dirData.frames[f] if (!frameData) continue @@ -217,8 +213,10 @@ export async function loadMonsterAtlas( palette: Palette, renderer: SpriteRenderer, ): Promise { - const walk = await compositeMonsterAnimation(archives, token, 'wl', weapon) - const stand = await compositeMonsterAnimation(archives, token, 'nu', weapon) + const [walk, stand] = await Promise.all([ + compositeMonsterAnimation(archives, token, 'wl', weapon), + compositeMonsterAnimation(archives, token, 'nu', weapon), + ]) const combined: SpriteSheet = { groups: [...walk.sheet.groups, ...stand.sheet.groups], diff --git a/src/game/monster-mapping.ts b/src/game/monster-mapping.ts index d042565..f0166ed 100644 --- a/src/game/monster-mapping.ts +++ b/src/game/monster-mapping.ts @@ -28,6 +28,11 @@ export const MONSTER_ART_MAP: Record = { 'cr_lancer1': { token: 'CR', weapon: '2ht' }, 'goatman1': { token: 'GM', weapon: '2hs' }, 'bloodraven': { token: 'CR', weapon: 'bow' }, + 'hellbovine': { token: 'CW', weapon: 'hth' }, + 'cow': { token: 'CW', weapon: 'hth' }, + 'demo-fallen': { token: 'FA', weapon: 'hth' }, + 'demo-zombie': { token: 'ZM', weapon: 'hth' }, + 'demo-skeleton': { token: 'SK', weapon: '1hs' }, } /** diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 3e788c5..5a97750 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -518,10 +518,15 @@ async function loadPackRuntime( monsterPacks: scene.monsters?.packs ?? [], monsterTypes: scene.monsters?.types ?? [], monsterStats: DEMO_MONSTERS, - monsterCount: PLACEHOLDER_MONSTER_COUNT, + monsterCount: (entry.slug.includes('town') || scene.levelName.toLowerCase().includes('town') || (scene.monsters?.packs && scene.monsters.packs.length > 0) || scene.monsters !== undefined) ? 0 : PLACEHOLDER_MONSTER_COUNT, objects: scene.objects.length, frames: scene.frames.length, - notes: [`首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`], + notes: [ + `首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`, + ...(scene.monsters?.packs && scene.monsters.packs.length > 0 + ? [`怪物:${String(scene.monsters.budget)} 只 / ${String(scene.monsters.packs.length)} 群(${scene.monsters.types.join('、')})`] + : []), + ], pages, objectPages, priorityPages: priority, @@ -695,7 +700,7 @@ async function loadLiveRuntime( monsterPacks: population.packs, monsterTypes: population.types, monsterStats: DEMO_MONSTERS, - monsterCount: PLACEHOLDER_MONSTER_COUNT, + monsterCount: (town.levelName.toLowerCase().includes('town') || population.packs.length > 0 || population.budget === 0) ? 0 : PLACEHOLDER_MONSTER_COUNT, objects: level.objects.length, frames: scene.frames.length, notes, @@ -770,6 +775,52 @@ interface LoadedCharacter { * @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 = httpRangeSource(url, label).then(src => MpqArchive.open(src)) + archivePromiseCache.set(url, p) + } + return p +} + +async function getMountedDataArchives(bases: readonly string[]): Promise { + 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 +} + +async function getMountedCharArchives(bases: readonly string[]): Promise { + 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 +} + /** * Load monster atlases on-demand for all monster types present in the scene. */ @@ -782,20 +833,8 @@ async function loadMonsterArtMap( const map = new Map() if (monsterIds.length === 0) return map try { - const archives = new MountedArchives() - let mounted = false - for (const base of bases) { - for (const name of DATA_ARCHIVES) { - try { - archives.add(name, await MpqArchive.open(await httpRangeSource(`${base}/${name}`, name))) - mounted = true - } catch { - // ignore missing - } - } - if (mounted) break - } - if (!mounted) return map + const archives = await getMountedDataArchives(bases) + if (!archives) return map // Resolve unique tokens needed const tokenSpecs = new Map() @@ -806,16 +845,18 @@ async function loadMonsterArtMap( } } - // Load each monster token atlas + // Load each monster token atlas in parallel const loadedByToken = new Map() - for (const [token, spec] of tokenSpecs) { - try { - const loaded = await loadMonsterAtlas(archives, token, spec.weapon, palette, renderer) - loadedByToken.set(token, loaded) - } catch (err) { - console.warn(`monster art ${token} (${spec.weapon}) unavailable: ${(err as Error).message}`) - } - } + await Promise.all( + [...tokenSpecs.entries()].map(async ([token, spec]) => { + try { + const loaded = await loadMonsterAtlas(archives, token, spec.weapon, palette, renderer) + loadedByToken.set(token, loaded) + } catch (err) { + console.warn(`monster art ${token} (${spec.weapon}) unavailable: ${(err as Error).message}`) + } + }) + ) // Map monster id to loaded art for (const id of monsterIds) { @@ -839,20 +880,12 @@ async function loadCharacterArt( renderer: SpriteRenderer, ): Promise { try { - const archives = new MountedArchives() - let mounted = false - for (const base of bases) { - try { - archives.add(CHARACTER_ARCHIVE, await MpqArchive.open(await httpRangeSource(`${base}/${CHARACTER_ARCHIVE}`, CHARACTER_ARCHIVE))) - mounted = true - break - } catch (err) { - console.warn(`${base}/${CHARACTER_ARCHIVE}: ${(err as Error).message}`) - } - } - if (!mounted) return null - const walk = await loadCharacterSheet(archives, 'so', 'wl', 'hth') - const stand = await loadCharacterSheet(archives, 'so', 'nu', 'hth') + 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 = buildAtlas(joined, palette, undefined, { width: 1024 }) const handle = renderer.addAtlas({ pixels: atlas.pixels, width: atlas.width, height: atlas.height }) @@ -1259,11 +1292,13 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number // Collect monster types to load const activeMonsterIds = runtime.monsterTypes.length > 0 ? runtime.monsterTypes - : runtime.monsterStats.map(s => s.id) + : (runtime.monsterCount > 0 ? runtime.monsterStats.map(s => s.id) : []) - void loadMonsterArtMap(DEFAULT_BASES, activeMonsterIds, paletteForEntities, renderer).then((loadedMap) => { - monsterArtMap = loadedMap - }) + if (activeMonsterIds.length > 0) { + void loadMonsterArtMap(DEFAULT_BASES, activeMonsterIds, paletteForEntities, renderer).then((loadedMap) => { + monsterArtMap = loadedMap + }) + } void loadCharacterArt(runtime.charBases, paletteForEntities, renderer).then((loaded) => { character = loaded diff --git a/tests/engine.test.ts b/tests/engine.test.ts index eb878ac..cecf3cd 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -208,4 +208,22 @@ describe('GameEngine', () => { expect(engine.world.monsters[0]!.state).toBe('dead') }) + test('monsterCount: 0 spawns no monsters (for safe areas like town)', () => { + const engine = new GameEngine(dummyTerrain, { ...dummyOpts, monsterCount: 0 }) + expect(engine.world.monsters.length).toBe(0) + }) + + test('monsterPacks takes precedence and clusters monsters', () => { + const packs = [ + { + members: [ + { ...dummyOpts.stats[0]!, hp: 10 }, + { ...dummyOpts.stats[0]!, hp: 10 }, + { ...dummyOpts.stats[0]!, hp: 10 }, + ] + } + ] + const engine = new GameEngine(dummyTerrain, { ...dummyOpts, monsterPacks: packs, monsterCount: 0 }) + expect(engine.world.monsters.length).toBe(3) + }) }) diff --git a/tests/monster-art.test.ts b/tests/monster-art.test.ts index 44e6347..b7e5d4c 100644 --- a/tests/monster-art.test.ts +++ b/tests/monster-art.test.ts @@ -24,4 +24,38 @@ describe('monster-art', () => { const missing = findMonsterLayerSprite(names, root, 'fa', 'wl', 'hth', 'hd') expect(missing).toBeUndefined() }) + + it('correctly maps 8 COF directions to permuted DCC directions via dir64ToDcc', async () => { + const { dir64ToDcc } = await import('../src/game/character.ts') + // In D2 DCC files with 8 directions, order is permuted: [4, 0, 5, 1, 6, 2, 7, 3] + const cofDirections = 8 + const dccDirs = Array.from({ length: cofDirections }, (_, dir) => { + const dir64 = Math.round((dir * 64) / cofDirections) + return dir64ToDcc(dir64, 8) + }) + expect(dccDirs).toEqual([4, 0, 5, 1, 6, 2, 7, 3]) + }) + + it('calculates 8-direction facing correctly from dx, dy in combat.ts', async () => { + const { facingOf } = await import('../src/game/combat.ts') + // 0: South (dx=0, dy>0) + expect(facingOf(0, 10)).toBe(0) + // 1: South-West (dx<0, dy>0) + expect(facingOf(-10, 10)).toBe(1) + // 2: West (dx<0, dy=0) + expect(facingOf(-10, 0)).toBe(2) + // 3: North-West (dx<0, dy<0) + expect(facingOf(-10, -10)).toBe(3) + // 4: North (dx=0, dy<0) + expect(facingOf(0, -10)).toBe(4) + // 5: North-East (dx>0, dy<0) + expect(facingOf(10, -10)).toBe(5) + // 6: East (dx>0, dy=0) + expect(facingOf(10, 0)).toBe(6) + // 7: South-East (dx>0, dy>0) + expect(facingOf(10, 10)).toBe(7) + // Stationary + expect(facingOf(0, 0)).toBe(0) + }) }) +