diablo2-web/src/game/engine/missile-engine.ts

780 lines
28 KiB
TypeScript

/**
* 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<string>
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<string, number>()
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<string>(),
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<string, { x: number; y: number }>): 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
}
}