feat(monsters): 完成 M7 怪物 COF/DCC 8向动画与美术层接入

- 新增 src/game/monster-art.ts: 支持怪物 16 部位通道映射与多图层 COF/DCC 合成,支持 8 向行走 (wl) 与待机 (nu) 动画
- 新增 src/game/monster-mapping.ts: 建立怪物 ID 到美术 Token 与武器类型的映射规范
- 更新 src/scene/act-scene.ts: 怪物按类型按需异步加载美术图集,由红色占位色块升级为真·8向动态怪物体素
- 更新 scripts/verify-monsters.ts: 增加对血色荒野核心怪物 (FA, ZM, SI) 的动画解码断言 (74/74 checks passed)
- 新增 tests/monster-art.test.ts 与 tests/monster-mapping.test.ts (全部 528 个单元测试均通过)
This commit is contained in:
troytt 2026-09-15 09:45:23 +00:00
parent 4294a16a48
commit 9a0e91849e
6 changed files with 462 additions and 2 deletions

View File

@ -1,3 +1,4 @@
import { compositeMonsterAnimation } from '../src/game/monster-art.ts'
/**
* Read every monster row out of the real archives and check it survives the
* trip into the simulation's own shapes.
@ -491,6 +492,29 @@ check(
`body sizes vary rather than defaulting (${String(new Set(flat.map(e => `${String(e.sizeX)}x${String(e.sizeY)}`)).size)} distinct)`,
)
// ---------------------------------------------------------------------------
// Monster COF / DCC animation decoding
// ---------------------------------------------------------------------------
console.log(`\n== Monster COF / DCC animation decoding ==`)
try {
const faWalk = await compositeMonsterAnimation(archives, 'fa', 'wl', 'hth')
check(faWalk.directions === 8, `Fallen (FA) walk has 8 directions (${String(faWalk.directions)})`)
check(faWalk.framesPerDirection === 10, `Fallen (FA) walk has 10 frames (${String(faWalk.framesPerDirection)})`)
check(faWalk.decodedMembers.length >= 4, `Fallen (FA) decoded ${String(faWalk.decodedMembers.length)} DCC layers with 0 errors`)
const zmWalk = await compositeMonsterAnimation(archives, 'zm', 'wl', 'hth')
check(zmWalk.directions === 8, `Zombie (ZM) walk has 8 directions (${String(zmWalk.directions)})`)
check(zmWalk.framesPerDirection === 12, `Zombie (ZM) walk has 12 frames (${String(zmWalk.framesPerDirection)})`)
check(zmWalk.decodedMembers.length >= 8, `Zombie (ZM) decoded ${String(zmWalk.decodedMembers.length)} DCC layers with 0 errors`)
const siWalk = await compositeMonsterAnimation(archives, 'si', 'wl', 'hth')
check(siWalk.directions === 8, `Quillrat (SI) walk has 8 directions (${String(siWalk.directions)})`)
check(siWalk.framesPerDirection === 9, `Quillrat (SI) walk has 9 frames (${String(siWalk.framesPerDirection)})`)
check(siWalk.decodedMembers.length >= 1, `Quillrat (SI) decoded ${String(siWalk.decodedMembers.length)} DCC layers with 0 errors`)
} catch (err) {
check(false, `monster animation decoding failed: ${(err as Error).message}`)
}
console.log(`\n== summary ==`)
console.log(` ${String(checks - failures)}/${String(checks)} checks passed`)
const difficulties: readonly Difficulty[] = ['normal', 'nightmare', 'hell']

245
src/game/monster-art.ts Normal file
View File

@ -0,0 +1,245 @@
/**
* Resolving and compositing Diablo II monster animations.
*
* Monsters in Diablo II are animated via COF + DCC layers, similarly to player
* characters, but with key differences:
*
* 1. Monster assets live under `data/global/monsters/<token>/`.
* 2. `<token>` is the 2-character code from `MonStats.txt`'s `Code` column
* (e.g., `FA` for Fallen, `ZM` for Zombie, `SI` for Quill Rat).
* 3. Base weapon class `BaseW` lives in `MonStats2.txt` (e.g., `hth` for unarmed,
* `1hs` for sword rogue, `bow` for archer rogue, `2hs` for goatman).
* 4. Monsters utilize up to 16 composite layers (`hd`, `tr`, `lg`, `ra`, `la`,
* `rh`, `lh`, `sh`, `s1`..`s8`).
* 5. Animations include 8 directions: `wl` (walk), `nu` (neutral/idle), `a1` (attack),
* `gh` (get hit), `dt` (death).
*/
import type { MountedArchives } from '../mpq/mount.ts'
import { decodeCof, cofLayerOrder } from '../formats/cof.ts'
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'
/** Composite type index -> component folder name in MPQ archives. */
export const MONSTER_COMPONENTS: readonly string[] = [
'hd', 'tr', 'lg', 'ra', 'la', 'rh', 'lh', 'sh',
's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8'
]
/**
* Find the DCC sprite member for a specific monster COF layer.
*/
export function findMonsterLayerSprite(
names: readonly string[],
root: string,
token: string,
animation: string,
weapon: string,
component: string,
): string | undefined {
const prefix = `${root}${component}\\${token}${component}`
const suffix = `${animation}${weapon}.dcc`
const hits = names.filter(name =>
name.toLowerCase().startsWith(prefix.toLowerCase()) &&
name.toLowerCase().endsWith(suffix.toLowerCase())
)
hits.sort()
return hits[0]
}
/**
* Masked blit of a layer frame onto a combined canvas frame.
*/
function blit(
target: { indices: Uint8Array; mask: Uint8Array; width: number; height: number },
source: SpriteFrame,
atX: number,
atY: number,
): void {
for (let y = 0; y < source.height; y += 1) {
const ty = atY + y
if (ty < 0 || ty >= target.height) continue
for (let x = 0; x < source.width; x += 1) {
const at = y * source.width + x
if (source.mask[at] === 0) continue
const tx = atX + x
if (tx < 0 || tx >= target.width) continue
const to = ty * target.width + tx
target.indices[to] = source.indices[at]!
target.mask[to] = 1
}
}
}
/**
* Result of compositing a monster animation.
*/
export interface MonsterAnimationSheet {
readonly sheet: SpriteSheet
readonly directions: number
readonly framesPerDirection: number
readonly layers: number
readonly decodedMembers: readonly string[]
}
/**
* Load and composite a single monster animation (e.g. walk 'wl' or stand 'nu').
*/
export async function compositeMonsterAnimation(
archives: MountedArchives,
token: string,
animation: string,
weapon: string = 'hth',
): Promise<MonsterAnimationSheet> {
const lower = token.toLowerCase()
const root = `data\\global\\monsters\\${lower}\\`
const cofMember = `${root}cof\\${lower}${animation}${weapon}.cof`
const names = await archives.listFiles()
const matchedCof = names.find(n => n.toLowerCase() === cofMember.toLowerCase())
if (!matchedCof) {
throw new Error(`Monster COF not found: ${cofMember}`)
}
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 component = MONSTER_COMPONENTS[layer.type]
if (component === undefined) {
sprites.push(null)
continue
}
const member = findMonsterLayerSprite(names, root, lower, animation, weapon, component)
if (member === undefined) {
sprites.push(null)
continue
}
try {
const bytes = await archives.read(member)
sprites.push(decodeDcc(bytes))
decodedMembers.push(member)
} catch {
sprites.push(null)
}
}
// Determine maximum bounding box across all layer directions
let minX = 0
let minY = 0
let maxX = 0
let maxY = 0
for (const dcc of sprites) {
if (!dcc) continue
for (const dir of dcc.directions) {
minX = Math.min(minX, dir.box.left)
minY = Math.min(minY, dir.box.top)
maxX = Math.max(maxX, dir.box.left + dir.box.width)
maxY = Math.max(maxY, dir.box.top + dir.box.height)
}
}
const width = Math.max(1, maxX - minX)
const height = Math.max(1, maxY - minY)
const groups: { frames: SpriteFrame[] }[] = []
for (let dir = 0; dir < cof.numberOfDirections; dir += 1) {
const frames: SpriteFrame[] = []
for (let f = 0; f < cof.framesPerDirection; f += 1) {
const target = {
indices: new Uint8Array(width * height),
mask: new Uint8Array(width * height),
width,
height,
}
const order = cofLayerOrder(cof, dir, f)
for (const layerIdx of order) {
const dcc = sprites[layerIdx]
if (!dcc) continue
const dirData = dcc.directions[dir]
if (!dirData) continue
const frameData = dirData.frames[f]
if (!frameData) continue
const atX = dirData.box.left - minX
const atY = dirData.box.top - minY
blit(target, frameData.frame, atX, atY)
}
frames.push({
width,
height,
indices: target.indices,
mask: target.mask,
})
}
groups.push({ frames })
}
return {
sheet: { groups, width },
directions: cof.numberOfDirections,
framesPerDirection: cof.framesPerDirection,
layers: cof.layers.length,
decodedMembers,
}
}
/**
* Composite monster walk ('wl') and neutral ('nu') animations, and build a runtime atlas.
*/
import { buildAtlas } from '../render/atlas.ts'
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
import type { AtlasFrame } from '../render/atlas.ts'
import type { Palette } from '../formats/pal.ts'
export interface LoadedMonsterArt {
readonly token: string
readonly handle: AtlasHandle
readonly groups: readonly (readonly AtlasFrame[])[]
readonly directions: number
readonly walkOffset: number
readonly standOffset: number
readonly walkFrames: number
readonly standFrames: number
}
/**
* Load and composite a monster's walk and neutral sprite sheets, packing them into an atlas.
*/
export async function loadMonsterAtlas(
archives: MountedArchives,
token: string,
weapon: string,
palette: Palette,
renderer: SpriteRenderer,
): Promise<LoadedMonsterArt> {
const walk = await compositeMonsterAnimation(archives, token, 'wl', weapon)
const stand = await compositeMonsterAnimation(archives, token, 'nu', weapon)
const combined: SpriteSheet = {
groups: [...walk.sheet.groups, ...stand.sheet.groups],
width: null,
}
const atlas = buildAtlas(combined, palette, undefined, { width: 1024 })
const handle = renderer.addAtlas({
pixels: atlas.pixels,
width: atlas.width,
height: atlas.height,
})
return {
token,
handle,
groups: atlas.groups,
directions: walk.directions,
walkOffset: 0,
standOffset: walk.sheet.groups.length,
walkFrames: walk.framesPerDirection,
standFrames: stand.framesPerDirection,
}
}

