diablo2-web/tests/skills/sor/issue-437-meteor-fire-extin...

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