/** * Diablo II: Lord of Destruction v1.13c `UNIT_MISSILE` Runtime (`MissileEngine`). * * Implements: * - `Missiles.txt` trajectory & frame lifecycle (`Vel`, `MaxVel`, `Accel`, `Range + (slvl - 1) * LevRange`). * - `NextHit` & `NextDelay` per-target immunity window (`lastNextDelayTickByTargetId`) so * multi-projectile skills (`Multiple Shot`, `Teeth`, `Charged Bolt`, `Nova`, `Shock Web`, * `Tornado`, `Twister`, `Wake of Fire`) obey exact 1.13c `NextDelay` frames. * - `Pierce` (`Pierce` column + Amazon `Pierce` `skillId=33` chance, capped at 4 pierces / 5 hits). * - `pSrvDoFunc` (linear flight, spiral `Blessed Hammer`, homing `Guided Arrow`/`Bone Spirit`, * radial bolt emitter `Frozen Orb` `pSrvDoFunc=15`, ground fire trail `Blaze`/`Fire Wall`/`Molten Boulder`). * - `pSrvHitFunc` (`Fire Ball` radial splash `1`, `Holy Bolt` undead/ally heal `7`, * `Chain Lightning`/`Lightning Strike`/`royalstrikechainlightning` `*12` chain jump, * `Lightning Fury` `20` radial bolts, `Fist of the Heavens` `22` radial `holybolt`, * `Frozen Orb` `29` final ice bolt nova). */ import type { D2DataRegistry, MissileRecord } from './data-registry.ts' import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket } from './combat-pipeline.ts' import { getBlizzardDropOffset } from './blizzard-table.ts' import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts' import { calculateMeteorResidualFireDamage } from '../skills.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 readonly name: string readonly celFile: string readonly sourceSkillId: number readonly slvl: number readonly owner: CombatUnitContext readonly record: MissileRecord x: number y: number vx: number vy: number angleRad: number ageTicks: number readonly maxRangeTicks: number readonly nextHit: boolean readonly nextDelayFrames: number readonly canPierce: boolean readonly pierceChancePct: number pierceCount: number readonly hitTargetIds: Set readonly dmgPacket: SUnitDmgPacket chainRemaining: number expired: boolean subMissileIdx?: number currentHitFrame?: number isTurret?: boolean | undefined fireCadenceTicks?: number | undefined spawnMissileType?: string | undefined } export interface MissileStepResult { readonly hits: { readonly missileName: string readonly targetId: string readonly damage: number readonly immune: boolean }[] readonly subMissilesSpawned: string[] } export class MissileEngine { readonly registry: D2DataRegistry private readonly missiles: ActiveMissile[] = [] private readonly nextDelayExpireByTarget = new Map() private uidCounter = 1 constructor(registry: D2DataRegistry) { this.registry = registry } spawnMissile(params: { missileNameOrId: string | number sourceSkillId: number slvl: number owner: CombatUnitContext startX: number startY: number targetX: number targetY: number dmgPacket: SUnitDmgPacket pierceChancePct?: number chainCount?: number isTurret?: boolean fireCadenceTicks?: number spawnMissileType?: string maxRangeTicks?: number }): ActiveMissile | null { const rec = typeof params.missileNameOrId === 'number' ? this.registry.getMissileById(params.missileNameOrId) : this.registry.getMissileByName(params.missileNameOrId) const fallbackName = String(params.missileNameOrId).trim().toLowerCase() const effectiveRec: MissileRecord = rec ?? { id: 0, name: fallbackName || 'defaultmissile', pCltDoFunc: 1, pCltHitFunc: 0, pSrvDoFunc: 1, pSrvHitFunc: 0, pSrvDmgFunc: 0, srvCalc1: '', param1: 0, param2: 0, param3: 0, param4: 0, param5: 0, cltCalc1: '', cParam1: 0, cParam2: 0, cParam3: 0, cParam4: 0, cParam5: 0, sHitCalc1: '', sHitPar1: 0, sHitPar2: 0, sHitPar3: 0, cHitCalc1: '', cHitPar1: 0, cHitPar2: 0, cHitPar3: 0, dmgCalc1: '', dParam1: 0, dParam2: 0, vel: 16, maxVel: 24, velLev: 0, accel: 0, range: 40, levRange: 0, light: 0, flicker: 0, red: 255, green: 255, blue: 255, initSteps: 0, activate: 0, loopAnim: 0, celFile: fallbackName || 'fireball', animRate: 1024, animLen: 8, animSpeed: 16, subLoop: false, subStart: 0, subStop: 0, collideType: 3, collideKill: true, collideFriend: false, lastCollide: false, collision: true, clientCol: true, nextHit: false, nextDelay: 0, size: 2, returnFire: false, trans: 1, alwaysExplode: false, explosion: false, town: true, noUniqueMod: false, noMultiShot: false, holy: 0, canSlow: false, canDestroy: false, toHit: false, pierce: false, skill: '', hitShift: 8, srcDamage: 0, minDamage: 0, minLevDam1: 0, minLevDam2: 0, minLevDam3: 0, minLevDam4: 0, minLevDam5: 0, maxDamage: 0, maxLevDam1: 0, maxLevDam2: 0, maxLevDam3: 0, maxLevDam4: 0, maxLevDam5: 0, dmgSymPerCalc: '', eType: 'fire', eMin: 0, eMinLev1: 0, eMinLev2: 0, eMinLev3: 0, eMinLev4: 0, eMinLev5: 0, eMax: 0, eMaxLev1: 0, eMaxLev2: 0, eMaxLev3: 0, eMaxLev4: 0, eMaxLev5: 0, eDmgSymPerCalc: '', eLen: 0, eLevLen1: 0, eLevLen2: 0, eLevLen3: 0, hitClass: 0, numDirections: 16, localBlood: 0, damageRate: 0, explosionMissile: '', subMissile1: '', subMissile2: '', subMissile3: '', hitSubMissile1: '', hitSubMissile2: '', hitSubMissile3: '', hitSubMissile4: '', cltSubMissile1: '', cltSubMissile2: '', cltSubMissile3: '', cltHitSubMissile1: '', cltHitSubMissile2: '', cltHitSubMissile3: '', cltHitSubMissile4: '', raw: {}, } const dx = params.targetX - params.startX const dy = params.targetY - params.startY const angleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx) const isTurret = Boolean(params.isTurret || effectiveRec.name.toLowerCase() === 'hydra_turret') const isStationary = isTurret || effectiveRec.vel === 0 || effectiveRec.name.toLowerCase().startsWith('blizzard') || effectiveRec.name.toLowerCase().startsWith('meteor') const speedPxPerTick = isStationary ? 0 : Math.max(8, effectiveRec.vel || 18) const vx = isStationary ? 0 : Math.cos(angleRad) * speedPxPerTick const vy = isStationary ? 0 : Math.sin(angleRad) * speedPxPerTick const baseRange = effectiveRec.range !== undefined && effectiveRec.range > 0 ? effectiveRec.range : 35 const calculatedRange = baseRange + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0) const maxRangeTicks = params.maxRangeTicks ?? ( isTurret ? 250 // 1.13c Hydra duration: 250 frames (10 seconds) : calculatedRange ) const missile: ActiveMissile = { uid: `msl-${String(this.uidCounter++)}`, missileId: effectiveRec.id, name: effectiveRec.name, celFile: effectiveRec.celFile || effectiveRec.name, sourceSkillId: params.sourceSkillId, slvl: params.slvl, owner: params.owner, record: effectiveRec, x: params.startX, y: params.startY, vx, vy, angleRad, ageTicks: 0, maxRangeTicks, nextHit: effectiveRec.nextHit, nextDelayFrames: effectiveRec.nextDelay, canPierce: effectiveRec.pierce || (params.pierceChancePct ?? 0) > 0, pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce ? 100 : 0), pierceCount: 0, hitTargetIds: new Set(), dmgPacket: params.dmgPacket, chainRemaining: params.chainCount ?? (effectiveRec.pSrvHitFunc === 12 ? 5 + Math.trunc(params.slvl / 5) : 0), expired: false, isTurret, ...(params.fireCadenceTicks !== undefined ? { fireCadenceTicks: params.fireCadenceTicks } : isTurret ? { fireCadenceTicks: 25 } : {}), ...(params.spawnMissileType !== undefined ? { spawnMissileType: params.spawnMissileType } : isTurret ? { spawnMissileType: 'hydra' } : {}), } this.missiles.push(missile) return missile } /** * Check if a target unit is currently within its `NextDelay` immunity window. */ isTargetInNextDelay(targetId: string, currentTick: number): boolean { const exp = this.nextDelayExpireByTarget.get(targetId) return exp !== undefined && exp > currentTick } /** * Step all active missiles by 1 tick (`25 Hz`), resolving movement (`pSrvDoFunc`), * collisions (`pSrvHitFunc`), `NextDelay` gating, `Pierce`, and sub-missiles. */ tick(currentTick: number, targets: readonly CombatUnitContext[], targetPositions: ReadonlyMap): MissileStepResult { const hits: { missileName: string; targetId: string; damage: number; immune: boolean }[] = [] const subMissilesSpawned: string[] = [] for (const m of [...this.missiles]) { if (m.expired) continue m.ageTicks += 1 // `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 } } // `pSrvDoFunc = 10` (`blizzardcenter`): emits falling shards across 64-entry circle table // Authentic D2Game.0x6FC60CA0 (MISSMODE_SrvDo10_blizzardcenter): // - Trigger frequency: dwParam[2] (4 frames, Param3 in Skills.txt) // - Angular step: 13 (coprime with 64) // - Radius: 7 subtiles // - Foreshortened by ISO_GROUND_ASPECT_RATIO = 0.5 in 2:1 isometric ground perspective if (m.record.pSrvDoFunc === 10 || m.name === 'blizzardcenter') { const cadence = Math.max(1, m.record.param3 || 4) if (m.ageTicks % cadence === 0) { const idx = Math.abs((m.subMissileIdx ?? 0) % 64) const offset = getBlizzardDropOffset(idx, 7) const shardVariants = ['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4'] as const const shardName = shardVariants[Math.floor((m.ageTicks / cadence) - 1) % shardVariants.length]! this.spawnMissile({ missileNameOrId: shardName, sourceSkillId: m.sourceSkillId, slvl: m.slvl, owner: m.owner, startX: m.x + offset.dx, startY: m.y + offset.dy, targetX: m.x + offset.dx, targetY: m.y + offset.dy, dmgPacket: m.dmgPacket, }) subMissilesSpawned.push(shardName) m.subMissileIdx = (idx + 13) % 64 } } // Turret emitter (Hydra heads): stationary emitter periodically firing bolts at nearest target const isTurretEmitter = Boolean(m.isTurret || m.name.toLowerCase() === 'hydra_turret') if (isTurretEmitter) { const cadence = m.fireCadenceTicks ?? 25 if (m.ageTicks % cadence === 0) { let closestTargetId: string | null = null let closestDist = Infinity const maxRangePx = 480 // 1.13c Range = 30, Vel = 16 -> 480 px for (const target of targets) { if (target.statList.getHp256() <= 0) continue const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 } const dx = pos.x - m.x const dy = (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO const dist = Math.hypot(dx, dy) if (dist <= maxRangePx && dist < closestDist) { closestDist = dist closestTargetId = target.id } } if (closestTargetId) { const targetPos = targetPositions.get(closestTargetId) ?? { x: 0, y: 120 } const subName = m.spawnMissileType || 'hydra' this.spawnMissile({ missileNameOrId: subName, sourceSkillId: m.sourceSkillId, slvl: m.slvl, owner: m.owner, startX: m.x, startY: m.y, targetX: targetPos.x, targetY: targetPos.y, dmgPacket: m.dmgPacket, }) subMissilesSpawned.push(subName) } } } // Spiral flight for Blessed Hammer (`blessedhammer`, `skillId = 112`) // In 2:1 isometric ground perspective, vertical expansion is foreshortened by 0.5. if (isTurretEmitter) { // Stationary turret does not move } else if (m.sourceSkillId === 112 || m.name.toLowerCase() === 'blessedhammer') { m.angleRad += 0.24 const radius = 14 + m.ageTicks * 4 m.x += Math.cos(m.angleRad) * (radius * 0.25) m.y += Math.sin(m.angleRad) * (radius * 0.25 * 0.5) } else { m.x += m.vx m.y += m.vy } // Check collision against alive enemy targets if (m.name === 'meteorfire' && m.ageTicks % 5 === 0) { m.hitTargetIds.clear() } const hitRadius = Math.max(24, (m.record.size || 2) * 14) for (const target of targets) { if (target.statList.getHp256() <= 0) continue if (m.hitTargetIds.has(target.id)) continue // Holy Bolt (`skillId = 101` or `fistoftheheavensbolt` `234`): only damages Undead (or Demons if FoH main bolt) if ( (m.name.toLowerCase() === 'holybolt' || m.name.toLowerCase() === 'fistoftheheavensbolt') && !target.isUndead ) { continue } // Main Frozen Orb (pSrvDoFunc === 15), Blizzard Center (pSrvDoFunc === 10), Meteor Center (pSrvHitFunc === 14 / meteorcenter), // and Hydra turret emitters do not deal direct contact damage; all damage is delivered through emitted child bolts / shards. if ( m.record.pSrvDoFunc === 15 || m.record.pSrvDoFunc === 10 || m.name === 'blizzardcenter' || m.record.pSrvHitFunc === 14 || m.name === 'meteorcenter' || isTurretEmitter ) { continue } const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 } const dist = m.name === 'meteorfire' ? Math.hypot(pos.x - m.x, (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO) : Math.hypot(pos.x - m.x, pos.y - m.y) if (dist <= hitRadius) { // Enforce `NextDelay` immunity gate if `nextHit` / `nextDelayFrames > 0` if (m.nextDelayFrames > 0 && this.isTargetInNextDelay(target.id, currentTick)) { continue } m.hitTargetIds.add(target.id) if (m.nextDelayFrames > 0) { this.nextDelayExpireByTarget.set(target.id, currentTick + m.nextDelayFrames) } const dmgOut = executeSUnitDmg(m.owner, target, m.dmgPacket) hits.push({ missileName: m.name, targetId: target.id, damage: dmgOut.totalDamage, immune: dmgOut.immuneToElem || dmgOut.immuneToPhys, }) // `pSrvHitFunc = 1` or `4` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Glacial Spike`) if ( m.record.pSrvHitFunc === 1 || m.record.pSrvHitFunc === 4 || m.record.pSrvHitFunc === 9 || m.record.pSrvHitFunc === 13 ) { for (const other of targets) { if (other.id === target.id || other.statList.getHp256() <= 0) continue const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 } if (Math.hypot(oPos.x - pos.x, oPos.y - pos.y) <= 85) { const splashOut = executeSUnitDmg(m.owner, other, m.dmgPacket) hits.push({ missileName: `${m.name}:splash`, targetId: other.id, damage: splashOut.totalDamage, immune: splashOut.immuneToElem || splashOut.immuneToPhys, }) } } } // `pSrvHitFunc = 12` (`Chain Lightning` 53, `Lightning Strike` 34, `royalstrikechainlightning` 568): // Jump to next unhit target within range! if (m.record.pSrvHitFunc === 12 && m.chainRemaining > 0) { const nextTarget = targets.find(t => t.id !== target.id && t.statList.getHp256() > 0 && !m.hitTargetIds.has(t.id)) if (nextTarget) { const nPos = targetPositions.get(nextTarget.id) ?? { x: 0, y: 120 } this.spawnMissile({ missileNameOrId: m.name, sourceSkillId: m.sourceSkillId, slvl: m.slvl, owner: m.owner, startX: pos.x, startY: pos.y, targetX: nPos.x, targetY: nPos.y, dmgPacket: m.dmgPacket, chainCount: m.chainRemaining - 1, }) subMissilesSpawned.push(m.name) } } // `pSrvHitFunc = 20` (`Lightning Fury` 35): release radial `lightningjavelin` bolts toward nearby enemies if (m.record.pSrvHitFunc === 20 || m.sourceSkillId === 35) { const subBolt = m.record.hitSubMissile1 || 'lightningjavelin' for (const other of targets) { if (other.id === target.id || other.statList.getHp256() <= 0) continue const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 } this.spawnMissile({ missileNameOrId: subBolt, sourceSkillId: m.sourceSkillId, slvl: m.slvl, owner: m.owner, startX: pos.x, startY: pos.y, targetX: oPos.x, targetY: oPos.y, dmgPacket: m.dmgPacket, }) subMissilesSpawned.push(subBolt) } } // `pSrvHitFunc = 22` (`Fist of the Heavens` 121): release radial `fistoftheheavensbolt` (`holybolt`) if (m.record.pSrvHitFunc === 22 || m.sourceSkillId === 121) { const subBolt = m.record.hitSubMissile1 || 'fistoftheheavensbolt' for (const other of targets) { if (other.id === target.id || other.statList.getHp256() <= 0) continue const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 } this.spawnMissile({ missileNameOrId: subBolt, sourceSkillId: m.sourceSkillId, slvl: m.slvl, owner: m.owner, startX: pos.x, startY: pos.y, targetX: oPos.x, targetY: oPos.y, dmgPacket: m.dmgPacket, }) subMissilesSpawned.push(subBolt) } } // Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit) if (m.name === 'meteorfire') { // Ground fire persists for its full duration and does not die on hit } else if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) { m.pierceCount += 1 } else if (m.record.collideKill || !m.canPierce) { if ( m.name.startsWith('blizzard') && !m.name.includes('explode') && m.name !== 'blizzardcenter' ) { const expName = m.record.explosionMissile || 'blizzardexplode1' this.spawnMissile({ missileNameOrId: expName, 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(expName) } m.expired = true break } } } if (m.ageTicks >= m.maxRangeTicks) { // `pSrvHitFunc = 14` (`Meteor` detonation and ground fire patch cascade) // Authentic D2Game 0x6FC5DEF0 (MISSMODE_SrvHit14_MeteorCenter) & 0x6FC5CEC0: if ( (m.record.pSrvHitFunc === 14 || m.name === 'meteorcenter') && (!m.currentHitFrame || m.currentHitFrame === 0) ) { m.currentHitFrame = 1 // 1. Primary explosion AOE damage (Radius = 6 subtiles = 120px) in 2:1 isometric ground perspective const blastRadius = 120 for (const target of targets) { if (target.statList.getHp256() <= 0) continue const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 } const inRange = Math.hypot(pos.x - m.x, (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO) <= blastRadius if (inRange) { const dmgOut = executeSUnitDmg(m.owner, target, m.dmgPacket) hits.push({ missileName: 'meteorexplode', targetId: target.id, damage: dmgOut.totalDamage, immune: dmgOut.immuneToElem || dmgOut.immuneToPhys, }) } } // 2. Spawn explosion missile: meteorexplode (ID 103) const expName = m.record.explosionMissile || 'meteorexplode' this.spawnMissile({ missileNameOrId: expName, 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(expName) // 3. Spawn 18 meteorfire submissiles using 18-offset table const subFireName = m.record.hitSubMissile1 || 'meteorfire' const fireDuration = 30 + (Math.max(1, m.slvl) - 1) * 15 const fireDmg = calculateMeteorResidualFireDamage(m.slvl) const fireDmgPacket: SUnitDmgPacket = { skillId: m.sourceSkillId, attackKind: 'spell', elemType: 'fire', elemMin256: fireDmg.min * 8, // HitShift 3: << 3 = * 8 elemMax256: fireDmg.max * 8, } for (const off of METEOR_FIRE_SUBTILE_OFFSETS) { const fx = m.x + off.x * 20 const fy = m.y + off.y * 10 this.spawnMissile({ missileNameOrId: subFireName, sourceSkillId: m.sourceSkillId, slvl: m.slvl, owner: m.owner, startX: fx, startY: fy, targetX: fx, targetY: fy, dmgPacket: fireDmgPacket, maxRangeTicks: fireDuration, }) subMissilesSpawned.push(subFireName) } } // Blizzard falling shard ground impact explosion (Missiles 159..162) if ( m.name.startsWith('blizzard') && !m.name.includes('explode') && m.name !== 'blizzardcenter' && (!m.currentHitFrame || m.currentHitFrame === 0) ) { m.currentHitFrame = 1 const expName = m.record.explosionMissile || 'blizzardexplode1' this.spawnMissile({ missileNameOrId: expName, 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(expName) } // `pSrvHitFunc = 29` (`Frozen Orb` expiration burst) // 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, 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 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 } } // Purge expired missiles for (let i = this.missiles.length - 1; i >= 0; i -= 1) { if (this.missiles[i]!.expired) { this.missiles.splice(i, 1) } } return { hits, subMissilesSpawned } } getActiveMissiles(): readonly ActiveMissile[] { return this.missiles } }