fix(skill): extend Meteor ground fire duration and add extinction decay animation (Fixes #437)
This commit is contained in:
parent
0aa7c0a121
commit
f12fd5674a
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue