644 lines
21 KiB
TypeScript
644 lines
21 KiB
TypeScript
/**
|
|
* Pure COF + DCC layer resolution and compositing for Diablo II monster animations.
|
|
*/
|
|
import type { MountedArchives } from '../mpq/mount.ts'
|
|
import { decodeCof, cofLayerOrder } from '../formats/cof.ts'
|
|
import type { CofFile } from '../formats/cof.ts'
|
|
import { decodeDc6 } from '../formats/dc6.ts'
|
|
import type { Dc6Frame, Dc6Sheet } from '../formats/dc6.ts'
|
|
import { decodeDcc } from '../formats/dcc.ts'
|
|
import type { DccDirection, DccFile, DccFrame } from '../formats/dcc.ts'
|
|
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
|
import { dir64ToDcc } from './character.ts'
|
|
|
|
/** Composite type index -> component folder name in MPQ archives. */
|
|
export const MONSTER_COMPONENTS: readonly string[] = [
|
|
'hd', 'tr', 'lg', 'ra', 'la', 'rh', 'lh', 'sh',
|
|
's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8'
|
|
]
|
|
|
|
/**
|
|
* Canonical 1.13c monster animation modes baked for full-spectrum monster & Town NPC packs.
|
|
*/
|
|
export const TARGET_MONSTER_MODES = [
|
|
'wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd',
|
|
's1', 's2', 's3', 's4', 'on', 'op',
|
|
] as const
|
|
|
|
/**
|
|
* Canonical 1.13c object animation modes (`objmode.txt` / `Objects.txt` Mode0..Mode7).
|
|
*/
|
|
export const TARGET_OBJECT_MODES = [
|
|
'nu', 'op', 'on', 's1', 's2', 's3', 's4', 's5',
|
|
] as const
|
|
|
|
/**
|
|
* Tokens of monsters that require additive blending for their ghostly or energy appearance.
|
|
*/
|
|
export const ADDITIVE_MONSTER_TOKENS: ReadonlySet<string> = new Set([
|
|
'WR', 'WW', 'FR', 'K9', '17', '46', 'FI', 'X3', 'X4', 'XW',
|
|
])
|
|
|
|
interface ArchiveFileIndex {
|
|
readonly allFiles: readonly string[]
|
|
readonly byLower: ReadonlyMap<string, string>
|
|
readonly byRootPrefix: ReadonlyMap<string, readonly string[]>
|
|
}
|
|
|
|
const archiveIndexCache = new WeakMap<MountedArchives, Promise<ArchiveFileIndex>>()
|
|
|
|
async function getArchiveFileIndex(archives: MountedArchives): Promise<ArchiveFileIndex> {
|
|
let promise = archiveIndexCache.get(archives)
|
|
if (!promise) {
|
|
promise = (async () => {
|
|
const allFiles = await archives.listFiles()
|
|
const byLower = new Map<string, string>()
|
|
const byRootPrefix = new Map<string, string[]>()
|
|
for (const name of allFiles) {
|
|
const lower = name.toLowerCase()
|
|
if (!byLower.has(lower)) {
|
|
byLower.set(lower, name)
|
|
}
|
|
if (lower.startsWith('data\\global\\monsters\\') || lower.startsWith('data\\global\\objects\\')) {
|
|
const parts = lower.split('\\')
|
|
if (parts.length >= 4) {
|
|
const rootKey = `${parts[0]}\\${parts[1]}\\${parts[2]}\\${parts[3]}\\`
|
|
let list = byRootPrefix.get(rootKey)
|
|
if (!list) {
|
|
list = []
|
|
byRootPrefix.set(rootKey, list)
|
|
}
|
|
list.push(name)
|
|
}
|
|
}
|
|
}
|
|
return { allFiles, byLower, byRootPrefix }
|
|
})()
|
|
archiveIndexCache.set(archives, promise)
|
|
}
|
|
return promise
|
|
}
|
|
|
|
/**
|
|
* Find the DCC or DC6 sprite member for a specific monster/object COF layer.
|
|
*/
|
|
export function findMonsterLayerSprite(
|
|
names: readonly string[],
|
|
root: string,
|
|
token: string,
|
|
animation: string,
|
|
weapon: string,
|
|
component: string,
|
|
layerWeaponClass?: string,
|
|
): string | undefined {
|
|
const prefix = `${root}${component}\\${token}${component}`.toLowerCase()
|
|
const weapons = Array.from(new Set([layerWeaponClass, weapon, 'hth'].filter((w): w is string => Boolean(w))))
|
|
const anims = animation.toLowerCase() === 'wl' ? ['wl', 'nu'] : [animation.toLowerCase()]
|
|
|
|
for (const ext of ['.dcc', '.dc6']) {
|
|
for (const anim of anims) {
|
|
for (const w of weapons) {
|
|
const suffix = `${anim}${w.toLowerCase()}${ext}`
|
|
const hits = names.filter(name => {
|
|
const lowerName = name.toLowerCase()
|
|
return lowerName.startsWith(prefix) && lowerName.endsWith(suffix)
|
|
})
|
|
if (hits.length > 0) {
|
|
hits.sort()
|
|
return hits[0]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Masked blit of a layer frame onto a combined canvas frame.
|
|
*/
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adapts a decoded DC6 sprite sheet into a DccFile-compatible directional structure
|
|
* so COF layer compositing handles both .dcc and .dc6 layers identically.
|
|
*/
|
|
export function dc6ToDccFile(
|
|
sheet: Dc6Sheet,
|
|
cofDirections = sheet.header.directions,
|
|
cofFramesPerDir = sheet.header.framesPerDirection,
|
|
): DccFile {
|
|
let dirFrameLists: (readonly Dc6Frame[])[] = []
|
|
if (sheet.groups.length === cofDirections) {
|
|
dirFrameLists = sheet.groups.map(g => g.frames)
|
|
} else if (
|
|
sheet.groups.length === 1 &&
|
|
cofDirections > 1 &&
|
|
(sheet.groups[0]?.frames.length ?? 0) >= cofDirections * cofFramesPerDir
|
|
) {
|
|
const allFrames = sheet.groups[0]!.frames
|
|
for (let d = 0; d < cofDirections; d += 1) {
|
|
dirFrameLists.push(allFrames.slice(d * cofFramesPerDir, (d + 1) * cofFramesPerDir))
|
|
}
|
|
} else {
|
|
dirFrameLists = sheet.groups.map(g => g.frames)
|
|
}
|
|
|
|
const directions: DccDirection[] = []
|
|
for (const dirFrames of dirFrameLists) {
|
|
if (dirFrames.length === 0) {
|
|
directions.push({
|
|
box: { left: 0, top: 0, width: 1, height: 1 },
|
|
frames: [],
|
|
})
|
|
continue
|
|
}
|
|
|
|
let left = Number.POSITIVE_INFINITY
|
|
let top = Number.POSITIVE_INFINITY
|
|
let right = Number.NEGATIVE_INFINITY
|
|
let bottom = Number.NEGATIVE_INFINITY
|
|
for (const f of dirFrames) {
|
|
const fLeft = f.offsetX
|
|
const fTop = f.offsetY - f.height + 1
|
|
left = Math.min(left, fLeft)
|
|
top = Math.min(top, fTop)
|
|
right = Math.max(right, fLeft + f.width)
|
|
bottom = Math.max(bottom, fTop + f.height)
|
|
}
|
|
|
|
const boxLeft = Number.isFinite(left) ? Math.round(left) : 0
|
|
const boxTop = Number.isFinite(top) ? Math.round(top) : 0
|
|
const boxWidth = Number.isFinite(right) ? Math.max(1, Math.round(right) - boxLeft) : 1
|
|
const boxHeight = Number.isFinite(bottom) ? Math.max(1, Math.round(bottom) - boxTop) : 1
|
|
const dirBox = { left: boxLeft, top: boxTop, width: boxWidth, height: boxHeight }
|
|
|
|
const dccFrames: DccFrame[] = []
|
|
for (const f of dirFrames) {
|
|
const fLeft = f.offsetX
|
|
const fTop = f.offsetY - f.height + 1
|
|
let canvas: SpriteFrame
|
|
if (fLeft === boxLeft && fTop === boxTop && f.width === boxWidth && f.height === boxHeight) {
|
|
canvas = {
|
|
width: boxWidth,
|
|
height: boxHeight,
|
|
indices: f.indices,
|
|
mask: f.mask,
|
|
}
|
|
} else {
|
|
const indices = new Uint8Array(boxWidth * boxHeight)
|
|
const mask = new Uint8Array(boxWidth * boxHeight)
|
|
const target = { width: boxWidth, height: boxHeight, indices, mask }
|
|
blit(target, f, fLeft - boxLeft, fTop - boxTop)
|
|
canvas = target
|
|
}
|
|
dccFrames.push({
|
|
width: f.width,
|
|
height: f.height,
|
|
offsetX: f.offsetX,
|
|
offsetY: f.offsetY,
|
|
frame: canvas,
|
|
bottomUp: false,
|
|
})
|
|
}
|
|
|
|
directions.push({
|
|
box: dirBox,
|
|
frames: dccFrames,
|
|
})
|
|
}
|
|
|
|
return {
|
|
version: 6,
|
|
directions,
|
|
}
|
|
}
|
|
|
|
function decodeLayerSpriteBytes(
|
|
member: string,
|
|
bytes: Uint8Array,
|
|
cofDirections: number,
|
|
cofFramesPerDir: number,
|
|
): DccFile {
|
|
if (member.toLowerCase().endsWith('.dc6')) {
|
|
return dc6ToDccFile(decodeDc6(bytes), cofDirections, cofFramesPerDir)
|
|
}
|
|
return decodeDcc(bytes)
|
|
}
|
|
|
|
export interface MonsterLayerFailure {
|
|
readonly token: string
|
|
readonly animation: string
|
|
readonly component: string | null
|
|
readonly member: string | null
|
|
readonly reason: 'unknown-component' | 'no-member' | 'decode'
|
|
readonly message?: string
|
|
}
|
|
|
|
export interface MonsterAnimationSheet {
|
|
readonly sheet: SpriteSheet
|
|
readonly glowSheet?: SpriteSheet | undefined
|
|
readonly blendMode: 'normal' | 'additive'
|
|
readonly directions: number
|
|
readonly framesPerDirection: number
|
|
readonly layers: number
|
|
readonly decodedMembers: readonly string[]
|
|
readonly layerFailures: readonly MonsterLayerFailure[]
|
|
}
|
|
|
|
/**
|
|
* Load and composite a single monster animation (e.g. walk 'wl' or stand 'nu').
|
|
*/
|
|
export async function compositeMonsterAnimation(
|
|
archives: MountedArchives,
|
|
token: string,
|
|
animation: string,
|
|
weapon: string = 'hth',
|
|
): Promise<MonsterAnimationSheet> {
|
|
const lower = token.trim().toLowerCase()
|
|
const root = `data\\global\\monsters\\${lower}\\`
|
|
|
|
const fileIndex = await getArchiveFileIndex(archives)
|
|
const tokenFiles = fileIndex.byRootPrefix.get(root) ?? fileIndex.allFiles
|
|
let usedAnim = animation.toLowerCase()
|
|
let usedWeapon = weapon.toLowerCase()
|
|
const candidates = [
|
|
`${root}cof\\${lower}${usedAnim}${usedWeapon}.cof`,
|
|
`${root}cof\\${lower}${usedAnim}hth.cof`,
|
|
...(usedAnim === 'wl'
|
|
? [
|
|
`${root}cof\\${lower}nu${usedWeapon}.cof`,
|
|
`${root}cof\\${lower}nuhth.cof`,
|
|
]
|
|
: []),
|
|
]
|
|
|
|
let matchedCof: string | undefined
|
|
for (const cand of candidates) {
|
|
matchedCof = fileIndex.byLower.get(cand.toLowerCase())
|
|
if (matchedCof) {
|
|
if (cand.endsWith(`nu${usedWeapon}.cof`) || cand.endsWith('nuhth.cof')) usedAnim = 'nu'
|
|
if (cand.endsWith('hth.cof')) usedWeapon = 'hth'
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!matchedCof) {
|
|
const cofPrefix = `${root}cof\\${lower}${usedAnim}`.toLowerCase()
|
|
const altCofs = tokenFiles
|
|
.filter(n => {
|
|
const ln = n.toLowerCase()
|
|
return ln.startsWith(cofPrefix) && ln.endsWith('.cof') && ln.length === cofPrefix.length + 7
|
|
})
|
|
.sort()
|
|
if (altCofs.length > 0) {
|
|
matchedCof = altCofs[0]
|
|
usedWeapon = matchedCof!.slice(-7, -4).toLowerCase()
|
|
}
|
|
}
|
|
|
|
let cof: CofFile
|
|
if (matchedCof) {
|
|
const cofBytes = await archives.read(matchedCof)
|
|
cof = decodeCof(cofBytes)
|
|
} else {
|
|
const standaloneTr = findMonsterLayerSprite(
|
|
tokenFiles,
|
|
root,
|
|
lower,
|
|
usedAnim,
|
|
usedWeapon,
|
|
'tr',
|
|
usedWeapon,
|
|
)
|
|
if (!standaloneTr) {
|
|
throw new Error(`Monster COF not found: ${candidates[0]}`)
|
|
}
|
|
const trBytes = await archives.read(standaloneTr)
|
|
const trSprite = decodeLayerSpriteBytes(standaloneTr, trBytes, 8, 8)
|
|
const numDirs = Math.max(1, trSprite.directions.length)
|
|
const framesPerDir = Math.max(1, trSprite.directions[0]?.frames.length ?? 1)
|
|
cof = {
|
|
numberOfLayers: 1,
|
|
framesPerDirection: framesPerDir,
|
|
numberOfDirections: numDirs,
|
|
speed: 256,
|
|
layers: [{ type: 1, shadow: 1, selectable: true, transparent: false, drawEffect: 0, weaponClass: usedWeapon }],
|
|
animationFrames: new Uint8Array(framesPerDir),
|
|
priority: Array.from({ length: numDirs }, () => Array.from({ length: framesPerDir }, () => [1])),
|
|
}
|
|
}
|
|
|
|
const layerResults = await Promise.all(cof.layers.map(async (layer) => {
|
|
const component = MONSTER_COMPONENTS[layer.type]
|
|
if (component === undefined) {
|
|
return {
|
|
sprite: null,
|
|
member: undefined,
|
|
failure: { token: lower, animation: usedAnim, component: null, member: null, reason: 'unknown-component' } as MonsterLayerFailure,
|
|
}
|
|
}
|
|
const member = findMonsterLayerSprite(
|
|
tokenFiles,
|
|
root,
|
|
lower,
|
|
usedAnim,
|
|
usedWeapon,
|
|
component,
|
|
layer.weaponClass,
|
|
)
|
|
if (member === undefined) {
|
|
return {
|
|
sprite: null,
|
|
member: undefined,
|
|
failure: { token: lower, animation: usedAnim, component, member: null, reason: 'no-member' } as MonsterLayerFailure,
|
|
}
|
|
}
|
|
try {
|
|
const bytes = await archives.read(member)
|
|
return {
|
|
sprite: decodeLayerSpriteBytes(member, bytes, cof.numberOfDirections, cof.framesPerDirection),
|
|
member,
|
|
failure: undefined,
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
sprite: null,
|
|
member: undefined,
|
|
failure: {
|
|
token: lower,
|
|
animation: usedAnim,
|
|
component,
|
|
member,
|
|
reason: 'decode',
|
|
message: (err as Error).message,
|
|
} as MonsterLayerFailure,
|
|
}
|
|
}
|
|
}))
|
|
|
|
const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite)
|
|
const decodedMembers: string[] = layerResults.flatMap(r => r.member ? [r.member] : [])
|
|
const layerFailures: MonsterLayerFailure[] = layerResults.flatMap(r => r.failure ? [r.failure] : [])
|
|
|
|
return compositeLoadedLayers(cof, sprites, decodedMembers, layerFailures, 0, 0)
|
|
}
|
|
|
|
/**
|
|
* Load and composite a single object animation (`nu`, `op`, `on`, `s1..s5`) across all directions and frames.
|
|
* Uses `objectDrawAnchor` offsets (`anchorX = -16 + boxLeft`, `anchorY = 16 + boxTop`).
|
|
*/
|
|
export async function compositeObjectAnimation(
|
|
archives: MountedArchives,
|
|
token: string,
|
|
animation: string,
|
|
baseIsMonsters = false,
|
|
): Promise<MonsterAnimationSheet> {
|
|
const lower = token.trim().toLowerCase()
|
|
const usedAnim = animation.trim().toLowerCase()
|
|
const root = baseIsMonsters
|
|
? `data\\global\\monsters\\${lower}\\`
|
|
: `data\\global\\objects\\${lower}\\`
|
|
|
|
const fileIndex = await getArchiveFileIndex(archives)
|
|
const tokenFiles = fileIndex.byRootPrefix.get(root) ?? fileIndex.allFiles
|
|
|
|
const exactCof = `${root}cof\\${lower}${usedAnim}hth.cof`.toLowerCase()
|
|
let matchedCof = fileIndex.byLower.get(exactCof)
|
|
let usedWeapon = 'hth'
|
|
|
|
if (!matchedCof) {
|
|
const cofPrefix = `${root}cof\\${lower}${usedAnim}`.toLowerCase()
|
|
const altCofs = tokenFiles
|
|
.filter(n => {
|
|
const ln = n.toLowerCase()
|
|
return ln.startsWith(cofPrefix) && ln.endsWith('.cof') && ln.length === cofPrefix.length + 7
|
|
})
|
|
.sort()
|
|
if (altCofs.length > 0) {
|
|
matchedCof = altCofs[0]
|
|
usedWeapon = matchedCof!.slice(-7, -4).toLowerCase()
|
|
}
|
|
}
|
|
|
|
if (!matchedCof) {
|
|
throw new Error(`Object COF not found: ${exactCof}`)
|
|
}
|
|
|
|
const cofBytes = await archives.read(matchedCof)
|
|
const cof: CofFile = decodeCof(cofBytes)
|
|
|
|
const layerResults = await Promise.all(cof.layers.map(async (layer) => {
|
|
const component = MONSTER_COMPONENTS[layer.type]
|
|
if (component === undefined) {
|
|
return {
|
|
sprite: null,
|
|
member: undefined,
|
|
failure: { token: lower, animation: usedAnim, component: null, member: null, reason: 'unknown-component' } as MonsterLayerFailure,
|
|
}
|
|
}
|
|
const litDcc = fileIndex.byLower.get(`${root}${component}\\${lower}${component}lit${usedAnim}${usedWeapon}.dcc`)
|
|
const litDc6 = fileIndex.byLower.get(`${root}${component}\\${lower}${component}lit${usedAnim}${usedWeapon}.dc6`)
|
|
const member =
|
|
litDcc ??
|
|
litDc6 ??
|
|
findMonsterLayerSprite(
|
|
tokenFiles,
|
|
root,
|
|
lower,
|
|
usedAnim,
|
|
usedWeapon,
|
|
component,
|
|
layer.weaponClass,
|
|
)
|
|
|
|
if (member === undefined) {
|
|
return {
|
|
sprite: null,
|
|
member: undefined,
|
|
failure: { token: lower, animation: usedAnim, component, member: null, reason: 'no-member' } as MonsterLayerFailure,
|
|
}
|
|
}
|
|
try {
|
|
const bytes = await archives.read(member)
|
|
return {
|
|
sprite: decodeLayerSpriteBytes(member, bytes, cof.numberOfDirections, cof.framesPerDirection),
|
|
member,
|
|
failure: undefined,
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
sprite: null,
|
|
member: undefined,
|
|
failure: {
|
|
token: lower,
|
|
animation: usedAnim,
|
|
component,
|
|
member,
|
|
reason: 'decode',
|
|
message: (err as Error).message,
|
|
} as MonsterLayerFailure,
|
|
}
|
|
}
|
|
}))
|
|
|
|
const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite)
|
|
const decodedMembers: string[] = layerResults.flatMap(r => r.member ? [r.member] : [])
|
|
const layerFailures: MonsterLayerFailure[] = layerResults.flatMap(r => r.failure ? [r.failure] : [])
|
|
|
|
// Sub-tile draw anchor offset from D2Common ordinal 10117 (subtileDrawPositionCoords): (-16, +16)
|
|
return compositeLoadedLayers(cof, sprites, decodedMembers, layerFailures, -16, 16)
|
|
}
|
|
|
|
function compositeLoadedLayers(
|
|
cof: CofFile,
|
|
sprites: readonly (DccFile | null)[],
|
|
decodedMembers: readonly string[],
|
|
layerFailures: readonly MonsterLayerFailure[],
|
|
anchorOffsetX: number,
|
|
anchorOffsetY: number,
|
|
): MonsterAnimationSheet {
|
|
const isAdditiveLayer = (layerIdx: number): boolean => {
|
|
const layer = cof.layers[layerIdx]
|
|
return layer !== undefined && (layer.drawEffect === 3 || layer.transparent)
|
|
}
|
|
|
|
const hasAdditive = cof.layers.some(l => l.drawEffect === 3 || l.transparent)
|
|
const hasNormal = cof.layers.some(l => !(l.drawEffect === 3 || l.transparent))
|
|
const isComposite = hasAdditive && hasNormal
|
|
const isPureAdditive = hasAdditive && !hasNormal
|
|
|
|
const groups: { frames: SpriteFrame[] }[] = []
|
|
const glowGroups: { frames: SpriteFrame[] }[] = []
|
|
|
|
for (let dir = 0; dir < cof.numberOfDirections; dir += 1) {
|
|
const dir64 = Math.round((dir * 64) / cof.numberOfDirections)
|
|
const baseFrames: SpriteFrame[] = []
|
|
const glowFrames: SpriteFrame[] = []
|
|
|
|
for (let f = 0; f < cof.framesPerDirection; f += 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 layerIdx = 0; layerIdx < cof.layers.length; layerIdx += 1) {
|
|
const dcc = sprites[layerIdx]
|
|
if (!dcc || dcc.directions.length === 0) continue
|
|
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
|
|
const dirData = dcc.directions[dccDir] ?? dcc.directions[0]
|
|
if (!dirData || dirData.frames.length === 0) continue
|
|
const frameData = dirData.frames[f] ?? dirData.frames[f % dirData.frames.length]
|
|
if (!frameData) continue
|
|
left = Math.min(left, dirData.box.left)
|
|
top = Math.min(top, dirData.box.top)
|
|
right = Math.max(right, dirData.box.left + dirData.box.width)
|
|
bottom = Math.max(bottom, dirData.box.top + dirData.box.height)
|
|
placedCount += 1
|
|
}
|
|
|
|
if (placedCount === 0) {
|
|
const emptyFrame: SpriteFrame = {
|
|
width: 1,
|
|
height: 1,
|
|
indices: new Uint8Array(1),
|
|
mask: new Uint8Array(1),
|
|
anchorX: anchorOffsetX,
|
|
anchorY: anchorOffsetY,
|
|
}
|
|
baseFrames.push(emptyFrame)
|
|
if (isComposite) glowFrames.push(emptyFrame)
|
|
continue
|
|
}
|
|
|
|
const boxLeft = Math.round(left)
|
|
const boxTop = Math.round(top)
|
|
const frameWidth = Math.max(1, Math.round(right) - boxLeft)
|
|
const frameHeight = Math.max(1, Math.round(bottom) - boxTop)
|
|
const baseTarget = {
|
|
indices: new Uint8Array(frameWidth * frameHeight),
|
|
mask: new Uint8Array(frameWidth * frameHeight),
|
|
width: frameWidth,
|
|
height: frameHeight,
|
|
}
|
|
const glowTarget = isComposite
|
|
? {
|
|
indices: new Uint8Array(frameWidth * frameHeight),
|
|
mask: new Uint8Array(frameWidth * frameHeight),
|
|
width: frameWidth,
|
|
height: frameHeight,
|
|
}
|
|
: null
|
|
|
|
const order = cofLayerOrder(cof, dir, f)
|
|
for (const layerIdx of order) {
|
|
const dcc = sprites[layerIdx]
|
|
if (!dcc || dcc.directions.length === 0) continue
|
|
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
|
|
const dirData = dcc.directions[dccDir] ?? dcc.directions[0]
|
|
if (!dirData || dirData.frames.length === 0) continue
|
|
const frameData = dirData.frames[f] ?? dirData.frames[f % dirData.frames.length]
|
|
if (!frameData) continue
|
|
const atX = Math.round(dirData.box.left) - boxLeft
|
|
const atY = Math.round(dirData.box.top) - boxTop
|
|
if (glowTarget !== null && isAdditiveLayer(layerIdx)) {
|
|
blit(glowTarget, frameData.frame, atX, atY)
|
|
} else {
|
|
blit(baseTarget, frameData.frame, atX, atY)
|
|
}
|
|
}
|
|
|
|
baseFrames.push({
|
|
width: frameWidth,
|
|
height: frameHeight,
|
|
indices: baseTarget.indices,
|
|
mask: baseTarget.mask,
|
|
anchorX: anchorOffsetX + boxLeft,
|
|
anchorY: anchorOffsetY + boxTop,
|
|
})
|
|
if (glowTarget !== null) {
|
|
glowFrames.push({
|
|
width: frameWidth,
|
|
height: frameHeight,
|
|
indices: glowTarget.indices,
|
|
mask: glowTarget.mask,
|
|
anchorX: anchorOffsetX + boxLeft,
|
|
anchorY: anchorOffsetY + boxTop,
|
|
})
|
|
}
|
|
}
|
|
groups.push({ frames: baseFrames })
|
|
if (isComposite) glowGroups.push({ frames: glowFrames })
|
|
}
|
|
|
|
return {
|
|
sheet: { groups, width: null },
|
|
glowSheet: isComposite ? { groups: glowGroups, width: null } : undefined,
|
|
blendMode: isPureAdditive ? 'additive' : 'normal',
|
|
directions: cof.numberOfDirections,
|
|
framesPerDirection: cof.framesPerDirection,
|
|
layers: cof.layers.length,
|
|
decodedMembers,
|
|
layerFailures,
|
|
}
|
|
}
|