diablo2-web/tests/skills/sor/issue-439-fire-wall-collisi...

188 lines
6.2 KiB
TypeScript

/**
* Issue #439: Fire Wall (#51) Collision Grid Check & SubLoop Animation
*
* 1.13c Ground Truth Invariants:
* 1. Collision Indexing: `isBlocked` correctly indexes collision cells using `isMissileBlockedAt`
* so walkable ground spawns all 15 fire wall patches instead of dropping them all as false-positive walls.
* 2. SubLoop Animation: `firewall` (`groundFireBig`, 37 frames) uses authentic SubLoop structure:
* - Frames 0..11: emergence flame rise
* - Frames 12..36: 25-frame sustain loop
* - Extinction (ttl <= 12): reverse frame sequence (11 down to 0) + shrinking scale & alpha fade.
* 3. Constant Parity: `skills.ts` canonical missile definition matches Missiles.txt row 69
* (id: 69, celFile: 'groundFireBig', animLen: 37).
*/
import { describe, expect, it, vi } from 'vitest'
import { GameEngine } from '../../../src/game/engine.ts'
import { castSkill, drawMissileProjectile } from '../../../src/scene/act-scene.ts'
import type { LoadedMissileArt } from '../../../src/scene/act-scene.ts'
import type { SpriteRenderer, AtlasHandle } from '../../../src/render/renderer.ts'
import { CANONICAL_113C_MISSILES, type Projectile } from '../../../src/game/skills.ts'
import { FIREWALL_META } from '../../../src/render/missiles-meta.ts'
import { DEMO_SKILLS } from '../../../src/game/demo-data.ts'
describe('Issue #439: Fire Wall (#51) Collision Check & SubLoop Animation', () => {
it('correctly spawns all 15 fire wall patches on open walkable ground without false obstacle clipping', () => {
const engine = new GameEngine(
{ widthPx: 2000, heightPx: 2000, overlap: () => 0 },
{ spawn: { x: 500, y: 500 }, stats: [], questDefs: [], npcDefs: [], skills: DEMO_SKILLS } as any,
)
const player = engine.world.player
player.x = 500
player.y = 500
player.mana = 500
player.cooldown = 0
// Construct a standard collision grid (all walkable: blocked array all 0s)
const cellsX = 20
const cellsY = 20
const subTilesX = cellsX * 5
const subTilesY = cellsY * 5
const mockGrid = {
cellsX,
cellsY,
gridWidth: subTilesX,
gridHeight: subTilesY,
blocked: new Uint8Array(subTilesX * subTilesY),
baseX: 0,
baseY: 0,
}
const mockRuntime = {
grid: mockGrid,
walkable: { isWalkable: () => true },
} as any
const statusEl = { textContent: '' } as HTMLElement
// Cast Fire Wall towards (700, 500)
const castOk = castSkill(51, 700, 500, {
engine,
runtime: mockRuntime,
hudManager: null,
status: statusEl,
})
expect(castOk).toBe(true)
// Verify all 15 patches were successfully spawned
const fireWallPatches = engine.projectiles.filter(p => p.missileType === 'firewall')
expect(fireWallPatches).toHaveLength(15)
// Verify patch attributes
for (const patch of fireWallPatches) {
expect(patch.ttl).toBe(90) // 3.60s per Missiles.txt
expect(patch.fireDuration).toBe(90)
expect(patch.pierce).toBe(true)
expect(patch.damage).toBeGreaterThan(0)
}
})
it('animates firewall with emergence (0..11), sustain loop (12..36), and reverse extinction decay', () => {
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 firewallArt = makeIndexedArt(FIREWALL_META)
// 1. Emergence: frames 0..11
for (let elapsed = 0; elapsed < 12; elapsed++) {
drawnCalls.length = 0
const shot: Projectile = {
skillId: '51',
missileType: 'firewall',
x: 100,
y: 100,
vx: 0,
vy: 0,
damage: 10,
ttl: 90 - elapsed,
ageTicks: elapsed,
fromPlayer: true,
fireDuration: 90,
}
drawMissileProjectile(mockRenderer, shot, firewallArt)
expect(drawnCalls[0]!.frameIdx).toBe(elapsed)
}
// 2. Sustain Loop: frames 12..36
for (let elapsed = 12; elapsed < 50; elapsed++) {
drawnCalls.length = 0
const shot: Projectile = {
skillId: '51',
missileType: 'firewall',
x: 100,
y: 100,
vx: 0,
vy: 0,
damage: 10,
ttl: 90 - elapsed,
ageTicks: elapsed,
fromPlayer: true,
fireDuration: 90,
}
drawMissileProjectile(mockRenderer, shot, firewallArt)
const expectedFrame = 12 + ((elapsed - 12) % 25)
expect(drawnCalls[0]!.frameIdx).toBe(expectedFrame)
expect(drawnCalls[0]!.frameIdx).toBeGreaterThanOrEqual(12)
expect(drawnCalls[0]!.frameIdx).toBeLessThanOrEqual(36)
}
// 3. Extinction Phase: ttl <= 12
for (let ttl = 12; ttl >= 1; ttl--) {
drawnCalls.length = 0
const shot: Projectile = {
skillId: '51',
missileType: 'firewall',
x: 100,
y: 100,
vx: 0,
vy: 0,
damage: 10,
ttl,
ageTicks: 90 - ttl,
fromPlayer: true,
fireDuration: 90,
}
drawMissileProjectile(mockRenderer, shot, firewallArt)
const call = drawnCalls[0]!
const expectedFrame = Math.max(0, Math.min(11, ttl - 1))
expect(call.frameIdx).toBe(expectedFrame)
expect(call.width).toBeDefined()
expect(call.tint).toBeDefined()
}
})
it('aligns canonical CANONICAL_113C_MISSILES.firewall with 1.13c Missiles.txt row 69', () => {
const fw = CANONICAL_113C_MISSILES['firewall']
expect(fw).toBeDefined()
expect(fw!.id).toBe(69)
expect(fw!.celFile).toBe('groundFireBig')
expect(fw!.animLen).toBe(37)
expect(fw!.range).toBe(90)
})
})