- 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:
parent
25ab2eea9d
commit
d6e624829a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue