756 lines
27 KiB
TypeScript
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,
|
|
}
|
|
}
|
|
}
|