diff --git a/scripts/pack-missiles.ts b/scripts/pack-missiles.ts index 7f96dc1..f31da6e 100644 --- a/scripts/pack-missiles.ts +++ b/scripts/pack-missiles.ts @@ -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, }) ` diff --git a/src/render/missiles-meta.ts b/src/render/missiles-meta.ts index 1b35d0c..f05d5d2 100644 --- a/src/render/missiles-meta.ts +++ b/src/render/missiles-meta.ts @@ -17748,4 +17748,8 @@ export const MISSILE_METAS: Readonly> = 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, }) diff --git a/src/scene/frontend/missile-overlay-renderer.ts b/src/scene/frontend/missile-overlay-renderer.ts index 81f2995..0ebb47e 100644 --- a/src/scene/frontend/missile-overlay-renderer.ts +++ b/src/scene/frontend/missile-overlay-renderer.ts @@ -53,7 +53,7 @@ export async function loadMissileArtMap( packBase?: string, ): Promise> { const map = new Map() - 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, + missileArt?: LoadedMissileArt | null, + missileArtMap?: Map | 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] diff --git a/src/scene/frontend/world-renderer.ts b/src/scene/frontend/world-renderer.ts index 24fc1e0..e0945f0 100644 --- a/src/scene/frontend/world-renderer.ts +++ b/src/scene/frontend/world-renderer.ts @@ -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) }) } diff --git a/tests/firebolt-missile-113c.test.ts b/tests/firebolt-missile-113c.test.ts index eb8920e..802ce01 100644 --- a/tests/firebolt-missile-113c.test.ts +++ b/tests/firebolt-missile-113c.test.ts @@ -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', () => { diff --git a/tests/p0-530-missile-art-failfast.test.ts b/tests/p0-530-missile-art-failfast.test.ts new file mode 100644 index 0000000..834d1aa --- /dev/null +++ b/tests/p0-530-missile-art-failfast.test.ts @@ -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]') + }) +})