diablo2-web/src/game/character.ts

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
}