181 lines
5.9 KiB
TypeScript
181 lines
5.9 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
})
|