|
|
|
|
@ -53,7 +53,7 @@ export async function loadMissileArtMap(
|
|
|
|
|
packBase?: string,
|
|
|
|
|
): Promise<Map<string, LoadedMissileArt>> {
|
|
|
|
|
const map = new Map<string, LoadedMissileArt>()
|
|
|
|
|
if (typeof fetch === 'undefined') return map
|
|
|
|
|
if (typeof fetch === 'undefined' || typeof createImageBitmap === 'undefined') return map
|
|
|
|
|
const targets = Array.from(new Set(Object.values(MISSILE_METAS).map(m => m.name)))
|
|
|
|
|
const baseCandidates = [
|
|
|
|
|
'/missiles',
|
|
|
|
|
@ -61,426 +61,273 @@ export async function loadMissileArtMap(
|
|
|
|
|
'samples/d2-packs/missiles',
|
|
|
|
|
].filter(Boolean)
|
|
|
|
|
|
|
|
|
|
const failures: string[] = []
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
targets.map(async name => {
|
|
|
|
|
let loaded = false
|
|
|
|
|
let lastReason = ''
|
|
|
|
|
const metaHint = MISSILE_METAS[name]
|
|
|
|
|
const candidateStems = Array.from(
|
|
|
|
|
new Set([name, metaHint?.celFile?.toLowerCase()].filter((s): s is string => Boolean(s))),
|
|
|
|
|
)
|
|
|
|
|
for (const base of baseCandidates) {
|
|
|
|
|
try {
|
|
|
|
|
const [jsonResp, pngResp] = await Promise.all([
|
|
|
|
|
fetch(`${base}/${name}.json`),
|
|
|
|
|
fetch(`${base}/${name}.png`),
|
|
|
|
|
])
|
|
|
|
|
if (jsonResp.ok && pngResp.ok) {
|
|
|
|
|
const meta = (await jsonResp.json()) as MissileMeta
|
|
|
|
|
const blob = await pngResp.blob()
|
|
|
|
|
const bitmap = await createImageBitmap(blob)
|
|
|
|
|
let handle: AtlasHandle
|
|
|
|
|
try {
|
|
|
|
|
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
|
|
|
|
|
} finally {
|
|
|
|
|
bitmap.close()
|
|
|
|
|
for (const stem of candidateStems) {
|
|
|
|
|
try {
|
|
|
|
|
const [jsonResp, pngResp] = await Promise.all([
|
|
|
|
|
fetch(`${base}/${stem}.json`),
|
|
|
|
|
fetch(`${base}/${stem}.png`),
|
|
|
|
|
])
|
|
|
|
|
if (jsonResp.ok && pngResp.ok) {
|
|
|
|
|
const rawMeta = (await jsonResp.json()) as MissileMeta
|
|
|
|
|
const meta: MissileMeta = metaHint && stem !== name ? { ...rawMeta, name } : rawMeta
|
|
|
|
|
const blob = await pngResp.blob()
|
|
|
|
|
const bitmap = await createImageBitmap(blob)
|
|
|
|
|
let handle: AtlasHandle
|
|
|
|
|
try {
|
|
|
|
|
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
|
|
|
|
|
} finally {
|
|
|
|
|
bitmap.close()
|
|
|
|
|
}
|
|
|
|
|
const frames: AtlasFrame[][] = meta.groups.map((group: readonly MissileFrameTuple[]) =>
|
|
|
|
|
group.map(([x, y, width, height, anchorX, anchorY]: MissileFrameTuple) => ({
|
|
|
|
|
x,
|
|
|
|
|
y,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
...(anchorX !== undefined ? { anchorX } : {}),
|
|
|
|
|
...(anchorY !== undefined ? { anchorY } : {}),
|
|
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
const art: LoadedMissileArt = { meta, handle, frames }
|
|
|
|
|
map.set(name, art)
|
|
|
|
|
if (meta.celFile) {
|
|
|
|
|
map.set(meta.celFile, art)
|
|
|
|
|
map.set(meta.celFile.toLowerCase(), art)
|
|
|
|
|
}
|
|
|
|
|
loaded = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
const frames: AtlasFrame[][] = meta.groups.map((group: readonly MissileFrameTuple[]) =>
|
|
|
|
|
group.map(([x, y, width, height, anchorX, anchorY]: MissileFrameTuple) => ({
|
|
|
|
|
x,
|
|
|
|
|
y,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
...(anchorX !== undefined ? { anchorX } : {}),
|
|
|
|
|
...(anchorY !== undefined ? { anchorY } : {}),
|
|
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
map.set(name, { meta, handle, frames })
|
|
|
|
|
break
|
|
|
|
|
lastReason = `${base}/${stem}.json (HTTP ${jsonResp.status}), ${base}/${stem}.png (HTTP ${pngResp.status})`
|
|
|
|
|
} catch (err) {
|
|
|
|
|
lastReason = `${base}/${stem}: ${err instanceof Error ? err.message : String(err)}`
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore candidate error and continue to next fallback path
|
|
|
|
|
}
|
|
|
|
|
if (loaded) break
|
|
|
|
|
}
|
|
|
|
|
if (!loaded) {
|
|
|
|
|
failures.push(`${name} [${lastReason || `tried: ${baseCandidates.join(', ')}`}]`)
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Map all alias keys to the loaded art handle to prevent duplicate atlas uploads
|
|
|
|
|
if (failures.length > 0) {
|
|
|
|
|
const msg = `[loadMissileArtMap] Failed to load ${failures.length} missile atlas(es): ${failures.slice(0, 5).join('; ')}`
|
|
|
|
|
console.error(msg)
|
|
|
|
|
throw new Error(msg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Map all alias keys in MISSILE_METAS to the loaded art handle to prevent duplicate atlas uploads
|
|
|
|
|
for (const [alias, meta] of Object.entries(MISSILE_METAS)) {
|
|
|
|
|
const art = map.get(meta.name)
|
|
|
|
|
const art = map.get(meta.name) ?? (meta.celFile ? map.get(meta.celFile.toLowerCase()) : undefined)
|
|
|
|
|
if (art && !map.has(alias)) {
|
|
|
|
|
map.set(alias, art)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (map.has('icebolt')) {
|
|
|
|
|
const iceboltArt = map.get('icebolt')!
|
|
|
|
|
map.set('frozenorbbolt', iceboltArt)
|
|
|
|
|
map.set('frozenorbnova', iceboltArt)
|
|
|
|
|
}
|
|
|
|
|
if (map.has('meteorexplode')) {
|
|
|
|
|
const meteorArt = map.get('meteorexplode')!
|
|
|
|
|
if (!map.has('explodingarrowexp')) map.set('explodingarrowexp', meteorArt)
|
|
|
|
|
}
|
|
|
|
|
if (map.has('firebolt')) {
|
|
|
|
|
const fireboltArt = map.get('firebolt')!
|
|
|
|
|
if (!map.has('hydra')) map.set('hydra', fireboltArt)
|
|
|
|
|
if (!map.has('hydrafire')) map.set('hydrafire', fireboltArt)
|
|
|
|
|
}
|
|
|
|
|
if (map.has('blizzard3') && !map.has('blizzard4')) {
|
|
|
|
|
map.set('blizzard4', map.get('blizzard3')!)
|
|
|
|
|
}
|
|
|
|
|
if (map.has('blizzardexplode2') && !map.has('blizzardexplode3')) {
|
|
|
|
|
map.set('blizzardexplode3', map.get('blizzardexplode2')!)
|
|
|
|
|
}
|
|
|
|
|
if (map.has('lightningstrike')) {
|
|
|
|
|
const ltArt = map.get('lightningstrike')!
|
|
|
|
|
if (!map.has('lightningbolt')) map.set('lightningbolt', ltArt)
|
|
|
|
|
if (!map.has('lightning')) map.set('lightning', ltArt)
|
|
|
|
|
}
|
|
|
|
|
return map
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Render a Diablo II 1.13c missile projectile:
|
|
|
|
|
* - Uses authentic baked DCC sprite atlas when available.
|
|
|
|
|
* - Gracefully falls back to procedural glowing fireball & trailing particles in test/offline environments.
|
|
|
|
|
* Render a Diablo II 1.13c missile projectile using authentic baked DCC sprite atlas.
|
|
|
|
|
*/
|
|
|
|
|
export function drawMissileProjectile(
|
|
|
|
|
renderer: SpriteRenderer,
|
|
|
|
|
shot: Projectile,
|
|
|
|
|
missileArt?: LoadedMissileArt,
|
|
|
|
|
missileArtMap?: Map<string, LoadedMissileArt>,
|
|
|
|
|
missileArt?: LoadedMissileArt | null,
|
|
|
|
|
missileArtMap?: Map<string, LoadedMissileArt> | null,
|
|
|
|
|
): void {
|
|
|
|
|
if (shot.missileType === 'blizzardcenter') {
|
|
|
|
|
return // Invisible controller missile
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const altitude = shot.altitude ?? 0
|
|
|
|
|
const resolvedArt =
|
|
|
|
|
missileArt ??
|
|
|
|
|
(shot.isTurret || shot.missileType === 'hydra_turret'
|
|
|
|
|
? (missileArtMap?.get(shot.missileType ?? 'hydra_turret') ?? missileArtMap?.get('hydra'))
|
|
|
|
|
: shot.missileType !== undefined
|
|
|
|
|
? missileArtMap?.get(shot.missileType)
|
|
|
|
|
: undefined)
|
|
|
|
|
|
|
|
|
|
if (missileArt !== undefined) {
|
|
|
|
|
const dccDir = velocityToDccDirection(shot.vx, shot.vy, missileArt.meta.directions)
|
|
|
|
|
const frameCount = Math.max(1, missileArt.meta.framesPerDirection)
|
|
|
|
|
|
|
|
|
|
let frameIndex: number
|
|
|
|
|
if (missileArt.meta.name === 'meteorcenter') {
|
|
|
|
|
// For meteorcenter: frame indexing must start at frame 0 and advance forward: (60 - shot.ttl) % numFrames
|
|
|
|
|
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
|
|
|
|
|
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
|
|
|
|
|
} else if (
|
|
|
|
|
(missileArt.meta.name === 'meteorfire' ||
|
|
|
|
|
missileArt.meta.name === 'firewall' ||
|
|
|
|
|
missileArt.meta.name === 'groundfirebig') &&
|
|
|
|
|
frameCount === 37
|
|
|
|
|
) {
|
|
|
|
|
// For meteorfire & firewall: use elapsed ticks (shot.ageTicks or duration - shot.ttl).
|
|
|
|
|
// Frames 0..11 play emergence once, and frames >= 12 loop infinitely within 12..36 (12 + ((elapsed - 12) % 25)).
|
|
|
|
|
// Extinction phase (shot.ttl <= 12): reverse emergence sequence (frames 11 down to 0) + shrinking scale & fade.
|
|
|
|
|
const defaultDuration =
|
|
|
|
|
missileArt.meta.name === 'meteorfire'
|
|
|
|
|
? 90 + (Math.max(1, shot.slvl ?? 1) - 1) * 15
|
|
|
|
|
: shot.ttl > 0
|
|
|
|
|
? (shot.ageTicks ?? 0) + shot.ttl
|
|
|
|
|
: 90
|
|
|
|
|
const duration = shot.fireDuration ?? defaultDuration
|
|
|
|
|
const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl)
|
|
|
|
|
const EXTINCTION_TICKS = 12
|
|
|
|
|
if (shot.ttl <= EXTINCTION_TICKS) {
|
|
|
|
|
frameIndex = Math.max(0, Math.min(11, shot.ttl - 1))
|
|
|
|
|
} else {
|
|
|
|
|
const animStep = Math.floor((elapsed * missileArt.meta.animSpeed) / 16)
|
|
|
|
|
frameIndex = animStep < 12 ? animStep : 12 + ((animStep - 12) % 25)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const elapsed = shot.ageTicks ?? Math.max(0, 100 - shot.ttl)
|
|
|
|
|
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const group = missileArt.frames[dccDir] ?? missileArt.frames[0]
|
|
|
|
|
const frame = group?.[frameIndex] ?? group?.[0]
|
|
|
|
|
if (frame !== undefined) {
|
|
|
|
|
const isGroundFlame =
|
|
|
|
|
missileArt.meta.name === 'meteorfire' ||
|
|
|
|
|
missileArt.meta.name === 'firewall' ||
|
|
|
|
|
missileArt.meta.name === 'groundfirebig'
|
|
|
|
|
const isExtinguishing = isGroundFlame && shot.ttl <= 12
|
|
|
|
|
const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0
|
|
|
|
|
const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0
|
|
|
|
|
const opacity = isExtinguishing ? decayRatio : 1.0
|
|
|
|
|
|
|
|
|
|
const drawW = frame.width * scale
|
|
|
|
|
const drawH = frame.height * scale
|
|
|
|
|
const baseAnchorX = frame.anchorX !== undefined ? frame.anchorX : -frame.width / 2
|
|
|
|
|
const baseAnchorY = frame.anchorY !== undefined ? frame.anchorY : -frame.height / 2
|
|
|
|
|
const drawX = shot.x + baseAnchorX * scale
|
|
|
|
|
const drawY = (shot.y + (baseAnchorY + frame.height) * scale - drawH) - altitude
|
|
|
|
|
|
|
|
|
|
const isAdditive = getMissileTxtData(shot.missileType ?? missileArt.meta.name).trans === 1
|
|
|
|
|
renderer.draw(frame, drawX, drawY, {
|
|
|
|
|
atlas: missileArt.handle,
|
|
|
|
|
...(isAdditive ? { blendMode: 'additive' } : {}),
|
|
|
|
|
...(isExtinguishing
|
|
|
|
|
? {
|
|
|
|
|
width: drawW,
|
|
|
|
|
height: drawH,
|
|
|
|
|
tint: [1, 1, 1, opacity],
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Dual Sprite Falling Trajectory & Rendering:
|
|
|
|
|
// During the 60-tick countdown of meteorcenter, render the falling meteor fireball (meteor, ID 100)
|
|
|
|
|
// and atmospheric flame tail (meteortail, ID 102) descending diagonally from sky altitude to the ground indicator.
|
|
|
|
|
if (shot.missileType === 'meteorcenter' && missileArtMap !== undefined && shot.ttl > 0) {
|
|
|
|
|
const remainingTicks = shot.ttl
|
|
|
|
|
const fallAltitude = remainingTicks * 25
|
|
|
|
|
const slideX = -remainingTicks * 15
|
|
|
|
|
const meteorX = shot.x + slideX
|
|
|
|
|
const meteorY = shot.y
|
|
|
|
|
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
|
|
|
|
|
|
|
|
|
|
// 1. Atmospheric flame tail (`meteortail`, ID 102)
|
|
|
|
|
const tailArt = missileArtMap.get('meteortail')
|
|
|
|
|
if (tailArt !== undefined) {
|
|
|
|
|
const tailGroup = tailArt.frames[0]
|
|
|
|
|
const tailFrameCount = Math.max(1, tailArt.meta.framesPerDirection)
|
|
|
|
|
const tailFrameIndex = Math.floor((elapsed * tailArt.meta.animSpeed) / 16) % tailFrameCount
|
|
|
|
|
const tailFrame = tailGroup?.[tailFrameIndex] ?? tailGroup?.[0]
|
|
|
|
|
if (tailFrame !== undefined) {
|
|
|
|
|
const tailDrawX = tailFrame.anchorX !== undefined ? meteorX + tailFrame.anchorX : meteorX - tailFrame.width / 2
|
|
|
|
|
const tailDrawY = (tailFrame.anchorY !== undefined ? meteorY + tailFrame.anchorY : meteorY - tailFrame.height / 2) - fallAltitude
|
|
|
|
|
renderer.draw(tailFrame, tailDrawX, tailDrawY, {
|
|
|
|
|
atlas: tailArt.handle,
|
|
|
|
|
blendMode: 'additive',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Falling meteor core fireball (`meteor`, ID 100)
|
|
|
|
|
const meteorArt = missileArtMap.get('meteor')
|
|
|
|
|
if (meteorArt !== undefined) {
|
|
|
|
|
const meteorGroup = meteorArt.frames[0]
|
|
|
|
|
const meteorFrameCount = Math.max(1, meteorArt.meta.framesPerDirection)
|
|
|
|
|
const meteorFrameIndex = Math.floor((elapsed * meteorArt.meta.animSpeed) / 16) % meteorFrameCount
|
|
|
|
|
const meteorFrame = meteorGroup?.[meteorFrameIndex] ?? meteorGroup?.[0]
|
|
|
|
|
if (meteorFrame !== undefined) {
|
|
|
|
|
const meteorDrawX = meteorFrame.anchorX !== undefined ? meteorX + meteorFrame.anchorX : meteorX - meteorFrame.width / 2
|
|
|
|
|
const meteorDrawY = (meteorFrame.anchorY !== undefined ? meteorY + meteorFrame.anchorY : meteorY - meteorFrame.height / 2) - fallAltitude
|
|
|
|
|
renderer.draw(meteorFrame, meteorDrawX, meteorDrawY, {
|
|
|
|
|
atlas: meteorArt.handle,
|
|
|
|
|
blendMode: 'additive',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const x = shot.x
|
|
|
|
|
const y = shot.y - altitude
|
|
|
|
|
|
|
|
|
|
if (shot.missileType === 'meteorcenter') {
|
|
|
|
|
// Procedural ground reticle (2:1 isometric ground ellipse)
|
|
|
|
|
renderer.drawSolid(shot.x - 20, shot.y - 10, 40, 20, [1.0, 0.4, 0.1, 0.4])
|
|
|
|
|
renderer.drawSolid(shot.x - 10, shot.y - 5, 20, 10, [1.0, 0.7, 0.2, 0.6])
|
|
|
|
|
if (shot.ttl > 0) {
|
|
|
|
|
const remainingTicks = shot.ttl
|
|
|
|
|
const fallAltitude = remainingTicks * 25
|
|
|
|
|
const slideX = -remainingTicks * 15
|
|
|
|
|
const mx = shot.x + slideX
|
|
|
|
|
const my = shot.y - fallAltitude
|
|
|
|
|
// Falling fireball core
|
|
|
|
|
renderer.drawSolid(mx - 8, my - 8, 16, 16, [1.0, 0.5, 0.1, 0.8])
|
|
|
|
|
renderer.drawSolid(mx - 4, my - 4, 8, 8, [1.0, 0.9, 0.3, 0.95])
|
|
|
|
|
// Trailing flame tail
|
|
|
|
|
renderer.drawSolid(mx - 12, my - 12 + fallAltitude * 0.05, 24, 24, [0.9, 0.3, 0.05, 0.5])
|
|
|
|
|
}
|
|
|
|
|
if (resolvedArt === undefined || resolvedArt === null) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (shot.isTurret || shot.missileType === 'hydra_turret') {
|
|
|
|
|
const age = shot.ageTicks ?? 0
|
|
|
|
|
const wobble = Math.sin(age * 0.25 + (shot.headIndex ?? 0) * 2.1) * 3
|
|
|
|
|
const swayX = Math.cos(age * 0.18 + (shot.headIndex ?? 0) * 1.7) * 2.5
|
|
|
|
|
const altitude = shot.altitude ?? 0
|
|
|
|
|
const dccDir = velocityToDccDirection(shot.vx, shot.vy, resolvedArt.meta.directions)
|
|
|
|
|
const frameCount = Math.max(1, resolvedArt.meta.framesPerDirection)
|
|
|
|
|
|
|
|
|
|
// 1. Ground magma / lava fire pit (2:1 isometric ground ellipse base)
|
|
|
|
|
renderer.drawSolid(x - 14, y - 5, 28, 10, [0.8, 0.2, 0.02, 0.5])
|
|
|
|
|
renderer.drawSolid(x - 9, y - 3, 18, 6, [1.0, 0.5, 0.08, 0.75])
|
|
|
|
|
renderer.drawSolid(x - 4, y - 2, 8, 4, [1.0, 0.9, 0.3, 0.9])
|
|
|
|
|
let frameIndex: number
|
|
|
|
|
if (resolvedArt.meta.name === 'meteorcenter') {
|
|
|
|
|
// For meteorcenter: frame indexing must start at frame 0 and advance forward: (60 - shot.ttl) % numFrames
|
|
|
|
|
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
|
|
|
|
|
frameIndex = Math.floor((elapsed * resolvedArt.meta.animSpeed) / 16) % frameCount
|
|
|
|
|
} else if (
|
|
|
|
|
(resolvedArt.meta.name === 'meteorfire' ||
|
|
|
|
|
resolvedArt.meta.name === 'firewall' ||
|
|
|
|
|
resolvedArt.meta.name === 'groundfirebig') &&
|
|
|
|
|
frameCount === 37
|
|
|
|
|
) {
|
|
|
|
|
// For meteorfire & firewall: use elapsed ticks (shot.ageTicks or duration - shot.ttl).
|
|
|
|
|
// Frames 0..11 play emergence once, and frames >= 12 loop infinitely within 12..36 (12 + ((elapsed - 12) % 25)).
|
|
|
|
|
// Extinction phase (shot.ttl <= 12): reverse emergence sequence (frames 11 down to 0) + shrinking scale & fade.
|
|
|
|
|
const defaultDuration =
|
|
|
|
|
resolvedArt.meta.name === 'meteorfire'
|
|
|
|
|
? 90 + (Math.max(1, shot.slvl ?? 1) - 1) * 15
|
|
|
|
|
: shot.ttl > 0
|
|
|
|
|
? (shot.ageTicks ?? 0) + shot.ttl
|
|
|
|
|
: 90
|
|
|
|
|
const duration = shot.fireDuration ?? defaultDuration
|
|
|
|
|
const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl)
|
|
|
|
|
const EXTINCTION_TICKS = 12
|
|
|
|
|
if (shot.ttl <= EXTINCTION_TICKS) {
|
|
|
|
|
frameIndex = Math.max(0, Math.min(11, shot.ttl - 1))
|
|
|
|
|
} else {
|
|
|
|
|
const animStep = Math.floor((elapsed * resolvedArt.meta.animSpeed) / 16)
|
|
|
|
|
frameIndex = animStep < 12 ? animStep : 12 + ((animStep - 12) % 25)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const elapsed = shot.ageTicks ?? Math.max(0, 100 - shot.ttl)
|
|
|
|
|
frameIndex = Math.floor((elapsed * resolvedArt.meta.animSpeed) / 16) % frameCount
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Serpentine segmented neck rising from the lava pit
|
|
|
|
|
const seg1Y = y - 7 + wobble * 0.3
|
|
|
|
|
const seg1X = x + swayX * 0.3
|
|
|
|
|
renderer.drawSolid(seg1X - 5, seg1Y - 3, 10, 6, [0.85, 0.28, 0.05, 0.85])
|
|
|
|
|
renderer.drawSolid(seg1X - 3, seg1Y - 2, 6, 4, [1.0, 0.6, 0.15, 0.9])
|
|
|
|
|
const group = resolvedArt.frames[dccDir] ?? resolvedArt.frames[0]
|
|
|
|
|
const frame = group?.[frameIndex] ?? group?.[0]
|
|
|
|
|
if (frame === undefined) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const seg2Y = y - 14 + wobble * 0.6
|
|
|
|
|
const seg2X = x + swayX * 0.7
|
|
|
|
|
renderer.drawSolid(seg2X - 4, seg2Y - 3, 8, 6, [0.9, 0.35, 0.05, 0.85])
|
|
|
|
|
renderer.drawSolid(seg2X - 2, seg2Y - 2, 4, 4, [1.0, 0.7, 0.2, 0.9])
|
|
|
|
|
const isGroundFlame =
|
|
|
|
|
resolvedArt.meta.name === 'meteorfire' ||
|
|
|
|
|
resolvedArt.meta.name === 'firewall' ||
|
|
|
|
|
resolvedArt.meta.name === 'groundfirebig'
|
|
|
|
|
const isExtinguishing = isGroundFlame && shot.ttl <= 12
|
|
|
|
|
const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0
|
|
|
|
|
const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0
|
|
|
|
|
const opacity = isExtinguishing ? decayRatio : 1.0
|
|
|
|
|
|
|
|
|
|
// 3. Serpent head crown with flaming mane & glowing eyes
|
|
|
|
|
const headY = y - 22 + wobble
|
|
|
|
|
const headX = x + swayX
|
|
|
|
|
// Flaming crest / mane
|
|
|
|
|
renderer.drawSolid(headX - 6, headY - 8, 12, 12, [1.0, 0.35, 0.05, 0.6])
|
|
|
|
|
renderer.drawSolid(headX - 4, headY - 6, 8, 9, [1.0, 0.65, 0.15, 0.85])
|
|
|
|
|
renderer.drawSolid(headX - 2, headY - 4, 4, 6, [1.0, 0.95, 0.4, 0.95])
|
|
|
|
|
// Dragon/serpent snout & jaw
|
|
|
|
|
renderer.drawSolid(headX - 3, headY, 6, 4, [0.8, 0.2, 0.05, 0.9])
|
|
|
|
|
// Glowing incandescent eye
|
|
|
|
|
renderer.drawSolid(headX - 1, headY - 2, 2, 2, [1.0, 1.0, 0.9, 1.0])
|
|
|
|
|
const drawW = frame.width * scale
|
|
|
|
|
const drawH = frame.height * scale
|
|
|
|
|
const baseAnchorX = frame.anchorX !== undefined ? frame.anchorX : -frame.width / 2
|
|
|
|
|
const baseAnchorY = frame.anchorY !== undefined ? frame.anchorY : -frame.height / 2
|
|
|
|
|
const drawX = shot.x + baseAnchorX * scale
|
|
|
|
|
const drawY = (shot.y + (baseAnchorY + frame.height) * scale - drawH) - altitude
|
|
|
|
|
|
|
|
|
|
// 4. If missile sprite art is available (firebolt/hydra), render the fiery head sprite over the crest
|
|
|
|
|
const art = missileArtMap?.get('hydra') ?? missileArtMap?.get('firebolt')
|
|
|
|
|
if (art !== undefined) {
|
|
|
|
|
const group = art.frames[0]
|
|
|
|
|
const frameCount = Math.max(1, art.meta.framesPerDirection)
|
|
|
|
|
const frameIndex = Math.floor((age * art.meta.animSpeed) / 16) % frameCount
|
|
|
|
|
let isAdditive = false
|
|
|
|
|
try {
|
|
|
|
|
isAdditive = getMissileTxtData(shot.missileType ?? resolvedArt.meta.name).trans === 1
|
|
|
|
|
} catch {
|
|
|
|
|
isAdditive = true
|
|
|
|
|
}
|
|
|
|
|
renderer.draw(frame, drawX, drawY, {
|
|
|
|
|
atlas: resolvedArt.handle,
|
|
|
|
|
...(isAdditive ? { blendMode: 'additive' } : {}),
|
|
|
|
|
...(isExtinguishing
|
|
|
|
|
? {
|
|
|
|
|
width: drawW,
|
|
|
|
|
height: drawH,
|
|
|
|
|
tint: [1, 1, 1, opacity],
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Dual Sprite Falling Trajectory & Rendering:
|
|
|
|
|
// During the 60-tick countdown of meteorcenter, render the falling meteor fireball (meteor, ID 100)
|
|
|
|
|
// and atmospheric flame tail (meteortail, ID 102) descending diagonally from sky altitude to the ground indicator.
|
|
|
|
|
if (shot.missileType === 'meteorcenter' && missileArtMap != null && shot.ttl > 0) {
|
|
|
|
|
const remainingTicks = shot.ttl
|
|
|
|
|
const fallAltitude = remainingTicks * 25
|
|
|
|
|
const slideX = -remainingTicks * 15
|
|
|
|
|
const meteorX = shot.x + slideX
|
|
|
|
|
const meteorY = shot.y
|
|
|
|
|
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
|
|
|
|
|
|
|
|
|
|
// 1. Atmospheric flame tail (`meteortail`, ID 102)
|
|
|
|
|
const tailArt = missileArtMap.get('meteortail')
|
|
|
|
|
if (tailArt !== undefined) {
|
|
|
|
|
const tailGroup = tailArt.frames[0]
|
|
|
|
|
const tailFrameCount = Math.max(1, tailArt.meta.framesPerDirection)
|
|
|
|
|
const tailFrameIndex = Math.floor((elapsed * tailArt.meta.animSpeed) / 16) % tailFrameCount
|
|
|
|
|
const tailFrame = tailGroup?.[tailFrameIndex] ?? tailGroup?.[0]
|
|
|
|
|
if (tailFrame !== undefined) {
|
|
|
|
|
const tailDrawX = tailFrame.anchorX !== undefined ? meteorX + tailFrame.anchorX : meteorX - tailFrame.width / 2
|
|
|
|
|
const tailDrawY = (tailFrame.anchorY !== undefined ? meteorY + tailFrame.anchorY : meteorY - tailFrame.height / 2) - fallAltitude
|
|
|
|
|
renderer.draw(tailFrame, tailDrawX, tailDrawY, {
|
|
|
|
|
atlas: tailArt.handle,
|
|
|
|
|
blendMode: 'additive',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Falling meteor core fireball (`meteor`, ID 100)
|
|
|
|
|
const meteorArt = missileArtMap.get('meteor')
|
|
|
|
|
if (meteorArt !== undefined) {
|
|
|
|
|
const meteorGroup = meteorArt.frames[0]
|
|
|
|
|
const meteorFrameCount = Math.max(1, meteorArt.meta.framesPerDirection)
|
|
|
|
|
const meteorFrameIndex = Math.floor((elapsed * meteorArt.meta.animSpeed) / 16) % meteorFrameCount
|
|
|
|
|
const meteorFrame = meteorGroup?.[meteorFrameIndex] ?? meteorGroup?.[0]
|
|
|
|
|
if (meteorFrame !== undefined) {
|
|
|
|
|
const meteorDrawX = meteorFrame.anchorX !== undefined ? meteorX + meteorFrame.anchorX : meteorX - meteorFrame.width / 2
|
|
|
|
|
const meteorDrawY = (meteorFrame.anchorY !== undefined ? meteorY + meteorFrame.anchorY : meteorY - meteorFrame.height / 2) - fallAltitude
|
|
|
|
|
renderer.draw(meteorFrame, meteorDrawX, meteorDrawY, {
|
|
|
|
|
atlas: meteorArt.handle,
|
|
|
|
|
blendMode: 'additive',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const drawFireboltProjectile = drawMissileProjectile
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Render a Diablo II 1.13c explosion using authentic baked DCC sprite atlas.
|
|
|
|
|
*/
|
|
|
|
|
export function drawExplosion(
|
|
|
|
|
renderer: SpriteRenderer,
|
|
|
|
|
exp: ActiveExplosion,
|
|
|
|
|
explosionArt?: LoadedMissileArt | null,
|
|
|
|
|
): void {
|
|
|
|
|
if (explosionArt === undefined || explosionArt === null) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const frameCount = Math.max(1, explosionArt.meta.framesPerDirection)
|
|
|
|
|
const frameIndex = Math.min(Math.max(0, exp.frame), frameCount - 1)
|
|
|
|
|
if (explosionArt.meta.directions > 1) {
|
|
|
|
|
for (let dir = 0; dir < explosionArt.meta.directions; dir++) {
|
|
|
|
|
const group = explosionArt.frames[dir]
|
|
|
|
|
const frame = group?.[frameIndex] ?? group?.[0]
|
|
|
|
|
if (frame !== undefined) {
|
|
|
|
|
const drawX = frame.anchorX !== undefined ? headX + frame.anchorX : headX - frame.width / 2
|
|
|
|
|
const drawY = frame.anchorY !== undefined ? headY + frame.anchorY : headY - frame.height / 2
|
|
|
|
|
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
|
|
|
|
|
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
|
|
|
|
|
renderer.draw(frame, drawX, drawY, {
|
|
|
|
|
atlas: art.handle,
|
|
|
|
|
atlas: explosionArt.handle,
|
|
|
|
|
blendMode: 'additive',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
shot.missileType === 'meteorfire' ||
|
|
|
|
|
shot.missileType === 'firewall' ||
|
|
|
|
|
shot.missileType === 'groundfirebig'
|
|
|
|
|
) {
|
|
|
|
|
const isExtinguishing = shot.ttl <= 12
|
|
|
|
|
const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0
|
|
|
|
|
const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0
|
|
|
|
|
const alpha = isExtinguishing ? decayRatio : 1.0
|
|
|
|
|
renderer.drawSolid(x - 12 * scale, y - 6 * scale, 24 * scale, 12 * scale, [1.0, 0.35, 0.05, 0.5 * alpha])
|
|
|
|
|
renderer.drawSolid(x - 8 * scale, y - 4 * scale, 16 * scale, 8 * scale, [1.0, 0.65, 0.1, 0.7 * alpha])
|
|
|
|
|
renderer.drawSolid(x - 4 * scale, y - 2 * scale, 8 * scale, 4 * scale, [1.0, 0.95, 0.3, 0.9 * alpha])
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const vx = shot.vx
|
|
|
|
|
const vy = shot.vy
|
|
|
|
|
const speed = Math.hypot(vx, vy)
|
|
|
|
|
const dirX = speed > 0 ? vx / speed : 1
|
|
|
|
|
const dirY = speed > 0 ? vy / speed : 0
|
|
|
|
|
|
|
|
|
|
const isLightningMissile =
|
|
|
|
|
shot.missileType === 'lightningbolt' ||
|
|
|
|
|
shot.missileType === 'chainlightning' ||
|
|
|
|
|
shot.missileType === 'lightning' ||
|
|
|
|
|
shot.missileType === 'lightninghit'
|
|
|
|
|
|
|
|
|
|
if (isLightningMissile) {
|
|
|
|
|
drawElectricBoltFallback(renderer, x, y)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 1. Dynamic flame trail: trailing flame and ember puffs along reverse trajectory
|
|
|
|
|
for (let i = 1; i <= 5; i += 1) {
|
|
|
|
|
const dist = i * 4.2
|
|
|
|
|
// Sinusoidal organic wobble perpendicular to travel direction
|
|
|
|
|
const flicker = Math.sin(shot.ttl * 1.8 + i * 1.5) * (i * 0.75)
|
|
|
|
|
const px = x - dirX * dist - dirY * flicker
|
|
|
|
|
const py = y - dirY * dist + dirX * flicker
|
|
|
|
|
const size = Math.max(3, 8 - i)
|
|
|
|
|
let color: readonly [number, number, number, number]
|
|
|
|
|
if (i === 1) color = [1.0, 0.72, 0.16, 0.78]
|
|
|
|
|
else if (i === 2) color = [0.98, 0.52, 0.08, 0.62]
|
|
|
|
|
else if (i === 3) color = [0.92, 0.32, 0.04, 0.46]
|
|
|
|
|
else if (i === 4) color = [0.75, 0.18, 0.02, 0.32]
|
|
|
|
|
else color = [0.45, 0.1, 0.02, 0.18]
|
|
|
|
|
renderer.drawSolid(px - size / 2, py - size / 2, size, size, color)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Wide soft orange-red heat haze / aura
|
|
|
|
|
renderer.drawSolid(x - 10, y - 10, 20, 20, [1.0, 0.35, 0.05, 0.22])
|
|
|
|
|
|
|
|
|
|
// 3. Medium bright flame body
|
|
|
|
|
renderer.drawSolid(x - 7, y - 7, 14, 14, [1.0, 0.62, 0.1, 0.55])
|
|
|
|
|
|
|
|
|
|
// 4. Inner intense fire
|
|
|
|
|
renderer.drawSolid(x - 4, y - 4, 8, 8, [1.0, 0.88, 0.22, 0.88])
|
|
|
|
|
|
|
|
|
|
// 5. Incandescent white-hot center core
|
|
|
|
|
renderer.drawSolid(x - 2, y - 2, 4, 4, [1.0, 1.0, 0.88, 0.98])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const drawFireboltProjectile = drawMissileProjectile
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Render a Diablo II 1.13c style explosion:
|
|
|
|
|
* - Uses authentic baked DCC sprite atlas when available.
|
|
|
|
|
* - Falls back to procedural explosion in test/offline environments.
|
|
|
|
|
*/
|
|
|
|
|
export function drawExplosion(
|
|
|
|
|
renderer: SpriteRenderer,
|
|
|
|
|
exp: ActiveExplosion,
|
|
|
|
|
explosionArt?: LoadedMissileArt,
|
|
|
|
|
): void {
|
|
|
|
|
if (explosionArt !== undefined) {
|
|
|
|
|
const frameCount = Math.max(1, explosionArt.meta.framesPerDirection)
|
|
|
|
|
const frameIndex = Math.min(Math.max(0, exp.frame), frameCount - 1)
|
|
|
|
|
if (explosionArt.meta.directions > 1) {
|
|
|
|
|
for (let dir = 0; dir < explosionArt.meta.directions; dir++) {
|
|
|
|
|
const group = explosionArt.frames[dir]
|
|
|
|
|
const frame = group?.[frameIndex] ?? group?.[0]
|
|
|
|
|
if (frame !== undefined) {
|
|
|
|
|
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
|
|
|
|
|
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
|
|
|
|
|
renderer.draw(frame, drawX, drawY, {
|
|
|
|
|
atlas: explosionArt.handle,
|
|
|
|
|
blendMode: 'additive',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const group = explosionArt.frames[0]
|
|
|
|
|
const frame = group?.[frameIndex] ?? group?.[0]
|
|
|
|
|
if (frame !== undefined) {
|
|
|
|
|
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
|
|
|
|
|
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
|
|
|
|
|
renderer.draw(frame, drawX, drawY, {
|
|
|
|
|
atlas: explosionArt.handle,
|
|
|
|
|
blendMode: 'additive',
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const progress = Math.min(1, Math.max(0, exp.frame / exp.maxFrames))
|
|
|
|
|
const inv = 1 - progress
|
|
|
|
|
const x = exp.x
|
|
|
|
|
const y = exp.y
|
|
|
|
|
|
|
|
|
|
// 1. Central expanding fireball burst
|
|
|
|
|
const radius = 6 + progress * 20
|
|
|
|
|
const alpha = inv * 0.85
|
|
|
|
|
const r = 1.0
|
|
|
|
|
const g = progress < 0.25 ? 0.92 - progress * 1.2 : 0.6 * inv
|
|
|
|
|
const b = progress < 0.2 ? 0.7 * inv : 0.04
|
|
|
|
|
renderer.drawSolid(x - radius, y - radius, radius * 2, radius * 2, [r, g, b, alpha * 0.35])
|
|
|
|
|
|
|
|
|
|
const innerRadius = radius * 0.62
|
|
|
|
|
renderer.drawSolid(x - innerRadius, y - innerRadius, innerRadius * 2, innerRadius * 2, [r, Math.min(1, g + 0.25), b * 1.4, alpha * 0.7])
|
|
|
|
|
|
|
|
|
|
// Early flash at impact point (frames 0..3)
|
|
|
|
|
if (progress < 0.3) {
|
|
|
|
|
const flashSize = Math.max(3, 7 * (1 - progress * 3.3))
|
|
|
|
|
renderer.drawSolid(x - flashSize, y - flashSize, flashSize * 2, flashSize * 2, [1.0, 1.0, 0.92, 0.95])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Eight radial fire sparks/embers flying outwards
|
|
|
|
|
for (let i = 0; i < 8; i += 1) {
|
|
|
|
|
const angle = i * (Math.PI / 4) + i * 0.16
|
|
|
|
|
const sparkDist = progress * 28 + (i % 3) * 3
|
|
|
|
|
const sx = x + Math.cos(angle) * sparkDist
|
|
|
|
|
const sy = y + Math.sin(angle) * sparkDist
|
|
|
|
|
const sparkSize = Math.max(2, 4.5 * inv)
|
|
|
|
|
renderer.drawSolid(sx - sparkSize / 2, sy - sparkSize / 2, sparkSize, sparkSize, [1.0, 0.55 * inv + 0.2, 0.05, inv * 0.9])
|
|
|
|
|
const group = explosionArt.frames[0]
|
|
|
|
|
const frame = group?.[frameIndex] ?? group?.[0]
|
|
|
|
|
if (frame !== undefined) {
|
|
|
|
|
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
|
|
|
|
|
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
|
|
|
|
|
renderer.draw(frame, drawX, drawY, {
|
|
|
|
|
atlas: explosionArt.handle,
|
|
|
|
|
blendMode: 'additive',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -511,8 +358,12 @@ export async function loadOverlayArtMap(
|
|
|
|
|
'samples/d2-packs/overlays',
|
|
|
|
|
].filter(Boolean)
|
|
|
|
|
|
|
|
|
|
const failures: string[] = []
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
targets.map(async name => {
|
|
|
|
|
let loaded = false
|
|
|
|
|
let lastReason = ''
|
|
|
|
|
for (const base of baseCandidates) {
|
|
|
|
|
try {
|
|
|
|
|
const [jsonResp, pngResp] = await Promise.all([
|
|
|
|
|
@ -544,14 +395,26 @@ export async function loadOverlayArtMap(
|
|
|
|
|
if (meta.celFile) {
|
|
|
|
|
map.set(meta.celFile.toLowerCase(), art)
|
|
|
|
|
}
|
|
|
|
|
loaded = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore candidate error and continue to next fallback path
|
|
|
|
|
lastReason = `${base}/${name}.json (HTTP ${jsonResp.status}), ${base}/${name}.png (HTTP ${pngResp.status})`
|
|
|
|
|
} catch (err) {
|
|
|
|
|
lastReason = `${base}/${name}: ${err instanceof Error ? err.message : String(err)}`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!loaded) {
|
|
|
|
|
failures.push(`${name} [${lastReason || `tried: ${baseCandidates.join(', ')}`}]`)
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (failures.length > 0) {
|
|
|
|
|
const msg = `[loadOverlayArtMap] Failed to load ${failures.length} overlay atlas(es): ${failures.slice(0, 5).join('; ')}`
|
|
|
|
|
console.error(msg)
|
|
|
|
|
throw new Error(msg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [alias, meta] of Object.entries(OVERLAY_METAS)) {
|
|
|
|
|
const art = map.get(meta.name)
|
|
|
|
|
if (art && !map.has(alias)) {
|
|
|
|
|
@ -571,10 +434,10 @@ export async function loadOverlayArtMap(
|
|
|
|
|
export function drawCastOverlay(
|
|
|
|
|
renderer: SpriteRenderer,
|
|
|
|
|
overlay: ActiveOverlay,
|
|
|
|
|
overlayArt?: LoadedOverlayArt,
|
|
|
|
|
overlayArt?: LoadedOverlayArt | null,
|
|
|
|
|
): void {
|
|
|
|
|
// 1. Sprite animation with additive blending
|
|
|
|
|
if (overlayArt !== undefined) {
|
|
|
|
|
if (overlayArt !== undefined && overlayArt !== null) {
|
|
|
|
|
const frameCount = Math.max(1, overlayArt.meta.framesPerDirection || overlayArt.meta.frames)
|
|
|
|
|
const frameIndex = Math.min(Math.max(0, Math.floor(overlay.frame)), frameCount - 1)
|
|
|
|
|
const group = overlayArt.frames[0]
|
|
|
|
|
|