diablo2-web/src/common/units/monster-art.ts

644 lines
21 KiB
TypeScript

/**
* Pure COF + DCC layer resolution and compositing for Diablo II monster animations.
*/
import type { MountedArchives } from '../mpq/mount.ts'
import { decodeCof, cofLayerOrder } from '../formats/cof.ts'
import type { CofFile } from '../formats/cof.ts'
import { decodeDc6 } from '../formats/dc6.ts'
import type { Dc6Frame, Dc6Sheet } from '../formats/dc6.ts'
import { decodeDcc } from '../formats/dcc.ts'
import type { DccDirection, DccFile, DccFrame } 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[] = [
'hd', 'tr', 'lg', 'ra', 'la', 'rh', 'lh', 'sh',
's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8'
]
/**
* Canonical 1.13c monster animation modes baked for full-spectrum monster & Town NPC packs.
*/
export const TARGET_MONSTER_MODES = [
'wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd',
's1', 's2', 's3', 's4', 'on', 'op',
] as const
/**
* Canonical 1.13c object animation modes (`objmode.txt` / `Objects.txt` Mode0..Mode7).
*/
export const TARGET_OBJECT_MODES = [
'nu', 'op', 'on', 's1', 's2', 's3', 's4', 's5',
] as const
/**
* Tokens of monsters that require additive blending for their ghostly or energy appearance.
*/
export const ADDITIVE_MONSTER_TOKENS: ReadonlySet<string> = new Set([
'WR', 'WW', 'FR', 'K9', '17', '46', 'FI', 'X3', 'X4', 'XW',
])
interface ArchiveFileIndex {
readonly allFiles: readonly string[]
readonly byLower: ReadonlyMap<string, string>
readonly byRootPrefix: ReadonlyMap<string, readonly string[]>
}
const archiveIndexCache = new WeakMap<MountedArchives, Promise<ArchiveFileIndex>>()
async function getArchiveFileIndex(archives: MountedArchives): Promise<ArchiveFileIndex> {
let promise = archiveIndexCache.get(archives)
if (!promise) {
promise = (async () => {
const allFiles = await archives.listFiles()
const byLower = new Map<string, string>()
const byRootPrefix = new Map<string, string[]>()
for (const name of allFiles) {
const lower = name.toLowerCase()
if (!byLower.has(lower)) {
byLower.set(lower, name)
}
if (lower.startsWith('data\\global\\monsters\\') || lower.startsWith('data\\global\\objects\\')) {
const parts = lower.split('\\')
if (parts.length >= 4) {
const rootKey = `${parts[0]}\\${parts[1]}\\${parts[2]}\\${parts[3]}\\`
let list = byRootPrefix.get(rootKey)
if (!list) {
list = []
byRootPrefix.set(rootKey, list)
}
list.push(name)
}
}
}
return { allFiles, byLower, byRootPrefix }
})()
archiveIndexCache.set(archives, promise)
}
return promise
}
/**
* Find the DCC or DC6 sprite member for a specific monster/object 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 ext of ['.dcc', '.dc6']) {
for (const anim of anims) {
for (const w of weapons) {
const suffix = `${anim}${w.toLowerCase()}${ext}`
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
}
}
}
/**
* Adapts a decoded DC6 sprite sheet into a DccFile-compatible directional structure
* so COF layer compositing handles both .dcc and .dc6 layers identically.
*/
export function dc6ToDccFile(
sheet: Dc6Sheet,
cofDirections = sheet.header.directions,
cofFramesPerDir = sheet.header.framesPerDirection,
): DccFile {
let dirFrameLists: (readonly Dc6Frame[])[] = []
if (sheet.groups.length === cofDirections) {
dirFrameLists = sheet.groups.map(g => g.frames)
} else if (
sheet.groups.length === 1 &&
cofDirections > 1 &&
(sheet.groups[0]?.frames.length ?? 0) >= cofDirections * cofFramesPerDir
) {
const allFrames = sheet.groups[0]!.frames
for (let d = 0; d < cofDirections; d += 1) {
dirFrameLists.push(allFrames.slice(d * cofFramesPerDir, (d + 1) * cofFramesPerDir))
}
} else {
dirFrameLists = sheet.groups.map(g => g.frames)
}
const directions: DccDirection[] = []
for (const dirFrames of dirFrameLists) {
if (dirFrames.length === 0) {
directions.push({
box: { left: 0, top: 0, width: 1, height: 1 },
frames: [],
})
continue
}
let left = Number.POSITIVE_INFINITY
let top = Number.POSITIVE_INFINITY
let right = Number.NEGATIVE_INFINITY
let bottom = Number.NEGATIVE_INFINITY
for (const f of dirFrames) {
const fLeft = f.offsetX
const fTop = f.offsetY - f.height + 1
left = Math.min(left, fLeft)
top = Math.min(top, fTop)
right = Math.max(right, fLeft + f.width)
bottom = Math.max(bottom, fTop + f.height)
}
const boxLeft = Number.isFinite(left) ? Math.round(left) : 0
const boxTop = Number.isFinite(top) ? Math.round(top) : 0
const boxWidth = Number.isFinite(right) ? Math.max(1, Math.round(right) - boxLeft) : 1
const boxHeight = Number.isFinite(bottom) ? Math.max(1, Math.round(bottom) - boxTop) : 1
const dirBox = { left: boxLeft, top: boxTop, width: boxWidth, height: boxHeight }
const dccFrames: DccFrame[] = []
for (const f of dirFrames) {
const fLeft = f.offsetX
const fTop = f.offsetY - f.height + 1
let canvas: SpriteFrame
if (fLeft === boxLeft && fTop === boxTop && f.width === boxWidth && f.height === boxHeight) {
canvas = {
width: boxWidth,
height: boxHeight,
indices: f.indices,
mask: f.mask,
}
} else {
const indices = new Uint8Array(boxWidth * boxHeight)
const mask = new Uint8Array(boxWidth * boxHeight)
const target = { width: boxWidth, height: boxHeight, indices, mask }
blit(target, f, fLeft - boxLeft, fTop - boxTop)
canvas = target
}
dccFrames.push({
width: f.width,
height: f.height,
offsetX: f.offsetX,
offsetY: f.offsetY,
frame: canvas,
bottomUp: false,
})
}
directions.push({
box: dirBox,
frames: dccFrames,
})
}
return {
version: 6,
directions,
}
}
function decodeLayerSpriteBytes(
member: string,
bytes: Uint8Array,
cofDirections: number,
cofFramesPerDir: number,
): DccFile {
if (member.toLowerCase().endsWith('.dc6')) {
return dc6ToDccFile(decodeDc6(bytes), cofDirections, cofFramesPerDir)
}
return decodeDcc(bytes)
}
export interface MonsterLayerFailure {
readonly token: string
readonly animation: string
readonly component: string | null
readonly member: string | null
readonly reason: 'unknown-component' | 'no-member' | 'decode'
readonly message?: string
}
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[]
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.trim().toLowerCase()
const root = `data\\global\\monsters\\${lower}\\`
const fileIndex = await getArchiveFileIndex(archives)
const tokenFiles = fileIndex.byRootPrefix.get(root) ?? fileIndex.allFiles
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 = fileIndex.byLower.get(cand.toLowerCase())
if (matchedCof) {
if (cand.endsWith(`nu${usedWeapon}.cof`) || cand.endsWith('nuhth.cof')) usedAnim = 'nu'
if (cand.endsWith('hth.cof')) usedWeapon = 'hth'
break
}
}
if (!matchedCof) {
const cofPrefix = `${root}cof\\${lower}${usedAnim}`.toLowerCase()
const altCofs = tokenFiles
.filter(n => {
const ln = n.toLowerCase()
return ln.startsWith(cofPrefix) && ln.endsWith('.cof') && ln.length === cofPrefix.length + 7
})
.sort()
if (altCofs.length > 0) {
matchedCof = altCofs[0]
usedWeapon = matchedCof!.slice(-7, -4).toLowerCase()
}
}
let cof: CofFile
if (matchedCof) {
const cofBytes = await archives.read(matchedCof)
cof = decodeCof(cofBytes)
} else {
const standaloneTr = findMonsterLayerSprite(
tokenFiles,
root,
lower,
usedAnim,
usedWeapon,
'tr',
usedWeapon,
)
if (!standaloneTr) {
throw new Error(`Monster COF not found: ${candidates[0]}`)
}
const trBytes = await archives.read(standaloneTr)
const trSprite = decodeLayerSpriteBytes(standaloneTr, trBytes, 8, 8)
const numDirs = Math.max(1, trSprite.directions.length)
const framesPerDir = Math.max(1, trSprite.directions[0]?.frames.length ?? 1)
cof = {
numberOfLayers: 1,
framesPerDirection: framesPerDir,
numberOfDirections: numDirs,
speed: 256,
layers: [{ type: 1, shadow: 1, selectable: true, transparent: false, drawEffect: 0, weaponClass: usedWeapon }],
animationFrames: new Uint8Array(framesPerDir),
priority: Array.from({ length: numDirs }, () => Array.from({ length: framesPerDir }, () => [1])),
}
}
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(
tokenFiles,
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: decodeLayerSpriteBytes(member, bytes, cof.numberOfDirections, cof.framesPerDirection),
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] : [])
return compositeLoadedLayers(cof, sprites, decodedMembers, layerFailures, 0, 0)
}
/**
* Load and composite a single object animation (`nu`, `op`, `on`, `s1..s5`) across all directions and frames.
* Uses `objectDrawAnchor` offsets (`anchorX = -16 + boxLeft`, `anchorY = 16 + boxTop`).
*/
export async function compositeObjectAnimation(
archives: MountedArchives,
token: string,
animation: string,
baseIsMonsters = false,
): Promise<MonsterAnimationSheet> {
const lower = token.trim().toLowerCase()
const usedAnim = animation.trim().toLowerCase()
const root = baseIsMonsters
? `data\\global\\monsters\\${lower}\\`
: `data\\global\\objects\\${lower}\\`
const fileIndex = await getArchiveFileIndex(archives)
const tokenFiles = fileIndex.byRootPrefix.get(root) ?? fileIndex.allFiles
const exactCof = `${root}cof\\${lower}${usedAnim}hth.cof`.toLowerCase()
let matchedCof = fileIndex.byLower.get(exactCof)
let usedWeapon = 'hth'
if (!matchedCof) {
const cofPrefix = `${root}cof\\${lower}${usedAnim}`.toLowerCase()
const altCofs = tokenFiles
.filter(n => {
const ln = n.toLowerCase()
return ln.startsWith(cofPrefix) && ln.endsWith('.cof') && ln.length === cofPrefix.length + 7
})
.sort()
if (altCofs.length > 0) {
matchedCof = altCofs[0]
usedWeapon = matchedCof!.slice(-7, -4).toLowerCase()
}
}
if (!matchedCof) {
throw new Error(`Object COF not found: ${exactCof}`)
}
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 litDcc = fileIndex.byLower.get(`${root}${component}\\${lower}${component}lit${usedAnim}${usedWeapon}.dcc`)
const litDc6 = fileIndex.byLower.get(`${root}${component}\\${lower}${component}lit${usedAnim}${usedWeapon}.dc6`)
const member =
litDcc ??
litDc6 ??
findMonsterLayerSprite(
tokenFiles,
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: decodeLayerSpriteBytes(member, bytes, cof.numberOfDirections, cof.framesPerDirection),
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] : [])
// Sub-tile draw anchor offset from D2Common ordinal 10117 (subtileDrawPositionCoords): (-16, +16)
return compositeLoadedLayers(cof, sprites, decodedMembers, layerFailures, -16, 16)
}
function compositeLoadedLayers(
cof: CofFile,
sprites: readonly (DccFile | null)[],
decodedMembers: readonly string[],
layerFailures: readonly MonsterLayerFailure[],
anchorOffsetX: number,
anchorOffsetY: number,
): MonsterAnimationSheet {
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 || dcc.directions.length === 0) continue
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
const dirData = dcc.directions[dccDir] ?? dcc.directions[0]
if (!dirData || dirData.frames.length === 0) continue
const frameData = dirData.frames[f] ?? dirData.frames[f % dirData.frames.length]
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: anchorOffsetX,
anchorY: anchorOffsetY,
}
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 || dcc.directions.length === 0) continue
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
const dirData = dcc.directions[dccDir] ?? dcc.directions[0]
if (!dirData || dirData.frames.length === 0) continue
const frameData = dirData.frames[f] ?? dirData.frames[f % dirData.frames.length]
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: anchorOffsetX + boxLeft,
anchorY: anchorOffsetY + boxTop,
})
if (glowTarget !== null) {
glowFrames.push({
width: frameWidth,
height: frameHeight,
indices: glowTarget.indices,
mask: glowTarget.mask,
anchorX: anchorOffsetX + boxLeft,
anchorY: anchorOffsetY + 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,
}
}