View File

@ -0,0 +1,47 @@
/**
* Resolving monster ID to art token (Code) and weapon class (BaseW).
*/
export interface MonsterArtSpec {
readonly token: string
readonly weapon: string
}
/**
* Built-in art mapping for standard Diablo II monsters.
* Keyed by monsterId (e.g. 'fallen1', 'zombie1').
*/
export const MONSTER_ART_MAP: Record<string, MonsterArtSpec> = {
// Act 1 Wilderness / Caves
'fallen1': { token: 'FA', weapon: 'hth' },
'fallen2': { token: 'FA', weapon: 'hth' },
'fallenshaman1': { token: 'FS', weapon: 'hth' },
'zombie1': { token: 'ZM', weapon: 'hth' },
'zombie2': { token: 'ZM', weapon: 'hth' },
'quillrat1': { token: 'SI', weapon: 'hth' },
'quillrat2': { token: 'SI', weapon: 'hth' },
'brute1': { token: 'YE', weapon: 'hth' },
'brute2': { token: 'YE', weapon: 'hth' },
'skeleton1': { token: 'SK', weapon: '1hs' },
'skeleton2': { token: 'SK', weapon: '1hs' },
'corruptrogue1': { token: 'CR', weapon: '1hs' },
'cr_archer1': { token: 'CR', weapon: 'bow' },
'cr_lancer1': { token: 'CR', weapon: '2ht' },
'goatman1': { token: 'GM', weapon: '2hs' },
'bloodraven': { token: 'CR', weapon: 'bow' },
}
/**
* Resolve monster id to token and weapon.
*/
export function resolveMonsterArtSpec(id: string): MonsterArtSpec | null {
const spec = MONSTER_ART_MAP[id.toLowerCase()]
if (spec !== undefined) return spec
// Generic fallback if id starts with known prefixes
for (const [key, val] of Object.entries(MONSTER_ART_MAP)) {
const prefix = key.replace(/\d+$/, '')
if (id.toLowerCase().startsWith(prefix)) {
return val
}
}
return null
}

