feat(skills): implement Frozen Orb with 1.13c parity and visual assets
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
|
@ -0,0 +1,141 @@
|
|||
{
|
||||
"name": "frozenorb",
|
||||
"celFile": "IceOrb",
|
||||
"width": 512,
|
||||
"height": 107,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 16,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
55,
|
||||
0,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
110,
|
||||
0,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
165,
|
||||
0,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
220,
|
||||
0,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
275,
|
||||
0,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
330,
|
||||
0,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
385,
|
||||
0,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
440,
|
||||
0,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
0,
|
||||
54,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
55,
|
||||
54,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
110,
|
||||
54,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
165,
|
||||
54,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
220,
|
||||
54,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
275,
|
||||
54,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
],
|
||||
[
|
||||
330,
|
||||
54,
|
||||
54,
|
||||
53,
|
||||
-27,
|
||||
-71
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
|
@ -0,0 +1,157 @@
|
|||
{
|
||||
"name": "frozenorbexplode",
|
||||
"celFile": "IceOrbExplode",
|
||||
"width": 1024,
|
||||
"height": 959,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 18,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
231,
|
||||
0,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
462,
|
||||
0,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
693,
|
||||
0,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
0,
|
||||
192,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
231,
|
||||
192,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
462,
|
||||
192,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
693,
|
||||
192,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
0,
|
||||
384,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
231,
|
||||
384,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
462,
|
||||
384,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
693,
|
||||
384,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
0,
|
||||
576,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
231,
|
||||
576,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
462,
|
||||
576,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
693,
|
||||
576,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
0,
|
||||
768,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
],
|
||||
[
|
||||
231,
|
||||
768,
|
||||
230,
|
||||
191,
|
||||
-111,
|
||||
-113
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 650 KiB After Width: | Height: | Size: 650 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
|
@ -67,6 +67,8 @@ const MISSILE_TARGETS = [
|
|||
{ name: 'glacialspike', celFile: 'GlacialSpike', animSpeed: 16 },
|
||||
{ name: 'iceexplode', celFile: 'IceArrowExplode', animSpeed: 16 },
|
||||
{ name: 'freezeexplode', celFile: 'FreezeExplodeCenter', animSpeed: 16 },
|
||||
{ name: 'frozenorb', celFile: 'IceOrb', animSpeed: 16 },
|
||||
{ name: 'frozenorbexplode', celFile: 'IceOrbExplode', animSpeed: 16 },
|
||||
|
||||
// Lightning
|
||||
{ name: 'chargedbolt', celFile: 'ChargedBolt', animSpeed: 16 },
|
||||
|
|
|
|||
|
|
@ -17,6 +17,27 @@
|
|||
import type { D2DataRegistry, MissileRecord } from './data-registry.ts'
|
||||
import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket } from './combat-pipeline.ts'
|
||||
|
||||
export const ISO_GROUND_ASPECT_RATIO = 0.5
|
||||
|
||||
/**
|
||||
* Authentic D2Game 64-entry discrete circle lookup tables (radius 30).
|
||||
* Replicated byte-for-byte from D2Game.dll 0x6FC581F0 (MISSMODE_SrvDo15_FrozenOrb)
|
||||
* and 0x6FC5CE10 (MISSMODE_SrvHit29_FrozenOrb).
|
||||
*/
|
||||
export const FROZEN_ORB_X_POSITIONS: readonly number[] = Object.freeze([
|
||||
30, 29, 29, 28, 27, 26, 24, 23, 21, 19, 16, 14, 11, 8, 5, 2,
|
||||
0, -2, -5, -8, -11, -14, -16, -19, -21, -23, -24, -26, -27, -28, -29, -29,
|
||||
-30, -29, -29, -28, -27, -26, -24, -23, -21, -19, -16, -14, -11, -8, -5, -2,
|
||||
0, 2, 5, 8, 11, 14, 16, 19, 21, 23, 24, 26, 27, 28, 29, 29,
|
||||
])
|
||||
|
||||
export const FROZEN_ORB_Y_POSITIONS: readonly number[] = Object.freeze([
|
||||
0, 2, 5, 8, 11, 14, 16, 19, 21, 23, 24, 26, 27, 28, 29, 29,
|
||||
30, 29, 29, 28, 27, 26, 24, 23, 21, 19, 16, 14, 11, 8, 5, 2,
|
||||
0, -2, -5, -8, -11, -14, -16, -19, -21, -23, -24, -26, -27, -28, -29, -29,
|
||||
-30, -29, -29, -28, -27, -26, -24, -23, -21, -19, -16, -14, -11, -8, -5, -2,
|
||||
])
|
||||
|
||||
export interface ActiveMissile {
|
||||
readonly uid: string
|
||||
readonly missileId: number
|
||||
|
|
@ -42,6 +63,8 @@ export interface ActiveMissile {
|
|||
readonly dmgPacket: SUnitDmgPacket
|
||||
chainRemaining: number
|
||||
expired: boolean
|
||||
subMissileIdx?: number
|
||||
currentHitFrame?: number
|
||||
}
|
||||
|
||||
export interface MissileStepResult {
|
||||
|
|
@ -273,22 +296,49 @@ export class MissileEngine {
|
|||
if (m.expired) continue
|
||||
m.ageTicks += 1
|
||||
|
||||
// `pSrvDoFunc = 15` (`frozenorb`): emits radial `icebolt` sub-missiles while flying
|
||||
if (m.record.pSrvDoFunc === 15 && m.ageTicks % 3 === 0) {
|
||||
const subName = m.record.subMissile1 || 'icebolt'
|
||||
const subAngle = m.angleRad + m.ageTicks * 0.8
|
||||
this.spawnMissile({
|
||||
missileNameOrId: subName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x + Math.cos(subAngle) * 100,
|
||||
targetY: m.y + Math.sin(subAngle) * (100 * 0.5),
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(subName)
|
||||
// `pSrvDoFunc = 15` (`frozenorb`): emits radial `frozenorbbolt` sub-missiles while flying
|
||||
// Authentic D2Game.0x6FC581F0 (MISSMODE_SrvDo15_FrozenOrb):
|
||||
// - 64-entry discrete circle lookup table (radius 30)
|
||||
// - Trigger frequency: dwParam[0] (1 frame)
|
||||
// - Angular step: dwParam[1] (19 directions per step, coprime with 64)
|
||||
// - Foreshortened by ISO_GROUND_ASPECT_RATIO = 0.5 in 2:1 isometric ground perspective
|
||||
if (m.record.pSrvDoFunc === 15) {
|
||||
const nFrames = Math.max(1, m.record.param1 || 1)
|
||||
if (m.ageTicks % nFrames === 0 || (m.maxRangeTicks - m.ageTicks) % nFrames <= 0) {
|
||||
const subName = m.record.subMissile1 || 'frozenorbbolt'
|
||||
const idx = Math.abs((m.subMissileIdx ?? 0) % 64)
|
||||
const offX = FROZEN_ORB_X_POSITIONS[idx]!
|
||||
const offY = FROZEN_ORB_Y_POSITIONS[idx]! * ISO_GROUND_ASPECT_RATIO
|
||||
this.spawnMissile({
|
||||
missileNameOrId: subName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x + offX * 10,
|
||||
targetY: m.y + offY * 10,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(subName)
|
||||
const step = m.record.param2 || 19
|
||||
m.subMissileIdx = (idx + step) % 64
|
||||
}
|
||||
}
|
||||
|
||||
// `pSrvDoFunc = 16` (`frozenorbnova`): authentic D2Game.0x6FC58340
|
||||
// In the final frames before expiry (dwParam[0] = 6), every dwParam[1] = 2 frames,
|
||||
// apply affine rotation & damping curl: x' = (x - y) / 2, y' = (x + y) / 2
|
||||
if (m.record.pSrvDoFunc === 16) {
|
||||
const remaining = m.maxRangeTicks - m.ageTicks
|
||||
const threshold = m.record.param1 || 6
|
||||
const nParam = Math.max(1, m.record.param2 || 2)
|
||||
if (remaining < threshold && remaining % nParam === 0) {
|
||||
const diff = m.vx - m.vy
|
||||
const sum = m.vx + m.vy
|
||||
m.vx = diff / 2
|
||||
m.vy = sum / 2
|
||||
}
|
||||
}
|
||||
|
||||
// Spiral flight for Blessed Hammer (`blessedhammer`, `skillId = 112`)
|
||||
|
|
@ -317,6 +367,12 @@ export class MissileEngine {
|
|||
continue
|
||||
}
|
||||
|
||||
// Main Frozen Orb (pSrvDoFunc === 15) does not deal direct contact damage;
|
||||
// all its damage is delivered through emitted child bolts (frozenorbbolt, frozenorbnova).
|
||||
if (m.record.pSrvDoFunc === 15) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
|
||||
const dist = Math.hypot(pos.x - m.x, pos.y - m.y)
|
||||
if (dist <= hitRadius) {
|
||||
|
|
@ -436,10 +492,17 @@ export class MissileEngine {
|
|||
|
||||
if (m.ageTicks >= m.maxRangeTicks) {
|
||||
// `pSrvHitFunc = 29` (`Frozen Orb` expiration burst)
|
||||
if (m.record.pSrvHitFunc === 29) {
|
||||
const subName = m.record.hitSubMissile1 || 'icebolt'
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const ang = (i * Math.PI) / 4
|
||||
// Authentic D2Game.0x6FC5CE10 (MISSMODE_SrvHit29_FrozenOrb):
|
||||
// - Triggers only on first frame of hit (currentHitFrame === 0)
|
||||
// - Loop with step sHitPar1 = 4 over 64 directions -> exactly 16 frozenorbnova missiles!
|
||||
// - Foreshortened by ISO_GROUND_ASPECT_RATIO = 0.5 in 2:1 isometric ground perspective
|
||||
if (m.record.pSrvHitFunc === 29 && (!m.currentHitFrame || m.currentHitFrame === 0)) {
|
||||
m.currentHitFrame = 1
|
||||
const subName = m.record.hitSubMissile1 || 'frozenorbnova'
|
||||
const step = Math.max(1, m.record.sHitPar1 || 4)
|
||||
for (let i = 0; i < 64; i += step) {
|
||||
const offX = FROZEN_ORB_X_POSITIONS[i]!
|
||||
const offY = FROZEN_ORB_Y_POSITIONS[i]! * ISO_GROUND_ASPECT_RATIO
|
||||
this.spawnMissile({
|
||||
missileNameOrId: subName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
|
|
@ -447,12 +510,25 @@ export class MissileEngine {
|
|||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x + Math.cos(ang) * 100,
|
||||
targetY: m.y + Math.sin(ang) * 100,
|
||||
targetX: m.x + offX * 10,
|
||||
targetY: m.y + offY * 10,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(subName)
|
||||
}
|
||||
subMissilesSpawned.push(subName)
|
||||
const explosionName = m.record.explosionMissile || 'frozenorbexplode'
|
||||
this.spawnMissile({
|
||||
missileNameOrId: explosionName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x,
|
||||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(explosionName)
|
||||
}
|
||||
m.expired = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type { D2Table } from './acts.ts'
|
|||
import type { Rng } from './rng.ts'
|
||||
import type { CharacterClassCode } from './classes.ts'
|
||||
import type { CombatPlayer } from './combat.ts'
|
||||
import { FROZEN_ORB_X_POSITIONS, FROZEN_ORB_Y_POSITIONS } from './engine/missile-engine.ts'
|
||||
|
||||
/** One castable skill, read from a table row. */
|
||||
export interface SkillDef {
|
||||
|
|
@ -97,6 +98,10 @@ export interface Projectile {
|
|||
readonly jitter?: boolean | undefined
|
||||
/** Target restriction: only hits undead monsters. */
|
||||
readonly isUndeadOnly?: boolean | undefined
|
||||
/** Sub-missile emission index along the 64-point discrete circle table (e.g. Frozen Orb). */
|
||||
readonly subMissileIdx?: number | undefined
|
||||
/** Affine curl damping trajectory in last 6 frames (e.g. Frozen Orb Nova). */
|
||||
readonly isNovaCurl?: boolean | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -361,6 +366,86 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
frozenorb: {
|
||||
name: 'frozenorb',
|
||||
id: 260,
|
||||
vel: 10,
|
||||
maxVel: 10,
|
||||
range: 30,
|
||||
levRange: 0,
|
||||
celFile: 'IceOrb',
|
||||
animLen: 16,
|
||||
animSpeed: 16,
|
||||
loopAnim: 1,
|
||||
numDirections: 16,
|
||||
explosionMissile: 'frozenorbexplode',
|
||||
light: 6,
|
||||
red: 81,
|
||||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 250,
|
||||
distancePx: 300,
|
||||
},
|
||||
frozenorbbolt: {
|
||||
name: 'frozenorbbolt',
|
||||
id: 261,
|
||||
vel: 18,
|
||||
maxVel: 18,
|
||||
range: 25,
|
||||
levRange: 0,
|
||||
celFile: 'IceBolt',
|
||||
animLen: 6,
|
||||
animSpeed: 16,
|
||||
loopAnim: 1,
|
||||
numDirections: 16,
|
||||
explosionMissile: 'iceexplode',
|
||||
light: 4,
|
||||
red: 81,
|
||||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 450,
|
||||
distancePx: 450,
|
||||
},
|
||||
frozenorbnova: {
|
||||
name: 'frozenorbnova',
|
||||
id: 262,
|
||||
vel: 24,
|
||||
maxVel: 24,
|
||||
range: 25,
|
||||
levRange: 0,
|
||||
celFile: 'IceBolt',
|
||||
animLen: 6,
|
||||
animSpeed: 16,
|
||||
loopAnim: 1,
|
||||
numDirections: 16,
|
||||
explosionMissile: 'iceexplode',
|
||||
light: 4,
|
||||
red: 81,
|
||||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 600,
|
||||
distancePx: 600,
|
||||
},
|
||||
frozenorbexplode: {
|
||||
name: 'frozenorbexplode',
|
||||
id: 263,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 16,
|
||||
levRange: 0,
|
||||
celFile: 'IceOrbExplode',
|
||||
animLen: 16,
|
||||
animSpeed: 16,
|
||||
loopAnim: 0,
|
||||
numDirections: 1,
|
||||
explosionMissile: '',
|
||||
light: 6,
|
||||
red: 81,
|
||||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
chargedbolt: {
|
||||
name: 'chargedbolt',
|
||||
id: 56,
|
||||
|
|
@ -1666,6 +1751,8 @@ export interface ProjectileOutcome {
|
|||
readonly missileType?: string | undefined
|
||||
readonly aoeRadius?: number | undefined
|
||||
}[] | undefined
|
||||
/** Newly spawned child projectiles during this tick (e.g. Frozen Orb bolts / nova). */
|
||||
readonly spawnedProjectiles?: readonly Projectile[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1687,6 +1774,7 @@ export function tickProjectiles(
|
|||
): ProjectileOutcome {
|
||||
const radius = options.radius ?? 8
|
||||
const alive: Projectile[] = []
|
||||
const spawned: Projectile[] = []
|
||||
const hits: {
|
||||
targetIndex: number
|
||||
damage: number
|
||||
|
|
@ -1710,18 +1798,68 @@ export function tickProjectiles(
|
|||
let wallHits = 0
|
||||
|
||||
for (const projectile of projectiles) {
|
||||
if (projectile.ttl <= 0) { expired += 1; continue }
|
||||
if (projectile.ttl <= 0) {
|
||||
expired += 1
|
||||
if (projectile.missileType === 'frozenorb') {
|
||||
// Authentic D2 1.13c MISSMODE_SrvHit29_FrozenOrb (0x6FC5CE10):
|
||||
// Detonates into 16 frozenorbnova missiles + frozenorbexplode explosion at expiry
|
||||
for (let i = 0; i < 64; i += 4) {
|
||||
const offX = FROZEN_ORB_X_POSITIONS[i]!
|
||||
const offY = FROZEN_ORB_Y_POSITIONS[i]! * ISO_GROUND_ASPECT_RATIO
|
||||
const angle = Math.atan2(offY, offX)
|
||||
const speed = 24
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
x: projectile.x,
|
||||
y: projectile.y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
damage: projectile.damage > 0 ? projectile.damage : 40,
|
||||
ttl: 25,
|
||||
fromPlayer: projectile.fromPlayer,
|
||||
missileType: 'frozenorbnova',
|
||||
statusEffect: 'chill',
|
||||
statusDuration: 100,
|
||||
isNovaCurl: true,
|
||||
pierce: false,
|
||||
})
|
||||
}
|
||||
wallHitEvents.push({
|
||||
x: projectile.x,
|
||||
y: projectile.y,
|
||||
skillId: projectile.skillId,
|
||||
missileType: 'frozenorb',
|
||||
aoeRadius: 24,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let vx = projectile.vx
|
||||
let vy = projectile.vy
|
||||
let x = projectile.x + vx
|
||||
let y = projectile.y + vy
|
||||
let spiral = projectile.spiral
|
||||
let subMissileIdx = projectile.subMissileIdx
|
||||
|
||||
// 1. Archimedean spiral trajectory (e.g. Blessed Hammer)
|
||||
// The hammer traces an Archimedean spiral on the map ground plane. In oblique/isometric
|
||||
// perspective (2:1 projection), a circular ground expansion projects to an ellipse with a 2:1 aspect ratio.
|
||||
if (spiral !== undefined) {
|
||||
// 0. Frozen Orb Nova affine rotation & damping curl in final frames
|
||||
// Authentic D2 1.13c MISSMODE_SrvDo16_FrozenOrbNova (0x6FC58340):
|
||||
// In final 6 frames before expiry, every 2 frames apply: x' = (x - y) / 2, y' = (x + y) / 2
|
||||
if (
|
||||
(projectile.isNovaCurl || projectile.missileType === 'frozenorbnova') &&
|
||||
projectile.ttl <= 6 &&
|
||||
projectile.ttl % 2 === 0
|
||||
) {
|
||||
const diff = vx - vy
|
||||
const sum = vx + vy
|
||||
vx = diff / 2
|
||||
vy = sum / 2
|
||||
x = projectile.x + vx
|
||||
y = projectile.y + vy
|
||||
} else if (spiral !== undefined) {
|
||||
// 1. Archimedean spiral trajectory (e.g. Blessed Hammer)
|
||||
// The hammer traces an Archimedean spiral on the map ground plane. In oblique/isometric
|
||||
// perspective (2:1 projection), a circular ground expansion projects to an ellipse with a 2:1 aspect ratio.
|
||||
const nextAngle = spiral.angle + 0.22
|
||||
const nextRadius = spiral.radius + 3.2
|
||||
spiral = { ...spiral, angle: nextAngle, radius: nextRadius }
|
||||
|
|
@ -1772,6 +1910,32 @@ export function tickProjectiles(
|
|||
}
|
||||
}
|
||||
|
||||
// 4. Frozen Orb sub-missile emitter (pSrvDoFunc = 15, frequency = 1, step = 19)
|
||||
// Authentic D2 1.13c MISSMODE_SrvDo15_FrozenOrb (0x6FC581F0):
|
||||
// Emits 1 radial frozenorbbolt along 64-point circle table, advancing by step 19
|
||||
if (projectile.missileType === 'frozenorb') {
|
||||
const idx = Math.abs((subMissileIdx ?? 0) % 64)
|
||||
const offX = FROZEN_ORB_X_POSITIONS[idx]!
|
||||
const offY = FROZEN_ORB_Y_POSITIONS[idx]! * ISO_GROUND_ASPECT_RATIO
|
||||
const angle = Math.atan2(offY, offX)
|
||||
const speed = 18 // vel = 18 in 1.13c Missiles.txt (450 px/s)
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
damage: projectile.damage > 0 ? projectile.damage : 40,
|
||||
ttl: 25,
|
||||
fromPlayer: projectile.fromPlayer,
|
||||
missileType: 'frozenorbbolt',
|
||||
statusEffect: 'chill',
|
||||
statusDuration: 100,
|
||||
pierce: false,
|
||||
})
|
||||
subMissileIdx = (idx + 19) % 64
|
||||
}
|
||||
|
||||
const hitTargets = projectile.hitTargets ?? new Set<number>()
|
||||
const moved: Projectile = {
|
||||
...projectile,
|
||||
|
|
@ -1780,6 +1944,7 @@ export function tickProjectiles(
|
|||
vx,
|
||||
vy,
|
||||
spiral,
|
||||
subMissileIdx,
|
||||
hitTargets,
|
||||
ttl: projectile.ttl - 1,
|
||||
}
|
||||
|
|
@ -1798,32 +1963,58 @@ export function tickProjectiles(
|
|||
missileType: projectile.missileType,
|
||||
aoeRadius: projectile.aoeRadius,
|
||||
})
|
||||
if (projectile.missileType === 'frozenorb') {
|
||||
for (let i = 0; i < 64; i += 4) {
|
||||
const offX = FROZEN_ORB_X_POSITIONS[i]!
|
||||
const offY = FROZEN_ORB_Y_POSITIONS[i]! * ISO_GROUND_ASPECT_RATIO
|
||||
const angle = Math.atan2(offY, offX)
|
||||
const speed = 24
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
damage: projectile.damage > 0 ? projectile.damage : 40,
|
||||
ttl: 25,
|
||||
fromPlayer: projectile.fromPlayer,
|
||||
missileType: 'frozenorbnova',
|
||||
statusEffect: 'chill',
|
||||
statusDuration: 100,
|
||||
isNovaCurl: true,
|
||||
pierce: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let struck = false
|
||||
for (const target of targets) {
|
||||
if (!target.alive) continue
|
||||
if (hitTargets.has(target.index)) continue // already struck by piercing projectile
|
||||
if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue
|
||||
const isHarmlessEmitter = projectile.missileType === 'frozenorb'
|
||||
if (!isHarmlessEmitter) {
|
||||
for (const target of targets) {
|
||||
if (!target.alive) continue
|
||||
if (hitTargets.has(target.index)) continue // already struck by piercing projectile
|
||||
if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue
|
||||
|
||||
hits.push({
|
||||
targetIndex: target.index,
|
||||
damage: projectile.damage,
|
||||
x,
|
||||
y,
|
||||
skillId: projectile.skillId,
|
||||
missileType: projectile.missileType,
|
||||
aoeRadius: projectile.aoeRadius,
|
||||
statusEffect: projectile.statusEffect,
|
||||
statusDuration: projectile.statusDuration,
|
||||
isUndeadOnly: projectile.isUndeadOnly,
|
||||
})
|
||||
hits.push({
|
||||
targetIndex: target.index,
|
||||
damage: projectile.damage,
|
||||
x,
|
||||
y,
|
||||
skillId: projectile.skillId,
|
||||
missileType: projectile.missileType,
|
||||
aoeRadius: projectile.aoeRadius,
|
||||
statusEffect: projectile.statusEffect,
|
||||
statusDuration: projectile.statusDuration,
|
||||
isUndeadOnly: projectile.isUndeadOnly,
|
||||
})
|
||||
|
||||
hitTargets.add(target.index)
|
||||
if (!projectile.pierce) {
|
||||
struck = true
|
||||
break
|
||||
hitTargets.add(target.index)
|
||||
if (!projectile.pierce) {
|
||||
struck = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1831,7 +2022,9 @@ export function tickProjectiles(
|
|||
alive.push(moved)
|
||||
}
|
||||
}
|
||||
return { alive, hits, expired, wallHits, wallHitEvents }
|
||||
|
||||
alive.push(...spawned)
|
||||
return { alive, hits, expired, wallHits, wallHitEvents, spawnedProjectiles: spawned }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -823,6 +823,68 @@ export const FREEZEEXPLODE_META: MissileMeta = {
|
|||
],
|
||||
}
|
||||
|
||||
export const FROZENORB_META: MissileMeta = {
|
||||
name: "frozenorb",
|
||||
celFile: "IceOrb",
|
||||
width: 512,
|
||||
height: 107,
|
||||
directions: 1,
|
||||
framesPerDirection: 16,
|
||||
animSpeed: 16,
|
||||
groups: [
|
||||
[
|
||||
[0, 0, 54, 53, -27, -71],
|
||||
[55, 0, 54, 53, -27, -71],
|
||||
[110, 0, 54, 53, -27, -71],
|
||||
[165, 0, 54, 53, -27, -71],
|
||||
[220, 0, 54, 53, -27, -71],
|
||||
[275, 0, 54, 53, -27, -71],
|
||||
[330, 0, 54, 53, -27, -71],
|
||||
[385, 0, 54, 53, -27, -71],
|
||||
[440, 0, 54, 53, -27, -71],
|
||||
[0, 54, 54, 53, -27, -71],
|
||||
[55, 54, 54, 53, -27, -71],
|
||||
[110, 54, 54, 53, -27, -71],
|
||||
[165, 54, 54, 53, -27, -71],
|
||||
[220, 54, 54, 53, -27, -71],
|
||||
[275, 54, 54, 53, -27, -71],
|
||||
[330, 54, 54, 53, -27, -71]
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
export const FROZENORBEXPLODE_META: MissileMeta = {
|
||||
name: "frozenorbexplode",
|
||||
celFile: "IceOrbExplode",
|
||||
width: 1024,
|
||||
height: 959,
|
||||
directions: 1,
|
||||
framesPerDirection: 18,
|
||||
animSpeed: 16,
|
||||
groups: [
|
||||
[
|
||||
[0, 0, 230, 191, -111, -113],
|
||||
[231, 0, 230, 191, -111, -113],
|
||||
[462, 0, 230, 191, -111, -113],
|
||||
[693, 0, 230, 191, -111, -113],
|
||||
[0, 192, 230, 191, -111, -113],
|
||||
[231, 192, 230, 191, -111, -113],
|
||||
[462, 192, 230, 191, -111, -113],
|
||||
[693, 192, 230, 191, -111, -113],
|
||||
[0, 384, 230, 191, -111, -113],
|
||||
[231, 384, 230, 191, -111, -113],
|
||||
[462, 384, 230, 191, -111, -113],
|
||||
[693, 384, 230, 191, -111, -113],
|
||||
[0, 576, 230, 191, -111, -113],
|
||||
[231, 576, 230, 191, -111, -113],
|
||||
[462, 576, 230, 191, -111, -113],
|
||||
[693, 576, 230, 191, -111, -113],
|
||||
[0, 768, 230, 191, -111, -113],
|
||||
[231, 768, 230, 191, -111, -113]
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
export const CHARGEDBOLT_META: MissileMeta = {
|
||||
name: "chargedbolt",
|
||||
celFile: "ChargedBolt",
|
||||
|
|
@ -4792,6 +4854,8 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
|
|||
'glacialspike': GLACIALSPIKE_META,
|
||||
'iceexplode': ICEEXPLODE_META,
|
||||
'freezeexplode': FREEZEEXPLODE_META,
|
||||
'frozenorb': FROZENORB_META,
|
||||
'frozenorbexplode': FROZENORBEXPLODE_META,
|
||||
'chargedbolt': CHARGEDBOLT_META,
|
||||
'teeth': TEETH_META,
|
||||
'teethexplode': TEETHEXPLODE_META,
|
||||
|
|
|
|||
|
|
@ -1505,10 +1505,15 @@ export function castSkill(
|
|||
missileType = 'lightningjavelin'
|
||||
skillName = '闪电之枪'
|
||||
} else if (skillId === 64) {
|
||||
speed = 400
|
||||
range = 480
|
||||
damage = 35
|
||||
const missile = getMissileTxtData('frozenorb')
|
||||
speed = missile.speedPxPerSec || 250
|
||||
range = missile.distancePx || 300
|
||||
damage = 40
|
||||
delay = 25
|
||||
pierce = true
|
||||
statusEffect = 'chill'
|
||||
statusDuration = 100
|
||||
missileType = 'frozenorb'
|
||||
skillName = '冰封球'
|
||||
} else if (skillId === 49) {
|
||||
speed = 600
|
||||
|
|
@ -3586,6 +3591,11 @@ export async function loadMissileArtMap(
|
|||
}
|
||||
}),
|
||||
)
|
||||
if (map.has('icebolt')) {
|
||||
const iceboltArt = map.get('icebolt')!
|
||||
map.set('frozenorbbolt', iceboltArt)
|
||||
map.set('frozenorbnova', iceboltArt)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
|
|
@ -5152,7 +5162,7 @@ if (typeof window !== 'undefined') {
|
|||
for (const shot of engine.projectiles) {
|
||||
pushEntity(shot.x, shot.y, () => {
|
||||
const mType = shot.missileType ?? (shot.skillId === '36' ? 'firebolt' : shot.skillId === '47' ? 'fireball' : undefined)
|
||||
const art = mType !== undefined ? missileArtMap.get(mType) : undefined
|
||||
const art = mType !== undefined ? (missileArtMap.get(mType) ?? (mType === 'frozenorbbolt' || mType === 'frozenorbnova' ? missileArtMap.get('icebolt') : undefined)) : undefined
|
||||
if (art !== undefined) {
|
||||
drawMissileProjectile(renderer, shot, art)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,390 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c — Skill #064: Frozen Orb (SOR)
|
||||
* Missile Physics, Discrete Circle Emitter, Visual Assets & 1.13c Parity Suite
|
||||
*
|
||||
* Requirements (Issue #218 / Frozen Orb Parity):
|
||||
* 1. Missiles.txt ground truth: parses and verifies frozenorb (ID 260), frozenorbbolt (ID 261),
|
||||
* frozenorbnova (ID 262), and frozenorbexplode (ID 263).
|
||||
* 2. Visual assets & atlas metadata: FROZENORB_META (IceOrb.dcc, 16 frames),
|
||||
* FROZENORBEXPLODE_META (IceOrbExplode.dcc, 18 frames), baked PNG/JSON files exist.
|
||||
* 3. Discrete circle emitter (pSrvDoFunc = 15): 64-point lookup table, step 19, 2:1 isometric
|
||||
* ground foreshortening (ISO_GROUND_ASPECT_RATIO = 0.5), emits 30 frozenorbbolt missiles over 30 ticks.
|
||||
* 4. Main orb monster penetration: pierces through monsters without dying (CollideKill = 0) and
|
||||
* does not deal direct contact damage (damage delivered by emitted bolts).
|
||||
* 5. Expiration burst & wall detonation (pSrvHitFunc = 29): 16 frozenorbnova missiles along 64
|
||||
* directions (step 4) + frozenorbexplode explosion.
|
||||
* 6. Frozen Orb Nova affine rotation & damping curl (pSrvDoFunc = 16): last 6 frames every 2 frames
|
||||
* transforms x' = (x - y)/2, y' = (x + y)/2.
|
||||
* 7. Zero NextDelay sweet spot burst damage: multiple overlapping child bolts hit in same frame.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
parseMissilesTxt,
|
||||
getMissileTxtData,
|
||||
CANONICAL_113C_MISSILES,
|
||||
tickProjectiles,
|
||||
ISO_GROUND_ASPECT_RATIO,
|
||||
type Projectile,
|
||||
} from '../src/game/skills.ts'
|
||||
import {
|
||||
FROZEN_ORB_X_POSITIONS,
|
||||
FROZEN_ORB_Y_POSITIONS,
|
||||
} from '../src/game/engine/missile-engine.ts'
|
||||
import {
|
||||
FROZENORB_META,
|
||||
FROZENORBEXPLODE_META,
|
||||
MISSILE_METAS,
|
||||
} from '../src/render/missiles-meta.ts'
|
||||
|
||||
describe('[Skill #064] Frozen Orb — 1.13c Missile Physics & Runtime Parity', () => {
|
||||
const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt')
|
||||
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
|
||||
|
||||
it('1. Parses authentic 1.13c Missiles.txt and verifies frozenorb missile suite', () => {
|
||||
const map = parseMissilesTxt(missilesTxtContent)
|
||||
|
||||
// frozenorb (ID 260)
|
||||
const orb = map.get('frozenorb')
|
||||
expect(orb).toBeDefined()
|
||||
expect(orb!.id).toBe(260)
|
||||
expect(orb!.vel).toBe(10)
|
||||
expect(orb!.range).toBe(30)
|
||||
expect(orb!.speedPxPerSec).toBe(250) // 10 * 25
|
||||
expect(orb!.distancePx).toBe(300) // 10 * 30
|
||||
expect(orb!.celFile.toLowerCase()).toBe('iceorb')
|
||||
expect(orb!.animLen).toBe(16)
|
||||
expect(orb!.explosionMissile).toBe('') // In raw Missiles.txt, pSrvHitFunc 29 spawns frozenorbexplode via code
|
||||
expect(orb!.light).toBe(6)
|
||||
expect(orb!.red).toBe(81)
|
||||
expect(orb!.green).toBe(81)
|
||||
expect(orb!.blue).toBe(255)
|
||||
|
||||
// frozenorbbolt (ID 261)
|
||||
const bolt = map.get('frozenorbbolt')
|
||||
expect(bolt).toBeDefined()
|
||||
expect(bolt!.id).toBe(261)
|
||||
expect(bolt!.vel).toBe(18)
|
||||
expect(bolt!.range).toBe(25)
|
||||
expect(bolt!.speedPxPerSec).toBe(450) // 18 * 25
|
||||
expect(bolt!.distancePx).toBe(450) // 18 * 25
|
||||
expect(bolt!.celFile.toLowerCase()).toBe('icebolt')
|
||||
expect(bolt!.animLen).toBe(6)
|
||||
|
||||
// frozenorbnova (ID 262)
|
||||
const nova = map.get('frozenorbnova')
|
||||
expect(nova).toBeDefined()
|
||||
expect(nova!.id).toBe(262)
|
||||
expect(nova!.vel).toBe(24)
|
||||
expect(nova!.range).toBe(25)
|
||||
expect(nova!.speedPxPerSec).toBe(600) // 24 * 25
|
||||
expect(nova!.distancePx).toBe(600) // 24 * 25
|
||||
expect(nova!.celFile.toLowerCase()).toBe('icebolt')
|
||||
expect(nova!.animLen).toBe(6)
|
||||
|
||||
// frozenorbexplode (ID 263)
|
||||
const exp = map.get('frozenorbexplode')
|
||||
expect(exp).toBeDefined()
|
||||
expect(exp!.id).toBe(263)
|
||||
expect(exp!.vel).toBe(0)
|
||||
expect(exp!.range).toBe(16)
|
||||
expect(exp!.celFile.toLowerCase()).toBe('iceorbexplode')
|
||||
expect(exp!.animLen).toBe(16)
|
||||
})
|
||||
|
||||
it('2. CANONICAL_113C_MISSILES export contains frozenorb suite with exact values', () => {
|
||||
const orb = getMissileTxtData('frozenorb')
|
||||
expect(orb.id).toBe(260)
|
||||
expect(orb.vel).toBe(10)
|
||||
expect(orb.range).toBe(30)
|
||||
expect(orb.speedPxPerSec).toBe(250)
|
||||
expect(orb.distancePx).toBe(300)
|
||||
expect(orb.explosionMissile).toBe('frozenorbexplode')
|
||||
|
||||
const bolt = getMissileTxtData('frozenorbbolt')
|
||||
expect(bolt.id).toBe(261)
|
||||
expect(bolt.vel).toBe(18)
|
||||
expect(bolt.range).toBe(25)
|
||||
expect(bolt.speedPxPerSec).toBe(450)
|
||||
|
||||
const nova = getMissileTxtData('frozenorbnova')
|
||||
expect(nova.id).toBe(262)
|
||||
expect(nova.vel).toBe(24)
|
||||
expect(nova.range).toBe(25)
|
||||
expect(nova.speedPxPerSec).toBe(600)
|
||||
|
||||
const explode = getMissileTxtData('frozenorbexplode')
|
||||
expect(explode.id).toBe(263)
|
||||
expect(explode.animLen).toBe(16)
|
||||
})
|
||||
|
||||
it('3. Authentic baked DCC atlas metadata and files exist with non-zero byte size', () => {
|
||||
expect(FROZENORB_META.name).toBe('frozenorb')
|
||||
expect(FROZENORB_META.celFile).toBe('IceOrb')
|
||||
expect(FROZENORB_META.directions).toBe(1)
|
||||
expect(FROZENORB_META.framesPerDirection).toBe(16)
|
||||
expect(FROZENORB_META.groups.length).toBe(1)
|
||||
expect(FROZENORB_META.groups[0]!.length).toBe(16)
|
||||
|
||||
expect(FROZENORBEXPLODE_META.name).toBe('frozenorbexplode')
|
||||
expect(FROZENORBEXPLODE_META.celFile).toBe('IceOrbExplode')
|
||||
expect(FROZENORBEXPLODE_META.directions).toBe(1)
|
||||
expect(FROZENORBEXPLODE_META.framesPerDirection).toBe(18)
|
||||
expect(FROZENORBEXPLODE_META.groups.length).toBe(1)
|
||||
expect(FROZENORBEXPLODE_META.groups[0]!.length).toBe(18)
|
||||
|
||||
expect(MISSILE_METAS['frozenorb']).toBe(FROZENORB_META)
|
||||
expect(MISSILE_METAS['frozenorbexplode']).toBe(FROZENORBEXPLODE_META)
|
||||
|
||||
const orbPngPath = path.resolve(__dirname, '../public/missiles/frozenorb.png')
|
||||
const orbJsonPath = path.resolve(__dirname, '../public/missiles/frozenorb.json')
|
||||
const expPngPath = path.resolve(__dirname, '../public/missiles/frozenorbexplode.png')
|
||||
const expJsonPath = path.resolve(__dirname, '../public/missiles/frozenorbexplode.json')
|
||||
|
||||
expect(fs.existsSync(orbPngPath)).toBe(true)
|
||||
expect(fs.statSync(orbPngPath).size).toBeGreaterThan(10000)
|
||||
expect(fs.existsSync(orbJsonPath)).toBe(true)
|
||||
expect(fs.existsSync(expPngPath)).toBe(true)
|
||||
expect(fs.statSync(expPngPath).size).toBeGreaterThan(20000)
|
||||
expect(fs.existsSync(expJsonPath)).toBe(true)
|
||||
})
|
||||
|
||||
it('4. Discrete circle table matches 1.13c binary assembly (64 entries, radius 30)', () => {
|
||||
expect(FROZEN_ORB_X_POSITIONS.length).toBe(64)
|
||||
expect(FROZEN_ORB_Y_POSITIONS.length).toBe(64)
|
||||
expect(FROZEN_ORB_X_POSITIONS[0]).toBe(30)
|
||||
expect(FROZEN_ORB_Y_POSITIONS[0]).toBe(0)
|
||||
expect(FROZEN_ORB_X_POSITIONS[16]).toBe(0)
|
||||
expect(FROZEN_ORB_Y_POSITIONS[16]).toBe(30)
|
||||
expect(FROZEN_ORB_X_POSITIONS[32]).toBe(-30)
|
||||
expect(FROZEN_ORB_Y_POSITIONS[32]).toBe(0)
|
||||
expect(FROZEN_ORB_X_POSITIONS[48]).toBe(0)
|
||||
expect(FROZEN_ORB_Y_POSITIONS[48]).toBe(-30)
|
||||
})
|
||||
|
||||
it('5. tickProjectiles: simulates 30-tick flight, emitting 1 bolt per frame along 64-table with step 19', () => {
|
||||
const orb: Projectile = {
|
||||
skillId: '64',
|
||||
x: 100,
|
||||
y: 100,
|
||||
vx: 10, // 250 px/s / 25 fps = 10 px/tick
|
||||
vy: 0,
|
||||
damage: 42,
|
||||
ttl: 30, // 30 frames
|
||||
fromPlayer: true,
|
||||
missileType: 'frozenorb',
|
||||
pierce: true,
|
||||
subMissileIdx: 0,
|
||||
}
|
||||
|
||||
let currentProjectiles: readonly Projectile[] = [orb]
|
||||
const allEmittedBolts: Projectile[] = []
|
||||
const openTerrain = { overlap: () => 0 }
|
||||
|
||||
for (let tick = 1; tick <= 30; tick += 1) {
|
||||
const outcome = tickProjectiles(currentProjectiles, [], openTerrain)
|
||||
expect(outcome.spawnedProjectiles).toBeDefined()
|
||||
expect(outcome.spawnedProjectiles!.length).toBe(1)
|
||||
const bolt = outcome.spawnedProjectiles![0]!
|
||||
expect(bolt.missileType).toBe('frozenorbbolt')
|
||||
expect(bolt.damage).toBe(42)
|
||||
expect(bolt.statusEffect).toBe('chill')
|
||||
expect(bolt.statusDuration).toBe(100)
|
||||
expect(bolt.ttl).toBe(25)
|
||||
expect(Math.hypot(bolt.vx, bolt.vy)).toBeCloseTo(18, 0.1) // vel = 18 px/tick
|
||||
|
||||
allEmittedBolts.push(bolt)
|
||||
|
||||
// Filter to keep only the orb for tracking emitter state
|
||||
currentProjectiles = outcome.alive.filter(p => p.missileType === 'frozenorb')
|
||||
if (tick < 30) {
|
||||
expect(currentProjectiles.length).toBe(1)
|
||||
expect(currentProjectiles[0]!.x).toBe(100 + tick * 10)
|
||||
expect(currentProjectiles[0]!.ttl).toBe(30 - tick)
|
||||
// Verify direction index advanced by 19 mod 64
|
||||
const expectedIdx = (tick * 19) % 64
|
||||
expect(currentProjectiles[0]!.subMissileIdx).toBe(expectedIdx)
|
||||
} else {
|
||||
// At tick 30, moved.ttl reaches 0
|
||||
expect(currentProjectiles.length).toBe(1)
|
||||
expect(currentProjectiles[0]!.ttl).toBe(0)
|
||||
}
|
||||
}
|
||||
|
||||
expect(allEmittedBolts.length).toBe(30)
|
||||
|
||||
// At tick 31, the orb with ttl: 0 detonates into 16 frozenorbnova missiles!
|
||||
const detonateOutcome = tickProjectiles(currentProjectiles, [], openTerrain)
|
||||
expect(detonateOutcome.expired).toBe(1)
|
||||
expect(detonateOutcome.alive.length).toBe(16) // 16 nova missiles spawned
|
||||
expect(detonateOutcome.spawnedProjectiles!.length).toBe(16)
|
||||
for (const nova of detonateOutcome.spawnedProjectiles!) {
|
||||
expect(nova.missileType).toBe('frozenorbnova')
|
||||
expect(nova.damage).toBe(42)
|
||||
expect(nova.statusEffect).toBe('chill')
|
||||
expect(nova.isNovaCurl).toBe(true)
|
||||
expect(nova.ttl).toBe(25)
|
||||
expect(Math.hypot(nova.vx, nova.vy)).toBeCloseTo(24, 0.1) // vel = 24 px/tick
|
||||
}
|
||||
|
||||
// Verify explosion event was pushed
|
||||
expect(detonateOutcome.wallHitEvents).toBeDefined()
|
||||
expect(detonateOutcome.wallHitEvents!.some(e => e.missileType === 'frozenorb')).toBe(true)
|
||||
})
|
||||
|
||||
it('6. Main orb pierces monsters without dealing direct contact damage (CollideKill = 0)', () => {
|
||||
const orb: Projectile = {
|
||||
skillId: '64',
|
||||
x: 100,
|
||||
y: 100,
|
||||
vx: 10,
|
||||
vy: 0,
|
||||
damage: 50,
|
||||
ttl: 10,
|
||||
fromPlayer: true,
|
||||
missileType: 'frozenorb',
|
||||
pierce: true,
|
||||
subMissileIdx: 0,
|
||||
}
|
||||
|
||||
// Place dummy enemy directly in front of the orb
|
||||
const targets = [{ index: 0, x: 110, y: 100, radius: 15, alive: true }]
|
||||
const outcome = tickProjectiles([orb], targets, { overlap: () => 0 })
|
||||
|
||||
// Main orb must NOT deal direct contact damage
|
||||
const directHits = outcome.hits.filter(h => h.missileType === 'frozenorb')
|
||||
expect(directHits.length).toBe(0)
|
||||
|
||||
// Main orb must NOT die on the monster
|
||||
const survivingOrb = outcome.alive.find(p => p.missileType === 'frozenorb')
|
||||
expect(survivingOrb).toBeDefined()
|
||||
expect(survivingOrb!.x).toBe(110)
|
||||
expect(survivingOrb!.ttl).toBe(9)
|
||||
})
|
||||
|
||||
it('7. Wall collision immediately detonates orb into 16 nova bolts and stops flight', () => {
|
||||
const orb: Projectile = {
|
||||
skillId: '64',
|
||||
x: 100,
|
||||
y: 100,
|
||||
vx: 10,
|
||||
vy: 0,
|
||||
damage: 40,
|
||||
ttl: 25,
|
||||
fromPlayer: true,
|
||||
missileType: 'frozenorb',
|
||||
pierce: true,
|
||||
subMissileIdx: 0,
|
||||
}
|
||||
|
||||
// Wall at x >= 110
|
||||
const wallTerrain = { overlap: (x: number) => (x >= 110 ? 1 : 0) }
|
||||
const outcome = tickProjectiles([orb], [], wallTerrain)
|
||||
|
||||
expect(outcome.wallHits).toBe(1)
|
||||
// Orb must not survive as an active frozenorb
|
||||
expect(outcome.alive.some(p => p.missileType === 'frozenorb')).toBe(false)
|
||||
// Must have detonated 16 nova bolts
|
||||
expect(outcome.spawnedProjectiles!.filter(p => p.missileType === 'frozenorbnova').length).toBe(16)
|
||||
// Wall hit events must register the explosion
|
||||
expect(outcome.wallHitEvents!.some(e => e.missileType === 'frozenorb')).toBe(true)
|
||||
})
|
||||
|
||||
it('8. Frozen Orb Nova: affine curl damping transforms velocity in last 6 frames', () => {
|
||||
// Nova bolt with ttl = 6, vx = 20, vy = 10
|
||||
const nova: Projectile = {
|
||||
skillId: '64',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 20,
|
||||
vy: 10,
|
||||
damage: 40,
|
||||
ttl: 6,
|
||||
fromPlayer: true,
|
||||
missileType: 'frozenorbnova',
|
||||
isNovaCurl: true,
|
||||
}
|
||||
|
||||
// Step 1: ttl 6 -> curl applied! (6 <= 6 && 6 % 2 === 0)
|
||||
// D2 1.13c: vx' = (vx - vy) / 2 = (20 - 10) / 2 = 5
|
||||
// vy' = (vx + vy) / 2 = (20 + 10) / 2 = 15
|
||||
// x' = 200 + 5 = 205, y' = 200 + 15 = 215
|
||||
const step1 = tickProjectiles([nova], [], { overlap: () => 0 })
|
||||
const moved1 = step1.alive.find(p => p.missileType === 'frozenorbnova')!
|
||||
expect(moved1).toBeDefined()
|
||||
expect(moved1.vx).toBe(5)
|
||||
expect(moved1.vy).toBe(15)
|
||||
expect(moved1.x).toBe(205)
|
||||
expect(moved1.y).toBe(215)
|
||||
expect(moved1.ttl).toBe(5)
|
||||
|
||||
// Step 2: ttl 5 (odd, no curl transform, normal linear flight with vx=5, vy=15)
|
||||
const step2 = tickProjectiles([moved1], [], { overlap: () => 0 })
|
||||
const moved2 = step2.alive.find(p => p.missileType === 'frozenorbnova')!
|
||||
expect(moved2.vx).toBe(5)
|
||||
expect(moved2.vy).toBe(15)
|
||||
expect(moved2.x).toBe(210)
|
||||
expect(moved2.y).toBe(230)
|
||||
expect(moved2.ttl).toBe(4)
|
||||
|
||||
// Step 3: ttl 4 (even, curl applied again!)
|
||||
// vx' = (5 - 15) / 2 = -5
|
||||
// vy' = (5 + 15) / 2 = 10
|
||||
// x' = 210 - 5 = 205, y' = 230 + 10 = 240
|
||||
const step3 = tickProjectiles([moved2], [], { overlap: () => 0 })
|
||||
const moved3 = step3.alive.find(p => p.missileType === 'frozenorbnova')!
|
||||
expect(moved3.vx).toBe(-5)
|
||||
expect(moved3.vy).toBe(10)
|
||||
expect(moved3.x).toBe(205)
|
||||
expect(moved3.y).toBe(240)
|
||||
})
|
||||
|
||||
it('9. Zero NextDelay sweet spot burst: multiple child bolts damage the same target in single frame', () => {
|
||||
// 3 separate nova bolts converging on the same enemy position
|
||||
const bolt1: Projectile = {
|
||||
skillId: '64',
|
||||
x: 95,
|
||||
y: 100,
|
||||
vx: 5,
|
||||
vy: 0,
|
||||
damage: 40,
|
||||
ttl: 20,
|
||||
fromPlayer: true,
|
||||
missileType: 'frozenorbnova',
|
||||
}
|
||||
const bolt2: Projectile = {
|
||||
skillId: '64',
|
||||
x: 105,
|
||||
y: 100,
|
||||
vx: -5,
|
||||
vy: 0,
|
||||
damage: 40,
|
||||
ttl: 20,
|
||||
fromPlayer: true,
|
||||
missileType: 'frozenorbnova',
|
||||
}
|
||||
const bolt3: Projectile = {
|
||||
skillId: '64',
|
||||
x: 100,
|
||||
y: 95,
|
||||
vx: 0,
|
||||
vy: 5,
|
||||
damage: 40,
|
||||
ttl: 20,
|
||||
fromPlayer: true,
|
||||
missileType: 'frozenorbnova',
|
||||
}
|
||||
|
||||
const dummyTarget = [{ index: 1, x: 100, y: 100, radius: 10, alive: true }]
|
||||
const outcome = tickProjectiles([bolt1, bolt2, bolt3], dummyTarget, { overlap: () => 0 })
|
||||
|
||||
// In 1.13c, Frozen Orb child missiles have NextDelay = 0.
|
||||
// Therefore all 3 intersecting bolts hit the dummy monster and register damage!
|
||||
expect(outcome.hits.length).toBe(3)
|
||||
const targetHits = outcome.hits.filter(h => h.targetIndex === 1)
|
||||
expect(targetHits.length).toBe(3)
|
||||
const totalDmg = targetHits.reduce((sum, h) => sum + h.damage, 0)
|
||||
expect(totalDmg).toBe(120) // 3 * 40
|
||||
})
|
||||
})
|
||||