fix(missile): implement authentic Charged Bolt snake-like random walk kinematics (Fixes #435)
This commit is contained in:
parent
d421a4549c
commit
e33738d394
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue