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:
parent
4294a16a48
commit
9a0e91849e
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue