fix(skill): restore Lightning and Chain Lightning continuous beam and arc visuals (Fixes #434)
This commit is contained in:
parent
6f935bccf6
commit
a3f5013481
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue