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
This commit is contained in:
troytt 2026-09-15 15:03:28 +00:00
parent 25ab2eea9d
commit d6e624829a
8 changed files with 171 additions and 88 deletions

View File

@ -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

View File

@ -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
}
/**

View File

@ -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)

View File

@ -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<LoadedMonsterArt> {
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],

View File

@ -28,6 +28,11 @@ export const MONSTER_ART_MAP: Record<string, MonsterArtSpec> = {
'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' },
}
/**

View File

@ -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<string, Promise<MpqArchive>>()
function getCachedMpqArchive(url: string, label = url): Promise<MpqArchive> {
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<MountedArchives | null> {
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<MountedArchives | null> {
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<string, LoadedMonsterArt>()
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<string, { token: string; weapon: string }>()
@ -806,16 +845,18 @@ async function loadMonsterArtMap(
}
}
// Load each monster token atlas
// Load each monster token atlas in parallel
const loadedByToken = new Map<string, LoadedMonsterArt>()
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<LoadedCharacter | null> {
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

View File

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

View File

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