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 prevX = m.x
|
||||||
const prevY = m.y
|
const prevY = m.y
|
||||||
m.x += m.vx
|
|
||||||
m.y += m.vy
|
|
||||||
|
|
||||||
// Authentic 1.13c Charged Bolt (Skill 38 / missile 56 / pDoFunc 11):
|
// Authentic 1.13c Charged Bolt (Skill 38 / missile 56 / pDoFunc 11):
|
||||||
// Discrete orthogonal jitter table: 32-entry lookup table gatChargedBoltOffsets
|
// 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.
|
// 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) {
|
if (m.name === 'chargedbolt' || m.sourceSkillId === 38 || m.record.pSrvDoFunc === 11 || m.record.pCltDoFunc === 11) {
|
||||||
const speed = Math.hypot(m.vx, m.vy)
|
let flatVx = m.vx
|
||||||
if (speed > 0) {
|
let flatVy = m.vy / ISO_GROUND_ASPECT_RATIO
|
||||||
const perpX = -m.vy / speed
|
const currentSpeed = Math.hypot(flatVx, flatVy)
|
||||||
const perpY = m.vx / speed
|
if (currentSpeed > 0) {
|
||||||
|
const perpX = -flatVy / currentSpeed
|
||||||
|
const perpY = flatVx / currentSpeed
|
||||||
const idx = Math.abs((m.jitterIndex ?? m.ageTicks) % 32)
|
const idx = Math.abs((m.jitterIndex ?? m.ageTicks) % 32)
|
||||||
const offset = GAT_CHARGED_BOLT_OFFSETS[idx]! * 3
|
const offset = GAT_CHARGED_BOLT_OFFSETS[idx]! * 3
|
||||||
m.x += perpX * offset
|
flatVx += perpX * offset
|
||||||
m.y += perpY * (offset * ISO_GROUND_ASPECT_RATIO)
|
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.jitterIndex = (idx + 1) % 32
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.x += m.vx
|
||||||
|
m.y += m.vy
|
||||||
|
|
||||||
const effectiveTerrain = collision ?? this.terrain
|
const effectiveTerrain = collision ?? this.terrain
|
||||||
if (effectiveTerrain !== undefined && !isTurretEmitter && (m.altitude === undefined || m.altitude <= 0)) {
|
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
|
// 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
|
// 3. Jitter trajectory (e.g. Charged Bolt) - discrete orthogonal jitter lookup table
|
||||||
// Authentic D2 1.13c PATH_ComputePathChargedBolt (0x6FD85470):
|
// Authentic D2 1.13c PATH_ComputePathChargedBolt (0x6FD85470):
|
||||||
// 32-entry lookup table gatChargedBoltOffsets applied perpendicularly to main propagation vector per step.
|
// 32-entry lookup table gatChargedBoltOffsets applied perpendicularly to main propagation vector per step.
|
||||||
const speed = Math.hypot(vx, vy)
|
let flatVx = vx
|
||||||
if (speed > 0) {
|
let flatVy = vy / ISO_GROUND_ASPECT_RATIO
|
||||||
const perpX = -vy / speed
|
const currentSpeed = Math.hypot(flatVx, flatVy)
|
||||||
const perpY = vx / speed
|
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 idx = Math.abs((jitterIndex ?? (projectile.seed !== undefined ? projectile.seed : projectile.ttl)) % 32)
|
||||||
const offset = GAT_CHARGED_BOLT_OFFSETS[idx]! * 3
|
const offset = GAT_CHARGED_BOLT_OFFSETS[idx]! * 3
|
||||||
x += perpX * offset
|
flatVx += perpX * offset
|
||||||
y += perpY * (offset * ISO_GROUND_ASPECT_RATIO)
|
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
|
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,
|
calculateChargedBoltDamage,
|
||||||
calculateSkillDamage,
|
calculateSkillDamage,
|
||||||
tickProjectiles,
|
tickProjectiles,
|
||||||
|
ISO_GROUND_ASPECT_RATIO,
|
||||||
type Projectile,
|
type Projectile,
|
||||||
} from '../../../src/game/skills.ts'
|
} from '../../../src/game/skills.ts'
|
||||||
import {
|
import {
|
||||||
|
|
@ -218,9 +219,10 @@ describe('[Skill #038] Charged Bolt (SOR) — 1.13c 7-Dimensional Verification S
|
||||||
const tick1 = tickProjectiles(projectiles, [], openTerrain)
|
const tick1 = tickProjectiles(projectiles, [], openTerrain)
|
||||||
expect(tick1.alive.length).toBe(1)
|
expect(tick1.alive.length).toBe(1)
|
||||||
const p1 = tick1.alive[0]!
|
const p1 = tick1.alive[0]!
|
||||||
// Forward advancement
|
// Forward advancement with heading angle deflection and normalized speed (12 px/tick)
|
||||||
expect(p1.x).toBe(12)
|
expect(p1.x).toBeCloseTo(11.64, 1)
|
||||||
// Orthogonal displacement: perp vector (-vy/speed, vx/speed) = (0, 1). Offset = GAT[0] * 3 = -1 * 3 = -3 * ISO_RATIO
|
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.y).not.toBe(0)
|
||||||
expect(p1.jitterIndex).toBe(1)
|
expect(p1.jitterIndex).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue