From 9a0e91849e4b53bdc5a48b7ea4cadd4bce16152d Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 15 Sep 2026 09:45:23 +0000 Subject: [PATCH] =?UTF-8?q?feat(monsters):=20=E5=AE=8C=E6=88=90=20M7=20?= =?UTF-8?q?=E6=80=AA=E7=89=A9=20COF/DCC=208=E5=90=91=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E4=B8=8E=E7=BE=8E=E6=9C=AF=E5=B1=82=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 个单元测试均通过) --- scripts/verify-monsters.ts | 24 ++++ src/game/monster-art.ts | 245 ++++++++++++++++++++++++++++++++++ src/game/monster-mapping.ts | 47 +++++++ src/scene/act-scene.ts | 99 +++++++++++++- tests/monster-art.test.ts | 27 ++++ tests/monster-mapping.test.ts | 22 +++ 6 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 src/game/monster-art.ts create mode 100644 src/game/monster-mapping.ts create mode 100644 tests/monster-art.test.ts create mode 100644 tests/monster-mapping.test.ts diff --git a/scripts/verify-monsters.ts b/scripts/verify-monsters.ts index c97682d..2d4f05d 100644 --- a/scripts/verify-monsters.ts +++ b/scripts/verify-monsters.ts @@ -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'] diff --git a/src/game/monster-art.ts b/src/game/monster-art.ts new file mode 100644 index 0000000..5768660 --- /dev/null +++ b/src/game/monster-art.ts @@ -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//`. + * 2. `` 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 { + 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 { + 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, + } +} diff --git a/src/game/monster-mapping.ts b/src/game/monster-mapping.ts new file mode 100644 index 0000000..d042565 --- /dev/null +++ b/src/game/monster-mapping.ts @@ -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 = { + // 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 +} diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 32d5104..3e788c5 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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> { + 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 + + // Resolve unique tokens needed + const tokenSpecs = new Map() + 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() + 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() + 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 diff --git a/tests/monster-art.test.ts b/tests/monster-art.test.ts new file mode 100644 index 0000000..44e6347 --- /dev/null +++ b/tests/monster-art.test.ts @@ -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() + }) +}) diff --git a/tests/monster-mapping.test.ts b/tests/monster-mapping.test.ts new file mode 100644 index 0000000..5c38e11 --- /dev/null +++ b/tests/monster-mapping.test.ts @@ -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() + }) +})