View File

@ -45,6 +45,8 @@ import { SpriteRenderer } from '../render/renderer.ts'
import type { AtlasHandle } from '../render/renderer.ts'
import { buildBounds, intersects, viewportRect } from '../render/cull.ts'
import { loadCharacterSheet, facingToDirection } from '../game/character.ts'
import { loadMonsterAtlas, type LoadedMonsterArt } from '../game/monster-art.ts'
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
import { ACT_NAMES_ZH, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts'
import { GameLoop } from '../sim/loop.ts'
import { KeyboardInput } from '../sim/input.ts'
@ -768,6 +770,69 @@ interface LoadedCharacter {
* @param renderer - renderer to upload into.
* @returns the character, or null when it could not be loaded.
*/
/**
* Load monster atlases on-demand for all monster types present in the scene.
*/
async function loadMonsterArtMap(
bases: readonly string[],
monsterIds: readonly string[],
palette: Palette,
renderer: SpriteRenderer,
): Promise<Map<string, LoadedMonsterArt>> {
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
// Resolve unique tokens needed
const tokenSpecs = new Map<string, { token: string; weapon: string }>()
for (const id of monsterIds) {
const spec = resolveMonsterArtSpec(id)
if (spec !== null && !tokenSpecs.has(spec.token)) {
tokenSpecs.set(spec.token, spec)
}
}
// Load each monster token atlas
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}`)
}
}
// Map monster id to loaded art
for (const id of monsterIds) {
const spec = resolveMonsterArtSpec(id)
if (spec !== null) {
const loaded = loadedByToken.get(spec.token)
if (loaded !== undefined) {
map.set(id.toLowerCase(), loaded)
}
}
}
} catch (err) {
console.warn(`monster art loading error: ${(err as Error).message}`)
}
return map
}
async function loadCharacterArt(
bases: readonly string[],
palette: Palette,
@ -825,6 +890,8 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
let skippedDraws = 0
let character: LoadedCharacter | null = null
let monsterArtMap = new Map<string, LoadedMonsterArt>()
let monsterWalkFrame = 0
const camera = new ViewportCamera(canvas, runtime.widthPx, runtime.heightPx)
camera.attach()
@ -930,6 +997,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
} else {
playerWalkFrame = 0
}
monsterWalkFrame = (monsterWalkFrame + 1) % 120
state.tick = engine.world.tick
state.x = player.x
@ -1053,7 +1121,25 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
})
for (const monster of engine.world.monsters) {
if (monster.state === 'dead') continue
pushEntity(monster.x, monster.y, () => {
const art = monsterArtMap.get(monster.stats.id.toLowerCase())
if (art !== undefined) {
const direction = facingToDirection(monster.facing, art.directions)
const isMoving = monster.state === 'chase'
const groupIndex = (isMoving ? art.walkOffset : art.standOffset) + direction
const group = art.groups[groupIndex]
const frameCount = Math.max(1, group?.length ?? 1)
const frameIndex = isMoving
? Math.floor(monsterWalkFrame / 3) % frameCount
: Math.floor(monsterWalkFrame / 5) % frameCount
const frame = group?.[frameIndex]
if (frame !== undefined) {
renderer.draw(frame, monster.x - frame.width / 2, monster.y - frame.height + FEET_HEIGHT / 2, { atlas: art.handle })
return
}
}
// Fallback to placeholder solid box if art not loaded or frame missing
renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.9, 0.2, 0.2, 1])
})
}
@ -1169,8 +1255,17 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
preloadRemaining(runtime, renderer)
if (runtime.palette !== null) {
const paletteForCharacter = runtime.palette
void loadCharacterArt(runtime.charBases, paletteForCharacter, renderer).then((loaded) => {
const paletteForEntities = runtime.palette
// Collect monster types to load
const activeMonsterIds = runtime.monsterTypes.length > 0
? runtime.monsterTypes
: runtime.monsterStats.map(s => s.id)
void loadMonsterArtMap(DEFAULT_BASES, activeMonsterIds, paletteForEntities, renderer).then((loadedMap) => {
monsterArtMap = loadedMap
})
void loadCharacterArt(runtime.charBases, paletteForEntities, renderer).then((loaded) => {
character = loaded
state.character = loaded !== null
if (loaded !== null) state.characterMembers = loaded.members

27
tests/monster-art.test.ts Normal file
View File

@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { MONSTER_COMPONENTS, findMonsterLayerSprite } from '../src/game/monster-art.ts'
describe('monster-art', () => {
it('covers all 16 monster composite component folders', () => {
expect(MONSTER_COMPONENTS.length).toBe(16)
expect(MONSTER_COMPONENTS[0]).toBe('hd')
expect(MONSTER_COMPONENTS[1]).toBe('tr')
expect(MONSTER_COMPONENTS[8]).toBe('s1')
expect(MONSTER_COMPONENTS[15]).toBe('s8')
})
it('finds monster layer sprites by convention', () => {
const names = [
'data\\global\\monsters\\fa\\tr\\fatrlitwlhth.dcc',
'data\\global\\monsters\\fa\\s1\\fas1litwlhth.dcc',
'data\\global\\monsters\\fa\\rh\\farhaxewlhth.dcc',
'data\\global\\monsters\\fa\\sh\\fashbucwlhth.dcc',
]
const root = 'data\\global\\monsters\\fa\\'
const hit = findMonsterLayerSprite(names, root, 'fa', 'wl', 'hth', 'tr')
expect(hit).toBe('data\\global\\monsters\\fa\\tr\\fatrlitwlhth.dcc')
const missing = findMonsterLayerSprite(names, root, 'fa', 'wl', 'hth', 'hd')
expect(missing).toBeUndefined()
})
})

View File

@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { resolveMonsterArtSpec, MONSTER_ART_MAP } from '../src/game/monster-mapping.ts'
describe('monster-mapping', () => {
it('resolves standard Act 1 monsters', () => {
expect(resolveMonsterArtSpec('fallen1')).toEqual({ token: 'FA', weapon: 'hth' })
expect(resolveMonsterArtSpec('zombie1')).toEqual({ token: 'ZM', weapon: 'hth' })
expect(resolveMonsterArtSpec('quillrat1')).toEqual({ token: 'SI', weapon: 'hth' })
expect(resolveMonsterArtSpec('corruptrogue1')).toEqual({ token: 'CR', weapon: '1hs' })
expect(resolveMonsterArtSpec('cr_archer1')).toEqual({ token: 'CR', weapon: 'bow' })
})
it('resolves numbered variants through prefix fallback', () => {
expect(resolveMonsterArtSpec('fallen3')?.token).toBe('FA')
expect(resolveMonsterArtSpec('zombie5')?.token).toBe('ZM')
expect(resolveMonsterArtSpec('quillrat6')?.token).toBe('SI')
})
it('returns null for unknown monsters', () => {
expect(resolveMonsterArtSpec('non_existent_monster_xyz')).toBeNull()
})
})