188 lines
6.2 KiB
TypeScript
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)
|
|
})
|
|
})
|