diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index a76c48f..8a2a0c8 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -1687,7 +1687,7 @@ export class MissileEngine { // 3. Spawn 18 meteorfire submissiles using 18-offset table const subFireName = m.record.hitSubMissile1 || 'meteorfire' - const fireDuration = 30 + (Math.max(1, m.slvl) - 1) * 15 + const fireDuration = 90 + (Math.max(1, m.slvl) - 1) * 15 const fireDmg = calculateMeteorResidualFireDamage(m.slvl) const fireDmgPacket: SUnitDmgPacket = { skillId: m.sourceSkillId, diff --git a/src/game/skills.ts b/src/game/skills.ts index 0a180d2..b7d9c05 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -4778,7 +4778,7 @@ export function tickProjectiles( // 3. Spawn 18 meteorfire submissiles using 18-offset table const slvl = Math.max(1, projectile.slvl ?? 1) - const fireDuration = projectile.fireDuration ?? (30 + (slvl - 1) * 15) + const fireDuration = projectile.fireDuration ?? (90 + (slvl - 1) * 15) const residualDmg = projectile.residualDamage ?? calculateMeteorResidualFireDamage(slvl).min for (const off of METEOR_FIRE_SUBTILE_OFFSETS) { const fx = projectile.x + off.x * 20 @@ -4801,6 +4801,7 @@ export function tickProjectiles( aoeRadius: 16, pierce: true, slvl, + fireDuration, }) } } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index cec9561..5e2a431 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -5287,10 +5287,16 @@ export function drawMissileProjectile( } else if (missileArt.meta.name === 'meteorfire' && frameCount === 37) { // For meteorfire: 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)). - const duration = shot.fireDuration ?? (30 + (Math.max(1, shot.slvl ?? 1) - 1) * 15) + // Extinction phase (shot.ttl <= 12): reverse emergence sequence (frames 11 down to 0) + shrinking scale & fade. + const duration = shot.fireDuration ?? (90 + (Math.max(1, shot.slvl ?? 1) - 1) * 15) const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl) - const animStep = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) - frameIndex = animStep < 12 ? animStep : 12 + ((animStep - 12) % 25) + 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 @@ -5299,12 +5305,29 @@ export function drawMissileProjectile( const group = missileArt.frames[dccDir] ?? missileArt.frames[0] const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { - const drawX = frame.anchorX !== undefined ? shot.x + frame.anchorX : shot.x - frame.width / 2 - const drawY = (frame.anchorY !== undefined ? shot.y + frame.anchorY : shot.y - frame.height / 2) - altitude + const isExtinguishing = missileArt.meta.name === 'meteorfire' && 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: @@ -5431,6 +5454,17 @@ export function drawMissileProjectile( return } + if (shot.missileType === 'meteorfire') { + 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) diff --git a/tests/skills/sor/issue-437-meteor-fire-extinction.test.ts b/tests/skills/sor/issue-437-meteor-fire-extinction.test.ts new file mode 100644 index 0000000..6f66cb3 --- /dev/null +++ b/tests/skills/sor/issue-437-meteor-fire-extinction.test.ts @@ -0,0 +1,180 @@ +/** + * Issue #437: Meteor (#56) Ground Fire Duration & Extinction Decay Animation + * + * 1.13c Ground Truth Invariants: + * 1. Duration: meteorfire base duration is 90 ticks (3.6s) + 15 ticks/lvl (90 + (slvl - 1) * 15). + * 2. Extinction Shrink & Fade: At life end (ttl <= 12), flames do not abruptly vanish in 1 frame. + * Instead, animation steps reverse from frame 11 down to 0, and renderer applies shrinking scale + * and opacity fade. + * 3. Procedural Fallback: When sprite art is unavailable, ground fire renders scaled and faded + * stationary flame patches rather than missile trails. + */ +import { describe, expect, it, vi } from 'vitest' +import { tickProjectiles } from '../../../src/game/skills.ts' +import type { Projectile } from '../../../src/game/skills.ts' +import { drawMissileProjectile } from '../../../src/scene/act-scene.ts' +import type { LoadedMissileArt } from '../../../src/scene/act-scene.ts' +import { METEORFIRE_META } from '../../../src/render/missiles-meta.ts' +import type { SpriteRenderer, AtlasHandle } from '../../../src/render/renderer.ts' + +describe('Issue #437: Meteor Ground Fire Duration & Extinction Animation', () => { + it('enforces 1.13c ground truth duration: 90 ticks base + 15 ticks/lvl', () => { + // slvl 1: 90 ticks (3.6s) + const meteorCenter1: Projectile = { + skillId: '56', + missileType: 'meteorcenter', + x: 100, + y: 100, + vx: 0, + vy: 0, + damage: 100, + ttl: 0, + fromPlayer: true, + slvl: 1, + } + const outcome1 = tickProjectiles([meteorCenter1], [], { overlap: () => 0 }) + const flames1 = outcome1.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? [] + expect(flames1.length).toBe(18) + for (const f of flames1) { + expect(f.ttl).toBe(90) + } + + // slvl 5: 90 + 4 * 15 = 150 ticks (6.0s) + const meteorCenter5: Projectile = { + skillId: '56', + missileType: 'meteorcenter', + x: 100, + y: 100, + vx: 0, + vy: 0, + damage: 100, + ttl: 0, + fromPlayer: true, + slvl: 5, + } + const outcome5 = tickProjectiles([meteorCenter5], [], { overlap: () => 0 }) + const flames5 = outcome5.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? [] + expect(flames5.length).toBe(18) + for (const f of flames5) { + expect(f.ttl).toBe(150) + } + + // slvl 20: 90 + 19 * 15 = 375 ticks (15.0s) + const meteorCenter20: Projectile = { + skillId: '56', + missileType: 'meteorcenter', + x: 100, + y: 100, + vx: 0, + vy: 0, + damage: 100, + ttl: 0, + fromPlayer: true, + slvl: 20, + } + const outcome20 = tickProjectiles([meteorCenter20], [], { overlap: () => 0 }) + const flames20 = outcome20.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? [] + expect(flames20.length).toBe(18) + for (const f of flames20) { + expect(f.ttl).toBe(375) + } + }) + + it('renders reverse emergence sequence and shrinking scale/alpha during extinction (ttl <= 12)', () => { + const drawnCalls: Array<{ frameIdx: number; width?: number; height?: number; tint?: readonly number[] }> = [] + const mockRenderer = { + draw(frame: any, _x: number, _y: number, options: any = {}) { + drawnCalls.push({ + frameIdx: frame.frameIdx, + width: options.width, + height: options.height, + tint: options.tint, + }) + }, + drawSolid: vi.fn(), + } as unknown as SpriteRenderer + + const makeIndexedArt = (meta: any): LoadedMissileArt => ({ + meta, + handle: 1 as unknown as AtlasHandle, + frames: [ + meta.groups[0].map(([x, y, w, h, ax, ay]: number[], idx: number) => ({ + x, + y, + width: w, + height: h, + anchorX: ax, + anchorY: ay, + frameIdx: idx, + })), + ], + }) + + const fireArt = makeIndexedArt(METEORFIRE_META) + + // Extinction phase: test ttl = 12 down to ttl = 1 + for (let ttl = 12; ttl >= 1; ttl--) { + drawnCalls.length = 0 + const shot: Projectile = { + skillId: '56', + missileType: 'meteorfire', + x: 100, + y: 100, + vx: 0, + vy: 0, + damage: 10, + ttl, + ageTicks: 90 - ttl, + fromPlayer: true, + fireDuration: 90, + } + drawMissileProjectile(mockRenderer, shot, fireArt) + expect(drawnCalls.length).toBe(1) + const call = drawnCalls[0]! + + // Frame index should step downwards from 11 to 0 + const expectedFrame = Math.max(0, Math.min(11, ttl - 1)) + expect(call.frameIdx).toBe(expectedFrame) + + // Scale must shrink towards ground + const originalW = fireArt.frames[0]![expectedFrame]!.width + expect(call.width).toBeDefined() + expect(call.width!).toBeLessThanOrEqual(originalW) + + // Opacity tint must fade towards 0 + expect(call.tint).toBeDefined() + expect(call.tint![3]).toBeCloseTo(ttl / 12, 2) + } + }) + + it('draws shrinking procedural fallback when art is not loaded', () => { + const solidCalls: Array<{ x: number; y: number; w: number; h: number; color: readonly number[] }> = [] + const mockRenderer = { + draw: vi.fn(), + drawSolid(x: number, y: number, w: number, h: number, color: any) { + solidCalls.push({ x, y, w, h, color }) + }, + } as unknown as SpriteRenderer + + // Test extinction at ttl = 6 (50% decay) + const shotExtinguishing: Projectile = { + skillId: '56', + missileType: 'meteorfire', + x: 200, + y: 200, + vx: 0, + vy: 0, + damage: 10, + ttl: 6, + ageTicks: 84, + fromPlayer: true, + fireDuration: 90, + } + drawMissileProjectile(mockRenderer, shotExtinguishing) + expect(solidCalls.length).toBe(3) // 3 concentric fire layers + // Middle layer width at 50% decay: scale = 0.2 + 0.8 * 0.5 = 0.6 => w = 16 * 0.6 = 9.6 + expect(solidCalls[1]!.w).toBeCloseTo(9.6, 1) + // Alpha at 50% decay: 0.7 * 0.5 = 0.35 + expect(solidCalls[1]!.color[3]).toBeCloseTo(0.35, 2) + }) +}) diff --git a/tests/skills/sor/skill-056-meteor.test.ts b/tests/skills/sor/skill-056-meteor.test.ts index 2499889..ea7ba8f 100644 --- a/tests/skills/sor/skill-056-meteor.test.ts +++ b/tests/skills/sor/skill-056-meteor.test.ts @@ -403,7 +403,7 @@ describe('[Skill #056] Meteor (SOR) — 1.13c Parity (Issue #210 & #401)', () => const flames10 = outcome10.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? [] expect(flames10.length).toBe(18) for (const f of flames10) { - expect(f.ttl).toBe(30 + 9 * 15) // 165 ticks = 6.60 seconds + expect(f.ttl).toBe(90 + 9 * 15) // 225 ticks = 9.00 seconds expect(f.pierce).toBe(true) }