diff --git a/public/missiles/arrow.png b/public/missiles/arrow.png index 7e5ceb5..294806d 100644 Binary files a/public/missiles/arrow.png and b/public/missiles/arrow.png differ diff --git a/public/missiles/blessedhammer.png b/public/missiles/blessedhammer.png index 87785a3..e3ff66b 100644 Binary files a/public/missiles/blessedhammer.png and b/public/missiles/blessedhammer.png differ diff --git a/public/missiles/bonespear.png b/public/missiles/bonespear.png index 47307f0..2a22883 100644 Binary files a/public/missiles/bonespear.png and b/public/missiles/bonespear.png differ diff --git a/public/missiles/bonespirit.png b/public/missiles/bonespirit.png index edd4f7a..7eab231 100644 Binary files a/public/missiles/bonespirit.png and b/public/missiles/bonespirit.png differ diff --git a/public/missiles/bonespiritexplode.png b/public/missiles/bonespiritexplode.png index d242bc2..b293ae6 100644 Binary files a/public/missiles/bonespiritexplode.png and b/public/missiles/bonespiritexplode.png differ diff --git a/public/missiles/chargedbolt.png b/public/missiles/chargedbolt.png index cc4dbf9..20377da 100644 Binary files a/public/missiles/chargedbolt.png and b/public/missiles/chargedbolt.png differ diff --git a/public/missiles/firearrow.png b/public/missiles/firearrow.png index dfd1bfb..5fe7d44 100644 Binary files a/public/missiles/firearrow.png and b/public/missiles/firearrow.png differ diff --git a/public/missiles/fireball.png b/public/missiles/fireball.png index 5df23f4..df555b8 100644 Binary files a/public/missiles/fireball.png and b/public/missiles/fireball.png differ diff --git a/public/missiles/firebolt.png b/public/missiles/firebolt.png index ab4fcc1..f4a46ce 100644 Binary files a/public/missiles/firebolt.png and b/public/missiles/firebolt.png differ diff --git a/public/missiles/fireexplode.png b/public/missiles/fireexplode.png index dcb8372..1c61a55 100644 Binary files a/public/missiles/fireexplode.png and b/public/missiles/fireexplode.png differ diff --git a/public/missiles/freezeexplode.png b/public/missiles/freezeexplode.png index 64356ac..8db4b79 100644 Binary files a/public/missiles/freezeexplode.png and b/public/missiles/freezeexplode.png differ diff --git a/public/missiles/frozenorb.json b/public/missiles/frozenorb.json new file mode 100644 index 0000000..c04dc99 --- /dev/null +++ b/public/missiles/frozenorb.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/frozenorb.png b/public/missiles/frozenorb.png new file mode 100644 index 0000000..54075d4 Binary files /dev/null and b/public/missiles/frozenorb.png differ diff --git a/public/missiles/frozenorbexplode.json b/public/missiles/frozenorbexplode.json new file mode 100644 index 0000000..c439d92 --- /dev/null +++ b/public/missiles/frozenorbexplode.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/frozenorbexplode.png b/public/missiles/frozenorbexplode.png new file mode 100644 index 0000000..a723423 Binary files /dev/null and b/public/missiles/frozenorbexplode.png differ diff --git a/public/missiles/glacialspike.png b/public/missiles/glacialspike.png index 4a9c95e..e6f89b7 100644 Binary files a/public/missiles/glacialspike.png and b/public/missiles/glacialspike.png differ diff --git a/public/missiles/holybolt.png b/public/missiles/holybolt.png index 5cc3b14..d582cbe 100644 Binary files a/public/missiles/holybolt.png and b/public/missiles/holybolt.png differ diff --git a/public/missiles/icearrow.png b/public/missiles/icearrow.png index 0e2c020..e352559 100644 Binary files a/public/missiles/icearrow.png and b/public/missiles/icearrow.png differ diff --git a/public/missiles/iceblast.png b/public/missiles/iceblast.png index 5e1136b..cd27969 100644 Binary files a/public/missiles/iceblast.png and b/public/missiles/iceblast.png differ diff --git a/public/missiles/icebolt.png b/public/missiles/icebolt.png index 6030193..afc501d 100644 Binary files a/public/missiles/icebolt.png and b/public/missiles/icebolt.png differ diff --git a/public/missiles/iceexplode.png b/public/missiles/iceexplode.png index 4f7745e..d9e8f27 100644 Binary files a/public/missiles/iceexplode.png and b/public/missiles/iceexplode.png differ diff --git a/public/missiles/lightningjavelin.png b/public/missiles/lightningjavelin.png index 8714af9..acee861 100644 Binary files a/public/missiles/lightningjavelin.png and b/public/missiles/lightningjavelin.png differ diff --git a/public/missiles/magicarrow.png b/public/missiles/magicarrow.png index 12cc329..994789b 100644 Binary files a/public/missiles/magicarrow.png and b/public/missiles/magicarrow.png differ diff --git a/public/missiles/poisonnova.png b/public/missiles/poisonnova.png index 3b30925..323e491 100644 Binary files a/public/missiles/poisonnova.png and b/public/missiles/poisonnova.png differ diff --git a/public/missiles/teeth.png b/public/missiles/teeth.png index 3f9fcf1..560d0d4 100644 Binary files a/public/missiles/teeth.png and b/public/missiles/teeth.png differ diff --git a/public/missiles/teethexplode.png b/public/missiles/teethexplode.png index f29601e..cdcedff 100644 Binary files a/public/missiles/teethexplode.png and b/public/missiles/teethexplode.png differ diff --git a/scripts/pack-missiles.ts b/scripts/pack-missiles.ts index 1a71cc5..d1758d6 100644 --- a/scripts/pack-missiles.ts +++ b/scripts/pack-missiles.ts @@ -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 }, diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index b755d51..ccab61d 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -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 } diff --git a/src/game/skills.ts b/src/game/skills.ts index 43c5674..3a3c4ea 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -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> = 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() 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 } } // ============================================================================ diff --git a/src/render/missiles-meta.ts b/src/render/missiles-meta.ts index 096e279..186b339 100644 --- a/src/render/missiles-meta.ts +++ b/src/render/missiles-meta.ts @@ -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> = 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, diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 5198303..f0de468 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 { diff --git a/tests/frozen-orb-missile-113c.test.ts b/tests/frozen-orb-missile-113c.test.ts new file mode 100644 index 0000000..fe3855e --- /dev/null +++ b/tests/frozen-orb-missile-113c.test.ts @@ -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 + }) +})