fix(skill): restore Lightning and Chain Lightning continuous beam and arc visuals (Fixes #434)

This commit is contained in:
troytt 2026-09-24 15:12:39 +00:00
parent 6f935bccf6
commit a3f5013481
6 changed files with 698 additions and 2 deletions

View File

@ -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

View File

@ -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<Record<string, MissileTxtData>> = 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<number | string, number> | Map<number, number> | 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) {

View File

@ -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<string, LoadedMissileArt>,
): 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<string, LoadedMissileArt>,
): 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])
}

View File

@ -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<Record<string, MissileMeta>> = 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<Record<string, MissileMeta>> = 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,

View File

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

View File

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