780 lines
28 KiB
TypeScript
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
|
|
}
|
|
}
|