diablo2-web/src/common/units/char-appearance.ts

756 lines
27 KiB
TypeScript

/**
* Dynamic Sorceress (`so`) paperdoll appearance resolver and runtime layer compositor.
*
* Implements the 1.13c composite layer, weapon class, and palette shift pipeline:
* - Resolves equipped `helm`, `armor`, `weapon1`, and `weapon2` into the 10 active
* character composite layers (`hd`, `tr`, `lg`, `ra`, `la`, `rh`, `lh`, `sh`, `s1`, `s2`)
* and active weapon animation class (`hth`, `1hs`, `1ht`, `stf`, `bow`, `xbw`, `2hs`, `2ht`).
* - Resolves per-item color shifts (`Transform` 1..8 x `Colors.txt` 0..20) across Unique, Set,
* Magic/Rare/Crafted (suffixes before prefixes), Socketed Gem, and AutoMagic items.
* - Composites multi-mode DCC layers in COF priority order with 256-byte palette remap tables
* and packs all 11 animation modes into an R8 indexed atlas page.
*/
import { decodeCof, cofLayerOrder, type CofFile } from '../formats/cof.ts'
import { decodeDcc, type DccFile } from '../formats/dcc.ts'
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
import type { AnimClipMeta } from './unit-types.ts'
import { COMPONENTS, dir64ToDcc } from './character.ts'
import type { DropTables } from '../items/drop-tables.ts'
import { COLOR_CODES } from '../items/gems-data.ts'
import type { Item } from '../items/items.ts'
import { resolveItemColorTransform } from '../items/item-bridge.ts'
import type { EquipSlotId, UiItemQuality } from '../items/inventory-model.ts'
/** All 10 active paperdoll component layers used by Sorceress animations. */
export const SORCERESS_COMPONENTS = [
'hd',
'tr',
'lg',
'ra',
'la',
'rh',
'lh',
'sh',
's1',
's2',
] as const
export type SorceressComponent = (typeof SORCERESS_COMPONENTS)[number]
/** Standard 11 player animation modes in pack order. */
export const SORCERESS_PLAYER_MODES = [
'wl',
'nu',
'rn',
'a1',
'a2',
'sc',
'gh',
'dt',
'dd',
'tn',
'tw',
] as const
const LOOPING_PLAYER_MODES = new Set<string>(['nu', 'wl', 'rn', 'tn', 'tw'])
const ARMOR_TIERS: readonly ('lit' | 'med' | 'hvy')[] = ['lit', 'med', 'hvy']
const VALID_HEAD_VARIANTS = new Set<string>([
'lit',
'bhm',
'cap',
'crn',
'fhl',
'ghm',
'hlm',
'msk',
'skp',
])
/** Visual specification for a single paperdoll layer. */
export interface LayerAppearance {
/** 3-letter DCC variant code (e.g. `'lit'`, `'cap'`, `'hvy'`, `'ob4'`, `'kit'`), or `null` if unequipped. */
readonly variant: string | null
/** Palette shift table index from `Armor.txt` / `Weapons.txt` `Transform` column (`0..8`). */
readonly transform: number
/** `Colors.txt` 4-letter code (e.g. `'cgrn'`, `'dpur'`, `'whit'`), or `null` if unshifted. */
readonly color: string | null
}
/** Complete resolved visual state of the Sorceress paperdoll across all layers and weapon class. */
export interface SorceressAppearanceSpec {
/** Active animation weapon class (`'hth'`, `'1hs'`, `'1ht'`, `'stf'`, `'bow'`, `'xbw'`, `'2hs'`, `'2ht'`). */
readonly wclass: string
/** Per-component visual variant and dye configuration. */
readonly layers: Readonly<Record<SorceressComponent, LayerAppearance>>
}
/** Minimal item interface required to resolve equipped paperdoll appearance. */
export interface ResolvableEquippedItem {
readonly code?: string | undefined
readonly name?: string | undefined
readonly quality?: UiItemQuality | undefined
readonly chrtransform?: string | undefined
readonly invtransform?: string | undefined
readonly identified?: boolean | undefined
readonly isGambleMystery?: boolean | undefined
readonly rawItem?: Item | undefined
}
/** Manifest structure for `samples/d2-packs/chars/so/manifest.json`. */
export interface SorceressLayerPackManifest {
readonly schema: 1
readonly metaFile: string
readonly cofs: Readonly<Record<string, readonly [number, number]>>
readonly palettes: Readonly<Record<number, readonly [number, number]>>
readonly animClips: Readonly<
Record<string, { readonly speed: number; readonly events: readonly (readonly [number, number])[] }>
>
readonly layers: Readonly<
Record<
string,
{
readonly file: string
readonly dccs: Readonly<Record<string, readonly [number, number]>>
}
>
>
}
/** Result of compositing and shelf-packing all 11 Sorceress animation modes into an R8 atlas. */
export interface CompositedSorceressR8 {
readonly key: string
readonly spec: SorceressAppearanceSpec
readonly r8: Uint8Array
readonly width: number
readonly height: number
readonly directions: number
readonly walk: number
readonly stand: number
readonly members: number
readonly clips: Readonly<Record<string, AnimClipMeta>>
readonly groups: readonly (readonly [number, number, number, number, number, number])[][]
}
/** Pluggable resource loader for browser `fetch` or Node test runners. */
export interface CharLayerAssetLoader {
loadJson(relPath: string): Promise<unknown>
loadBin(relPath: string): Promise<Uint8Array>
}
function tierCode(value: number | undefined): 'lit' | 'med' | 'hvy' {
const idx = Math.max(0, Math.min(2, Math.trunc(value ?? 0)))
return ARMOR_TIERS[idx] ?? 'lit'
}
function isItemUnidentified(item: ResolvableEquippedItem): boolean {
return Boolean(
item.identified === false ||
item.isGambleMystery === true ||
item.rawItem?.identified === false ||
item.rawItem?.isGambleMystery === true ||
(item.rawItem?.flags !== undefined && item.rawItem.flags.identified === false),
)
}
function resolveEquippedChrColor(
item: ResolvableEquippedItem,
code: string,
dropTables: DropTables,
): string | null {
if (isItemUnidentified(item)) {
return null
}
if (item.chrtransform && COLOR_CODES.includes(item.chrtransform as any)) {
return item.chrtransform
}
const resolved = resolveItemColorTransform(
item.rawItem ?? ({
code,
name: item.name,
quality: item.quality,
identified: item.identified,
runewordRunes: (item as any).runewordRunes,
} as any),
item.quality ?? 'normal',
code,
dropTables,
)
if (resolved.chrtransform && COLOR_CODES.includes(resolved.chrtransform as any)) {
return resolved.chrtransform
}
return null
}
/**
* Produce a deterministic string key uniquely identifying a Sorceress appearance state.
*/
export function appearanceKey(spec: SorceressAppearanceSpec): string {
const parts: string[] = [`wc=${spec.wclass}`]
for (const comp of SORCERESS_COMPONENTS) {
const layer = spec.layers[comp]
const v = layer.variant ?? '-'
const t = layer.variant && layer.color && layer.transform > 0 ? String(layer.transform) : '0'
const c = layer.variant && layer.color && layer.transform > 0 ? layer.color : '-'
parts.push(`${comp}=${v}:${t}:${c}`)
}
return parts.join('|')
}
/**
* Check whether the resolved appearance is the baseline unarmed `lit` state
* (matching the static pre-baked `char-so.r8`).
*/
export function isDefaultUnarmedAppearance(spec: SorceressAppearanceSpec): boolean {
if (spec.wclass !== 'hth') return false
for (const comp of ['hd', 'tr', 'lg', 'ra', 'la'] as const) {
const l = spec.layers[comp]
if (l.variant !== 'lit') return false
if (l.transform > 0 && l.color !== null) return false
}
for (const comp of ['rh', 'lh', 'sh', 's1', 's2'] as const) {
if (spec.layers[comp].variant !== null) return false
}
return true
}
/**
* Resolve the Sorceress's 10 paperdoll layers, weapon class, and color shifts
* from the currently equipped items.
*/
export function resolveSorceressAppearance(
equipped: Partial<Record<EquipSlotId, ResolvableEquippedItem | null | undefined>>,
dropTables: DropTables,
): SorceressAppearanceSpec {
const layers: Record<SorceressComponent, LayerAppearance> = {
hd: { variant: 'lit', transform: 0, color: null },
tr: { variant: 'lit', transform: 0, color: null },
lg: { variant: 'lit', transform: 0, color: null },
ra: { variant: 'lit', transform: 0, color: null },
la: { variant: 'lit', transform: 0, color: null },
rh: { variant: null, transform: 0, color: null },
lh: { variant: null, transform: 0, color: null },
sh: { variant: null, transform: 0, color: null },
s1: { variant: null, transform: 0, color: null },
s2: { variant: null, transform: 0, color: null },
}
// 1. Helm -> HD (Circlets keep default 'lit' head and do not dye hair/skin)
const helmItem = equipped.helm
const helmCode = helmItem?.code?.trim()
if (helmItem && helmCode) {
const helmBase = dropTables.armor.get(helmCode)
if (helmBase) {
const isCirclet =
helmBase.type === 'circ' ||
helmBase.alternategfx === 'cir' ||
helmBase.alternategfx === 'lit' ||
dropTables.isA(helmBase.type, 'circ')
if (!isCirclet) {
const rawVar = (helmBase.alternategfx || helmBase.code).toLowerCase()
const variant = VALID_HEAD_VARIANTS.has(rawVar) ? rawVar : 'lit'
const transform = helmBase.transform ?? 0
const color = transform > 0 ? resolveEquippedChrColor(helmItem, helmCode, dropTables) : null
layers.hd = { variant, transform, color }
}
}
}
// 2. Body Armor -> TR, LG, RA, LA, S1, S2
const armorItem = equipped.armor
const armorCode = armorItem?.code?.trim()
if (armorItem && armorCode) {
const armorBase = dropTables.armor.get(armorCode)
if (armorBase) {
const transform = armorBase.transform ?? 0
const color = transform > 0 ? resolveEquippedChrColor(armorItem, armorCode, dropTables) : null
layers.tr = { variant: tierCode(armorBase.torso), transform, color }
layers.lg = { variant: tierCode(armorBase.legs), transform, color }
layers.ra = { variant: tierCode(armorBase.rArm), transform, color }
layers.la = { variant: tierCode(armorBase.lArm), transform, color }
layers.s1 = { variant: tierCode(armorBase.rSPad), transform, color }
layers.s2 = { variant: tierCode(armorBase.lSPad), transform, color }
}
}
// 3. Weapon1 & Weapon2 -> wclass, RH (weapons/orbs/staves/crossbows), LH (bows), SH (shields)
let wclass = 'hth'
let hasTwoHandedWeapon = false
const handSlots = [equipped.weapon1, equipped.weapon2]
for (const handItem of handSlots) {
const rawCode = handItem?.code?.trim()
if (!handItem || !rawCode) continue
const code = rawCode === '7wa' && handItem.name === 'Colossus Blade' ? '7gd' : rawCode
const weapBase = dropTables.weapons.get(code)
if (weapBase) {
const activeWc = weapBase.twoHanded
? (weapBase.twoHandedWClass || weapBase.wclass)
: weapBase.wclass
if (activeWc) {
wclass = activeWc.toLowerCase()
}
if (weapBase.twoHanded || wclass === 'bow' || wclass === 'xbw' || wclass === 'stf' || wclass === '2hs' || wclass === '2ht') {
hasTwoHandedWeapon = true
}
const rawVar = (weapBase.alternateGfx || weapBase.code).toLowerCase()
const transform = weapBase.transform ?? 0
const color = transform > 0 ? resolveEquippedChrColor(handItem, code, dropTables) : null
if (weapBase.component === 6 || wclass === 'bow') {
layers.lh = { variant: rawVar, transform, color }
} else if (weapBase.component === 5) {
layers.rh = { variant: rawVar, transform, color }
}
break
}
}
// Check shield in either hand slot when not wielding a two-handed weapon
if (!hasTwoHandedWeapon) {
for (const handItem of handSlots) {
const code = handItem?.code?.trim()
if (!handItem || !code) continue
const shieldBase = dropTables.armor.get(code)
if (shieldBase && (shieldBase.component === 7 || shieldBase.type === 'shie' || dropTables.isA(shieldBase.type, 'shie'))) {
const rawVar = (shieldBase.alternategfx || shieldBase.code).toLowerCase()
const transform = shieldBase.transform ?? 0
const color = transform > 0 ? resolveEquippedChrColor(handItem, code, dropTables) : null
layers.sh = { variant: rawVar, transform, color }
break
}
}
}
return { wclass, layers }
}
/**
* Blit one DCC layer frame onto a destination canvas, optionally applying a 256-byte palette remap table.
*/
function blitWithRemap(
target: { indices: Uint8Array; mask: Uint8Array; width: number; height: number },
source: SpriteFrame,
atX: number,
atY: number,
remap: Uint8Array | null,
): 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
const rawIdx = source.indices[at]!
target.indices[to] = remap !== null ? (remap[rawIdx] ?? rawIdx) : rawIdx
target.mask[to] = 1
}
}
}
const MAX_ATLAS_PAGE_SIZE = 4096
/**
* Shelf-pack all frames of a SpriteSheet into a single raw 8-bit index buffer (`gl.R8`).
* Index 0 represents transparency; opaque pixels with index 0 are remapped to `opaqueBlack` (172).
*/
export function packAppearanceSheetToR8(
sheet: SpriteSheet,
opaqueBlack = 172,
): {
readonly r8: Uint8Array
readonly width: number
readonly height: number
readonly groups: [number, number, number, number, number, number][][]
} {
const frames = sheet.groups.flatMap(g => g.frames)
const widest = Math.max(1, ...frames.map(f => f.width))
const area = frames.reduce((sum, f) => sum + (f.width + 1) * (f.height + 1), 0)
let pageWidth = Math.min(MAX_ATLAS_PAGE_SIZE, Math.max(widest + 2, 512))
while (pageWidth < MAX_ATLAS_PAGE_SIZE && pageWidth * pageWidth < area * 1.15) {
pageWidth *= 2
}
pageWidth = Math.min(MAX_ATLAS_PAGE_SIZE, pageWidth)
let cursorX = 0
let shelfY = 0
let shelfHeight = 0
const placements: { x: number; y: number; width: number; height: number }[] = []
for (const frame of frames) {
const w = Math.max(1, Math.min(frame.width, pageWidth))
const h = Math.max(1, Math.min(frame.height, MAX_ATLAS_PAGE_SIZE))
if (cursorX + w > pageWidth) {
shelfY += shelfHeight + 1
shelfHeight = 0
cursorX = 0
}
placements.push({ x: cursorX, y: shelfY, width: w, height: h })
cursorX += w + 1
shelfHeight = Math.max(shelfHeight, h)
}
const pageHeight = Math.max(1, shelfY + shelfHeight)
const r8 = new Uint8Array(pageWidth * pageHeight)
let idx = 0
for (const frame of frames) {
const place = placements[idx]!
idx += 1
for (let row = 0; row < place.height; row += 1) {
const fromRow = row * frame.width
const toRow = (place.y + row) * pageWidth + place.x
for (let col = 0; col < place.width; col += 1) {
const srcAt = fromRow + col
if (frame.mask[srcAt] === 0) {
r8[toRow + col] = 0
} else {
const val = frame.indices[srcAt]!
r8[toRow + col] = val === 0 ? opaqueBlack : val
}
}
}
}
const groups: [number, number, number, number, number, number][][] = []
let consumed = 0
for (const group of sheet.groups) {
const gList: [number, number, number, number, number, number][] = []
for (let i = 0; i < group.frames.length; i += 1) {
const p = placements[consumed]!
const f = group.frames[i]!
consumed += 1
gList.push([p.x, p.y, p.width, p.height, f.anchorX ?? 0, f.anchorY ?? 0])
}
groups.push(gList)
}
return { r8, width: pageWidth, height: pageHeight, groups }
}
function createFetchLoader(packBaseUrl: string): CharLayerAssetLoader {
const base = packBaseUrl.replace(/\/+$/, '').replace(/\/chars\/so$/i, '')
const fetchFn = (
globalThis as {
fetch?: (
url: string,
) => Promise<{ ok: boolean; status: number; json(): Promise<unknown>; arrayBuffer(): Promise<ArrayBuffer> }>
}
).fetch
return {
async loadJson(relPath: string): Promise<unknown> {
if (!fetchFn) throw new Error('fetch is not available in this environment')
const resp = await fetchFn(`${base}/chars/so/${relPath}`)
if (!resp.ok) {
throw new Error(`Failed to fetch Sorceress layer JSON ${relPath}: HTTP ${String(resp.status)}`)
}
return resp.json()
},
async loadBin(relPath: string): Promise<Uint8Array> {
if (!fetchFn) throw new Error('fetch is not available in this environment')
const resp = await fetchFn(`${base}/chars/so/${relPath}`)
if (!resp.ok) {
throw new Error(`Failed to fetch Sorceress layer binary ${relPath}: HTTP ${String(resp.status)}`)
}
return new Uint8Array(await resp.arrayBuffer())
},
}
}
/**
* On-demand Sorceress paperdoll layer compositor with in-memory COF, Palette, and DCC caching.
*/
export class SorceressLayerCompositor {
private readonly loader: CharLayerAssetLoader
private initPromise: Promise<void> | null = null
private manifest: SorceressLayerPackManifest | null = null
private metaBin: Uint8Array | null = null
private readonly cofCache = new Map<string, CofFile>()
private readonly layerBinCache = new Map<string, Promise<Uint8Array>>()
private readonly dccCache = new Map<string, DccFile>()
constructor(packBaseOrLoader: string | CharLayerAssetLoader = 'samples/d2-packs') {
this.loader =
typeof packBaseOrLoader === 'string'
? createFetchLoader(packBaseOrLoader)
: packBaseOrLoader
}
private async ensureInitialized(): Promise<void> {
if (this.manifest !== null && this.metaBin !== null) return
if (this.initPromise === null) {
this.initPromise = (async () => {
const [manifestRaw, metaBytes] = await Promise.all([
this.loader.loadJson('manifest.json'),
this.loader.loadBin('meta.bin'),
])
this.manifest = manifestRaw as SorceressLayerPackManifest
this.metaBin = metaBytes
})()
}
await this.initPromise
}
private getCof(cofToken: string): CofFile | null {
const upper = cofToken.toUpperCase()
const cached = this.cofCache.get(upper)
if (cached !== undefined) return cached
if (!this.manifest || !this.metaBin) return null
const range = this.manifest.cofs[upper]
if (!range) return null
const [offset, length] = range
const decoded = decodeCof(this.metaBin.subarray(offset, offset + length))
this.cofCache.set(upper, decoded)
return decoded
}
/**
* Look up the 256-byte palette remap table for `(transform, colorCode)`.
*/
getPaletteRemap(transform: number, colorCode: string | null): Uint8Array | null {
if (transform <= 0 || !colorCode || !this.manifest || !this.metaBin) return null
const colorIdx = COLOR_CODES.indexOf(colorCode as any)
if (colorIdx < 0 || colorIdx >= 21) return null
const range = this.manifest.palettes[transform]
if (!range) return null
const [palOffset, palLen] = range
if (palLen < (colorIdx + 1) * 256) return null
const start = palOffset + colorIdx * 256
return this.metaBin.subarray(start, start + 256)
}
private async getLayerBin(compVariantKey: string): Promise<Uint8Array | null> {
if (!this.manifest) return null
const entry = this.manifest.layers[compVariantKey]
if (!entry) return null
let p = this.layerBinCache.get(compVariantKey)
if (!p) {
p = this.loader.loadBin(entry.file)
this.layerBinCache.set(compVariantKey, p)
}
return p
}
private async getDcc(
comp: SorceressComponent,
variant: string,
mode: string,
wclass: string,
): Promise<DccFile | null> {
if (!this.manifest) return null
const compVariantKey = `${comp}-${variant.toLowerCase()}`
const layerEntry = this.manifest.layers[compVariantKey]
if (!layerEntry) return null
const modeWclass = `${mode.toUpperCase()}${wclass.toUpperCase()}`
const range = layerEntry.dccs[modeWclass]
if (!range) return null
const cacheKey = `${compVariantKey}:${modeWclass}`
const cached = this.dccCache.get(cacheKey)
if (cached !== undefined) return cached
const bin = await this.getLayerBin(compVariantKey)
if (!bin) return null
const [offset, length] = range
const decoded = decodeDcc(bin.subarray(offset, offset + length))
this.dccCache.set(cacheKey, decoded)
return decoded
}
/**
* Composite all 11 Sorceress animation modes for the given `SorceressAppearanceSpec`
* and pack them into a single R8 indexed atlas buffer.
*/
async composite(spec: SorceressAppearanceSpec): Promise<CompositedSorceressR8> {
await this.ensureInitialized()
const manifest = this.manifest!
// Pre-fetch all required <comp>-<variant>.bin files in parallel
const neededLayerKeys = new Set<string>()
for (const comp of SORCERESS_COMPONENTS) {
const v = spec.layers[comp].variant
if (v) {
const key = `${comp}-${v.toLowerCase()}`
if (manifest.layers[key]) {
neededLayerKeys.add(key)
} else if (comp === 'hd' && manifest.layers['hd-lit']) {
neededLayerKeys.add('hd-lit')
}
}
}
await Promise.all([...neededLayerKeys].map(k => this.getLayerBin(k)))
const allGroups: { frames: SpriteFrame[] }[] = []
const clips: Record<string, AnimClipMeta> = {}
let groupOffset = 0
let totalMembers = 0
for (const mode of SORCERESS_PLAYER_MODES) {
// Per D2Composit.cpp:68-75, death (dt) and corpse (dd) always use 'hth'
const requestedWc = mode === 'dt' || mode === 'dd' ? 'hth' : spec.wclass
let activeWc = requestedWc
let effectiveMode: string = mode
if (mode === 'a2' && (activeWc === 'hth' || !this.getCof(`SOA2${activeWc.toUpperCase()}`))) {
effectiveMode = 'a1'
}
let cofToken = `SO${effectiveMode.toUpperCase()}${activeWc.toUpperCase()}`
let cof = this.getCof(cofToken)
if (!cof && activeWc !== 'hth') {
activeWc = 'hth'
cofToken = `SO${effectiveMode.toUpperCase()}HTH`
cof = this.getCof(cofToken)
}
if (!cof) {
throw new Error(`Missing Sorceress COF descriptor for ${cofToken}`)
}
// Resolve DCC + palette remap for each layer in the COF
const layerEntries = await Promise.all(
cof.layers.map(async (cofLayer) => {
const compName = COMPONENTS[cofLayer.type] as SorceressComponent | undefined
if (!compName || !SORCERESS_COMPONENTS.includes(compName)) {
return { dcc: null, remap: null }
}
// In dt/dd modes, weapons and shields are dropped (unarmed death animation)
if ((mode === 'dt' || mode === 'dd') && (compName === 'rh' || compName === 'lh' || compName === 'sh')) {
return { dcc: null, remap: null }
}
const layerSpec = spec.layers[compName]
if (!layerSpec || !layerSpec.variant) {
return { dcc: null, remap: null }
}
const layerWc =
cofLayer.weaponClass && cofLayer.weaponClass.trim().length > 0
? cofLayer.weaponClass.trim().toLowerCase()
: activeWc
let dcc = await this.getDcc(compName, layerSpec.variant, effectiveMode, layerWc)
if (!dcc && layerWc !== activeWc) {
dcc = await this.getDcc(compName, layerSpec.variant, effectiveMode, activeWc)
}
if (!dcc && activeWc !== 'hth') {
dcc = await this.getDcc(compName, layerSpec.variant, effectiveMode, 'hth')
}
if (!dcc && compName === 'hd' && layerSpec.variant !== 'lit') {
dcc = await this.getDcc('hd', 'lit', effectiveMode, layerWc)
}
if (!dcc) {
return { dcc: null, remap: null }
}
const remap = this.getPaletteRemap(layerSpec.transform, layerSpec.color)
return { dcc, remap }
}),
)
for (const entry of layerEntries) {
if (entry.dcc !== null) totalMembers += 1
}
for (let direction = 0; direction < cof.numberOfDirections; direction += 1) {
const dir64 = Math.round((direction * 64) / cof.numberOfDirections)
const frames: SpriteFrame[] = []
for (let frameIdx = 0; frameIdx < cof.framesPerDirection; frameIdx += 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 l = 0; l < cof.layers.length; l += 1) {
const sprite = layerEntries[l]?.dcc
if (!sprite) continue
const dccDir = dir64ToDcc(dir64, sprite.directions.length)
const layerDir = sprite.directions[dccDir]
const frame = layerDir?.frames[frameIdx]
if (!frame) continue
left = Math.min(left, layerDir!.box.left)
top = Math.min(top, layerDir!.box.top)
right = Math.max(right, layerDir!.box.left + layerDir!.box.width)
bottom = Math.max(bottom, layerDir!.box.top + layerDir!.box.height)
placedCount += 1
}
if (placedCount === 0) {
frames.push({
width: 1,
height: 1,
indices: new Uint8Array(1),
mask: new Uint8Array(1),
anchorX: 0,
anchorY: 0,
})
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),
anchorX: boxLeft,
anchorY: boxTop,
}
const order = cofLayerOrder(cof, direction, frameIdx)
for (const layerIndex of order) {
const entry = layerEntries[layerIndex]
if (!entry || !entry.dcc) continue
const dccDir = dir64ToDcc(dir64, entry.dcc.directions.length)
const layerDir = entry.dcc.directions[dccDir]
const frame = layerDir?.frames[frameIdx]
if (!frame) continue
blitWithRemap(
composed,
frame.frame,
Math.round(layerDir!.box.left) - boxLeft,
Math.round(layerDir!.box.top) - boxTop,
entry.remap,
)
}
frames.push(composed)
}
allGroups.push({ frames })
}
const animRecord = manifest.animClips[cofToken]
const speed = animRecord?.speed ?? 256
const events = (animRecord?.events ?? []).map(([f, c]) => ({ f, c }))
clips[mode] = {
group: groupOffset,
directions: cof.numberOfDirections,
frames: cof.framesPerDirection,
speed,
loop: LOOPING_PLAYER_MODES.has(mode),
events,
}
groupOffset += cof.numberOfDirections
}
const packed = packAppearanceSheetToR8({ groups: allGroups, width: null }, 172)
return {
key: appearanceKey(spec),
spec,
r8: packed.r8,
width: packed.width,
height: packed.height,
directions: clips.wl?.directions ?? 16,
walk: clips.wl?.group ?? 0,
stand: clips.nu?.group ?? 16,
members: totalMembers,
clips,
groups: packed.groups,
}
}
}