/** * Compositing Diablo II's character and object animations. * * A `DCC` file holds one *layer* of an animation (a head, a torso, a leg, a * shield), and a `COF` file says which layers make up a given animation and in * what order they stack. Neither is drawable alone: the file that says "this is * the Sorceress walking" is the COF, and the pixels are in eight DCCs beside it. * * This module joins the two into the plain `SpriteSheet` the renderer already * consumes, so the rest of the engine treats a character exactly like a tile * atlas. It reuses the path convention the verification script proved out: * a layer record names no file — it carries a *composite type* (which body part) * and a *weapon class*, and the COF's own name carries the animation and weapon * codes, so the sprite path is * `/.dcc`. * `` is an armour/object tier code that lives in the item tables rather * than the COF; the lexicographically first candidate is taken, and the chosen * members are reported so the choice is visible instead of implied. */ import type { MpqArchive } from '../mpq/archive.ts' 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 directory, as the archives name them. */ const COMPONENTS: readonly string[] = ['hd', 'tr', 'lg', 'ra', 'la', 'rh', 'lh', 'sh', 's1', 's2'] /** A composited animation, ready to draw. */ export interface CharacterSheet { /** One group per direction, frames in animation order. */ readonly sheet: SpriteSheet /** Directions in the COF (16 for characters, fewer for objects). */ readonly directions: number /** Frames per direction. */ readonly framesPerDirection: number /** Layers the COF declared. */ readonly layers: number /** Layer/direction combinations skipped because a sprite was missing. */ readonly skipped: number /** The DCC members that were decoded, for provenance. */ readonly members: readonly string[] /** Non-fatal observations worth reporting. */ readonly notes: readonly string[] } /** * Find the sprite a COF layer draws. * * @param names - the archive's name list. * @param root - directory shared by the COF and its art, e.g. `data\\global\\chars\\so\\`. * @param token - class or object code, e.g. `so`. * @param animation - two-letter animation code from the COF name, e.g. `wl`. * @param weapon - weapon-class code from the COF name, e.g. `hth`. * @param component - component directory for the layer's composite type. * @returns the member name, or undefined. */ function findLayerSprite( 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] } /** * Blit one layer onto a bigger canvas. * * The frames are palette-indexed with a transparency mask, so compositing is a * "masked copy": later layers paint over earlier ones exactly where they have * pixels, which is the same rule the DT1 tile compositor uses. * * @param target - destination frame (its buffers are written). * @param source - layer frame. * @param atX - destination x. * @param atY - destination y. */ 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 } } } /** * Load and composite one animation. * * @param archives - the mounted archives. * @param token - class or object code, e.g. `so` for the Sorceress. * @param animation - animation code from the COF name, e.g. `wl` (walk) or `nu` (neutral). * @param weapon - weapon class, e.g. `hth` for unarmed. * @returns the composited animation. */ export async function loadCharacterSheet( archives: MountedArchives, token: string, animation: string, weapon: string, ): Promise { const lower = token.toLowerCase() const root = `data\\global\\chars\\${lower}\\` const cofMember = `${root}cof\\${lower}${animation}${weapon}.cof` const cofBytes = await archives.read(cofMember) const cof: CofFile = decodeCof(cofBytes) const names = await archives.listFiles() const notes: string[] = [] const members: string[] = [] const sprites: (DccFile | null)[] = [] for (const layer of cof.layers) { const component = COMPONENTS[layer.type] if (component === undefined) { notes.push(`layer type ${String(layer.type)} has no component directory`) sprites.push(null) continue } const member = findLayerSprite(names, root, lower, animation, weapon, component) if (member === undefined) { notes.push(`no sprite for component ${component} (${animation}${weapon})`) sprites.push(null) continue } try { sprites.push(decodeDcc(await archives.read(member))) members.push(member) } catch (err) { notes.push(`${member}: ${(err as Error).message}`) sprites.push(null) } } const groups: { frames: SpriteFrame[] }[] = [] let skipped = 0 for (let direction = 0; direction < cof.numberOfDirections; direction += 1) { const frames: SpriteFrame[] = [] for (let index = 0; index < cof.framesPerDirection; index += 1) { // Union box across the layers that have art for this direction, so every // layer keeps its position in sprite space. 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 layer = 0; layer < cof.layers.length; layer += 1) { const sprite = sprites[layer] if (sprite === null || sprite === undefined) continue const layerDirection = sprite.directions[direction % sprite.directions.length] const frame = layerDirection?.frames[index] if (frame === undefined) { skipped += 1; continue } left = Math.min(left, layerDirection!.box.left) top = Math.min(top, layerDirection!.box.top) right = Math.max(right, layerDirection!.box.left + layerDirection!.box.width) bottom = Math.max(bottom, layerDirection!.box.top + layerDirection!.box.height) placedCount += 1 } if (placedCount === 0) { frames.push({ width: 1, height: 1, indices: new Uint8Array(1), mask: new Uint8Array(1) }) continue } const boxLeft = Math.round(left) const boxTop = Math.round(top) const width = Math.max(1, Math.round(right) - boxLeft) const height = Math.max(1, Math.round(bottom) - boxTop) const composed: SpriteFrame = { width, height, indices: new Uint8Array(width * height), mask: new Uint8Array(width * height), } // Draw in the COF's own back-to-front order for this direction and frame; // the priority table is the authority on what covers what. const order = cofLayerOrder(cof, direction, index) const ordered = order .map(layerIndex => { const sprite = sprites[layerIndex]; return { layerIndex, sprite } }) .filter(entry => entry.sprite !== null && entry.sprite !== undefined) for (const entry of ordered) { const sprite = entry.sprite as DccFile const layerDirection = sprite.directions[direction % sprite.directions.length] const frame = layerDirection?.frames[index] if (frame === undefined) continue blit(composed, frame.frame, Math.round(layerDirection!.box.left) - boxLeft, Math.round(layerDirection!.box.top) - boxTop) } frames.push(composed) } groups.push({ frames }) } return { sheet: { groups, width: null }, directions: cof.numberOfDirections, framesPerDirection: cof.framesPerDirection, layers: cof.layers.length, skipped, members, notes, } } /** * The engine's 64-direction space → COF direction tables. * * Ported from OpenDiablo2 `d2fileformats/d2cof/cof_dir_lookup.go` (`Dir64ToCof`), * which reproduces the engine's own lookup: the mapping is **not** * `floor(dir64 * n / 64)` — it is five literal tables, and they are uneven in * places (16 directions: `dir64` 0 and 1 → 0, 2..5 → 1, 6..9 → 2, …). */ const DIR64_TO_COF: Readonly> = { 4: [ 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, ], 8: [ 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 0, 0, 0, 0, ], 16: [ 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6, 7, 7, 7, 7, 8, 8, 8, 8, 9, 9, 9, 9, 10, 10, 10, 10, 11, 11, 11, 11, 12, 12, 12, 12, 13, 13, 13, 13, 14, 14, 14, 14, 15, 15, 15, 15, 0, 0, ], 32: [ 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23, 24, 24, 25, 25, 26, 26, 27, 27, 28, 28, 29, 29, 30, 30, 31, 31, 0, ], 64: Array.from({ length: 64 }, (_, index) => index), } /** * Map a 64-direction space direction to a COF direction, the way the engine does. * * @param direction - 0..63. * @param directions - directions in the COF (4, 8, 16, 32 or 64). * @returns the COF direction index, 0 when the count is not one the engine uses. */ export function dir64ToCof(direction: number, directions: number): number { const table = DIR64_TO_COF[directions] if (table === undefined) return 0 const index = ((Math.trunc(direction) % 64) + 64) % 64 return table[index] ?? 0 } /** * Map a screen facing to a COF direction. * * The page's input is eight screen directions starting north, which in the * engine's 64-direction space are the eight multiples of 8 — so this is * {@link dir64ToCof} applied to `facing * 8`. Keeping the conversion in the * engine's terms means the result stays right if the facing ever gets finer * (mouse aiming, 16-way input), where the plain "two steps per facing" reading * would not: with eight facings the two agree, which * `npm run verify:dcc` checks for every direction count. * * @param facing - 0 = north, clockwise, 0..7. * @param directions - directions in the COF. * @returns the direction index. */ export function facingToDirection(facing: number, directions: number): number { return dir64ToCof(facing * 8, directions) } /** Decode one DCC member, for callers that only need its first frame. */ export async function firstFrameOf(archive: MpqArchive, member: string): Promise { const file = archive.find(member) if (file === undefined) return undefined const dcc = decodeDcc(await archive.read(file)) return dcc.directions[0]?.frames[0]?.frame }