368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
/**
|
|
* Issue #434: Lightning (#49) & Chain Lightning (#53) Continuous Beam & Arc Visuals
|
|
*
|
|
* 1.13c Ground Truth Invariants Verification Suite:
|
|
* 1. Sub-Segment Sampling: 3 slices generated per 30px travel step (CltParam1 = 3).
|
|
* 2. Perpendicular Lateral Jitter: Jitter vector strictly orthogonal to flight direction,
|
|
* bounded within [-8, +8] px (CltParam2 = 16).
|
|
* 3. Frame Randomization & Looping: Initial frame in 0..7, loops frames 1..7 (SubLoop = 1, SubStop = 7).
|
|
* 4. 12-Tick Lingering Lifecycle & Head Decoupling: Slices live for 12 ticks (480ms) and persist
|
|
* independently when the projectile head terminates on a wall or enemy.
|
|
* 5. Chain Lightning Connected Arc Network: Multi-hop jumps leave all inter-target arc segments
|
|
* concurrently active in the trail manager.
|
|
* 6. Additive Blending & Electric Procedural Fallback: Draws with blendMode: 'additive' and
|
|
* authentic electric blue/cyan/white palette.
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
LightningTrailManager,
|
|
drawElectricBoltFallback,
|
|
type LightningTrailSlice,
|
|
type LoadedMissileArt,
|
|
} from '../../../src/render/missile-renderer.ts'
|
|
import {
|
|
CANONICAL_113C_MISSILES,
|
|
getMissileTxtData,
|
|
tickProjectiles,
|
|
type Projectile,
|
|
} from '../../../src/game/skills.ts'
|
|
import {
|
|
LIGHTNINGBOLT_META,
|
|
LIGHTNING_META,
|
|
LIGHTNINGHIT_META,
|
|
CHAINLIGHTNING_META,
|
|
LIGHTNINGSTRIKE_META,
|
|
MISSILE_METAS,
|
|
} from '../../../src/render/missiles-meta.ts'
|
|
|
|
describe('Issue #434: Lightning (#49) & Chain Lightning (#53) Continuous Beam & Arc Visuals', () => {
|
|
// =========================================================================
|
|
// Dimension 1: Canonical 1.13c Registries & Asset Metadata
|
|
// =========================================================================
|
|
describe('Dimension 1: Canonical 1.13c Registries & Asset Metadata', () => {
|
|
it('verifies lightninghit (ID 99) registration in CANONICAL_113C_MISSILES', () => {
|
|
const hit = CANONICAL_113C_MISSILES['lightninghit']
|
|
expect(hit).toBeDefined()
|
|
expect(hit!.id).toBe(99)
|
|
expect(hit!.celFile).toBe('LightningStrike')
|
|
expect(hit!.range).toBe(12) // Lingers for 12 ticks (480ms)
|
|
expect(hit!.animLen).toBe(8)
|
|
expect(hit!.numDirections).toBe(32)
|
|
expect(hit!.trans).toBe(1) // Additive blending
|
|
expect(hit!.subLoop).toBe(1)
|
|
expect(hit!.subStop).toBe(7)
|
|
})
|
|
|
|
it('verifies LIGHTNINGBOLT_META, LIGHTNING_META, and LIGHTNINGHIT_META in MISSILE_METAS', () => {
|
|
expect(LIGHTNINGBOLT_META.name).toBe('lightningbolt')
|
|
expect(LIGHTNINGBOLT_META.directions).toBe(32)
|
|
expect(LIGHTNING_META.name).toBe('lightning')
|
|
expect(LIGHTNINGHIT_META.name).toBe('lightninghit')
|
|
|
|
expect(MISSILE_METAS['lightningbolt']).toBe(LIGHTNINGBOLT_META)
|
|
expect(MISSILE_METAS['lightning']).toBe(LIGHTNING_META)
|
|
expect(MISSILE_METAS['lightninghit']).toBe(LIGHTNINGHIT_META)
|
|
expect(MISSILE_METAS['chainlightning']).toBe(CHAINLIGHTNING_META)
|
|
})
|
|
|
|
it('verifies lightninghit is recognized as a harmless visual emitter', () => {
|
|
// Create a dummy lightninghit projectile at the location of an alive target
|
|
const target = { index: 0, x: 100, y: 100, radius: 20, alive: true }
|
|
const projectile: Projectile = {
|
|
skillId: '49',
|
|
x: 100,
|
|
y: 100,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 999,
|
|
ttl: 12,
|
|
fromPlayer: true,
|
|
missileType: 'lightninghit',
|
|
}
|
|
const outcome = tickProjectiles([projectile], [target], {
|
|
overlap: () => 0,
|
|
})
|
|
// Must deal 0 damage and produce 0 hits because it is a harmless visual slice
|
|
expect(outcome.hits.length).toBe(0)
|
|
expect(outcome.alive.length).toBe(1)
|
|
expect(outcome.alive[0]!.ttl).toBe(11)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 2: Sub-Segment Sampling & Kinematics
|
|
// =========================================================================
|
|
describe('Dimension 2: Sub-Segment Sampling & Kinematics', () => {
|
|
it('generates exactly 3 sub-segment slices per 30px projectile step (CltParam1 = 3)', () => {
|
|
const manager = new LightningTrailManager()
|
|
expect(manager.activeCount).toBe(0)
|
|
|
|
// Step 30px along X axis: from (100, 100) to (130, 100)
|
|
manager.stepMissile('lightningbolt', 100, 100, 130, 100)
|
|
expect(manager.activeCount).toBe(3)
|
|
|
|
const slices = manager.getSlices()
|
|
// Sub-segments spaced 10px apart: t = 0, 1/3, 2/3 -> x = 100, 110, 120
|
|
expect(slices[0]!.x).toBeCloseTo(100, 4)
|
|
expect(slices[0]!.y).toBeCloseTo(100, 4)
|
|
|
|
expect(slices[1]!.x).toBeCloseTo(110, 4)
|
|
expect(slices[1]!.y).toBeCloseTo(100, 4)
|
|
|
|
expect(slices[2]!.x).toBeCloseTo(120, 4)
|
|
expect(slices[2]!.y).toBeCloseTo(100, 4)
|
|
})
|
|
|
|
it('ignores zero-distance stationary steps', () => {
|
|
const manager = new LightningTrailManager()
|
|
manager.stepMissile('lightningbolt', 200, 200, 200, 200)
|
|
expect(manager.activeCount).toBe(0)
|
|
})
|
|
|
|
it('aligns DCC direction with 32-direction discrete grid', () => {
|
|
const manager = new LightningTrailManager()
|
|
// Moving due East (+X, 0 Y)
|
|
manager.stepMissile('lightningbolt', 0, 0, 30, 0)
|
|
const slices = manager.getSlices()
|
|
for (const slice of slices) {
|
|
expect(slice.dccDir).toBeGreaterThanOrEqual(0)
|
|
expect(slice.dccDir).toBeLessThan(32)
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 3: Perpendicular Lateral Jitter & Frame Cycling
|
|
// =========================================================================
|
|
describe('Dimension 3: Perpendicular Lateral Jitter & Frame Cycling', () => {
|
|
it('enforces perpendicular lateral jitter strictly within [-8, +8] px (CltParam2 = 16)', () => {
|
|
// Use deterministic PRNG
|
|
let seed = 42
|
|
const pseudoRng = () => {
|
|
seed = (seed * 16807) % 2147483647
|
|
return (seed - 1) / 2147483646
|
|
}
|
|
const manager = new LightningTrailManager({ rng: pseudoRng })
|
|
|
|
// Diagonal travel: dx = 21.21, dy = 21.21 (speed ~30)
|
|
const startX = 50
|
|
const startY = 50
|
|
const endX = 50 + 30 * Math.SQRT1_2
|
|
const endY = 50 + 30 * Math.SQRT1_2
|
|
const dirX = (endX - startX) / 30
|
|
const dirY = (endY - startY) / 30
|
|
|
|
for (let step = 0; step < 20; step++) {
|
|
manager.stepMissile('lightningbolt', startX, startY, endX, endY)
|
|
}
|
|
|
|
const slices = manager.getSlices()
|
|
expect(slices.length).toBe(60)
|
|
|
|
for (const slice of slices) {
|
|
// Dot product of flight direction with jitter vector must be 0 (orthogonal)
|
|
const dot = dirX * slice.jitterX + dirY * slice.jitterY
|
|
expect(dot).toBeCloseTo(0, 4)
|
|
|
|
// Jitter magnitude must be <= 8.0 px
|
|
const jitterDist = Math.hypot(slice.jitterX, slice.jitterY)
|
|
expect(jitterDist).toBeLessThanOrEqual(8.0001)
|
|
}
|
|
})
|
|
|
|
it('randomizes initial animation frame in 0..7 and loops through 1..7 (SubLoop = 1, SubStop = 7)', () => {
|
|
const initialFrames = new Set<number>()
|
|
for (let i = 0; i < 50; i++) {
|
|
const mgr = new LightningTrailManager()
|
|
mgr.stepMissile('lightningbolt', 0, 0, 30, 0)
|
|
for (const s of mgr.getSlices()) {
|
|
expect(s.frameIndex).toBeGreaterThanOrEqual(0)
|
|
expect(s.frameIndex).toBeLessThan(8)
|
|
initialFrames.add(s.frameIndex)
|
|
}
|
|
}
|
|
// Over 150 sampled slices, all frames 0..7 should appear
|
|
expect(initialFrames.size).toBeGreaterThanOrEqual(6)
|
|
|
|
// Test looping behavior: frame 0 -> 1 -> 2 -> ... -> 7 -> 1 -> 2
|
|
const mgr = new LightningTrailManager({ rng: () => 0.0 }) // frameIndex starts at 0
|
|
mgr.stepMissile('lightningbolt', 0, 0, 30, 0)
|
|
const slice = mgr.getSlices()[0]!
|
|
expect(slice.frameIndex).toBe(0)
|
|
|
|
const expectedFrames = [1, 2, 3, 4, 5, 6, 7, 1, 2, 3, 4]
|
|
for (const expected of expectedFrames) {
|
|
mgr.tick()
|
|
expect(slice.frameIndex).toBe(expected)
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 4: 12-Tick Lingering Lifetime & Head Decoupling
|
|
// =========================================================================
|
|
describe('Dimension 4: 12-Tick Lingering Lifetime & Head Decoupling', () => {
|
|
it('persists slices for exactly 12 ticks (480ms) and cleans up on tick 12', () => {
|
|
const manager = new LightningTrailManager()
|
|
manager.stepMissile('lightningbolt', 0, 0, 30, 0)
|
|
expect(manager.activeCount).toBe(3)
|
|
|
|
for (let tick = 1; tick <= 11; tick++) {
|
|
manager.tick()
|
|
expect(manager.activeCount).toBe(3)
|
|
expect(manager.getSlices()[0]!.ttl).toBe(12 - tick)
|
|
}
|
|
|
|
// On tick 12, TTL reaches 0 and slices expire
|
|
manager.tick()
|
|
expect(manager.activeCount).toBe(0)
|
|
})
|
|
|
|
it('persists slices independently when the projectile head terminates upon wall hit', () => {
|
|
const manager = new LightningTrailManager()
|
|
|
|
// Tick 1: Projectile advances from (0, 0) to (30, 0)
|
|
manager.stepMissile('lightningbolt', 0, 0, 30, 0)
|
|
expect(manager.activeCount).toBe(3)
|
|
|
|
// Tick 2: Projectile advances from (30, 0) to (60, 0)
|
|
manager.stepMissile('lightningbolt', 30, 0, 60, 0)
|
|
expect(manager.activeCount).toBe(6)
|
|
|
|
// Projectile now hits a wall and is deleted from game engine!
|
|
// But the trail slices MUST remain alive in LightningTrailManager!
|
|
manager.tick()
|
|
expect(manager.activeCount).toBe(6)
|
|
|
|
// Run 5 more ticks
|
|
for (let t = 0; t < 5; t++) {
|
|
manager.tick()
|
|
}
|
|
expect(manager.activeCount).toBe(6)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 5: Chain Lightning Multi-Target Connected Arc Network
|
|
// =========================================================================
|
|
describe('Dimension 5: Chain Lightning Multi-Target Connected Arc Network', () => {
|
|
it('maintains continuous connected arcs across multi-target chain hops', () => {
|
|
const manager = new LightningTrailManager()
|
|
|
|
// Hop 1: Caster (0, 0) -> Target A (90, 0) over 3 ticks (30px/tick)
|
|
manager.stepMissile('chainlightning', 0, 0, 30, 0)
|
|
manager.tick()
|
|
manager.stepMissile('chainlightning', 30, 0, 60, 0)
|
|
manager.tick()
|
|
manager.stepMissile('chainlightning', 60, 0, 90, 0)
|
|
manager.tick()
|
|
|
|
// Hop 1 has deposited 9 slices along Caster -> Target A
|
|
expect(manager.activeCount).toBe(9)
|
|
|
|
// Hop 1 strikes Target A and terminates!
|
|
// Hop 2 leaps from Target A (90, 0) -> Target B (90, 90) over 3 ticks
|
|
manager.stepMissile('chainlightning', 90, 0, 90, 30)
|
|
manager.tick()
|
|
manager.stepMissile('chainlightning', 90, 30, 90, 60)
|
|
manager.tick()
|
|
manager.stepMissile('chainlightning', 90, 60, 90, 90)
|
|
|
|
// Slices from BOTH Hop 1 and Hop 2 coexist concurrently!
|
|
// Total slices = 9 from Hop 1 (still within 12 ticks) + 9 from Hop 2 = 18 slices!
|
|
expect(manager.activeCount).toBe(18)
|
|
|
|
// Verify spatial continuity: slices span both segments
|
|
const slices = manager.getSlices()
|
|
const hasSegment1 = slices.some(s => s.x < 80 && Math.abs(s.y) < 15)
|
|
const hasSegment2 = slices.some(s => Math.abs(s.x - 90) < 15 && s.y > 20)
|
|
expect(hasSegment1).toBe(true)
|
|
expect(hasSegment2).toBe(true)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 6: Additive Blending & Procedural Fallback Palette
|
|
// =========================================================================
|
|
describe('Dimension 6: Additive Blending & Procedural Fallback Palette', () => {
|
|
it('renders with blendMode: additive when missileArt is provided', () => {
|
|
const manager = new LightningTrailManager()
|
|
manager.stepMissile('lightningbolt', 0, 0, 30, 0)
|
|
|
|
const drawCalls: any[] = []
|
|
const mockRenderer = {
|
|
drawSolid: () => {},
|
|
draw: (frame: any, x: number, y: number, options: any) => {
|
|
drawCalls.push({ frame, x, y, options })
|
|
},
|
|
}
|
|
|
|
const mockArt: LoadedMissileArt = {
|
|
meta: LIGHTNINGSTRIKE_META,
|
|
handle: { texture: {} } as any,
|
|
frames: Array.from({ length: 32 }, () => [
|
|
{ x: 0, y: 0, width: 112, height: 79, anchorX: -56, anchorY: -40 },
|
|
]),
|
|
}
|
|
const artMap = new Map<string, LoadedMissileArt>([['lightningstrike', mockArt]])
|
|
|
|
manager.render(mockRenderer as any, artMap)
|
|
|
|
expect(drawCalls.length).toBe(3)
|
|
for (const call of drawCalls) {
|
|
expect(call.options.blendMode).toBe('additive')
|
|
expect(call.options.atlas).toBe(mockArt.handle)
|
|
}
|
|
})
|
|
|
|
it('renders electric blue, cyan, and white glowing quads when art is absent', () => {
|
|
const manager = new LightningTrailManager()
|
|
manager.stepMissile('lightningbolt', 100, 100, 130, 100)
|
|
|
|
const solidCalls: { x: number; y: number; w: number; h: number; color: any }[] = []
|
|
const mockRenderer = {
|
|
drawSolid: (x: number, y: number, w: number, h: number, color: any) => {
|
|
solidCalls.push({ x, y, w, h, color })
|
|
},
|
|
}
|
|
|
|
manager.render(mockRenderer as any, undefined)
|
|
|
|
// 3 slices * 3 layers (blue aura, cyan bolt, white core) = 9 solid draws
|
|
expect(solidCalls.length).toBe(9)
|
|
|
|
// First slice layers:
|
|
// Outer electric blue aura: [0.2, 0.5, 1.0, ...]
|
|
expect(solidCalls[0]!.color[0]).toBeCloseTo(0.2, 2)
|
|
expect(solidCalls[0]!.color[1]).toBeCloseTo(0.5, 2)
|
|
expect(solidCalls[0]!.color[2]).toBeCloseTo(1.0, 2)
|
|
|
|
// Cyan mid bolt: [0.4, 0.9, 1.0, ...]
|
|
expect(solidCalls[1]!.color[0]).toBeCloseTo(0.4, 2)
|
|
expect(solidCalls[1]!.color[1]).toBeCloseTo(0.9, 2)
|
|
expect(solidCalls[1]!.color[2]).toBeCloseTo(1.0, 2)
|
|
|
|
// Incandescent white hot core: [1.0, 1.0, 1.0, ...]
|
|
expect(solidCalls[2]!.color[0]).toBeCloseTo(1.0, 2)
|
|
expect(solidCalls[2]!.color[1]).toBeCloseTo(1.0, 2)
|
|
expect(solidCalls[2]!.color[2]).toBeCloseTo(1.0, 2)
|
|
})
|
|
|
|
it('drawElectricBoltFallback draws electric palette without orange fireball colors', () => {
|
|
const solidCalls: any[] = []
|
|
const mockRenderer = {
|
|
drawSolid: (x: number, y: number, w: number, h: number, color: any) => {
|
|
solidCalls.push({ x, y, w, h, color })
|
|
},
|
|
}
|
|
|
|
drawElectricBoltFallback(mockRenderer, 200, 200)
|
|
expect(solidCalls.length).toBe(3)
|
|
// Must not use reddish orange colors
|
|
for (const call of solidCalls) {
|
|
expect(call.color[2]).toBeGreaterThanOrEqual(call.color[0]) // Blue >= Red
|
|
}
|
|
})
|
|
})
|
|
})
|