fix(missile): implement authentic Charged Bolt snake-like random walk kinematics (Fixes #435)

This commit is contained in:
troytt 2026-09-24 15:55:57 +00:00
parent d421a4549c
commit e33738d394
4 changed files with 143 additions and 18 deletions

View File

@ -1123,25 +1123,35 @@ export class MissileEngine {
const prevX = m.x
const prevY = m.y
m.x += m.vx
m.y += m.vy
// Authentic 1.13c Charged Bolt (Skill 38 / missile 56 / pDoFunc 11):
// Discrete orthogonal jitter table: 32-entry lookup table gatChargedBoltOffsets
// applied perpendicularly to main propagation vector per step (2 subtiles/step) with individual low-seed PRNG.
if (m.name === 'chargedbolt' || m.sourceSkillId === 38 || m.record.pSrvDoFunc === 11 || m.record.pCltDoFunc === 11) {
const speed = Math.hypot(m.vx, m.vy)
if (speed > 0) {
const perpX = -m.vy / speed
const perpY = m.vx / speed
let flatVx = m.vx
let flatVy = m.vy / ISO_GROUND_ASPECT_RATIO
const currentSpeed = Math.hypot(flatVx, flatVy)
if (currentSpeed > 0) {
const perpX = -flatVy / currentSpeed
const perpY = flatVx / currentSpeed
const idx = Math.abs((m.jitterIndex ?? m.ageTicks) % 32)
const offset = GAT_CHARGED_BOLT_OFFSETS[idx]! * 3
m.x += perpX * offset
m.y += perpY * (offset * ISO_GROUND_ASPECT_RATIO)
flatVx += perpX * offset
flatVy += perpY * offset
const newSpeed = Math.hypot(flatVx, flatVy)
if (newSpeed > 0) {
const scaleDown = currentSpeed / newSpeed
flatVx *= scaleDown
flatVy *= scaleDown
}
m.vx = flatVx
m.vy = flatVy * ISO_GROUND_ASPECT_RATIO
m.jitterIndex = (idx + 1) % 32
}
}
m.x += m.vx
m.y += m.vy
const effectiveTerrain = collision ?? this.terrain
if (effectiveTerrain !== undefined && !isTurretEmitter && (m.altitude === undefined || m.altitude <= 0)) {
// Continuous collision stepping between (prevX, prevY) and (m.x, m.y) to prevent tunneling through thin (<24px) walls

View File

@ -4876,14 +4876,26 @@ export function tickProjectiles(
// 3. Jitter trajectory (e.g. Charged Bolt) - discrete orthogonal jitter lookup table
// Authentic D2 1.13c PATH_ComputePathChargedBolt (0x6FD85470):
// 32-entry lookup table gatChargedBoltOffsets applied perpendicularly to main propagation vector per step.
const speed = Math.hypot(vx, vy)
if (speed > 0) {
const perpX = -vy / speed
const perpY = vx / speed
let flatVx = vx
let flatVy = vy / ISO_GROUND_ASPECT_RATIO
const currentSpeed = Math.hypot(flatVx, flatVy)
if (currentSpeed > 0) {
const perpX = -flatVy / currentSpeed
const perpY = flatVx / currentSpeed
const idx = Math.abs((jitterIndex ?? (projectile.seed !== undefined ? projectile.seed : projectile.ttl)) % 32)
const offset = GAT_CHARGED_BOLT_OFFSETS[idx]! * 3
x += perpX * offset
y += perpY * (offset * ISO_GROUND_ASPECT_RATIO)
flatVx += perpX * offset
flatVy += perpY * offset
const newSpeed = Math.hypot(flatVx, flatVy)
if (newSpeed > 0) {
const scaleDown = currentSpeed / newSpeed
flatVx *= scaleDown
flatVy *= scaleDown
}
vx = flatVx
vy = flatVy * ISO_GROUND_ASPECT_RATIO
x = projectile.x + vx
y = projectile.y + vy
jitterIndex = (idx + 1) % 32
}
}

View File

@ -0,0 +1,101 @@
/**
* Issue #435: Charged Bolt (#38) Kinematics & Random Walk
*
* 1.13c Ground Truth Invariants Verification Suite:
* 1. Lateral Wander & Heading Drift: Charged Bolt accumulates directional changes
* across steps, resulting in snake-like serpentine wandering across the ground plane.
* 2. Elimination of ±3px Rigid Bound: Maximum lateral excursion significantly exceeds 3px
* over extended flight distance, proving genuine path divergence.
* 3. Speed Preservation: Throughout Cartesian angular drift and isometric mapping,
* the velocity magnitude remains calibrated and normalized.
* 4. Multi-Bolt Divergence: Multiple bolts fired with different seeds or ticks diverge
* into independent wandering paths.
*/
import { describe, expect, it } from 'vitest'
import { tickProjectiles, type Projectile } from '../../../src/game/skills.ts'
describe('Issue #435: Charged Bolt (#38) Kinematics & Random Walk', () => {
it('demonstrates serpentine lateral wander exceeding ±3px bound in skills.ts tickProjectiles', () => {
// Launch a charged bolt moving strictly East (vx = 12, vy = 0)
let p: Projectile = {
id: 1,
name: 'chargedbolt',
sourceSkillId: 38,
x: 500,
y: 500,
vx: 12,
vy: 0,
ttl: 50,
damage: 10,
radius: 8,
jitter: true,
jitterIndex: 0,
seed: 7,
}
const startY = p.y
let maxLateralDisplacement = 0
for (let t = 0; t < 35; t++) {
const state = tickProjectiles([p], [], { overlap: () => 0 })
if (state.alive.length === 0) break
p = state.alive[0]!
const dy = Math.abs(p.y - startY)
if (dy > maxLateralDisplacement) {
maxLateralDisplacement = dy
}
}
// Over 35 ticks, a genuine wandering path MUST exceed the obsolete 3px jitter bound
expect(maxLateralDisplacement).toBeGreaterThan(10)
// The bolt traveled forward along X
expect(p.x).toBeGreaterThan(700)
})
it('ensures distinct random seeds yield divergent trajectories', () => {
let p1: Projectile = {
id: 1,
name: 'chargedbolt',
sourceSkillId: 38,
x: 0,
y: 0,
vx: 10,
vy: 5,
ttl: 30,
damage: 10,
radius: 8,
jitter: true,
jitterIndex: 0,
seed: 1,
}
let p2: Projectile = {
id: 2,
name: 'chargedbolt',
sourceSkillId: 38,
x: 0,
y: 0,
vx: 10,
vy: 5,
ttl: 30,
damage: 10,
radius: 8,
jitter: true,
jitterIndex: 5,
seed: 29,
}
for (let t = 0; t < 25; t++) {
const s1 = tickProjectiles([p1], [], { overlap: () => 0 })
const s2 = tickProjectiles([p2], [], { overlap: () => 0 })
if (s1.alive.length === 0 || s2.alive.length === 0) break
p1 = s1.alive[0]!
p2 = s2.alive[0]!
}
// Both started at identical coordinates and base velocity, but different jitter state
// Their final positions must diverge
const distanceBetween = Math.hypot(p1.x - p2.x, p1.y - p2.y)
expect(distanceBetween).toBeGreaterThan(15)
})
})

View File

@ -35,6 +35,7 @@ import {
calculateChargedBoltDamage,
calculateSkillDamage,
tickProjectiles,
ISO_GROUND_ASPECT_RATIO,
type Projectile,
} from '../../../src/game/skills.ts'
import {
@ -218,9 +219,10 @@ describe('[Skill #038] Charged Bolt (SOR) — 1.13c 7-Dimensional Verification S
const tick1 = tickProjectiles(projectiles, [], openTerrain)
expect(tick1.alive.length).toBe(1)
const p1 = tick1.alive[0]!
// Forward advancement
expect(p1.x).toBe(12)
// Orthogonal displacement: perp vector (-vy/speed, vx/speed) = (0, 1). Offset = GAT[0] * 3 = -1 * 3 = -3 * ISO_RATIO
// Forward advancement with heading angle deflection and normalized speed (12 px/tick)
expect(p1.x).toBeCloseTo(11.64, 1)
expect(Math.hypot(p1.vx, p1.vy / ISO_GROUND_ASPECT_RATIO)).toBeCloseTo(12, 1)
// Orthogonal displacement: perp vector deflects trajectory
expect(p1.y).not.toBe(0)
expect(p1.jitterIndex).toBe(1)
})