diff --git a/src/game/engine.ts b/src/game/engine.ts index e019a35..0cbe724 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -237,6 +237,7 @@ export class GameEngine { activeAuraOverlay: ActiveOverlay | null = null activeAuraOverlays: ActiveOverlay[] = [] activeAuraLevel = 1 + onMissileStep?: ((missileType: string, prevX: number, prevY: number, curX: number, curY: number, projectile: Projectile) => void) | undefined /** * Lazy room population driver, when the level supplied a room plan. * @@ -495,6 +496,7 @@ export class GameEngine { isMissileBlocked: this.terrain.isMissileBlocked ? (x, y, fromX, fromY) => this.terrain.isMissileBlocked!(x, y, fromX, fromY) : undefined, nextDelayByTarget: this.nextDelayByTarget, currentTick: this.tickCount, + onMissileStep: this.onMissileStep, }) this.projectiles.length = 0 // outcome.alive already contains spawned projectiles from tickProjectiles diff --git a/src/game/skills.ts b/src/game/skills.ts index 6b89c4f..8f15fc3 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -393,6 +393,9 @@ export interface Projectile { readonly chainRemaining?: number | undefined /** Previous target index hit by this chain projectile to avoid immediately retargeting it. */ readonly lastHitTargetIndex?: number | undefined + /** Previous position before last tick step (for client-side trail generation). */ + readonly prevX?: number | undefined + readonly prevY?: number | undefined } /** @@ -406,7 +409,6 @@ export interface Projectile { */ export const ISO_GROUND_ASPECT_RATIO = 0.5 - /** What a cast produced. */ export type CastResult = | { readonly kind: 'mana'; readonly cost: number } @@ -456,10 +458,35 @@ export interface MissileTxtData { readonly nextHit?: number | boolean | undefined readonly nextDelay?: number | undefined readonly trans?: number | undefined + readonly subLoop?: number | undefined + readonly subStop?: number | undefined } /** Canonical 1.13c Ground Truth Missiles.txt defaults. */ export const CANONICAL_113C_MISSILES: Readonly> = Object.freeze({ + lightninghit: { + name: 'lightninghit', + id: 99, + vel: 0, + maxVel: 0, + range: 12, + levRange: 0, + celFile: 'LightningStrike', + animLen: 8, + animSpeed: 16, + loopAnim: 1, + numDirections: 32, + explosionMissile: '', + light: 0, + red: 255, + green: 255, + blue: 255, + trans: 1, + speedPxPerSec: 0, + distancePx: 0, + subLoop: 1, + subStop: 7, + }, lightningbolt: { name: 'lightningbolt', id: 98, @@ -4622,6 +4649,7 @@ export function tickProjectiles( readonly radius?: number readonly nextDelayByTarget?: Map | Map | undefined readonly currentTick?: number | undefined + readonly onMissileStep?: ((missileType: string, prevX: number, prevY: number, curX: number, curY: number, projectile: Projectile) => void) | undefined }, ): ProjectileOutcome { const radius = options.radius ?? 8 @@ -4991,12 +5019,17 @@ export function tickProjectiles( ) { hitTargets.clear() } + if (options.onMissileStep !== undefined && (projectile.x !== x || projectile.y !== y)) { + options.onMissileStep(projectile.missileType ?? '', projectile.x, projectile.y, x, y, projectile) + } const moved: Projectile = { ...projectile, x, y, vx, vy, + prevX: projectile.x, + prevY: projectile.y, spiral, subMissileIdx, jitterIndex, @@ -5129,6 +5162,7 @@ export function tickProjectiles( projectile.missileType === 'glacialspikeejecta' || projectile.missileType === 'thunderstorm1' || projectile.missileType === 'thunderstorm' || + projectile.missileType === 'lightninghit' || isTurretEmitter if (!isHarmlessEmitter) { for (const target of targets) { diff --git a/src/render/missile-renderer.ts b/src/render/missile-renderer.ts new file mode 100644 index 0000000..0b6b055 --- /dev/null +++ b/src/render/missile-renderer.ts @@ -0,0 +1,241 @@ +/** + * Diablo II v1.13c Missile Rendering & Trail Management. + * + * Ground truth parity (D2Client.dll:0x6fb691c0 pCltDoFunc = 8, CltParam1 = 3, CltParam2 = 16, Range = 12): + * - Lightning (#49, lightningbolt ID 98) & Chain Lightning (#53, chainlightning ID 93). + * - For each tick step (30 px), linearly interpolates 3 sub-segment slices (10 px spacing). + * - Perpendicular lateral jitter in range [-8, +8] px (CltParam2 = 16). + * - Initial animation frame randomized in 0..7, loops through 1..7 (SubLoop = 1, SubStop = 7). + * - Lingers for 12 ticks (480 ms) with additive blending (Trans = 1). + * - Slices persist independently even when the head projectile hits a target or expires. + */ +import type { AtlasFrame } from './atlas.ts' +import type { SpriteRenderer, AtlasHandle } from './renderer.ts' +import { velocityToDccDirection, type MissileMeta } from './missiles-meta.ts' +import type { Projectile } from '../game/skills.ts' + +export interface LoadedMissileArt { + readonly meta: MissileMeta + readonly handle: AtlasHandle + readonly frames: AtlasFrame[][] +} + +/** + * A single lingering electric slice corresponding to 1.13c `lightninghit` (ID 99). + */ +export interface LightningTrailSlice { + x: number + y: number + dccDir: number + frameIndex: number + ttl: number + maxTtl: number + jitterX: number + jitterY: number + isChain: boolean + missileType: string +} + +export interface LightningTrailManagerOptions { + /** Optional custom PRNG (e.g. for deterministic unit tests). Defaults to Math.random. */ + readonly rng?: () => number +} + +/** + * Minimal renderer interface compatible with both SpriteRenderer and lightweight test mocks. + */ +export interface GenericRenderer { + drawSolid(x: number, y: number, width: number, height: number, color: readonly [number, number, number, number]): void + draw?( + frame: AtlasFrame, + x: number, + y: number, + options?: { + atlas?: AtlasHandle + blendMode?: 'normal' | 'additive' + alpha?: number + }, + ): void +} + +/** + * Client-side state manager for continuous penetrating lightning beams and electric arc networks. + */ +export class LightningTrailManager { + private readonly slices: LightningTrailSlice[] = [] + private readonly rng: () => number + + constructor(options?: LightningTrailManagerOptions) { + this.rng = options?.rng ?? Math.random + } + + /** Read-only view of active lingering trail slices. */ + public getSlices(): readonly LightningTrailSlice[] { + return this.slices + } + + /** Number of active slices currently alive. */ + public get activeCount(): number { + return this.slices.length + } + + /** Clears all active slices. */ + public clear(): void { + this.slices.length = 0 + } + + /** + * Called every tick when a projectile moves. + * Linearly interpolates 3 sub-segment slices between (prevX, prevY) and (curX, curY). + */ + public stepMissile( + missileType: string, + prevX: number, + prevY: number, + curX: number, + curY: number, + ): void { + const isChain = missileType === 'chainlightning' + const dx = curX - prevX + const dy = curY - prevY + const dist = Math.hypot(dx, dy) + if (dist <= 0.001) return + + const dccDir = velocityToDccDirection(dx, dy, 32) + const normX = -dy / dist + const normY = dx / dist + + // 1.13c CltParam1 = 3 slices per step segment (offsets 0, 10, 20 px for 30px step) + const subLoops = 3 + for (let i = 0; i < subLoops; i++) { + const t = i / subLoops + const baseX = prevX + dx * t + const baseY = prevY + dy * t + + // 1.13c CltParam2 = 16: random perpendicular lateral jitter in [-8, +8] px + const jitterMag = (this.rng() - 0.5) * 16 + const jx = normX * jitterMag + const jy = normY * jitterMag + + // 1.13c: random initial animation frame in 0..7 + const startFrame = Math.floor(this.rng() * 8) % 8 + + this.slices.push({ + x: baseX, + y: baseY, + dccDir, + frameIndex: startFrame, + ttl: 12, // 1.13c Range = 12 ticks (480ms) + maxTtl: 12, + jitterX: jx, + jitterY: jy, + isChain, + missileType, + }) + } + } + + /** + * Convenience step helper from a Projectile record. + */ + public stepProjectile(projectile: Projectile): void { + const curX = projectile.x + const curY = projectile.y + const prevX = projectile.prevX ?? (curX - projectile.vx) + const prevY = projectile.prevY ?? (curY - projectile.vy) + this.stepMissile(projectile.missileType ?? '', prevX, prevY, curX, curY) + } + + /** + * Advances simulation tick: decrements TTL and cycles animation frames (1..7). + */ + public tick(): void { + for (let i = this.slices.length - 1; i >= 0; i--) { + const slice = this.slices[i]! + slice.ttl -= 1 + if (slice.ttl <= 0) { + this.slices.splice(i, 1) + continue + } + // 1.13c SubLoop = 1, SubStop = 7: loop through frames 1..7 + slice.frameIndex = 1 + ((slice.frameIndex) % 7) + } + } + + /** + * Renders a single slice using DCC sprite atlas or procedural fallback. + */ + public renderSlice( + renderer: GenericRenderer, + slice: LightningTrailSlice, + missileArtMap?: Map, + ): void { + const art = missileArtMap?.get('lightningstrike') ?? + missileArtMap?.get('chainlightning') ?? + missileArtMap?.get('lightningbolt') ?? + missileArtMap?.get('lightninghit') + + if (art !== undefined && typeof renderer.draw === 'function') { + const group = art.frames[slice.dccDir] ?? art.frames[0] + const frame = group?.[slice.frameIndex] ?? group?.[0] + if (frame !== undefined) { + const px = slice.x + slice.jitterX + const py = slice.y + slice.jitterY + const drawX = frame.anchorX !== undefined ? px + frame.anchorX : px - frame.width / 2 + const drawY = frame.anchorY !== undefined ? py + frame.anchorY : py - frame.height / 2 + renderer.draw(frame, drawX, drawY, { + atlas: art.handle, + blendMode: 'additive', + }) + return + } + } + + // Procedural electric blue/cyan/white fallback + this.renderSliceFallback(renderer, slice) + } + + /** + * Renders all active slices with additive blending. + */ + public render( + renderer: GenericRenderer, + missileArtMap?: Map, + ): void { + for (const slice of this.slices) { + this.renderSlice(renderer, slice, missileArtMap) + } + } + + /** + * Procedural electric blue, cyan, and incandescent white glowing slice fallback. + */ + private renderSliceFallback( + renderer: GenericRenderer, + slice: LightningTrailSlice, + ): void { + const px = slice.x + slice.jitterX + const py = slice.y + slice.jitterY + const alpha = Math.max(0.1, slice.ttl / slice.maxTtl) + // Outer electric blue aura + renderer.drawSolid(px - 6, py - 6, 12, 12, [0.2, 0.5, 1.0, 0.3 * alpha]) + // Cyan mid bolt + renderer.drawSolid(px - 3, py - 3, 6, 6, [0.4, 0.9, 1.0, 0.7 * alpha]) + // Incandescent white hot core + renderer.drawSolid(px - 1, py - 1, 2, 2, [1.0, 1.0, 1.0, 0.95 * alpha]) + } +} + +/** + * Procedural electric bolt rendering helper. + */ +export function drawElectricBoltFallback( + renderer: GenericRenderer, + x: number, + y: number, + alpha = 1.0, +): void { + renderer.drawSolid(x - 6, y - 6, 12, 12, [0.2, 0.5, 1.0, 0.35 * alpha]) + renderer.drawSolid(x - 3, y - 3, 6, 6, [0.4, 0.9, 1.0, 0.75 * alpha]) + renderer.drawSolid(x - 1, y - 1, 2, 2, [1.0, 1.0, 1.0, 0.98 * alpha]) +} diff --git a/src/render/missiles-meta.ts b/src/render/missiles-meta.ts index b13793d..8aa1ca3 100644 --- a/src/render/missiles-meta.ts +++ b/src/render/missiles-meta.ts @@ -7939,6 +7939,16 @@ export const LIGHTNINGHIT_META: MissileMeta = { name: 'lightninghit', } +export const LIGHTNINGBOLT_META: MissileMeta = { + ...LIGHTNINGSTRIKE_META, + name: 'lightningbolt', +} + +export const LIGHTNING_META: MissileMeta = { + ...LIGHTNINGSTRIKE_META, + name: 'lightning', +} + export const THUNDERSTORM_META: MissileMeta = { ...THUNDERSTORM1_META, name: 'thunderstorm', @@ -8092,6 +8102,8 @@ export const MISSILE_METAS: Readonly> = Object.freez 'electricnova': ELECTRICNOVA_META, 'chainlightning': CHAINLIGHTNING_META, 'lightninghit': LIGHTNINGHIT_META, + 'lightningbolt': LIGHTNINGBOLT_META, + 'lightning': LIGHTNING_META, 'thunderstorm': THUNDERSTORM_META, 'lightningboltbig': LIGHTNINGBOLTBIG_META, 'gleam': GLEAM_META, @@ -8110,6 +8122,8 @@ export const MISSILE_METAS: Readonly> = Object.freez 'LightningStrike': LIGHTNINGSTRIKE_META, 'ChainLightning': CHAINLIGHTNING_META, 'LightningHit': LIGHTNINGHIT_META, + 'LightningBolt': LIGHTNINGBOLT_META, + 'Lightning': LIGHTNING_META, 'Thunderstorm': THUNDERSTORM_META, 'LightningboltBig': LIGHTNINGBOLTBIG_META, 'Gleam': GLEAM_META, diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 37b1e13..c48b5c7 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -78,6 +78,7 @@ import { facingToDirection } from '../game/character.ts' import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../game/monster-art.ts' export { ADDITIVE_MONSTER_TOKENS } from '../game/monster-art.ts' import { velocityToDccDirection, MISSILE_METAS, type MissileMeta, type MissileFrameTuple } from '../render/missiles-meta.ts' +import { LightningTrailManager, drawElectricBoltFallback } from '../render/missile-renderer.ts' import { OVERLAY_METAS, type OverlayMeta } from '../render/overlays-meta.ts' import { ActorAnimator, type AnimClipMeta } from '../game/actor-animator.ts' import { resolveMonsterArtSpec } from '../game/monster-mapping.ts' @@ -5377,6 +5378,17 @@ export function drawMissileProjectile( const dirX = speed > 0 ? vx / speed : 1 const dirY = speed > 0 ? vy / speed : 0 + const isLightningMissile = + shot.missileType === 'lightningbolt' || + shot.missileType === 'chainlightning' || + shot.missileType === 'lightning' || + shot.missileType === 'lightninghit' + + if (isLightningMissile) { + drawElectricBoltFallback(renderer, x, y) + return + } + // 1. Dynamic flame trail: trailing flame and ember puffs along reverse trajectory for (let i = 1; i <= 5; i += 1) { const dist = i * 4.2 @@ -6122,6 +6134,21 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st engine.setDropTables(dropTables) + const lightningTrailManager = new LightningTrailManager() + engine.onMissileStep = (missileType, prevX, prevY, curX, curY) => { + if ( + missileType === 'lightningbolt' || + missileType === 'chainlightning' || + missileType === 'lightning' + ) { + lightningTrailManager.stepMissile(missileType, prevX, prevY, curX, curY) + } + } + ;(state as any).lightningTrailManager = lightningTrailManager + if (typeof window !== 'undefined' && window.__d2webActScene) { + ;(window.__d2webActScene as any).lightningTrailManager = lightningTrailManager + } + // `npcRingFallback: false` above means the engine left this empty, so we are // filling it rather than replacing a synthetic ring. for (const n of runtime.npcs) { @@ -6819,6 +6846,7 @@ if (typeof window !== 'undefined') { saving: input.saving, loading: input.loading, }) + lightningTrailManager.tick() const player = engine.world.player const movement = effectiveMovement @@ -7342,6 +7370,13 @@ if (typeof window !== 'undefined') { }) } + // Lingering lightning and chain lightning trail slices (1.13c pCltDoFunc = 8, CltSubMissile1 = lightninghit) + for (const slice of lightningTrailManager.getSlices()) { + pushEntity(slice.x, slice.y, () => { + lightningTrailManager.renderSlice(renderer, slice, missileArtMap) + }, 0.2) + } + for (const shot of engine.projectiles) { if (shot.missileType === 'blizzardcenter') continue pushEntity(shot.x, shot.y, () => { @@ -7350,10 +7385,13 @@ if (typeof window !== 'undefined') { ? (missileArtMap.get(mType) ?? (mType === 'frozenorbbolt' || mType === 'frozenorbnova' ? missileArtMap.get('icebolt') : mType === 'blizzard4' ? missileArtMap.get('blizzard3') : - mType === 'blizzardexplode3' ? missileArtMap.get('blizzardexplode2') : undefined)) + mType === 'blizzardexplode3' ? missileArtMap.get('blizzardexplode2') : + (mType === 'lightningbolt' || mType === 'lightning' || mType === 'chainlightning' ? (missileArtMap.get('lightningstrike') ?? missileArtMap.get('chainlightning')) : undefined))) : undefined if (art !== undefined) { drawMissileProjectile(renderer, shot, art, missileArtMap) + } else if (mType === 'lightningbolt' || mType === 'chainlightning' || mType === 'lightning') { + drawMissileProjectile(renderer, shot, undefined, missileArtMap) } else { renderer.drawSolid(shot.x - 4, shot.y - 4, 8, 8, [0.9, 0.9, 0.2, 1]) } diff --git a/tests/skills/sor/issue-434-lightning-trail.test.ts b/tests/skills/sor/issue-434-lightning-trail.test.ts new file mode 100644 index 0000000..c923710 --- /dev/null +++ b/tests/skills/sor/issue-434-lightning-trail.test.ts @@ -0,0 +1,367 @@ +/** + * 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() + 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([['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 + } + }) + }) +})