291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
/**
|
|
* 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
|
|
* `<root><component>/<token><component><variant><animation><weapon>.dcc`.
|
|
* `<variant>` 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<CharacterSheet> {
|
|
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<Record<number, readonly number[]>> = {
|
|
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<SpriteFrame | undefined> {
|
|
const file = archive.find(member)
|
|
if (file === undefined) return undefined
|
|
const dcc = decodeDcc(await archive.read(file))
|
|
return dcc.directions[0]?.frames[0]?.frame
|
|
}
|