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:
parent
97abcb3bf7
commit
3465c8052b
|
|
@ -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,
|
||||
})
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,16 +61,26 @@ 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) {
|
||||
for (const stem of candidateStems) {
|
||||
try {
|
||||
const [jsonResp, pngResp] = await Promise.all([
|
||||
fetch(`${base}/${name}.json`),
|
||||
fetch(`${base}/${name}.png`),
|
||||
fetch(`${base}/${stem}.json`),
|
||||
fetch(`${base}/${stem}.png`),
|
||||
])
|
||||
if (jsonResp.ok && pngResp.ok) {
|
||||
const meta = (await jsonResp.json()) as MissileMeta
|
||||
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
|
||||
|
|
@ -89,89 +99,90 @@ export async function loadMissileArtMap(
|
|||
...(anchorY !== undefined ? { anchorY } : {}),
|
||||
})),
|
||||
)
|
||||
map.set(name, { meta, handle, frames })
|
||||
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
|
||||
}
|
||||
} catch {
|
||||
// ignore candidate error and continue to next fallback path
|
||||
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)}`
|
||||
}
|
||||
}
|
||||
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)
|
||||
if (resolvedArt === undefined || resolvedArt === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const altitude = shot.altitude ?? 0
|
||||
const dccDir = velocityToDccDirection(shot.vx, shot.vy, resolvedArt.meta.directions)
|
||||
const frameCount = Math.max(1, resolvedArt.meta.framesPerDirection)
|
||||
|
||||
let frameIndex: number
|
||||
if (missileArt.meta.name === 'meteorcenter') {
|
||||
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 * missileArt.meta.animSpeed) / 16) % frameCount
|
||||
frameIndex = Math.floor((elapsed * resolvedArt.meta.animSpeed) / 16) % frameCount
|
||||
} else if (
|
||||
(missileArt.meta.name === 'meteorfire' ||
|
||||
missileArt.meta.name === 'firewall' ||
|
||||
missileArt.meta.name === 'groundfirebig') &&
|
||||
(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 =
|
||||
missileArt.meta.name === 'meteorfire'
|
||||
resolvedArt.meta.name === 'meteorfire'
|
||||
? 90 + (Math.max(1, shot.slvl ?? 1) - 1) * 15
|
||||
: shot.ttl > 0
|
||||
? (shot.ageTicks ?? 0) + shot.ttl
|
||||
|
|
@ -182,21 +193,24 @@ export function drawMissileProjectile(
|
|||
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)
|
||||
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 * missileArt.meta.animSpeed) / 16) % frameCount
|
||||
frameIndex = Math.floor((elapsed * resolvedArt.meta.animSpeed) / 16) % frameCount
|
||||
}
|
||||
|
||||
const group = missileArt.frames[dccDir] ?? missileArt.frames[0]
|
||||
const group = resolvedArt.frames[dccDir] ?? resolvedArt.frames[0]
|
||||
const frame = group?.[frameIndex] ?? group?.[0]
|
||||
if (frame !== undefined) {
|
||||
if (frame === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const isGroundFlame =
|
||||
missileArt.meta.name === 'meteorfire' ||
|
||||
missileArt.meta.name === 'firewall' ||
|
||||
missileArt.meta.name === 'groundfirebig'
|
||||
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
|
||||
|
|
@ -209,9 +223,14 @@ export function drawMissileProjectile(
|
|||
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
|
||||
let isAdditive = false
|
||||
try {
|
||||
isAdditive = getMissileTxtData(shot.missileType ?? resolvedArt.meta.name).trans === 1
|
||||
} catch {
|
||||
isAdditive = true
|
||||
}
|
||||
renderer.draw(frame, drawX, drawY, {
|
||||
atlas: missileArt.handle,
|
||||
atlas: resolvedArt.handle,
|
||||
...(isAdditive ? { blendMode: 'additive' } : {}),
|
||||
...(isExtinguishing
|
||||
? {
|
||||
|
|
@ -225,7 +244,7 @@ export function drawMissileProjectile(
|
|||
// 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) {
|
||||
if (shot.missileType === 'meteorcenter' && missileArtMap != null && shot.ttl > 0) {
|
||||
const remainingTicks = shot.ttl
|
||||
const fallAltitude = remainingTicks * 25
|
||||
const slideX = -remainingTicks * 15
|
||||
|
|
@ -267,160 +286,22 @@ export function drawMissileProjectile(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
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
|
||||
|
||||
// 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])
|
||||
|
||||
// 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 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])
|
||||
|
||||
// 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])
|
||||
|
||||
// 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
|
||||
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
|
||||
renderer.draw(frame, drawX, drawY, {
|
||||
atlas: art.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.
|
||||
* Render a Diablo II 1.13c explosion using authentic baked DCC sprite atlas.
|
||||
*/
|
||||
export function drawExplosion(
|
||||
renderer: SpriteRenderer,
|
||||
exp: ActiveExplosion,
|
||||
explosionArt?: LoadedMissileArt,
|
||||
explosionArt?: LoadedMissileArt | null,
|
||||
): void {
|
||||
if (explosionArt !== undefined) {
|
||||
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) {
|
||||
|
|
@ -447,40 +328,6 @@ export function drawExplosion(
|
|||
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])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
// Check explosion rendering with baked sprite art
|
||||
drawnSprites.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)
|
||||
},
|
||||
mockArt,
|
||||
)
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
expect(drawnQuads.length).toBe(0)
|
||||
})
|
||||
|
||||
it('10. Verifies offline baked authentic missile DCC assets and metadata', () => {
|
||||
|
|
|
|||
|
|
@ -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]')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue