diablo2-web/src/formats/sprite.ts

104 lines
3.5 KiB
TypeScript

/**
* Shared sprite vocabulary: the frame/group/sheet shape every sprite decoder
* produces, regardless of container (Diablo I `.cel`/`.cl2` today, Diablo II
* `.dc6`/`.dcc` next).
*
* Frames stay palette-indexed on purpose. Both games implement unit variants
* (a red skeleton, a black bow skeleton) as an index remap applied at draw
* time, so keeping indices plus a transparency mask is what lets one decoded
* sprite serve every variant without re-decoding — the same reason Diablo II's
* palette shifts exist.
*/
/** One decoded frame. */
export interface SpriteFrame {
/** Frame width in pixels. */
readonly width: number
/** Frame height in pixels. */
readonly height: number
/** Palette indices, row-major, `width * height` entries. */
readonly indices: Uint8Array
/** 1 where the pixel is opaque, 0 where transparent. */
readonly mask: Uint8Array
/** Horizontal anchor offset in sprite space (box.left in DCC coordinates). */
readonly anchorX?: number
/** Vertical anchor offset in sprite space (box.top in DCC coordinates). */
readonly anchorY?: number
}
/** Frames belonging to one animation direction. */
export interface SpriteGroup {
/** The group's frames, in animation order. */
readonly frames: readonly SpriteFrame[]
}
/** A decoded file: one group for plain sheets, many for multi-direction art. */
export interface SpriteSheet {
/** The groups, in file order. */
readonly groups: readonly SpriteGroup[]
/**
* The uniform width every frame was decoded with, or null when the frames
* were given individual widths.
*/
readonly width: number | null
}
/** Raised when a sprite file cannot be decoded. */
export class CelError extends Error {
constructor(message: string) {
super(message)
this.name = 'CelError'
}
}
/** Smallest frame width worth trying. */
const MIN_WIDTH = 8
/**
* Largest frame width worth trying. Diablo I's cutscene and panel art is
* 640 wide, so the cap has to sit above that; Diablo II's widest sheets stay
* below 256.
*/
const MAX_WIDTH = 1024
/**
* Width candidates for auto-detection, smallest first.
*
* Only meaningful for formats whose runs are line-bounded (Diablo I `.cel`):
* there a width is valid exactly when every row's runs sum to it, so a wrong
* width is rejected outright. For formats whose runs may cross rows (`.cl2`,
* Diablo II `.dcc`) the run stream is consumed to the frame end under *any*
* width, so the width cannot be recovered from the file at all — the game
* supplies it per animation, and so must a caller.
*
* @returns candidate widths.
*/
export function spriteWidthCandidates(): number[] {
const widths: number[] = []
for (let width = MIN_WIDTH; width <= MAX_WIDTH; width += 1) widths.push(width)
return widths
}
/**
* Player animation frame widths.
*
* These are the values the original game assigns in `SetPlrAnims`
* (`Source/player.cpp` of the Devilution reconstruction): every class stands
* and walks at 96 (the Hellfire monk at 112), attacks at 128, and drops to 96
* for bow and unarmed attacks. They are data, not guesses — a `.cl2` file
* cannot be decoded without them.
*/
export const PLAYER_SPRITE_WIDTH = {
/** Stand and walk, base classes. */
stand: 96,
/** Walk, base classes. */
walk: 96,
/** Melee attack. */
attack: 128,
/** Attack without a weapon, or with a bow. */
attackNarrow: 96,
/** Stand/walk for the Hellfire monk. */
monk: 112,
/** Melee attack for the Hellfire monk. */
monkAttack: 130,
} as const