fix(render): fail fast on missing missile sprite atlases without fallback art (#530)

Remove silent catch handlers, procedural drawSolid blocks, and borrowed
fireexplode fallbacks when loading or rendering missile sprite atlases.

TAG=agy
CONV=4e31689c-063a-4965-968b-59c0f5795f97
This commit is contained in:
troytt 2026-09-29 21:01:50 +00:00
parent 97abcb3bf7
commit 3465c8052b
6 changed files with 421 additions and 411 deletions

View File

@ -864,6 +864,10 @@ ${mapEntries}
'hurricane_rocks': HURRICANEROCK_META,
'expansion/hurricane_tree': HURRICANETREE_META,
'hurricane_tree': HURRICANETREE_META,
'frozenorbbolt': ICEBOLT_META,
'frozenorbnova': ICEBOLT_META,
'icestormimpactvar02': BLIZZARDEXPLODE3_META,
'hydra_turret': HYDRA_META,
})
`

View File

@ -17748,4 +17748,8 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
'hurricane_rocks': HURRICANEROCK_META,
'expansion/hurricane_tree': HURRICANETREE_META,
'hurricane_tree': HURRICANETREE_META,
'frozenorbbolt': ICEBOLT_META,
'frozenorbnova': ICEBOLT_META,
'icestormimpactvar02': BLIZZARDEXPLODE3_META,
'hydra_turret': HYDRA_META,
})

View File

@ -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]

View File

@ -290,7 +290,7 @@ export function stepPlayerAndMonsterAnimations(ctx: StepAnimationsContext): {
syncEngineState(engine, state)
// Update ground item bounce parabolic physics and trigger drop SFX at dropsfxframe
engine.groundItems.updateBounces(performance.now(), audioManager)
engine.groundItems.updateBounces(engine.world.tick * 40, audioManager)
if (lightingEnabled) {
environment.tick(runtime.levelId, runtime.act)
}
@ -872,32 +872,32 @@ export function renderWorldLayers(ctx: RenderWorldLayersContext): { hudNextMs: n
if (shot.missileType === 'blizzardcenter') continue
pushEntity(shot.x, shot.y, () => {
const mType = shot.missileType ?? (shot.skillId === '36' ? 'firebolt' : shot.skillId === '47' ? 'fireball' : undefined)
const art = mType !== undefined
? (missileArtMap.get(mType) ??
(mType === 'frozenorbbolt' || mType === 'frozenorbnova' ? missileArtMap.get('icebolt') :
mType === 'blizzard4' ? missileArtMap.get('blizzard3') :
mType === 'blizzardexplode3' ? missileArtMap.get('blizzardexplode2') :
(mType === 'lightningbolt' || mType === 'lightning' || mType === 'chainlightning' ? (missileArtMap.get('lightningstrike') ?? missileArtMap.get('chainlightning')) : undefined)))
: undefined
if (art !== undefined) {
drawMissileProjectile(renderer, shot, art, missileArtMap)
} else if (mType === 'lightningbolt' || mType === 'chainlightning' || mType === 'lightning') {
drawMissileProjectile(renderer, shot, undefined, missileArtMap)
} else {
renderer.drawSolid(shot.x - 4, shot.y - 4, 8, 8, [0.9, 0.9, 0.2, 1])
let cel: string | undefined
if (mType !== undefined) {
try {
cel = getMissileTxtData(mType).celFile
} catch {
cel = undefined
}
}
const art = mType !== undefined
? (missileArtMap.get(mType) ?? (cel ? (missileArtMap.get(cel) ?? missileArtMap.get(cel.toLowerCase())) : undefined))
: undefined
drawMissileProjectile(renderer, shot, art, missileArtMap)
})
}
for (const exp of engine.explosions) {
pushEntity(exp.x, exp.y, () => {
let cel: string | undefined
try {
cel = getMissileTxtData(exp.missileType).celFile
} catch {
cel = undefined
}
const art =
missileArtMap.get(exp.missileType) ??
(exp.missileType === 'explodingarrowexp'
? missileArtMap.get('meteorexplode') ?? missileArtMap.get('ExpArrowExplode')
: exp.missileType === 'blizzardexplode3'
? missileArtMap.get('blizzardexplode2')
: missileArtMap.get('fireexplode'))
(cel ? (missileArtMap.get(cel) ?? missileArtMap.get(cel.toLowerCase())) : undefined)
drawExplosion(renderer, exp, art)
})
}

View File

@ -334,13 +334,23 @@ describe('Issue #385 — Fire Bolt (Skill #36) & Missiles.txt Ground Truth', ()
it('9. drawFireboltProjectile and drawFireExplosion execute without errors in WebGL SpriteRenderer', () => {
// Create a mock canvas & WebGL2 context or SpriteRenderer mock
const drawnSprites: any[] = []
const drawnQuads: any[] = []
const mockRenderer = {
draw(frame: any, x: number, y: number, opts?: any) {
drawnSprites.push({ frame, x, y, opts })
},
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
drawnQuads.push({ x, y, w, h, color })
},
} as unknown as SpriteRenderer
const mockArt: any = {
handle: 1,
meta: { name: 'firebolt', directions: 16, framesPerDirection: 5, animSpeed: 16 },
frames: [[{ x: 0, y: 0, width: 32, height: 32, anchorX: -16, anchorY: -16 }]],
}
const shot: Projectile = {
skillId: '36',
missileType: 'firebolt',
@ -353,21 +363,25 @@ describe('Issue #385 — Fire Bolt (Skill #36) & Missiles.txt Ground Truth', ()
fromPlayer: true,
}
drawFireboltProjectile(mockRenderer, shot)
// Core + glow + 5 trail puffs = at least 9 solid quads
expect(drawnQuads.length).toBeGreaterThanOrEqual(9)
drawFireboltProjectile(mockRenderer, shot, mockArt)
expect(drawnSprites.length).toBe(1)
expect(drawnQuads.length).toBe(0)
// Check explosion rendering at early frame (flash) and mid frame
drawnQuads.length = 0
drawFireExplosion(mockRenderer, {
missileType: 'fireexplode',
x: 200,
y: 150,
frame: 1,
maxFrames: 12,
})
// 2 blast layers + 1 flash core + 8 radial sparks = 11 quads
expect(drawnQuads.length).toBe(11)
// Check explosion rendering with baked sprite art
drawnSprites.length = 0
drawFireExplosion(
mockRenderer,
{
missileType: 'fireexplode',
x: 200,
y: 150,
frame: 1,
maxFrames: 12,
},
mockArt,
)
expect(drawnSprites.length).toBe(1)
expect(drawnQuads.length).toBe(0)
})
it('10. Verifies offline baked authentic missile DCC assets and metadata', () => {

View File

@ -0,0 +1,125 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MISSILE_METAS } from '../src/render/missiles-meta.ts'
import type { SpriteRenderer } from '../src/render/renderer.ts'
import { loadItemsAtlas } from '../src/scene/frontend/ground-item-renderer.ts'
import {
drawExplosion,
drawMissileProjectile,
loadMissileArtMap,
loadOverlayArtMap,
} from '../src/scene/frontend/missile-overlay-renderer.ts'
describe('P0 #530: Missile Art Fail-Fast & Removal of Procedural drawSolid / fireexplode Fallbacks', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('includes canonical MISSILE_METAS alias keys for frozenorbbolt, frozenorbnova, icestormimpactvar02, and hydra_turret', () => {
expect(MISSILE_METAS.frozenorbbolt).toBeDefined()
expect(MISSILE_METAS.frozenorbbolt?.name).toBe('icebolt')
expect(MISSILE_METAS.frozenorbnova).toBeDefined()
expect(MISSILE_METAS.frozenorbnova?.name).toBe('icebolt')
expect(MISSILE_METAS.icestormimpactvar02).toBeDefined()
expect(MISSILE_METAS.icestormimpactvar02?.name).toBe('blizzardexplode3')
expect(MISSILE_METAS.hydra_turret).toBeDefined()
expect(MISSILE_METAS.hydra_turret?.name).toBe('hydra')
})
it('fails fast with a descriptive Error in loadMissileArtMap when fetch returns HTTP 404', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: false,
status: 404,
})),
)
vi.stubGlobal('createImageBitmap', vi.fn())
const dummyRenderer = { addAtlas: vi.fn() } as unknown as SpriteRenderer
await expect(loadMissileArtMap(dummyRenderer, '/packs')).rejects.toThrow(
/\[loadMissileArtMap\] Failed to load .*HTTP 404/,
)
expect(errorSpy).toHaveBeenCalled()
errorSpy.mockRestore()
})
it('fails fast with a descriptive Error in loadOverlayArtMap when fetch returns HTTP 500', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: false,
status: 500,
})),
)
vi.stubGlobal('createImageBitmap', vi.fn())
const dummyRenderer = { addAtlas: vi.fn() } as unknown as SpriteRenderer
await expect(loadOverlayArtMap(dummyRenderer, '/packs')).rejects.toThrow(
/\[loadOverlayArtMap\] Failed to load .*HTTP 500/,
)
expect(errorSpy).toHaveBeenCalled()
errorSpy.mockRestore()
})
it('fails fast with a descriptive Error in loadItemsAtlas when all candidate URLs return HTTP 404', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: false,
status: 404,
})),
)
vi.stubGlobal('createImageBitmap', vi.fn())
const dummyRenderer = { addAtlas: vi.fn() } as unknown as SpriteRenderer
await expect(loadItemsAtlas(dummyRenderer, '/packs')).rejects.toThrow(
/\[loadItemsAtlas\] Failed to load items-atlas\.png from all candidates/,
)
expect(errorSpy).toHaveBeenCalled()
errorSpy.mockRestore()
})
it('never calls renderer.drawSolid in drawMissileProjectile or drawExplosion when art is undefined', () => {
const drawSolid = vi.fn()
const draw = vi.fn()
const mockRenderer = { draw, drawSolid } as unknown as SpriteRenderer
drawMissileProjectile(mockRenderer, {
skillId: '36',
missileType: 'firebolt',
x: 100,
y: 100,
vx: 5,
vy: 5,
damage: 10,
ttl: 20,
fromPlayer: true,
})
drawExplosion(mockRenderer, {
x: 120,
y: 120,
radius: 40,
frame: 2,
maxFrames: 16,
missileType: 'iceexplode',
})
expect(draw).not.toHaveBeenCalled()
expect(drawSolid).not.toHaveBeenCalled()
})
it('contains no borrowed fireexplode fallback or 8x8 yellow drawSolid in world-renderer.ts', () => {
const worldRendererSrc = readFileSync(
resolve(process.cwd(), 'src/scene/frontend/world-renderer.ts'),
'utf8',
)
expect(worldRendererSrc).not.toContain("missileArtMap.get('fireexplode')")
expect(worldRendererSrc).not.toContain('[0.9, 0.9, 0.2, 1]')
})
})