fix(skill): extend Meteor ground fire duration and add extinction decay animation (Fixes #437)

This commit is contained in:
troytt 2026-09-24 16:17:38 +00:00
parent 0aa7c0a121
commit f12fd5674a
5 changed files with 223 additions and 8 deletions

View File

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

View File

@ -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,
})
}
}

View File

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

View File

@ -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)
})
})

View File

@ -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)
}