diablo2-web/src/game/monster-art.ts

469 lines
16 KiB
TypeScript

/**
* 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, SpriteGroup, 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[] = [
'hd', 'tr', 'lg', 'ra', 'la', 'rh', 'lh', 'sh',
's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8'
]
/**
* Tokens of monsters that require additive blending for their ghostly or energy appearance.
* Ground truth parity (1.13c):
* - WR (Wraith / Ghost / Specter): All layers drawEffect=3, transparent=true.
* - WW (Willowisp / Gloam / Black Soul): All layers drawEffect=3, transparent=true.
* - FR (Finger Mage / Storm Caster / Strangler): Torso normal, s1 glow wings drawEffect=3.
* - K9 (Hellhound / Blood Clan variant), 17, 46, FI, X3, X4, XW: Additional energy/ghostly variants.
*/
export const ADDITIVE_MONSTER_TOKENS: ReadonlySet<string> = new Set([
'WR', 'WW', 'FR', 'K9', '17', '46', 'FI', 'X3', 'X4', 'XW',
])
/**
* 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,
layerWeaponClass?: string,
): string | undefined {
const prefix = `${root}${component}\\${token}${component}`.toLowerCase()
const weapons = Array.from(new Set([layerWeaponClass, weapon, 'hth'].filter((w): w is string => Boolean(w))))
const anims = animation.toLowerCase() === 'wl' ? ['wl', 'nu'] : [animation.toLowerCase()]
for (const anim of anims) {
for (const w of weapons) {
const suffix = `${anim}${w.toLowerCase()}.dcc`
const hits = names.filter(name => {
const lowerName = name.toLowerCase()
return lowerName.startsWith(prefix) && lowerName.endsWith(suffix)
})
if (hits.length > 0) {
hits.sort()
return hits[0]
}
}
}
return undefined
}
/**
* 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
}
}
}
/**
* One COF layer that did not make it into the composite.
*
* A dropped layer is not a missing monster — it is a monster rendered without
* its head, or its weapon arm. That used to happen behind a bare `catch`, so
* the only evidence was the picture. Every drop is now described here and
* logged at error level.
*/
export interface MonsterLayerFailure {
/** The monster token, e.g. `fa`. */
readonly token: string
/** The animation the layer belonged to, e.g. `wl`. */
readonly animation: string
/** The composite component folder, e.g. `hd`; `null` for a layer type outside {@link MONSTER_COMPONENTS}. */
readonly component: string | null
/** The DCC member that failed, when one was found. */
readonly member: string | null
/**
* - `unknown-component` — the COF names a layer type this port does not map.
* - `no-member` — no DCC in the archives matches the layer's naming convention.
* - `decode` — the DCC was found but could not be read.
*/
readonly reason: 'unknown-component' | 'no-member' | 'decode'
/** The decoder's message, for `decode`. */
readonly message?: string
}
/**
* Result of compositing a monster animation.
*/
export interface MonsterAnimationSheet {
readonly sheet: SpriteSheet
readonly glowSheet?: SpriteSheet | undefined
readonly blendMode: 'normal' | 'additive'
readonly directions: number
readonly framesPerDirection: number
readonly layers: number
readonly decodedMembers: readonly string[]
/** Layers that were dropped; empty when the monster composited whole. */
readonly layerFailures: readonly MonsterLayerFailure[]
}
/**
* 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()
let usedAnim = animation.toLowerCase()
let usedWeapon = weapon.toLowerCase()
const candidates = [
`${root}cof\\${lower}${usedAnim}${usedWeapon}.cof`,
`${root}cof\\${lower}${usedAnim}hth.cof`,
...(usedAnim === 'wl'
? [
`${root}cof\\${lower}nu${usedWeapon}.cof`,
`${root}cof\\${lower}nuhth.cof`,
]
: []),
]
let matchedCof: string | undefined
for (const cand of candidates) {
matchedCof = names.find(n => n.toLowerCase() === cand.toLowerCase())
if (matchedCof) {
if (cand.includes('nu')) usedAnim = 'nu'
if (cand.endsWith('hth.cof')) usedWeapon = 'hth'
break
}
}
if (!matchedCof) {
throw new Error(`Monster COF not found: ${candidates[0]}`)
}
const cofBytes = await archives.read(matchedCof)
const cof: CofFile = decodeCof(cofBytes)
const layerResults = await Promise.all(cof.layers.map(async (layer) => {
const component = MONSTER_COMPONENTS[layer.type]
if (component === undefined) {
return {
sprite: null,
member: undefined,
failure: { token: lower, animation: usedAnim, component: null, member: null, reason: 'unknown-component' } as MonsterLayerFailure,
}
}
const member = findMonsterLayerSprite(
names,
root,
lower,
usedAnim,
usedWeapon,
component,
layer.weaponClass,
)
if (member === undefined) {
return {
sprite: null,
member: undefined,
failure: { token: lower, animation: usedAnim, component, member: null, reason: 'no-member' } as MonsterLayerFailure,
}
}
try {
const bytes = await archives.read(member)
return { sprite: decodeDcc(bytes), member, failure: undefined }
} catch (err) {
return {
sprite: null,
member: undefined,
failure: {
token: lower,
animation: usedAnim,
component,
member,
reason: 'decode',
message: (err as Error).message,
} as MonsterLayerFailure,
}
}
}))
const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite)
const decodedMembers: string[] = layerResults.flatMap(r => r.member ? [r.member] : [])
const layerFailures: MonsterLayerFailure[] = layerResults.flatMap(r => r.failure ? [r.failure] : [])
// A monster missing a limb is a data failure, not a cosmetic one, so it is
// said out loud even though the composite still returns a usable sheet.
for (const failure of layerFailures) {
console.error(
`monster art layer dropped: ${failure.token}/${failure.component ?? '?'} (${failure.animation}) `
+ `— ${failure.reason}${failure.member !== null ? ` [${failure.member}]` : ''}`
+ `${failure.message !== undefined ? `: ${failure.message}` : ''}`,
)
}
const isAdditiveLayer = (layerIdx: number): boolean => {
const layer = cof.layers[layerIdx]
return layer !== undefined && (layer.drawEffect === 3 || layer.transparent)
}
const hasAdditive = cof.layers.some(l => l.drawEffect === 3 || l.transparent)
const hasNormal = cof.layers.some(l => !(l.drawEffect === 3 || l.transparent))
const isComposite = hasAdditive && hasNormal
const isPureAdditive = hasAdditive && !hasNormal
const groups: { frames: SpriteFrame[] }[] = []
const glowGroups: { frames: SpriteFrame[] }[] = []
for (let dir = 0; dir < cof.numberOfDirections; dir += 1) {
const dir64 = Math.round((dir * 64) / cof.numberOfDirections)
const baseFrames: SpriteFrame[] = []
const glowFrames: SpriteFrame[] = []
for (let f = 0; f < cof.framesPerDirection; f += 1) {
let left = Number.POSITIVE_INFINITY
let top = Number.POSITIVE_INFINITY
let right = Number.NEGATIVE_INFINITY
let bottom = Number.NEGATIVE_INFINITY
let placedCount = 0
for (let layerIdx = 0; layerIdx < cof.layers.length; layerIdx += 1) {
const dcc = sprites[layerIdx]
if (!dcc) continue
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
const dirData = dcc.directions[dccDir]
if (!dirData) continue
const frameData = dirData.frames[f]
if (!frameData) continue
left = Math.min(left, dirData.box.left)
top = Math.min(top, dirData.box.top)
right = Math.max(right, dirData.box.left + dirData.box.width)
bottom = Math.max(bottom, dirData.box.top + dirData.box.height)
placedCount += 1
}
if (placedCount === 0) {
const emptyFrame: SpriteFrame = {
width: 1,
height: 1,
indices: new Uint8Array(1),
mask: new Uint8Array(1),
anchorX: 0,
anchorY: 0,
}
baseFrames.push(emptyFrame)
if (isComposite) glowFrames.push(emptyFrame)
continue
}
const boxLeft = Math.round(left)
const boxTop = Math.round(top)
const frameWidth = Math.max(1, Math.round(right) - boxLeft)
const frameHeight = Math.max(1, Math.round(bottom) - boxTop)
const baseTarget = {
indices: new Uint8Array(frameWidth * frameHeight),
mask: new Uint8Array(frameWidth * frameHeight),
width: frameWidth,
height: frameHeight,
}
const glowTarget = isComposite
? {
indices: new Uint8Array(frameWidth * frameHeight),
mask: new Uint8Array(frameWidth * frameHeight),
width: frameWidth,
height: frameHeight,
}
: null
const order = cofLayerOrder(cof, dir, f)
for (const layerIdx of order) {
const dcc = sprites[layerIdx]
if (!dcc) continue
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
const dirData = dcc.directions[dccDir]
if (!dirData) continue
const frameData = dirData.frames[f]
if (!frameData) continue
const atX = Math.round(dirData.box.left) - boxLeft
const atY = Math.round(dirData.box.top) - boxTop
if (glowTarget !== null && isAdditiveLayer(layerIdx)) {
blit(glowTarget, frameData.frame, atX, atY)
} else {
blit(baseTarget, frameData.frame, atX, atY)
}
}
baseFrames.push({
width: frameWidth,
height: frameHeight,
indices: baseTarget.indices,
mask: baseTarget.mask,
anchorX: boxLeft,
anchorY: boxTop,
})
if (glowTarget !== null) {
glowFrames.push({
width: frameWidth,
height: frameHeight,
indices: glowTarget.indices,
mask: glowTarget.mask,
anchorX: boxLeft,
anchorY: boxTop,
})
}
}
groups.push({ frames: baseFrames })
if (isComposite) glowGroups.push({ frames: glowFrames })
}
return {
sheet: { groups, width: null },
glowSheet: isComposite ? { groups: glowGroups, width: null } : undefined,
blendMode: isPureAdditive ? 'additive' : 'normal',
directions: cof.numberOfDirections,
framesPerDirection: cof.framesPerDirection,
layers: cof.layers.length,
decodedMembers,
layerFailures,
}
}
/**
* Composite monster walk ('wl') and neutral ('nu') animations, and build a runtime atlas.
*/
import { buildIndexedAtlas } 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'
import type { AnimClipMeta } from './actor-animator.ts'
export interface LoadedMonsterArt {
readonly token: string
readonly handle: AtlasHandle
readonly groups: readonly (readonly AtlasFrame[])[]
readonly glowGroups?: readonly (readonly AtlasFrame[])[]
readonly blendMode?: 'normal' | 'additive'
readonly directions: number
readonly walkOffset: number
readonly standOffset: number
readonly walkFrames: number
readonly standFrames: number
readonly clips?: Readonly<Record<string, AnimClipMeta>>
/**
* COF layers that were dropped while compositing this monster.
*
* Non-empty means the sprite on screen is missing a body part. Pre-baked pack
* art has no layers of its own, so its loader reports an empty list.
*/
readonly layerFailures: readonly MonsterLayerFailure[]
}
/**
* Load and composite a monster's walk and neutral sprite sheets, packing them into an 8-bit R8 atlas.
*/
export async function loadMonsterAtlas(
archives: MountedArchives,
token: string,
weapon: string,
_palette: Palette,
renderer: SpriteRenderer,
): Promise<LoadedMonsterArt> {
const [walk, stand] = await Promise.all([
compositeMonsterAnimation(archives, token, 'wl', weapon),
compositeMonsterAnimation(archives, token, 'nu', weapon),
])
const hasGlow = walk.glowSheet !== undefined || stand.glowSheet !== undefined
const baseGroups = [...walk.sheet.groups, ...stand.sheet.groups]
const baseCount = baseGroups.length
const blendMode: 'normal' | 'additive' =
walk.blendMode === 'additive' || stand.blendMode === 'additive'
? 'additive'
: 'normal'
let combined: SpriteSheet
if (hasGlow) {
const makeEmptyGlowGroups = (sheet: SpriteSheet): SpriteGroup[] =>
sheet.groups.map(g => ({
frames: g.frames.map(f => ({
width: 1,
height: 1,
indices: new Uint8Array(1),
mask: new Uint8Array(1),
...(f.anchorX !== undefined ? { anchorX: f.anchorX } : {}),
...(f.anchorY !== undefined ? { anchorY: f.anchorY } : {}),
})),
}))
const walkGlow = walk.glowSheet?.groups ?? makeEmptyGlowGroups(walk.sheet)
const standGlow = stand.glowSheet?.groups ?? makeEmptyGlowGroups(stand.sheet)
const allGlowGroups = [...walkGlow, ...standGlow]
combined = {
groups: [...baseGroups, ...allGlowGroups],
width: null,
}
} else {
combined = {
groups: baseGroups,
width: null,
}
}
const atlas = buildIndexedAtlas(combined, 172, { width: 1024 })
const handle = renderer.addIndexedAtlas(atlas.r8, atlas.width, atlas.height)
const groups = atlas.groups.slice(0, baseCount)
const glowGroups = hasGlow ? atlas.groups.slice(baseCount) : undefined
return {
token,
handle,
groups,
...(glowGroups !== undefined ? { glowGroups } : {}),
blendMode,
directions: walk.directions,
walkOffset: 0,
standOffset: walk.sheet.groups.length,
walkFrames: walk.framesPerDirection,
standFrames: stand.framesPerDirection,
layerFailures: [...walk.layerFailures, ...stand.layerFailures],
}
}