3632 lines
124 KiB
TypeScript
3632 lines
124 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'
|
|
import { compute5BandScaling } from './calc-ast.ts'
|
|
|
|
export const ISO_GROUND_ASPECT_RATIO = 0.5
|
|
|
|
export interface DamageRange {
|
|
readonly min: number
|
|
readonly max: number
|
|
}
|
|
|
|
export interface PoisonDamageSpec {
|
|
readonly minDamage: number
|
|
readonly maxDamage: number
|
|
readonly durationFrames: number
|
|
readonly durationSeconds: number
|
|
readonly minRate: number
|
|
readonly maxRate: number
|
|
}
|
|
|
|
export type SynergyState = Record<string, number | undefined>
|
|
|
|
export interface StaticFieldResult {
|
|
readonly damage: number
|
|
readonly newHp: number
|
|
readonly immune: boolean
|
|
readonly blockedByFloor: boolean
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Static Field damage calculation with pre-check invariant and difficulty floors.
|
|
* Source: D2Game.dll VA 0x6FC62F13 (SKILLS_AuraCallback_StaticField) & DifficultyLevels.txt
|
|
*/
|
|
export function calculateStaticFieldDamage(
|
|
currentHp: number,
|
|
maxHp: number,
|
|
difficulty: 'normal' | 'nightmare' | 'hell' = 'normal',
|
|
lightningRes = 0,
|
|
): StaticFieldResult {
|
|
const minPct = difficulty === 'hell' ? 50 : difficulty === 'nightmare' ? 33 : 0
|
|
const hpFloor = difficulty === 'normal' ? 1 : Math.max(1, Math.floor((maxHp * minPct) / 100))
|
|
|
|
// 1.13c Assembly Invariant (SKILLS_AuraCallback_StaticField at 0x6FC62F13):
|
|
// If current HP is already at or below the difficulty floor, return 0 damage immediately!
|
|
if (currentHp <= hpFloor) {
|
|
return { damage: 0, newHp: currentHp, immune: false, blockedByFloor: true }
|
|
}
|
|
|
|
if (lightningRes >= 100) {
|
|
return { damage: 0, newHp: currentHp, immune: true, blockedByFloor: false }
|
|
}
|
|
|
|
let damage = Math.floor(currentHp * 0.25)
|
|
|
|
// Support negative resistance scaling (Lower Resist, Conviction, -% enemy lightning resistance).
|
|
// Clamped to [-100, 99]% per Diablo II resistance lower bound.
|
|
const clampedRes = Math.max(-100, Math.min(99, lightningRes))
|
|
damage = Math.floor((damage * (100 - clampedRes)) / 100)
|
|
|
|
// CRITICAL SAFETY INVARIANT: Non-lethal damage clamping MUST occur AFTER resistance scaling
|
|
// so Static Field NEVER deals lethal damage (newHp >= 1, damage <= currentHp - 1)
|
|
damage = Math.min(damage, Math.max(0, currentHp - 1))
|
|
|
|
return { damage, newHp: currentHp - damage, immune: false, blockedByFloor: false }
|
|
}
|
|
|
|
export function calculateStaticFieldRadiusYards(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return 4 + lvl
|
|
}
|
|
|
|
export function calculateStaticFieldRadiusPx(slvl: number): number {
|
|
return calculateStaticFieldRadiusYards(slvl) * 40
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Nova 5-band damage scaling (Skills.txt row 48):
|
|
* EMin: 1, EMinLev1..5: 6, 7, 8, 9, 10
|
|
* EMax: 20, EMaxLev1..5: 8, 9, 10, 11, 12
|
|
* 0 synergies in 1.13c.
|
|
*/
|
|
export function calculateNovaDamage(
|
|
slvl: number,
|
|
synergies?: {
|
|
readonly lightningMastery?: number | undefined
|
|
} | number,
|
|
lightningMastery?: number,
|
|
): { min: number; max: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let min = 1
|
|
let max = 20
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
min += b1 * 6
|
|
max += b1 * 8
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
min += b2 * 7
|
|
max += b2 * 9
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
min += b3 * 8
|
|
max += b3 * 10
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
min += b4 * 9
|
|
max += b4 * 11
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * 10
|
|
max += b5 * 12
|
|
}
|
|
}
|
|
|
|
const masteryLvl =
|
|
typeof lightningMastery === 'number'
|
|
? lightningMastery
|
|
: typeof synergies === 'object' && synergies !== null
|
|
? synergies.lightningMastery ?? 0
|
|
: 0
|
|
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
|
|
const synMult = typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
|
|
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
|
|
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
|
|
return { min, max }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Lightning (Skill 49) synergy multiplier:
|
|
* +8% lightning damage per hard point in Charged Bolt (38), Nova (48), and Chain Lightning (53).
|
|
*/
|
|
export function calculateLightningSynergyMultiplier(synergies: {
|
|
readonly chargedBolt?: number | undefined
|
|
readonly chargedBoltPoints?: number | undefined
|
|
readonly nova?: number | undefined
|
|
readonly novaPoints?: number | undefined
|
|
readonly chainLightning?: number | undefined
|
|
readonly chainLightningPoints?: number | undefined
|
|
}): number {
|
|
const points =
|
|
(synergies.chargedBolt ?? synergies.chargedBoltPoints ?? 0) +
|
|
(synergies.nova ?? synergies.novaPoints ?? 0) +
|
|
(synergies.chainLightning ?? synergies.chainLightningPoints ?? 0)
|
|
return 1.0 + points * 0.08
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Lightning (Skill 49) 5-band damage scaling (Skills.txt row 51):
|
|
* EMin: 1, EMinLev1..5: 0 (Min damage is always 1)
|
|
* EMax: 40, EMaxLev1..5: 8, 12, 20, 28, 36
|
|
* Synergy: +8% per hard point in Charged Bolt, Nova, Chain Lightning.
|
|
*/
|
|
export function calculateLightningDamage(
|
|
slvl: number,
|
|
synergies?: {
|
|
readonly chargedBolt?: number | undefined
|
|
readonly chargedBoltPoints?: number | undefined
|
|
readonly nova?: number | undefined
|
|
readonly novaPoints?: number | undefined
|
|
readonly chainLightning?: number | undefined
|
|
readonly chainLightningPoints?: number | undefined
|
|
readonly lightningMastery?: number | undefined
|
|
} | number,
|
|
lightningMastery?: number,
|
|
): { min: number; max: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let min = 1
|
|
let max = 40
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
max += b1 * 8
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
max += b2 * 12
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
max += b3 * 20
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
max += b4 * 28
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
max += b5 * 36
|
|
}
|
|
}
|
|
|
|
const synMult =
|
|
typeof synergies === 'number'
|
|
? (Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0)
|
|
: synergies
|
|
? calculateLightningSynergyMultiplier(synergies)
|
|
: 1.0
|
|
|
|
const masteryLvl =
|
|
typeof lightningMastery === 'number'
|
|
? lightningMastery
|
|
: typeof synergies === 'object' && synergies !== null
|
|
? synergies.lightningMastery ?? 0
|
|
: 0
|
|
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
|
|
|
|
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
|
|
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
|
|
return { min, max }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Chain Lightning (Skill 53) synergy multiplier:
|
|
* +4% lightning damage per hard point in Charged Bolt (38), Nova (48), and Lightning (49).
|
|
*/
|
|
export function calculateChainLightningSynergyMultiplier(synergies: {
|
|
readonly chargedBolt?: number | undefined
|
|
readonly chargedBoltPoints?: number | undefined
|
|
readonly nova?: number | undefined
|
|
readonly novaPoints?: number | undefined
|
|
readonly lightning?: number | undefined
|
|
readonly lightningPoints?: number | undefined
|
|
}): number {
|
|
const points =
|
|
(synergies.chargedBolt ?? synergies.chargedBoltPoints ?? 0) +
|
|
(synergies.nova ?? synergies.novaPoints ?? 0) +
|
|
(synergies.lightning ?? synergies.lightningPoints ?? 0)
|
|
return 1.0 + points * 0.04
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Chain Lightning (Skill 53) jumps calculation:
|
|
* 1.13c Skills.txt: Param5 = 5 (base jumps), calc1 = ln34 / 5 -> 5 + Math.floor(slvl / 5).
|
|
*/
|
|
export function calculateChainLightningJumps(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return 5 + Math.floor(lvl / 5)
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Chain Lightning (Skill 53) 5-band damage scaling (Skills.txt row 55):
|
|
* EMin: 1, EMinLev1..5: 0 (Min damage is always 1)
|
|
* EMax: 40, EMaxLev1..5: 11, 13, 15, 15, 15
|
|
* Synergy: +4% per hard point in Charged Bolt, Nova, Lightning.
|
|
*/
|
|
export function calculateChainLightningDamage(
|
|
slvl: number,
|
|
synergies?: {
|
|
readonly chargedBolt?: number | undefined
|
|
readonly chargedBoltPoints?: number | undefined
|
|
readonly nova?: number | undefined
|
|
readonly novaPoints?: number | undefined
|
|
readonly lightning?: number | undefined
|
|
readonly lightningPoints?: number | undefined
|
|
readonly lightningMastery?: number | undefined
|
|
} | number,
|
|
lightningMastery?: number,
|
|
): { min: number; max: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let min = 1
|
|
let max = 40
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
max += b1 * 11
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
max += b2 * 13
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
max += b3 * 15
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
max += b4 * 15
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
max += b5 * 15
|
|
}
|
|
}
|
|
|
|
const synMult =
|
|
typeof synergies === 'number'
|
|
? (Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0)
|
|
: synergies
|
|
? calculateChainLightningSynergyMultiplier(synergies)
|
|
: 1.0
|
|
|
|
const masteryLvl =
|
|
typeof lightningMastery === 'number'
|
|
? lightningMastery
|
|
: typeof synergies === 'object' && synergies !== null
|
|
? synergies.lightningMastery ?? 0
|
|
: 0
|
|
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
|
|
|
|
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
|
|
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
|
|
return { min, max }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Charged Bolt (Skill 38) 32-entry discrete orthogonal jitter offset lookup table.
|
|
* Source: D2Common.dll VA 0x6FD85470 (PATH_ComputePathChargedBolt)
|
|
*/
|
|
export const GAT_CHARGED_BOLT_OFFSETS: readonly number[] = Object.freeze([
|
|
-1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1,
|
|
0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 1,
|
|
])
|
|
|
|
/**
|
|
* Authentic 1.13c Charged Bolt (Skill 38) bolt count:
|
|
* min(24, slvl + 2)
|
|
*/
|
|
export function calculateChargedBoltCount(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return Math.min(24, lvl + 2)
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Charged Bolt (Skill 38) synergy multiplier:
|
|
* +6% lightning damage per hard point in Lightning (49).
|
|
*/
|
|
export function calculateChargedBoltSynergyMultiplier(synergies: {
|
|
readonly lightning?: number | undefined
|
|
readonly lightningPoints?: number | undefined
|
|
} | number): number {
|
|
if (typeof synergies === 'number') {
|
|
return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
}
|
|
const points = synergies?.lightning ?? synergies?.lightningPoints ?? 0
|
|
return 1.0 + points * 0.06
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Charged Bolt (Skill 38) 5-band damage scaling (Skills.txt row 40):
|
|
* Base: 2-4, lvl 2-8: +1/+1, lvl 9-16: +1/+1, lvl 17-22: +2/+2, lvl 23-28: +3/+3, lvl 29+: +4/+4.
|
|
* Synergy: +6% per hard point in Lightning (49).
|
|
*/
|
|
export function calculateChargedBoltDamage(
|
|
slvl: number,
|
|
synergies?: {
|
|
readonly lightning?: number | undefined
|
|
readonly lightningPoints?: number | undefined
|
|
readonly lightningMastery?: number | undefined
|
|
} | number,
|
|
lightningMastery?: number,
|
|
): { min: number; max: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let min = 2
|
|
let max = 4
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
min += b1 * 1
|
|
max += b1 * 1
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
min += b2 * 1
|
|
max += b2 * 1
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
min += b3 * 2
|
|
max += b3 * 2
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
min += b4 * 3
|
|
max += b4 * 3
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * 4
|
|
max += b5 * 4
|
|
}
|
|
}
|
|
|
|
const synMult =
|
|
typeof synergies === 'number'
|
|
? (Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0)
|
|
: synergies
|
|
? calculateChargedBoltSynergyMultiplier(synergies)
|
|
: 1.0
|
|
|
|
const masteryLvl =
|
|
typeof lightningMastery === 'number'
|
|
? lightningMastery
|
|
: typeof synergies === 'object' && synergies !== null
|
|
? synergies.lightningMastery ?? 0
|
|
: 0
|
|
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
|
|
|
|
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
|
|
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
|
|
return { min, max }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Telekinesis (Skill 43) hard synergy to Energy Shield (Skill 58):
|
|
* Reduces Energy Shield damage-to-mana consumption ratio by 1/16 (0.0625) per hard point:
|
|
* ratio = (32 - Math.min(20, TK_blvl)) / 16.
|
|
* Level 0 -> 2.00, Level 8 -> 1.50, Level 16 -> 1.00, Level 20 -> 0.75.
|
|
*/
|
|
export function calculateEnergyShieldManaRatio(telekinesisBaseLevel: number): number {
|
|
const blvl = Number.isFinite(telekinesisBaseLevel) ? Math.max(0, Math.floor(telekinesisBaseLevel)) : 0
|
|
return (32 - Math.min(20, blvl)) / 16
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Telekinesis (Skill 43) combat damage (Skills.txt row 45):
|
|
* Deals 1-2 lightning damage base (slvl to slvl + 1).
|
|
*/
|
|
export function calculateTelekinesisDamage(slvl: number): { min: number; max: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return { min: lvl, max: lvl + 1 }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Telekinesis (Skill 43) remote item pickup whitelist:
|
|
* Allowed: Potions, scrolls, gold, ammo (arrows/bolts), keys.
|
|
* Forbidden: Equipment (weapons, armor, helms, shields, boots, gloves, belts, rings, amulets).
|
|
*/
|
|
export function isTelekinesisItemAllowed(item: any): boolean {
|
|
if (!item) return false
|
|
if (item.isGold === true) return true
|
|
const code = (item.code || item.item?.code || item.base?.code || item.id || item.item?.id || '').toLowerCase().trim()
|
|
const name = (item.name || item.item?.name || item.nameZh || item.item?.nameZh || '').toLowerCase()
|
|
const type = (item.type || item.item?.type || item.base?.type || item.category || '').toLowerCase()
|
|
|
|
if (code === 'gld' || code === 'gold' || item.isGold) return true
|
|
if (/^(hp[1-5]|mp[1-5]|rvs|rvl|vps|yps|wms|ant|thw|sta)$/.test(code)) return true
|
|
if (name.includes('potion') || name.includes('药剂') || name.includes('药水') || name.includes('活力') || name.includes('解毒') || name.includes('解冻')) return true
|
|
if (/^(tsc|isc|tbk|ibk)$/.test(code)) return true
|
|
if (name.includes('scroll') || name.includes('卷轴') || name.includes('tome') || name.includes('书')) return true
|
|
if (/^(aqv|cqv)$/.test(code)) return true
|
|
if (name.includes('arrow') || name.includes('bolt') || name.includes('箭矢') || name.includes('十字弓弹')) return true
|
|
if (code === 'key' || name.includes('key') || name.includes('钥匙')) return true
|
|
if (
|
|
type === 'potion' ||
|
|
type.includes('potion') ||
|
|
type === 'scroll' ||
|
|
type.includes('scroll') ||
|
|
type === 'gold' ||
|
|
type === 'ammo' ||
|
|
type.includes('ammo') ||
|
|
type.includes('arrow') ||
|
|
type.includes('bolt') ||
|
|
type === 'key'
|
|
) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Telekinesis (Skill 43) remote interactive object whitelist:
|
|
* Supports: chest, locked chest, waypoint, portal, shrine, door, well, urn, sarcophagus, casket, stash, barrel.
|
|
*/
|
|
export function isTelekinesisObjectAllowed(objectTypeOrName: string): boolean {
|
|
if (!objectTypeOrName) return false
|
|
const norm = objectTypeOrName.toLowerCase().trim()
|
|
return (
|
|
norm.includes('chest') ||
|
|
norm.includes('waypoint') ||
|
|
norm.includes('portal') ||
|
|
norm.includes('shrine') ||
|
|
norm.includes('door') ||
|
|
norm.includes('well') ||
|
|
norm.includes('urn') ||
|
|
norm.includes('casket') ||
|
|
norm.includes('stash') ||
|
|
norm.includes('barrel') ||
|
|
norm.includes('sarcophagus') ||
|
|
norm.includes('tomb')
|
|
)
|
|
}
|
|
|
|
|
|
export function hasLineOfSight(
|
|
x0: number,
|
|
y0: number,
|
|
x1: number,
|
|
y1: number,
|
|
terrain?: MissileTerrainCollision | ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | undefined,
|
|
): boolean {
|
|
if (!terrain) return true
|
|
const dist = Math.hypot(x1 - x0, y1 - y0)
|
|
if (dist === 0) return true
|
|
const stepSize = 8
|
|
const steps = Math.max(1, Math.ceil(dist / stepSize))
|
|
let prevX = x0
|
|
let prevY = y0
|
|
for (let i = 1; i <= steps; i++) {
|
|
const t = Math.min(1, i / steps)
|
|
const curX = x0 + (x1 - x0) * t
|
|
const curY = y0 + (y1 - y0) * t
|
|
let blocked = false
|
|
if (typeof terrain === 'function') {
|
|
blocked = Boolean(terrain(curX, curY, prevX, prevY))
|
|
} else if (terrain.isMissileBlocked !== undefined) {
|
|
blocked = terrain.isMissileBlocked(curX, curY, prevX, prevY)
|
|
} else if (terrain.overlap !== undefined) {
|
|
blocked = terrain.overlap(curX, curY) > 0
|
|
}
|
|
if (blocked) {
|
|
return false
|
|
}
|
|
prevX = curX
|
|
prevY = curY
|
|
}
|
|
return true
|
|
}
|
|
|
|
|
|
/**
|
|
* 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
|
|
lastHitTargetId?: string | undefined
|
|
expired: boolean
|
|
subMissileIdx?: number
|
|
currentHitFrame?: number
|
|
isTurret?: boolean | undefined
|
|
fireCadenceTicks?: number | undefined
|
|
spawnMissileType?: string | undefined
|
|
altitude?: number | undefined
|
|
seed?: number | undefined
|
|
jitterIndex?: number | undefined
|
|
}
|
|
|
|
export interface MissileTerrainCollision {
|
|
readonly overlap?: ((x: number, y: number) => number) | undefined
|
|
readonly isMissileBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | 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
|
|
terrain?: MissileTerrainCollision | undefined
|
|
|
|
constructor(registry: D2DataRegistry, terrain?: MissileTerrainCollision) {
|
|
this.registry = registry
|
|
this.terrain = terrain
|
|
}
|
|
|
|
setTerrain(terrain?: MissileTerrainCollision): void {
|
|
this.terrain = terrain
|
|
}
|
|
|
|
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
|
|
chainRemaining?: number
|
|
lastHitTargetId?: string
|
|
isTurret?: boolean
|
|
fireCadenceTicks?: number
|
|
spawnMissileType?: string
|
|
maxRangeTicks?: number
|
|
altitude?: number
|
|
seed?: number
|
|
jitterIndex?: number
|
|
vx?: number
|
|
vy?: number
|
|
angleRad?: 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 calculatedAngleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx)
|
|
const angleRad = params.angleRad ?? calculatedAngleRad
|
|
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 = params.vx !== undefined ? params.vx : (isStationary ? 0 : Math.cos(angleRad) * speedPxPerTick)
|
|
const vy = params.vy !== undefined ? params.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 || !effectiveRec.collideKill || (params.pierceChancePct ?? 0) > 0 || params.sourceSkillId === 84 || effectiveRec.name === 'bonespear',
|
|
pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce || !effectiveRec.collideKill || params.sourceSkillId === 84 || effectiveRec.name === 'bonespear' ? 100 : 0),
|
|
pierceCount: 0,
|
|
hitTargetIds: new Set<string>(),
|
|
dmgPacket: params.dmgPacket,
|
|
chainRemaining: params.chainRemaining ?? params.chainCount ?? (effectiveRec.pSrvHitFunc === 12 ? 5 + Math.trunc(params.slvl / 5) : 0),
|
|
lastHitTargetId: params.lastHitTargetId,
|
|
expired: false,
|
|
isTurret,
|
|
...(params.altitude !== undefined ? { altitude: params.altitude } : {}),
|
|
...(params.fireCadenceTicks !== undefined
|
|
? { fireCadenceTicks: params.fireCadenceTicks }
|
|
: isTurret
|
|
? { fireCadenceTicks: 25 }
|
|
: {}),
|
|
...(params.spawnMissileType !== undefined
|
|
? { spawnMissileType: params.spawnMissileType }
|
|
: isTurret
|
|
? { spawnMissileType: 'hydra' }
|
|
: {}),
|
|
...(params.seed !== undefined ? { seed: params.seed } : {}),
|
|
...(params.jitterIndex !== undefined
|
|
? { jitterIndex: params.jitterIndex }
|
|
: params.seed !== undefined
|
|
? { jitterIndex: Math.abs(params.seed) % 32 }
|
|
: {}),
|
|
}
|
|
|
|
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 = 0,
|
|
targets: readonly CombatUnitContext[] = [],
|
|
targetPositions: ReadonlyMap<string, { x: number; y: number }> = new Map(),
|
|
collision?: MissileTerrainCollision,
|
|
): 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
|
|
|
|
if (m.name === 'meteor' || m.name === 'meteortail') {
|
|
if (m.altitude === undefined) m.altitude = 60 * 25
|
|
m.altitude = Math.max(0, m.altitude - 25)
|
|
}
|
|
|
|
// `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
|
|
}
|
|
}
|
|
|
|
// `pSrvDoFunc = 6` (`firewallmaker`): drops a `firewall` submissile every step
|
|
if (m.record.pSrvDoFunc === 6 || m.name === 'firewallmaker') {
|
|
const subName = m.record.subMissile1 || 'firewall'
|
|
this.spawnMissile({
|
|
missileNameOrId: subName,
|
|
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(subName)
|
|
}
|
|
|
|
// 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 {
|
|
// Authentic 1.13c Homing mechanics (Bone Spirit / Guided Arrow, pSrvDoFunc = 7):
|
|
// Continuously tracks and steers toward closest active hostile target in 2:1 isometric perspective
|
|
if (m.sourceSkillId === 93 || m.name.toLowerCase() === 'bonespirit' || m.record.pSrvDoFunc === 7) {
|
|
let closestTarget: { x: number; y: number } | null = null
|
|
let closestDist = Infinity
|
|
for (const target of targets) {
|
|
if (target.statList.getHp256() <= 0) continue
|
|
const pos = targetPositions.get(target.id) ?? {
|
|
x: target.statList.getAccruedStat('x') || 0,
|
|
y: target.statList.getAccruedStat('y') || 0,
|
|
}
|
|
const dx = pos.x - m.x
|
|
const dy = (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO
|
|
const dist = Math.hypot(dx, dy)
|
|
if (dist < closestDist) {
|
|
closestDist = dist
|
|
closestTarget = pos
|
|
}
|
|
}
|
|
if (closestTarget) {
|
|
const speed = Math.hypot(m.vx, m.vy / ISO_GROUND_ASPECT_RATIO) || (m.record.vel ? m.record.vel : 12)
|
|
const targetAngle = Math.atan2((closestTarget.y - m.y) / ISO_GROUND_ASPECT_RATIO, closestTarget.x - m.x)
|
|
m.angleRad = targetAngle
|
|
m.vx = Math.cos(targetAngle) * speed
|
|
m.vy = Math.sin(targetAngle) * speed * ISO_GROUND_ASPECT_RATIO
|
|
}
|
|
}
|
|
|
|
const prevX = m.x
|
|
const prevY = m.y
|
|
m.x += m.vx
|
|
m.y += m.vy
|
|
|
|
// Authentic 1.13c Charged Bolt (Skill 38 / missile 56 / pDoFunc 11):
|
|
// Discrete orthogonal jitter table: 32-entry lookup table gatChargedBoltOffsets
|
|
// applied perpendicularly to main propagation vector per step (2 subtiles/step) with individual low-seed PRNG.
|
|
if (m.name === 'chargedbolt' || m.sourceSkillId === 38 || m.record.pSrvDoFunc === 11 || m.record.pCltDoFunc === 11) {
|
|
const speed = Math.hypot(m.vx, m.vy)
|
|
if (speed > 0) {
|
|
const perpX = -m.vy / speed
|
|
const perpY = m.vx / speed
|
|
const idx = Math.abs((m.jitterIndex ?? m.ageTicks) % 32)
|
|
const offset = GAT_CHARGED_BOLT_OFFSETS[idx]! * 3
|
|
m.x += perpX * offset
|
|
m.y += perpY * (offset * ISO_GROUND_ASPECT_RATIO)
|
|
m.jitterIndex = (idx + 1) % 32
|
|
}
|
|
}
|
|
|
|
const effectiveTerrain = collision ?? this.terrain
|
|
if (effectiveTerrain !== undefined && !isTurretEmitter && (m.altitude === undefined || m.altitude <= 0)) {
|
|
// Continuous collision stepping between (prevX, prevY) and (m.x, m.y) to prevent tunneling through thin (<24px) walls
|
|
const moveDist = Math.hypot(m.vx, m.vy)
|
|
const stepSize = 8
|
|
const steps = Math.max(1, Math.ceil(moveDist / stepSize))
|
|
let blocked = false
|
|
let checkPrevX = prevX
|
|
let checkPrevY = prevY
|
|
let hitWallX = m.x
|
|
let hitWallY = m.y
|
|
for (let s = 1; s <= steps; s++) {
|
|
const t = s / steps
|
|
const curX = prevX + (m.x - prevX) * t
|
|
const curY = prevY + (m.y - prevY) * t
|
|
if (effectiveTerrain.isMissileBlocked !== undefined) {
|
|
if (effectiveTerrain.isMissileBlocked(curX, curY, checkPrevX, checkPrevY)) {
|
|
blocked = true
|
|
hitWallX = curX
|
|
hitWallY = curY
|
|
break
|
|
}
|
|
} else if (effectiveTerrain.overlap !== undefined && effectiveTerrain.overlap(curX, curY) > 0) {
|
|
blocked = true
|
|
hitWallX = curX
|
|
hitWallY = curY
|
|
break
|
|
}
|
|
checkPrevX = curX
|
|
checkPrevY = curY
|
|
}
|
|
if (blocked) {
|
|
// Glacial Spike (pSrvHitFunc === 13) explodes at wall collision coordinates (64px AoE burst)
|
|
if (m.record.pSrvHitFunc === 13 || m.name === 'glacialspike' || m.sourceSkillId === 55) {
|
|
const splashRadius = 64
|
|
for (const other of targets) {
|
|
if (other.statList.getHp256() <= 0) continue
|
|
const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 }
|
|
if (Math.hypot(oPos.x - hitWallX, oPos.y - hitWallY) <= splashRadius) {
|
|
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,
|
|
})
|
|
if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) {
|
|
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateGlacialSpikeFreezeLength(m.slvl)
|
|
other.stateBus.applyState({
|
|
stateNameOrId: 'freeze',
|
|
sourceSkillId: 55,
|
|
slvl: m.slvl,
|
|
durationFrames: frzFrames,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
const expName = m.record.explosionMissile || 'freezeexplode'
|
|
this.spawnMissile({
|
|
missileNameOrId: expName,
|
|
sourceSkillId: m.sourceSkillId,
|
|
slvl: m.slvl,
|
|
owner: m.owner,
|
|
startX: hitWallX,
|
|
startY: hitWallY,
|
|
targetX: hitWallX,
|
|
targetY: hitWallY,
|
|
dmgPacket: m.dmgPacket,
|
|
})
|
|
subMissilesSpawned.push(expName)
|
|
subMissilesSpawned.push('glacialspikeejecta')
|
|
} else if (m.name === 'iceblast' || m.sourceSkillId === 45) {
|
|
const expName = m.record.explosionMissile || 'freezeexplode'
|
|
this.spawnMissile({
|
|
missileNameOrId: expName,
|
|
sourceSkillId: m.sourceSkillId,
|
|
slvl: m.slvl,
|
|
owner: m.owner,
|
|
startX: hitWallX,
|
|
startY: hitWallY,
|
|
targetX: hitWallX,
|
|
targetY: hitWallY,
|
|
dmgPacket: m.dmgPacket,
|
|
})
|
|
subMissilesSpawned.push(expName)
|
|
}
|
|
m.expired = true
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check collision against alive enemy targets
|
|
if (
|
|
(m.name === 'meteorfire' && m.ageTicks % 5 === 0) ||
|
|
m.name === 'firewall' ||
|
|
m.name === 'blaze' ||
|
|
m.name === 'groundfirebig' ||
|
|
m.name === 'infernoflame1' ||
|
|
m.name === 'infernoflame2'
|
|
) {
|
|
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
|
|
|
|
// Blaze self-hit immunity: caster does not take damage from their own footstep fire
|
|
if (m.name === 'blaze' && m.owner && target.id === m.owner.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),
|
|
// firewallmaker emitter (pSrvDoFunc === 6), and Hydra turret emitters do not deal direct contact damage;
|
|
// all damage is delivered through emitted child bolts / patches.
|
|
if (
|
|
m.record.pSrvDoFunc === 15 ||
|
|
m.record.pSrvDoFunc === 10 ||
|
|
m.name === 'blizzardcenter' ||
|
|
m.record.pSrvHitFunc === 14 ||
|
|
m.name === 'meteorcenter' ||
|
|
m.name === 'meteor' ||
|
|
m.name === 'meteortail' ||
|
|
m.name === 'firewallmaker' ||
|
|
m.record.pSrvDoFunc === 6 ||
|
|
isTurretEmitter
|
|
) {
|
|
continue
|
|
}
|
|
|
|
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
|
|
const isGroundPatch =
|
|
m.name === 'meteorfire' ||
|
|
m.name === 'firewall' ||
|
|
m.name === 'blaze' ||
|
|
m.name === 'groundfirebig'
|
|
const dist = isGroundPatch
|
|
? 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,
|
|
})
|
|
|
|
// Solid freeze on hit:
|
|
// Ice Blast (pSrvDmgFunc = 4 or skillId 45): converts cold chill into solid freeze
|
|
if (m.name === 'iceblast' || m.sourceSkillId === 45 || m.record.pSrvDmgFunc === 4) {
|
|
if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) {
|
|
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateIceBlastFreezeLength(m.slvl)
|
|
target.stateBus.applyState({
|
|
stateNameOrId: 'freeze',
|
|
sourceSkillId: 45,
|
|
slvl: m.slvl,
|
|
durationFrames: frzFrames,
|
|
})
|
|
}
|
|
}
|
|
// Glacial Spike (skillId 55 or pSrvHitFunc = 13): freezes primary target solid
|
|
if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) {
|
|
if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) {
|
|
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateGlacialSpikeFreezeLength(m.slvl)
|
|
target.stateBus.applyState({
|
|
stateNameOrId: 'freeze',
|
|
sourceSkillId: 55,
|
|
slvl: m.slvl,
|
|
durationFrames: frzFrames,
|
|
})
|
|
}
|
|
}
|
|
|
|
// `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
|
|
) {
|
|
const splashRadius = (m.record.pSrvHitFunc === 13 || m.name === 'glacialspike' || m.sourceSkillId === 55) ? 64 : 85
|
|
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) <= splashRadius) {
|
|
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,
|
|
})
|
|
// Glacial Spike splash targets also receive solid freeze
|
|
if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) {
|
|
if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) {
|
|
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateGlacialSpikeFreezeLength(m.slvl)
|
|
other.stateBus.applyState({
|
|
stateNameOrId: 'freeze',
|
|
sourceSkillId: 55,
|
|
slvl: m.slvl,
|
|
durationFrames: frzFrames,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// `pSrvHitFunc = 12` (`Chain Lightning` 53, `Lightning Strike` 34, `royalstrikechainlightning` 568):
|
|
// Authentic 1.13c ping-pong chaining: excludes the target just hit (target.id),
|
|
// searches up to 20 subtiles (10 yards = 400 px), and leaps to the closest valid alive enemy.
|
|
if (m.record.pSrvHitFunc === 12 && m.chainRemaining > 0) {
|
|
const searchRadiusPx = 400
|
|
let nextTarget: CombatUnitContext | undefined
|
|
let minDistance = Infinity
|
|
for (const cand of targets) {
|
|
if (cand.id === target.id || cand.statList.getHp256() <= 0) continue
|
|
const candPos = targetPositions.get(cand.id) ?? { x: 0, y: 120 }
|
|
const dist = Math.hypot(candPos.x - pos.x, candPos.y - pos.y)
|
|
if (dist <= searchRadiusPx && dist < minDistance) {
|
|
minDistance = dist
|
|
nextTarget = cand
|
|
}
|
|
}
|
|
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,
|
|
lastHitTargetId: target.id,
|
|
})
|
|
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, EXCEPT infinite piercing missiles like Lightning / Bone Spear)
|
|
const isInfinitePierce =
|
|
m.name === 'lightningbolt' ||
|
|
m.name === 'lightning' ||
|
|
m.name === 'bonespear' ||
|
|
m.sourceSkillId === 84 ||
|
|
(!m.record.collideKill && m.canPierce)
|
|
if (
|
|
m.name === 'meteorfire' ||
|
|
m.name === 'firewall' ||
|
|
m.name === 'blaze' ||
|
|
m.name === 'groundfirebig' ||
|
|
m.name === 'infernoflame1' ||
|
|
m.name === 'infernoflame2'
|
|
) {
|
|
// Ground fire and stream flames persist for their full duration and do not die on hit
|
|
} else if (isInfinitePierce) {
|
|
m.pierceCount += 1
|
|
if (m.name === 'bonespear' || m.sourceSkillId === 84) {
|
|
const expName = m.record.explosionMissile || 'bonespearexplode'
|
|
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)
|
|
}
|
|
} 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)
|
|
} else if (m.name === 'iceblast' || m.sourceSkillId === 45) {
|
|
const expName = m.record.explosionMissile || 'freezeexplode'
|
|
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)
|
|
} else if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) {
|
|
const expName = m.record.explosionMissile || 'freezeexplode'
|
|
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)
|
|
} else if (m.name === 'teeth' || m.sourceSkillId === 67) {
|
|
const expName = m.record.explosionMissile || 'teethexplode'
|
|
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)
|
|
} else if (m.name === 'bonespirit' || m.sourceSkillId === 93) {
|
|
const expName = m.record.explosionMissile || 'bonespiritexplode'
|
|
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,
|
|
}
|
|
const effectiveTerrain = collision ?? this.terrain
|
|
for (const off of METEOR_FIRE_SUBTILE_OFFSETS) {
|
|
const fx = m.x + off.x * 20
|
|
const fy = m.y + off.y * 10
|
|
if (effectiveTerrain !== undefined) {
|
|
const blocked =
|
|
effectiveTerrain.isMissileBlocked !== undefined
|
|
? effectiveTerrain.isMissileBlocked(fx, fy, m.x, m.y)
|
|
: effectiveTerrain.overlap !== undefined && effectiveTerrain.overlap(fx, fy) > 0
|
|
if (blocked) {
|
|
continue
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Spawns authentic 1.13c 64-bolt circular Nova ring with 2:1 isometric ground foreshortening.
|
|
* Speed = 24, Range = 13 frames, NextDelay = 4 frames.
|
|
*/
|
|
spawnNovaRing(params: {
|
|
sourceSkillId?: number
|
|
slvl?: number
|
|
owner: CombatUnitContext
|
|
startX: number
|
|
startY: number
|
|
dmgPacket: SUnitDmgPacket
|
|
missileName?: string
|
|
}): ActiveMissile[] {
|
|
const spawned: ActiveMissile[] = []
|
|
const skillId = params.sourceSkillId ?? 48
|
|
const slvl = params.slvl ?? 1
|
|
const missileName = params.missileName ?? 'nova'
|
|
const speed = 24 // 1.13c Missiles.txt: Vel = 24
|
|
for (let i = 0; i < 64; i++) {
|
|
const angle = (i / 64) * 2 * Math.PI
|
|
const vx = Math.cos(angle) * speed
|
|
const vy = Math.sin(angle) * speed * ISO_GROUND_ASPECT_RATIO
|
|
const m = this.spawnMissile({
|
|
missileNameOrId: missileName,
|
|
sourceSkillId: skillId,
|
|
slvl,
|
|
owner: params.owner,
|
|
startX: params.startX,
|
|
startY: params.startY,
|
|
targetX: params.startX + Math.cos(angle) * 100,
|
|
targetY: params.startY + Math.sin(angle) * 100 * ISO_GROUND_ASPECT_RATIO,
|
|
dmgPacket: params.dmgPacket,
|
|
vx,
|
|
vy,
|
|
angleRad: angle,
|
|
})
|
|
if (m) spawned.push(m)
|
|
}
|
|
return spawned
|
|
}
|
|
|
|
/**
|
|
* Executes authentic 1.13c instantaneous 360° Static Field discharge with difficulty floors and LOS.
|
|
* Cuts 25% current HP with Normal (1 HP), Nightmare (33% max HP), and Hell (50% max HP) pre-check clamps.
|
|
*/
|
|
executeStaticField(params: {
|
|
caster: CombatUnitContext
|
|
casterPos: { x: number; y: number }
|
|
slvl?: number
|
|
targets: readonly CombatUnitContext[]
|
|
targetPositions: ReadonlyMap<string, { x: number; y: number }>
|
|
difficulty?: 'normal' | 'nightmare' | 'hell'
|
|
terrain?: MissileTerrainCollision
|
|
}): {
|
|
hits: {
|
|
targetId: string
|
|
damage: number
|
|
immune: boolean
|
|
blockedByFloor: boolean
|
|
blockedByLos: boolean
|
|
}[]
|
|
radiusPx: number
|
|
} {
|
|
const slvl = params.slvl ?? 1
|
|
const radiusPx = calculateStaticFieldRadiusPx(slvl)
|
|
const difficulty = params.difficulty ?? 'normal'
|
|
const effectiveTerrain = params.terrain ?? this.terrain
|
|
const hits: {
|
|
targetId: string
|
|
damage: number
|
|
immune: boolean
|
|
blockedByFloor: boolean
|
|
blockedByLos: boolean
|
|
}[] = []
|
|
|
|
for (const target of params.targets) {
|
|
if (target.statList.getHp256() <= 0) continue
|
|
const pos = params.targetPositions.get(target.id) ?? { x: 0, y: 0 }
|
|
const dist = Math.hypot(pos.x - params.casterPos.x, pos.y - params.casterPos.y)
|
|
if (dist > radiusPx) continue
|
|
|
|
const los = hasLineOfSight(params.casterPos.x, params.casterPos.y, pos.x, pos.y, effectiveTerrain)
|
|
if (!los) {
|
|
hits.push({
|
|
targetId: target.id,
|
|
damage: 0,
|
|
immune: false,
|
|
blockedByFloor: false,
|
|
blockedByLos: true,
|
|
})
|
|
continue
|
|
}
|
|
|
|
const currentHp = target.statList.getHp()
|
|
const maxHp = target.statList.getMaxHp() || currentHp
|
|
const ltngRes = target.statList.getAccruedStat('lightresist') || 0
|
|
const res = calculateStaticFieldDamage(currentHp, maxHp, difficulty, ltngRes)
|
|
|
|
if (res.damage > 0) {
|
|
target.statList.setHp256(target.statList.getHp256() - res.damage * 256)
|
|
}
|
|
|
|
hits.push({
|
|
targetId: target.id,
|
|
damage: res.damage,
|
|
immune: res.immune,
|
|
blockedByFloor: res.blockedByFloor,
|
|
blockedByLos: false,
|
|
})
|
|
}
|
|
|
|
return { hits, radiusPx }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Standalone helper to spawn 64 radial Projectile objects for Nova with 2:1 isometric ground foreshortening.
|
|
*/
|
|
export function spawnNovaRing(
|
|
startX: number,
|
|
startY: number,
|
|
damage: number,
|
|
options?: {
|
|
skillId?: string
|
|
missileType?: string
|
|
fromPlayer?: boolean
|
|
speed?: number
|
|
ttl?: number
|
|
statusEffect?: 'chill' | 'freeze' | 'poison'
|
|
statusDuration?: number
|
|
}
|
|
): {
|
|
skillId: string
|
|
missileType: string
|
|
x: number
|
|
y: number
|
|
vx: number
|
|
vy: number
|
|
damage: number
|
|
ttl: number
|
|
fromPlayer: boolean
|
|
nextDelay: number
|
|
statusEffect?: 'chill' | 'freeze' | 'poison'
|
|
statusDuration?: number
|
|
}[] {
|
|
const speed = options?.speed ?? 24 // 1.13c Missiles.txt: vel = 24 (600 px/s)
|
|
const ttl = options?.ttl ?? 13
|
|
const skillId = options?.skillId ?? '48'
|
|
const missileType = options?.missileType ?? 'electricnova'
|
|
const fromPlayer = options?.fromPlayer ?? true
|
|
const statusEffect = options?.statusEffect
|
|
const statusDuration = options?.statusDuration
|
|
const bolts: {
|
|
skillId: string
|
|
missileType: string
|
|
x: number
|
|
y: number
|
|
vx: number
|
|
vy: number
|
|
damage: number
|
|
ttl: number
|
|
fromPlayer: boolean
|
|
nextDelay: number
|
|
statusEffect?: 'chill' | 'freeze' | 'poison'
|
|
statusDuration?: number
|
|
}[] = []
|
|
|
|
for (let i = 0; i < 64; i++) {
|
|
const angle = (i / 64) * 2 * Math.PI
|
|
const vx = Math.cos(angle) * speed
|
|
const vy = Math.sin(angle) * speed * ISO_GROUND_ASPECT_RATIO
|
|
|
|
bolts.push({
|
|
skillId,
|
|
missileType,
|
|
x: startX,
|
|
y: startY,
|
|
vx,
|
|
vy,
|
|
damage,
|
|
ttl,
|
|
fromPlayer,
|
|
nextDelay: 4,
|
|
...(statusEffect !== undefined ? { statusEffect } : {}),
|
|
...(statusDuration !== undefined ? { statusDuration } : {}),
|
|
})
|
|
}
|
|
|
|
return bolts
|
|
}
|
|
|
|
/**
|
|
* Spawns 64 radial Poison Nova (Skill 92) missiles with 2:1 isometric ground foreshortening.
|
|
* In 1.13c: Exactly 64 missiles emitted radially (360 degrees), fixed 2.0s duration (50 frames).
|
|
*/
|
|
export function spawnPoisonNovaRing(
|
|
startX: number,
|
|
startY: number,
|
|
damage: number,
|
|
options?: {
|
|
skillId?: string
|
|
fromPlayer?: boolean
|
|
}
|
|
) {
|
|
return spawnNovaRing(startX, startY, damage, {
|
|
skillId: options?.skillId ?? '92',
|
|
missileType: 'poisonnova',
|
|
fromPlayer: options?.fromPlayer ?? true,
|
|
speed: 12,
|
|
ttl: 30,
|
|
statusEffect: 'poison',
|
|
statusDuration: 50,
|
|
})
|
|
}
|
|
|
|
export interface StaticFieldTarget {
|
|
readonly id: string
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly currentHp: number
|
|
readonly maxHp: number
|
|
readonly lightResist?: number
|
|
}
|
|
|
|
/**
|
|
* Standalone helper to execute 1.13c Static Field on target records.
|
|
*/
|
|
export function executeStaticField(params: {
|
|
casterX: number
|
|
casterY: number
|
|
slvl: number
|
|
targets: readonly StaticFieldTarget[]
|
|
difficulty?: 'normal' | 'nightmare' | 'hell'
|
|
isBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | MissileTerrainCollision | undefined
|
|
}): {
|
|
radiusPx: number
|
|
hits: {
|
|
targetId: string
|
|
damage: number
|
|
newHp: number
|
|
immune: boolean
|
|
blockedByFloor: boolean
|
|
blockedByLos: boolean
|
|
}[]
|
|
} {
|
|
const radiusPx = calculateStaticFieldRadiusPx(params.slvl)
|
|
const difficulty = params.difficulty ?? 'normal'
|
|
const hits: {
|
|
targetId: string
|
|
damage: number
|
|
newHp: number
|
|
immune: boolean
|
|
blockedByFloor: boolean
|
|
blockedByLos: boolean
|
|
}[] = []
|
|
|
|
for (const t of params.targets) {
|
|
const dist = Math.hypot(t.x - params.casterX, t.y - params.casterY)
|
|
if (dist > radiusPx) continue
|
|
|
|
if (params.isBlocked && !hasLineOfSight(params.casterX, params.casterY, t.x, t.y, params.isBlocked)) {
|
|
hits.push({
|
|
targetId: t.id,
|
|
damage: 0,
|
|
newHp: t.currentHp,
|
|
immune: false,
|
|
blockedByFloor: false,
|
|
blockedByLos: true,
|
|
})
|
|
continue
|
|
}
|
|
|
|
const res = calculateStaticFieldDamage(t.currentHp, t.maxHp, difficulty, t.lightResist ?? 0)
|
|
hits.push({
|
|
targetId: t.id,
|
|
damage: res.damage,
|
|
newHp: res.newHp,
|
|
immune: res.immune,
|
|
blockedByFloor: res.blockedByFloor,
|
|
blockedByLos: false,
|
|
})
|
|
}
|
|
|
|
return { radiusPx, hits }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Lightning Mastery (Skill 63) passive bonus percentage:
|
|
* Skills.txt row 63: Param1: 50, Param2: 12.
|
|
* Formula: 50 + (slvl - 1) * 12 percent.
|
|
* Level 1 -> 50%, Level 2 -> 62%, Level 10 -> 158%, Level 20 -> 278%.
|
|
*/
|
|
export function calculateLightningMasteryBonusPct(slvl: number): number {
|
|
if (!Number.isFinite(slvl) || slvl <= 0) return 0
|
|
const lvl = Math.floor(slvl)
|
|
return 50 + (lvl - 1) * 12
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Thunder Storm (Skill 57) duration in frames (Skills.txt row 57):
|
|
* Param1: 800, Param2: 200.
|
|
* Duration: 800 + (slvl - 1) * 200 frames (32s base + 8s/lvl).
|
|
*/
|
|
export function calculateThunderStormDuration(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return 800 + (lvl - 1) * 200
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Thunder Storm (Skill 57) strike interval in frames:
|
|
* Param3: 25 (minimum floor), Param4: 100, Param6: 100.
|
|
* Standard 1.13c hyperbolic cadence formula:
|
|
* interval = Math.max(25, 125 - Math.floor((110 * slvl) / (slvl + 6))) frames.
|
|
* Level 1 -> 110 frames (4.4s), Level 10 -> 57 frames (2.28s), Level 20 -> 41 frames (1.64s).
|
|
*/
|
|
export function calculateThunderStormInterval(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return Math.max(25, 125 - Math.floor((110 * lvl) / (lvl + 6)))
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Thunder Storm (Skill 57) 5-band damage scaling (Skills.txt row 57):
|
|
* EMin: 1, EMinLev1..5: 10, 10, 11, 11, 11
|
|
* EMax: 100, EMaxLev1..5: 10, 10, 11, 11, 11
|
|
* 0 synergies in 1.13c.
|
|
* Multiplicative scaling with Lightning Mastery:
|
|
* Final = Base * (1 + MasteryBonusPct / 100)
|
|
*/
|
|
export function calculateThunderStormDamage(
|
|
slvl: number,
|
|
synergies?: {
|
|
readonly lightningMastery?: number | undefined
|
|
} | number,
|
|
lightningMastery?: number,
|
|
): { min: number; max: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let min = 1
|
|
let max = 100
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
min += b1 * 10
|
|
max += b1 * 10
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
min += b2 * 10
|
|
max += b2 * 10
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
min += b3 * 11
|
|
max += b3 * 11
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
min += b4 * 11
|
|
max += b4 * 11
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * 11
|
|
max += b5 * 11
|
|
}
|
|
}
|
|
|
|
const masteryLvl =
|
|
typeof lightningMastery === 'number'
|
|
? lightningMastery
|
|
: typeof synergies === 'object' && synergies !== null
|
|
? synergies.lightningMastery ?? 0
|
|
: 0
|
|
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
|
|
const synMult = typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
|
|
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
|
|
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
|
|
return { min, max }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Energy Shield (Skill 58) duration in frames (Skills.txt row 58):
|
|
* Param1: 3600, Param2: 1500.
|
|
* Duration: 3600 + (slvl - 1) * 1500 frames (144s base + 60s/lvl).
|
|
*/
|
|
export function calculateEnergyShieldDuration(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return 3600 + (lvl - 1) * 1500
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Energy Shield (Skill 58) damage absorption percentage (Skills.txt row 58):
|
|
* EMin: 20, EMinLev1: 5 (lvl 2-8), EMinLev2: 2 (lvl 9-16), EMinLev3..5: 1 (lvl 17+).
|
|
* Hard cap: 95% (achieved at level 40).
|
|
*/
|
|
export function calculateEnergyShieldAbsorbPct(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let pct = 20
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
pct += b1 * 5
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
pct += b2 * 2
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = lvl - 16
|
|
pct += b3 * 1
|
|
}
|
|
}
|
|
return Math.min(95, pct)
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Frozen Armor (Skill 40) % Enhanced Defense:
|
|
* Param1: 30 (% base), Param2: 5 (% per level).
|
|
* Formula: 30 + (slvl - 1) * 5.
|
|
* Level 1 -> 30%, Level 10 -> 75%, Level 20 -> 125%.
|
|
*/
|
|
export function calculateFrozenArmorDefense(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return 30 + (lvl - 1) * 5
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Frozen Armor (Skill 40) duration in frames:
|
|
* Param3: 3000 (120s base), Param4: 300 (+12s/lvl), Param7: 250 (+10s/syn).
|
|
* Default 1.13c: 3000 + (slvl - 1) * 300 + synPoints * 250.
|
|
* If options.base1200 is set: 1200 + (slvl - 1) * 120 + synPoints * 120.
|
|
*/
|
|
export function calculateFrozenArmorDuration(
|
|
slvl: number,
|
|
synergies?: { shiverArmor?: number; chillingArmor?: number } | number,
|
|
options?: { base1200?: boolean },
|
|
): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const synPoints = Math.max(
|
|
0,
|
|
typeof synergies === 'number'
|
|
? synergies
|
|
: (synergies?.shiverArmor ?? 0) + (synergies?.chillingArmor ?? 0),
|
|
)
|
|
if (options?.base1200) {
|
|
return Math.floor((1200 + (lvl - 1) * 120) * (1 + (synPoints * 10) / 100))
|
|
}
|
|
return 3000 + (lvl - 1) * 300 + synPoints * 250
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Frozen Armor (Skill 40) freeze length in frames (calc1):
|
|
* Param5: 30 (1.2s base), Param6: 3 (+0.12s/lvl), Param8: 5 (+5% freeze length per synergy point).
|
|
* Formula: Math.floor((30 + (slvl - 1) * 3) * (100 + synPoints * 5) / 100).
|
|
* Level 1 -> 30 frames (1.20s), Level 10 -> 57 frames (2.28s), Level 20 -> 87 frames (3.48s).
|
|
*/
|
|
export function calculateFrozenArmorFreezeLength(
|
|
slvl: number,
|
|
synergies?: { shiverArmor?: number; chillingArmor?: number } | number,
|
|
): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const synPoints = Math.max(
|
|
0,
|
|
typeof synergies === 'number'
|
|
? synergies
|
|
: (synergies?.shiverArmor ?? 0) + (synergies?.chillingArmor ?? 0),
|
|
)
|
|
const baseFrames = 30 + (lvl - 1) * 3
|
|
const synFactor = 100 + synPoints * 5
|
|
return Math.floor((baseFrames * synFactor) / 100)
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Shiver Armor (Skill 50) % Enhanced Defense:
|
|
* Param1: 45 (% base), Param2: 6 (% per level).
|
|
* Formula: 45 + (slvl - 1) * 6.
|
|
* Level 1 -> 45%, Level 10 -> 99%, Level 20 -> 159%.
|
|
*/
|
|
export function calculateShiverArmorDefense(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return 45 + (lvl - 1) * 6
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Shiver Armor (Skill 50) duration in frames:
|
|
* Param3: 3000 (120s base), Param4: 300 (+12s/lvl), Param7: 250 (+10s/syn).
|
|
* Default 1.13c: 3000 + (slvl - 1) * 300 + synPoints * 250.
|
|
*/
|
|
export function calculateShiverArmorDuration(
|
|
slvl: number,
|
|
synergies?: { frozenArmor?: number; chillingArmor?: number } | number,
|
|
options?: { base1200?: boolean },
|
|
): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const synPoints = Math.max(
|
|
0,
|
|
typeof synergies === 'number'
|
|
? synergies
|
|
: (synergies?.frozenArmor ?? 0) + (synergies?.chillingArmor ?? 0),
|
|
)
|
|
if (options?.base1200) {
|
|
return Math.floor((1200 + (lvl - 1) * 120) * (1 + (synPoints * 10) / 100))
|
|
}
|
|
return 3000 + (lvl - 1) * 300 + synPoints * 250
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Shiver Armor (Skill 50) 5-band cold damage scaling (HitShift = 7 -> divide by 2):
|
|
* EMin: 12, EMax: 16 (HitShift 7 -> 6 - 8 at lvl 1)
|
|
* EMinLev: [4, 6, 8, 10, 12], EMaxLev: [5, 7, 9, 11, 13]
|
|
* Synergy: +9% (or +11% if specified) per point in Frozen Armor / Chilling Armor.
|
|
*/
|
|
export function calculateShiverArmorDamage(
|
|
slvl: number,
|
|
synergies?: { frozenArmor?: number; chillingArmor?: number; synPctPerLvl?: number } | number,
|
|
options?: { synPctPerLvl?: number; synMultiplier?: number },
|
|
): { min: number; max: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let rawMin = 12
|
|
let rawMax = 16
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
rawMin += b1 * 4
|
|
rawMax += b1 * 5
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
rawMin += b2 * 6
|
|
rawMax += b2 * 7
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
rawMin += b3 * 8
|
|
rawMax += b3 * 9
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
rawMin += b4 * 10
|
|
rawMax += b4 * 11
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
rawMin += b5 * 12
|
|
rawMax += b5 * 13
|
|
}
|
|
}
|
|
|
|
let min = rawMin / 2
|
|
let max = rawMax / 2
|
|
|
|
let synMultiplier = 1.0
|
|
if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) {
|
|
synMultiplier = Math.max(0, options.synMultiplier)
|
|
} else if (typeof synergies === 'number') {
|
|
const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0
|
|
const pctPerLvl = options?.synPctPerLvl ?? 9
|
|
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
|
|
} else if (typeof synergies === 'object' && synergies !== null) {
|
|
const synPoints = Math.max(0, (synergies.frozenArmor ?? 0) + (synergies.chillingArmor ?? 0))
|
|
const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 9
|
|
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
|
|
}
|
|
|
|
min = Math.floor((min * synMultiplier + 1e-9) * 10) / 10
|
|
max = Math.floor((max * synMultiplier + 1e-9) * 10) / 10
|
|
return { min, max }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Shiver Armor (Skill 50) chill length in frames:
|
|
* ELen: 100 frames base (4.0s). ELevLen1: 0, ELevLen2: 25 frames (lvl 9-16), ELevLen3: 50 frames (lvl 17+).
|
|
* Level 1-8: 100 frames (4.0s), Level 10: 150 frames (6.0s), Level 20: 500 frames (20.0s).
|
|
* Optional synergy: +7% chill length per synergy point.
|
|
*/
|
|
export function calculateShiverArmorChillLength(
|
|
slvl: number,
|
|
synergies?: { frozenArmor?: number; chillingArmor?: number } | number,
|
|
): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let frames = 100
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
frames += b2 * 25
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = lvl - 16
|
|
frames += b3 * 50
|
|
}
|
|
const synPoints = Math.max(
|
|
0,
|
|
typeof synergies === 'number'
|
|
? synergies
|
|
: (synergies?.frozenArmor ?? 0) + (synergies?.chillingArmor ?? 0),
|
|
)
|
|
if (synPoints > 0) {
|
|
frames = Math.floor((frames * (100 + synPoints * 7)) / 100)
|
|
}
|
|
return frames
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Chilling Armor (Skill 60) % Enhanced Defense:
|
|
* Param1: 45 (% base), Param2: 5 (% per level).
|
|
* Formula: 45 + (slvl - 1) * 5.
|
|
* Level 1 -> 45%, Level 10 -> 90%, Level 20 -> 140%.
|
|
*/
|
|
export function calculateChillingArmorDefense(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return 45 + (lvl - 1) * 5
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Chilling Armor (Skill 60) duration in frames:
|
|
* Param3: 3600 (144s base), Param4: 150 (+6s/lvl), Param7: 250 (+10s/syn).
|
|
* Default 1.13c: 3600 + (slvl - 1) * 150 + synPoints * 250.
|
|
*/
|
|
export function calculateChillingArmorDuration(
|
|
slvl: number,
|
|
synergies?: { frozenArmor?: number; shiverArmor?: number } | number,
|
|
options?: { base1200?: boolean },
|
|
): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const synPoints = Math.max(
|
|
0,
|
|
typeof synergies === 'number'
|
|
? synergies
|
|
: (synergies?.frozenArmor ?? 0) + (synergies?.shiverArmor ?? 0),
|
|
)
|
|
if (options?.base1200) {
|
|
return Math.floor((1200 + (lvl - 1) * 120) * (1 + (synPoints * 10) / 100))
|
|
}
|
|
return 3600 + (lvl - 1) * 150 + synPoints * 250
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Chilling Armor (Skill 60) retaliatory ice bolt cold damage:
|
|
* EMin: 8, EMax: 12 (HitShift 7 -> 4 - 6 at lvl 1)
|
|
* EMinLev: [2, 4, 6, 8, 10], EMaxLev: [3, 5, 7, 9, 11]
|
|
* Synergy: +7% per point in Frozen Armor / Shiver Armor.
|
|
*/
|
|
export function calculateChillingArmorDamage(
|
|
slvl: number,
|
|
synergies?: { frozenArmor?: number; shiverArmor?: number; synPctPerLvl?: number } | number,
|
|
options?: { synPctPerLvl?: number; synMultiplier?: number },
|
|
): { min: number; max: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let rawMin = 8
|
|
let rawMax = 12
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
rawMin += b1 * 2
|
|
rawMax += b1 * 3
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
rawMin += b2 * 4
|
|
rawMax += b2 * 5
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
rawMin += b3 * 6
|
|
rawMax += b3 * 7
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
rawMin += b4 * 8
|
|
rawMax += b4 * 9
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
rawMin += b5 * 10
|
|
rawMax += b5 * 11
|
|
}
|
|
}
|
|
|
|
let min = rawMin / 2
|
|
let max = rawMax / 2
|
|
|
|
let synMultiplier = 1.0
|
|
if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) {
|
|
synMultiplier = Math.max(0, options.synMultiplier)
|
|
} else if (typeof synergies === 'number') {
|
|
const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0
|
|
const pctPerLvl = options?.synPctPerLvl ?? 7
|
|
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
|
|
} else if (typeof synergies === 'object' && synergies !== null) {
|
|
const synPoints = Math.max(0, (synergies.frozenArmor ?? 0) + (synergies.shiverArmor ?? 0))
|
|
const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 7
|
|
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
|
|
}
|
|
|
|
min = Math.floor((min * synMultiplier + 1e-9) * 10) / 10
|
|
max = Math.floor((max * synMultiplier + 1e-9) * 10) / 10
|
|
return { min, max }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Chilling Armor (Skill 60) chill length in frames:
|
|
* ELen: 100 frames base (4.0s). ELevLen1: 0, ELevLen2: 25 frames, ELevLen3: 25 frames.
|
|
* Level 1-8: 100 frames (4.0s), Level 10: 150 frames (6.0s), Level 20: 400 frames (16.0s).
|
|
* Optional synergy: +10% chill length per synergy point.
|
|
*/
|
|
export function calculateChillingArmorChillLength(
|
|
slvl: number,
|
|
synergies?: { frozenArmor?: number; shiverArmor?: number } | number,
|
|
): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let frames = 100
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
frames += b2 * 25
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = lvl - 16
|
|
frames += b3 * 25
|
|
}
|
|
const synPoints = Math.max(
|
|
0,
|
|
typeof synergies === 'number'
|
|
? synergies
|
|
: (synergies?.frozenArmor ?? 0) + (synergies?.shiverArmor ?? 0),
|
|
)
|
|
if (synPoints > 0) {
|
|
frames = Math.floor((frames * (100 + synPoints * 10)) / 100)
|
|
}
|
|
return frames
|
|
}
|
|
|
|
/**
|
|
* Standard Cold Armor base duration calculator (1200f base + 120f/lvl = 48s + 4.8s/lvl):
|
|
*/
|
|
export function calculateColdArmorDuration(
|
|
slvl: number,
|
|
baseFrames = 1200,
|
|
perLvlFrames = 120,
|
|
synBonusPct = 10,
|
|
synPoints = 0,
|
|
): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const safeSyn = Math.max(0, synPoints)
|
|
return Math.floor((baseFrames + (lvl - 1) * perLvlFrames) * (1 + (safeSyn * synBonusPct) / 100))
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Ice Blast (Skill 45) 5-band cold damage scaling (HitShift = 7 -> divide by 2):
|
|
* EMin: 16, EMax: 24 (HitShift 7 -> 8 - 12 at lvl 1)
|
|
* EMinLev: [14, 28, 42, 56, 70] / 2 -> [7, 14, 21, 28, 35]
|
|
* EMaxLev: [15, 29, 43, 57, 71] / 2 -> [7.5, 14.5, 21.5, 28.5, 35.5]
|
|
* Level 1: 8 - 12
|
|
* Level 2-8: +7 min, +7.5 max per level (b1 = slvl - 1)
|
|
* Level 9-16: +14 min, +14.5 max per level (b2 = slvl - 8)
|
|
* Level 17-22: +21 min, +21.5 max per level (b3 = slvl - 16)
|
|
* Level 23-28: +28 min, +28.5 max per level (b4 = slvl - 22)
|
|
* Level 29+: +35 min, +35.5 max per level (b5 = slvl - 28)
|
|
* Level 1: 8 - 12
|
|
* Level 10: 85 - 93.5
|
|
* Level 20: 253 - 266.5
|
|
* Synergies: +8% cold damage per point from Ice Bolt, Blizzard, Frozen Orb, Glacial Spike.
|
|
*/
|
|
export function calculateIceBlastDamage(
|
|
slvl: number,
|
|
synergies?: {
|
|
iceBolt?: number
|
|
blizzard?: number
|
|
frozenOrb?: number
|
|
glacialSpike?: number
|
|
synPctPerLvl?: number
|
|
} | number,
|
|
options?: { synPctPerLvl?: number; synMultiplier?: number },
|
|
): { min: number; max: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let rawMin = 16
|
|
let rawMax = 24
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
rawMin += b1 * 14
|
|
rawMax += b1 * 15
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
rawMin += b2 * 28
|
|
rawMax += b2 * 29
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
rawMin += b3 * 42
|
|
rawMax += b3 * 43
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
rawMin += b4 * 56
|
|
rawMax += b4 * 57
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
rawMin += b5 * 70
|
|
rawMax += b5 * 71
|
|
}
|
|
}
|
|
|
|
let min = rawMin / 2
|
|
let max = rawMax / 2
|
|
|
|
let synMultiplier = 1.0
|
|
if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) {
|
|
synMultiplier = Math.max(0, options.synMultiplier)
|
|
} else if (typeof synergies === 'number') {
|
|
const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0
|
|
const pctPerLvl = options?.synPctPerLvl ?? 8
|
|
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
|
|
} else if (typeof synergies === 'object' && synergies !== null) {
|
|
const synPoints = Math.max(
|
|
0,
|
|
(synergies.iceBolt ?? 0) +
|
|
(synergies.blizzard ?? 0) +
|
|
(synergies.frozenOrb ?? 0) +
|
|
(synergies.glacialSpike ?? 0),
|
|
)
|
|
const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 8
|
|
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
|
|
}
|
|
|
|
min = Math.floor((min * synMultiplier + 1e-9) * 10) / 10
|
|
max = Math.floor((max * synMultiplier + 1e-9) * 10) / 10
|
|
return { min, max }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Ice Blast (Skill 45) freeze length in frames:
|
|
* Param1: 75 frames base (3.0s), Param2 / ELevLen1: 5 frames/lvl (0.20s/lvl).
|
|
* Formula: 75 + (slvl - 1) * 5.
|
|
* Level 1 -> 75 frames (3.00s), Level 10 -> 120 frames (4.80s), Level 20 -> 170 frames (6.80s).
|
|
* Synergy: +10% freeze length per point from Glacial Spike (and Ice Bolt if passed).
|
|
* Formula: Math.floor(((75 + (slvl - 1) * 5) * (100 + synPoints * 10)) / 100).
|
|
*/
|
|
export function calculateIceBlastFreezeLength(
|
|
slvl: number,
|
|
synergies?: { glacialSpike?: number; iceBolt?: number } | number,
|
|
): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const synPoints = Math.max(
|
|
0,
|
|
typeof synergies === 'number'
|
|
? synergies
|
|
: (synergies?.glacialSpike ?? 0) + (synergies?.iceBolt ?? 0),
|
|
)
|
|
const baseFrames = 75 + (lvl - 1) * 5
|
|
const synFactor = 100 + synPoints * 10
|
|
return Math.floor((baseFrames * synFactor) / 100)
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Glacial Spike (Skill 55) 5-band cold damage scaling (HitShift = 7 -> divide by 2):
|
|
* EMin: 32, EMax: 48 (HitShift 7 -> 16 - 24 at lvl 1)
|
|
* EMinLev: [14, 26, 28, 30, 32] / 2 -> [7, 13, 14, 15, 16]
|
|
* EMaxLev: [15, 27, 29, 31, 33] / 2 -> [7.5, 13.5, 14.5, 15.5, 16.5]
|
|
* Level 1: 16 - 24
|
|
* Level 2-8: +7 min, +7.5 max per level (b1 = slvl - 1)
|
|
* Level 9-16: +13 min, +13.5 max per level (b2 = slvl - 8)
|
|
* Level 17-22: +14 min, +14.5 max per level (b3 = slvl - 16)
|
|
* Level 23-28: +15 min, +15.5 max per level (b4 = slvl - 22)
|
|
* Level 29+: +16 min, +16.5 max per level (b5 = slvl - 28)
|
|
* Level 1: 16 - 24
|
|
* Level 10: 91 - 103.5
|
|
* Level 20: 225 - 242.5
|
|
* Synergies: +5% cold damage per point from Ice Bolt, Ice Blast, Blizzard, Frozen Orb.
|
|
*/
|
|
export function calculateGlacialSpikeDamage(
|
|
slvl: number,
|
|
synergies?: {
|
|
iceBolt?: number
|
|
iceBlast?: number
|
|
blizzard?: number
|
|
frozenOrb?: number
|
|
synPctPerLvl?: number
|
|
} | number,
|
|
options?: { synPctPerLvl?: number; synMultiplier?: number },
|
|
): { min: number; max: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let rawMin = 32
|
|
let rawMax = 48
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
rawMin += b1 * 14
|
|
rawMax += b1 * 15
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
rawMin += b2 * 26
|
|
rawMax += b2 * 27
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
rawMin += b3 * 28
|
|
rawMax += b3 * 29
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
rawMin += b4 * 30
|
|
rawMax += b4 * 31
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
rawMin += b5 * 32
|
|
rawMax += b5 * 33
|
|
}
|
|
}
|
|
|
|
let min = rawMin / 2
|
|
let max = rawMax / 2
|
|
|
|
let synMultiplier = 1.0
|
|
if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) {
|
|
synMultiplier = Math.max(0, options.synMultiplier)
|
|
} else if (typeof synergies === 'number') {
|
|
const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0
|
|
const pctPerLvl = options?.synPctPerLvl ?? 5
|
|
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
|
|
} else if (typeof synergies === 'object' && synergies !== null) {
|
|
const synPoints = Math.max(
|
|
0,
|
|
(synergies.iceBolt ?? 0) +
|
|
(synergies.iceBlast ?? 0) +
|
|
(synergies.blizzard ?? 0) +
|
|
(synergies.frozenOrb ?? 0),
|
|
)
|
|
const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 5
|
|
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
|
|
}
|
|
|
|
min = Math.floor((min * synMultiplier + 1e-9) * 10) / 10
|
|
max = Math.floor((max * synMultiplier + 1e-9) * 10) / 10
|
|
return { min, max }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Glacial Spike (Skill 55) freeze length in frames:
|
|
* Param3: 50 frames base (2.0s), Param4: 3 frames/lvl (0.12s/lvl).
|
|
* Formula: 50 + (slvl - 1) * 3.
|
|
* Level 1 -> 50 frames (2.00s), Level 10 -> 77 frames (3.08s), Level 20 -> 107 frames (4.28s).
|
|
* Synergy: +3% freeze length per point from Blizzard (and Ice Bolt, Ice Blast if passed).
|
|
* Formula: Math.floor(((50 + (slvl - 1) * 3) * (100 + synPoints * 3)) / 100).
|
|
*/
|
|
export function calculateGlacialSpikeFreezeLength(
|
|
slvl: number,
|
|
synergies?: { blizzard?: number; iceBolt?: number; iceBlast?: number } | number,
|
|
): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const synPoints = Math.max(
|
|
0,
|
|
typeof synergies === 'number'
|
|
? synergies
|
|
: (synergies?.blizzard ?? 0) + (synergies?.iceBolt ?? 0) + (synergies?.iceBlast ?? 0),
|
|
)
|
|
const baseFrames = 50 + (lvl - 1) * 3
|
|
const synFactor = 100 + synPoints * 3
|
|
return Math.floor((baseFrames * synFactor) / 100)
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Cold Mastery (Skill 65) passive enemy cold resistance reduction:
|
|
* Param1: 20 (% base), Param2: 5 (% per level).
|
|
* Formula: 20 + (slvl - 1) * 5.
|
|
* Level 1 -> 20% (-20%), Level 10 -> 65% (-65%), Level 20 -> 115% (-115%), Level 30 -> 165% (-165%).
|
|
* Clamped: Returns 0 for slvl <= 0.
|
|
*/
|
|
export function calculateColdMasteryPierce(slvl: number): number {
|
|
if (!Number.isFinite(slvl) || slvl <= 0) return 0
|
|
const lvl = Math.floor(slvl)
|
|
return 20 + (lvl - 1) * 5
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Fire Wall (Skill 51) 5-band fire damage progression (HitShift = 4 -> multiply raw by 16 into 256ths):
|
|
* EMin: 15, EMinLev: [9, 14, 21, 21, 21]
|
|
* EMax: 20, EMaxLev: [9, 14, 21, 21, 21]
|
|
* Level 1: raw 15 - 20 -> in 256ths: 240 - 320 (23.44 - 31.25 fire dmg/sec)
|
|
* Level 10: raw 106 - 111 -> in 256ths: 1696 - 1776 (165.62 - 173.44 fire dmg/sec)
|
|
* Level 20: raw 274 - 279 -> in 256ths: 4384 - 4464 (428.12 - 435.94 fire dmg/sec)
|
|
* Synergies: Warmth (+4%/lvl), Inferno (+1%/lvl).
|
|
*/
|
|
export function calculateFireWallDamage(
|
|
slvl: number,
|
|
synergies?: { warmth?: number; inferno?: number; fireMastery?: number; synPctPerLvl?: number } | number,
|
|
options?: { synMultiplier?: number; asPerSecond?: boolean },
|
|
): { min: number; max: number; perSecMin: number; perSecMax: number; min256: number; max256: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let rawMin = 15
|
|
let rawMax = 20
|
|
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
rawMin += b1 * 9
|
|
rawMax += b1 * 9
|
|
}
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
rawMin += b2 * 14
|
|
rawMax += b2 * 14
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
rawMin += b3 * 21
|
|
rawMax += b3 * 21
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
rawMin += b4 * 21
|
|
rawMax += b4 * 21
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
rawMin += b5 * 21
|
|
rawMax += b5 * 21
|
|
}
|
|
|
|
let synMultiplier = 1.0
|
|
if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) {
|
|
synMultiplier = Math.max(0, options.synMultiplier)
|
|
} else if (typeof synergies === 'number') {
|
|
const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0
|
|
synMultiplier = 1.0 + (synPoints * 4) / 100
|
|
} else if (typeof synergies === 'object' && synergies !== null) {
|
|
const warmthPts = Math.max(0, synergies.warmth ?? 0)
|
|
const infernoPts = Math.max(0, synergies.inferno ?? 0)
|
|
const synPct = warmthPts * 4 + infernoPts * 1
|
|
const masteryPct = synergies.fireMastery && synergies.fireMastery > 0
|
|
? 30 + (synergies.fireMastery - 1) * 7
|
|
: 0
|
|
synMultiplier = (1.0 + synPct / 100) * (1.0 + masteryPct / 100)
|
|
}
|
|
|
|
const min256 = Math.floor(rawMin * 16 * synMultiplier)
|
|
const max256 = Math.floor(rawMax * 16 * synMultiplier)
|
|
const perSecMin = Math.floor(((rawMin * 25) / 16) * synMultiplier * 100) / 100
|
|
const perSecMax = Math.floor(((rawMax * 25) / 16) * synMultiplier * 100) / 100
|
|
|
|
let min = Math.floor((rawMin * synMultiplier + 1e-9) * 10) / 10
|
|
let max = Math.floor((rawMax * synMultiplier + 1e-9) * 10) / 10
|
|
if (options?.asPerSecond) {
|
|
min = perSecMin
|
|
max = perSecMax
|
|
}
|
|
|
|
return { min, max, perSecMin, perSecMax, min256, max256 }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Blaze (Skill 46) 5-band fire damage progression (HitShift = 4 -> multiply raw by 16 into 256ths):
|
|
* EMin: 4, EMinLev: [2, 3, 4, 6, 9]
|
|
* EMax: 8, EMaxLev: [2, 3, 4, 6, 9]
|
|
* Level 1: raw 4 - 8 -> in 256ths: 64 - 128 (6.25 - 12.50 fire dmg/sec)
|
|
* Level 10: raw 24 - 28 -> in 256ths: 384 - 448 (37.50 - 43.75 fire dmg/sec)
|
|
* Level 20: raw 58 - 62 -> in 256ths: 928 - 992 (90.62 - 96.88 fire dmg/sec)
|
|
* Synergies: Warmth (+4%/lvl), Fire Wall (+1%/lvl).
|
|
*/
|
|
export function calculateBlazeDamage(
|
|
slvl: number,
|
|
synergies?: { warmth?: number; fireWall?: number; fireMastery?: number; synPctPerLvl?: number } | number,
|
|
options?: { synMultiplier?: number; asPerSecond?: boolean },
|
|
): { min: number; max: number; perSecMin: number; perSecMax: number; min256: number; max256: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let rawMin = 4
|
|
let rawMax = 8
|
|
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
rawMin += b1 * 2
|
|
rawMax += b1 * 2
|
|
}
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
rawMin += b2 * 3
|
|
rawMax += b2 * 3
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
rawMin += b3 * 4
|
|
rawMax += b3 * 4
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
rawMin += b4 * 6
|
|
rawMax += b4 * 6
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
rawMin += b5 * 9
|
|
rawMax += b5 * 9
|
|
}
|
|
|
|
let synMultiplier = 1.0
|
|
if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) {
|
|
synMultiplier = Math.max(0, options.synMultiplier)
|
|
} else if (typeof synergies === 'number') {
|
|
const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0
|
|
synMultiplier = 1.0 + (synPoints * 4) / 100
|
|
} else if (typeof synergies === 'object' && synergies !== null) {
|
|
const warmthPts = Math.max(0, synergies.warmth ?? 0)
|
|
const fireWallPts = Math.max(0, synergies.fireWall ?? 0)
|
|
const synPct = warmthPts * 4 + fireWallPts * 1
|
|
const masteryPct = synergies.fireMastery && synergies.fireMastery > 0
|
|
? 30 + (synergies.fireMastery - 1) * 7
|
|
: 0
|
|
synMultiplier = (1.0 + synPct / 100) * (1.0 + masteryPct / 100)
|
|
}
|
|
|
|
const min256 = Math.floor(rawMin * 16 * synMultiplier)
|
|
const max256 = Math.floor(rawMax * 16 * synMultiplier)
|
|
const perSecMin = Math.floor(((rawMin * 25) / 16) * synMultiplier * 100) / 100
|
|
const perSecMax = Math.floor(((rawMax * 25) / 16) * synMultiplier * 100) / 100
|
|
|
|
let min = Math.floor((rawMin * synMultiplier + 1e-9) * 10) / 10
|
|
let max = Math.floor((rawMax * synMultiplier + 1e-9) * 10) / 10
|
|
if (options?.asPerSecond) {
|
|
min = perSecMin
|
|
max = perSecMax
|
|
}
|
|
|
|
return { min, max, perSecMin, perSecMax, min256, max256 }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Inferno (Skill 41) 5-band fire damage progression (HitShift = 2 -> multiply raw by 4 into 256ths):
|
|
* EMin: 32, EMinLev: [24, 26, 28, 32, 36]
|
|
* EMax: 64, EMaxLev: [24, 27, 29, 33, 37]
|
|
* Level 1: raw 32 - 64 -> in 256ths: 128 - 256 (12.50 - 25.00 fire dmg/sec)
|
|
* Level 10: raw 252 - 286 -> in 256ths: 1008 - 1144 (98.44 - 111.72 fire dmg/sec)
|
|
* Level 20: raw 520 - 564 -> in 256ths: 2080 - 2256 (203.12 - 220.31 fire dmg/sec)
|
|
* Synergies: Warmth (+13%/lvl).
|
|
*/
|
|
export function calculateInfernoDamage(
|
|
slvl: number,
|
|
synergies?: { warmth?: number; fireMastery?: number; synPctPerLvl?: number } | number,
|
|
options?: { synMultiplier?: number; asPerSecond?: boolean },
|
|
): { min: number; max: number; perSecMin: number; perSecMax: number; min256: number; max256: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let rawMin = 32
|
|
let rawMax = 64
|
|
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
rawMin += b1 * 24
|
|
rawMax += b1 * 24
|
|
}
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
rawMin += b2 * 26
|
|
rawMax += b2 * 27
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
rawMin += b3 * 28
|
|
rawMax += b3 * 29
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
rawMin += b4 * 32
|
|
rawMax += b4 * 33
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
rawMin += b5 * 36
|
|
rawMax += b5 * 37
|
|
}
|
|
|
|
let synMultiplier = 1.0
|
|
if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) {
|
|
synMultiplier = Math.max(0, options.synMultiplier)
|
|
} else if (typeof synergies === 'number') {
|
|
const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0
|
|
synMultiplier = 1.0 + (synPoints * 13) / 100
|
|
} else if (typeof synergies === 'object' && synergies !== null) {
|
|
const warmthPts = Math.max(0, synergies.warmth ?? 0)
|
|
const synPct = warmthPts * 13
|
|
const masteryPct = synergies.fireMastery && synergies.fireMastery > 0
|
|
? 30 + (synergies.fireMastery - 1) * 7
|
|
: 0
|
|
synMultiplier = (1.0 + synPct / 100) * (1.0 + masteryPct / 100)
|
|
}
|
|
|
|
const min256 = Math.floor(rawMin * 4 * synMultiplier)
|
|
const max256 = Math.floor(rawMax * 4 * synMultiplier)
|
|
const perSecMin = Math.floor(((rawMin * 25) / 64) * synMultiplier * 100) / 100
|
|
const perSecMax = Math.floor(((rawMax * 25) / 64) * synMultiplier * 100) / 100
|
|
|
|
let min = Math.floor((rawMin * synMultiplier + 1e-9) * 10) / 10
|
|
let max = Math.floor((rawMax * synMultiplier + 1e-9) * 10) / 10
|
|
if (options?.asPerSecond) {
|
|
min = perSecMin
|
|
max = perSecMax
|
|
}
|
|
|
|
return { min, max, perSecMin, perSecMax, min256, max256 }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Blaze (Skill 46) buff duration in frames:
|
|
* Formula (dm12): Math.floor((110 * slvl * 450) / (100 * (slvl + 6))) + 50.
|
|
* Level 1 -> 120 frames (4.80s), Level 10 -> 359 frames (14.36s), Level 20 -> 430 frames (17.20s).
|
|
*/
|
|
export function calculateBlazeDuration(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return Math.floor((110 * lvl * 450) / (100 * (lvl + 6))) + 50
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Blaze (Skill 46) ground fire patch duration in frames:
|
|
* Formula: 90 + slvl * 25.
|
|
* Level 1 -> 115 frames (4.60s), Level 10 -> 340 frames (13.60s), Level 20 -> 590 frames (23.60s).
|
|
*/
|
|
export function calculateBlazePatchDuration(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return 90 + lvl * 25
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Inferno (Skill 41) stream length in yards:
|
|
* Formula: (20 + 3 * (slvl - 1)) / 2.
|
|
* Level 1 -> 10.0 yards, Level 10 -> 23.5 yards, Level 20 -> 38.5 yards.
|
|
*/
|
|
export function calculateInfernoRange(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return (20 + 3 * (lvl - 1)) / 2
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Inferno (Skill 41) stream length in pixels:
|
|
* Formula: calculateInfernoRange(slvl) * 32 (1 yard = 1 subtile = 32 horizontal pixels).
|
|
* Level 1 -> 320.0 px, Level 10 -> 752.0 px, Level 20 -> 1232.0 px.
|
|
*/
|
|
export function calculateInfernoRangePx(slvl: number): number {
|
|
return calculateInfernoRange(slvl) * 32
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Inferno (Skill 41) continuous mana drain per frame in 256ths:
|
|
* Formula: (36 + (slvl - 1)) * 4.
|
|
* Level 1 -> 144 / 256 mana/frame, Level 10 -> 180, Level 20 -> 220.
|
|
*/
|
|
export function calculateInfernoContinuousMana256(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return (36 + (lvl - 1)) * 4
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Fire Wall (Skill 51) 15-patch perpendicular line emitter geometry:
|
|
* Given caster (cx, cy) and target (tx, ty):
|
|
* Normal vector is perpendicular to (tx - cx, ty - cy): (-dy, dx) / len.
|
|
* Generates 15 patches with indices i in [-7, +7] spaced 24px apart,
|
|
* with Y coordinates scaled by ISO_GROUND_ASPECT_RATIO (0.5).
|
|
* If options.isBlocked is supplied, patches landing on solid obstacles are omitted.
|
|
*/
|
|
export function calculateFireWallPatches(
|
|
casterX: number,
|
|
casterY: number,
|
|
targetX: number,
|
|
targetY: number,
|
|
options?: {
|
|
count?: number
|
|
spacing?: number
|
|
isBlocked?: (x: number, y: number) => boolean
|
|
},
|
|
): Array<{ x: number; y: number; index: number }> {
|
|
const count = options?.count ?? 15
|
|
const spacing = options?.spacing ?? 24
|
|
const dx = targetX - casterX
|
|
const dy = targetY - casterY
|
|
const len = Math.hypot(dx, dy)
|
|
|
|
let nx = 0
|
|
let ny = 1
|
|
if (len > 1e-6) {
|
|
nx = -dy / len
|
|
ny = dx / len
|
|
}
|
|
|
|
const half = Math.floor(count / 2)
|
|
const patches: Array<{ x: number; y: number; index: number }> = []
|
|
|
|
for (let i = -half; i <= half; i++) {
|
|
const px = Math.round(targetX + i * spacing * nx)
|
|
const py = Math.round(targetY + i * spacing * ny * ISO_GROUND_ASPECT_RATIO)
|
|
if (options?.isBlocked && options.isBlocked(px, py)) {
|
|
continue
|
|
}
|
|
patches.push({ x: px, y: py, index: i })
|
|
}
|
|
|
|
return patches
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Fire Mastery (Skill 61) passive percentage fire damage bonus:
|
|
* Param1: 30 (% base at slvl 1), Param2: 7 (% per level thereafter).
|
|
* Formula: slvl <= 0 ? 0 : 30 + (slvl - 1) * 7.
|
|
* Level 1 -> +30%, Level 10 -> +93%, Level 20 -> +163%, Level 30 -> +233%.
|
|
* Clamped: Returns 0 for slvl <= 0.
|
|
*/
|
|
export function calculateFireMasteryBonus(slvl: number): number {
|
|
if (!Number.isFinite(slvl) || slvl <= 0) return 0
|
|
const lvl = Math.floor(slvl)
|
|
return 30 + (lvl - 1) * 7
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Enchant (Skill 52) duration in frames at 25 fps:
|
|
* Formula (ln12): 3600 + (slvl - 1) * 600 frames (144s base + 24s/lvl).
|
|
* Level 1 -> 3600 frames (144.0s), Level 10 -> 9000 frames (360.0s), Level 20 -> 15000 frames (600.0s).
|
|
* Clamped: Returns 0 for slvl <= 0.
|
|
*/
|
|
export function calculateEnchantDuration(slvl: number): number {
|
|
if (!Number.isFinite(slvl) || slvl <= 0) return 0
|
|
const lvl = Math.floor(slvl)
|
|
return 3600 + (lvl - 1) * 600
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Enchant (Skill 52) Attack Rating percentage bonus:
|
|
* ToHit: 20 (% base at slvl 1), LevToHit: 9 (% per level thereafter).
|
|
* Formula: 20 + (slvl - 1) * 9.
|
|
* Level 1 -> +20%, Level 10 -> +101%, Level 20 -> +191%.
|
|
* Clamped: Returns 0 for slvl <= 0.
|
|
*/
|
|
export function calculateEnchantAttackRating(slvl: number): number {
|
|
if (!Number.isFinite(slvl) || slvl <= 0) return 0
|
|
const lvl = Math.floor(slvl)
|
|
return 20 + (lvl - 1) * 9
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Enchant (Skill 52) 5-band weapon fire damage progression:
|
|
* HitShift = 7 (128/256 precision -> 0.5 fire damage per integer step).
|
|
* EMin: 16, EMinLev: [3, 7, 11, 15, 19]
|
|
* EMax: 20, EMaxLev: [5, 9, 13, 17, 21]
|
|
* Level 1: 16 - 20 raw -> 8.0 - 10.0 fire dmg (2048 - 2560 in 256ths)
|
|
* Level 10: 51 - 73 raw -> 25.5 - 36.5 fire dmg (6528 - 9344 in 256ths)
|
|
* Level 20: 137 - 179 raw -> 68.5 - 89.5 fire dmg (17536 - 22912 in 256ths)
|
|
* Warmth synergy: +9% per hard point (blvl).
|
|
* Fire Mastery multiplier upon cast: damage * (1 + calculateFireMasteryBonus(fmSlvl) / 100).
|
|
*/
|
|
export function calculateEnchantDamage(
|
|
slvl: number,
|
|
synergies?: { warmth?: number | undefined; fireMastery?: number | undefined } | number | undefined,
|
|
fireMasterySlvl?: number | undefined,
|
|
): { min: number; max: number; min256: number; max256: number } {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let rawMin = 16
|
|
let rawMax = 20
|
|
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
rawMin += b1 * 3
|
|
rawMax += b1 * 5
|
|
}
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
rawMin += b2 * 7
|
|
rawMax += b2 * 9
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
rawMin += b3 * 11
|
|
rawMax += b3 * 13
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
rawMin += b4 * 15
|
|
rawMax += b4 * 17
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
rawMin += b5 * 19
|
|
rawMax += b5 * 21
|
|
}
|
|
|
|
let warmthPts = 0
|
|
let fmLvl = 0
|
|
if (typeof synergies === 'number') {
|
|
warmthPts = Number.isFinite(synergies) ? Math.max(0, synergies) : 0
|
|
} else if (typeof synergies === 'object' && synergies !== null) {
|
|
warmthPts = Math.max(0, synergies.warmth ?? 0)
|
|
fmLvl = Math.max(0, synergies.fireMastery ?? 0)
|
|
}
|
|
if (fireMasterySlvl !== undefined && Number.isFinite(fireMasterySlvl)) {
|
|
fmLvl = Math.max(0, fireMasterySlvl)
|
|
}
|
|
|
|
const warmthMultiplier = 1.0 + (warmthPts * 9) / 100
|
|
const fmMultiplier = 1.0 + calculateFireMasteryBonus(fmLvl) / 100
|
|
const totalMultiplier = warmthMultiplier * fmMultiplier
|
|
|
|
const min256 = Math.floor(rawMin * 128 * totalMultiplier)
|
|
const max256 = Math.floor(rawMax * 128 * totalMultiplier)
|
|
|
|
// Floating point 0.5 step rounded to 1 decimal place
|
|
const min = Math.round((rawMin * 0.5 * totalMultiplier) * 10) / 10
|
|
const max = Math.round((rawMax * 0.5 * totalMultiplier) * 10) / 10
|
|
|
|
return { min, max, min256, max256 }
|
|
}
|
|
|
|
/**
|
|
* Authentic 1.13c Enchant attack damage calculation including:
|
|
* - Ranged attack penalty: Ranged attacks (bow/crossbow/throwing) receive only 33% of the Enchant fire damage bonus.
|
|
* - Sorceress Melee Double-Dip: When a Sorceress with Fire Mastery attacks in melee while buffed with Enchant,
|
|
* her outgoing fire damage is multiplied by (1 + calculateFireMasteryBonus(fmSlvl) / 100) again!
|
|
* Ranged attacks do NOT get the second melee multiplier.
|
|
* - Fire resistance and immunity: Fire Mastery does NOT pierce fire resistance and CANNOT break fire immunities (Res >= 100%).
|
|
*/
|
|
export function calculateEnchantAttackDamage(params: {
|
|
enchantDamage: { min: number; max: number }
|
|
isRanged?: boolean | undefined
|
|
isSorceress?: boolean | undefined
|
|
fireMasterySlvl?: number | undefined
|
|
targetFireResist?: number | undefined
|
|
}): { min: number; max: number; average: number } {
|
|
let min = params.enchantDamage.min
|
|
let max = params.enchantDamage.max
|
|
|
|
if (params.isRanged) {
|
|
// Ranged attack penalty: 33% of Enchant fire damage bonus, no second Fire Mastery
|
|
min = Math.round(min * 0.33 * 10) / 10
|
|
max = Math.round(max * 0.33 * 10) / 10
|
|
} else {
|
|
// Melee attack: full Enchant fire damage
|
|
// Sorceress Melee Double-Dip:
|
|
if (params.isSorceress && params.fireMasterySlvl && params.fireMasterySlvl > 0) {
|
|
const fmMul = 1.0 + calculateFireMasteryBonus(params.fireMasterySlvl) / 100
|
|
min = Math.round(min * fmMul * 10) / 10
|
|
max = Math.round(max * fmMul * 10) / 10
|
|
}
|
|
}
|
|
|
|
// Fire resistance / immunity check
|
|
if (params.targetFireResist !== undefined) {
|
|
if (params.targetFireResist >= 100) {
|
|
// Fire Mastery cannot break fire immunity (Res >= 100%)
|
|
return { min: 0, max: 0, average: 0 }
|
|
}
|
|
const resFactor = Math.max(0, 100 - params.targetFireResist) / 100
|
|
min = Math.round(min * resFactor * 10) / 10
|
|
max = Math.round(max * resFactor * 10) / 10
|
|
}
|
|
|
|
const average = Math.round(((min + max) / 2) * 10) / 10
|
|
return { min, max, average }
|
|
}
|
|
|
|
/* ========================================================================= */
|
|
/* Necromancer Poison & Bone Spells (Milestone M2, IDs 67, 68, 73, 74, 78, */
|
|
/* 83, 84, 88, 92, 93) Ground Truth 1.13c Decoupled Simulation & Damage */
|
|
/* ========================================================================= */
|
|
|
|
/**
|
|
* Calculates Teeth (Skill 67) synergy multiplier:
|
|
* +15% magic damage per hard point in Bone Wall (78), Bone Prison (88), Bone Spear (84), Bone Spirit (93).
|
|
*/
|
|
export function calculateTeethSynergyMultiplier(synergies: {
|
|
readonly boneWall?: number | undefined
|
|
readonly bonePrison?: number | undefined
|
|
readonly boneSpear?: number | undefined
|
|
readonly boneSpirit?: number | undefined
|
|
} | number = {}): number {
|
|
if (typeof synergies === 'number') {
|
|
return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
}
|
|
const points =
|
|
(synergies.boneWall ?? 0) +
|
|
(synergies.bonePrison ?? 0) +
|
|
(synergies.boneSpear ?? 0) +
|
|
(synergies.boneSpirit ?? 0)
|
|
return 1.0 + points * 0.15
|
|
}
|
|
|
|
/**
|
|
* Calculates number of teeth fired by Teeth (Skill 67):
|
|
* min(ln12, 24) where par1=2, par2=1 -> min(2 + (slvl - 1) * 1, 24).
|
|
*/
|
|
export function calculateTeethCount(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return Math.min(24, Math.max(2, 2 + (lvl - 1) * 1))
|
|
}
|
|
|
|
/**
|
|
* Calculates Teeth (Skill 67) magic damage range:
|
|
* 1.13c ground truth:
|
|
* - EMin: 4, EMax: 8
|
|
* - eMinLev: [2, 2, 3, 4, 5]
|
|
* - eMaxLev: [2, 3, 4, 5, 6]
|
|
* - HitShift: 7 (half-point scaling, 128/256 precision)
|
|
* - Synergies: +15% per hard point from Bone Wall, Bone Prison, Bone Spear, Bone Spirit.
|
|
*/
|
|
export function calculateTeethDamage(
|
|
slvl: number,
|
|
synergies: {
|
|
readonly boneWall?: number | undefined
|
|
readonly bonePrison?: number | undefined
|
|
readonly boneSpear?: number | undefined
|
|
readonly boneSpirit?: number | undefined
|
|
} | number = {},
|
|
): DamageRange {
|
|
const syn = calculateTeethSynergyMultiplier(synergies)
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
|
|
const baseMin = compute5BandScaling(lvl, 4, 2, 2, 3, 4, 5)
|
|
const baseMax = compute5BandScaling(lvl, 8, 2, 3, 4, 5, 6)
|
|
|
|
// HitShift 7 = factor 0.5 (or << 7 then / 256)
|
|
const min = Math.floor((baseMin * 0.5) * syn)
|
|
const max = Math.floor((baseMax * 0.5) * syn)
|
|
|
|
return { min, max }
|
|
}
|
|
|
|
/**
|
|
* Calculates Bone Armor (Skill 68) synergy absorption bonus:
|
|
* +15 physical damage absorbed per hard point in Bone Wall (78) and Bone Prison (88).
|
|
*/
|
|
export function calculateBoneArmorSynergyBonus(synergies: {
|
|
readonly boneWall?: number | undefined
|
|
readonly bonePrison?: number | undefined
|
|
} | number = {}): number {
|
|
if (typeof synergies === 'number') {
|
|
return Number.isFinite(synergies) && synergies > 0 ? synergies : 0
|
|
}
|
|
const points = (synergies.boneWall ?? 0) + (synergies.bonePrison ?? 0)
|
|
return points * 15
|
|
}
|
|
|
|
/**
|
|
* Calculates Bone Armor (Skill 68) total physical damage absorption capacity:
|
|
* 1.13c ground truth:
|
|
* - ln12: par1=20, par2=10 -> 20 + (slvl - 1) * 10
|
|
* - Synergies: +15 damage per hard point from Bone Wall and Bone Prison
|
|
* Absorbs PHYSICAL melee and missile damage only.
|
|
*/
|
|
export function calculateBoneArmorCapacity(
|
|
slvl: number,
|
|
synergies: {
|
|
readonly boneWall?: number | undefined
|
|
readonly bonePrison?: number | undefined
|
|
} | number = {},
|
|
): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const baseCapacity = 20 + (lvl - 1) * 10
|
|
const synBonus = calculateBoneArmorSynergyBonus(synergies)
|
|
return baseCapacity + synBonus
|
|
}
|
|
|
|
/**
|
|
* Calculates Poison Dagger (Skill 73) synergy multiplier:
|
|
* +20% poison damage per hard point in Poison Explosion (83) and Poison Nova (92).
|
|
*/
|
|
export function calculatePoisonDaggerSynergyMultiplier(synergies: {
|
|
readonly poisonExplosion?: number | undefined
|
|
readonly poisonNova?: number | undefined
|
|
} | number = {}): number {
|
|
if (typeof synergies === 'number') {
|
|
return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
}
|
|
const points = (synergies.poisonExplosion ?? 0) + (synergies.poisonNova ?? 0)
|
|
return 1.0 + points * 0.20
|
|
}
|
|
|
|
/**
|
|
* Calculates Poison Dagger (Skill 73) attack rating bonus %:
|
|
* ln12 (par1=30, par2=20) -> +30% at slvl 1, +20%/lvl (+30% to +410% at slvl 20).
|
|
*/
|
|
export function calculatePoisonDaggerAttackRatingBonus(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
return 30 + (lvl - 1) * 20
|
|
}
|
|
|
|
/**
|
|
* Calculates Poison Dagger (Skill 73) poison damage specification:
|
|
* 1.13c ground truth:
|
|
* - eMin: 18, eMax: 40
|
|
* - elev: [10, 15, 20, 23, 26]
|
|
* - HitShift: 1 (rate = base << 1 in 256ths/frame)
|
|
* - Duration: 50 + (slvl - 1) * 10 frames (2.0s to 9.6s)
|
|
* - Synergies: +20% per hard point from Poison Explosion and Poison Nova
|
|
*/
|
|
export function calculatePoisonDaggerDamage(
|
|
slvl: number,
|
|
synergies: {
|
|
readonly poisonExplosion?: number | undefined
|
|
readonly poisonNova?: number | undefined
|
|
} | number = {},
|
|
): PoisonDamageSpec {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const syn = calculatePoisonDaggerSynergyMultiplier(synergies)
|
|
|
|
const baseMin = compute5BandScaling(lvl, 18, 10, 15, 20, 23, 26)
|
|
const baseMax = compute5BandScaling(lvl, 40, 10, 15, 20, 23, 26)
|
|
|
|
const minRate = (baseMin << 1)
|
|
const maxRate = (baseMax << 1)
|
|
|
|
const minRateScaled = Math.trunc(minRate * syn)
|
|
const maxRateScaled = Math.trunc(maxRate * syn)
|
|
|
|
const durationFrames = 50 + (lvl - 1) * 10
|
|
const durationSeconds = durationFrames / 25
|
|
|
|
const minDamage = Math.floor((minRateScaled * durationFrames) / 256)
|
|
const maxDamage = Math.floor((maxRateScaled * durationFrames) / 256)
|
|
|
|
return {
|
|
minDamage,
|
|
maxDamage,
|
|
durationFrames,
|
|
durationSeconds,
|
|
minRate: minRateScaled,
|
|
maxRate: maxRateScaled,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates Corpse Explosion (Skill 74) damage from base unscaled monster hit points:
|
|
* 1.13c ground truth:
|
|
* - 70% to 120% of base monster HP (unscaled by player count / difficulty)
|
|
* - Exactly 50% physical damage and 50% fire damage.
|
|
*/
|
|
export function calculateCorpseExplosionDamage(
|
|
monsterBaseHp: number,
|
|
rollPct?: number,
|
|
): {
|
|
phys: number
|
|
fire: number
|
|
minPhys: number
|
|
maxPhys: number
|
|
minFire: number
|
|
maxFire: number
|
|
minTotal: number
|
|
maxTotal: number
|
|
} {
|
|
const hp = Math.max(0, monsterBaseHp)
|
|
const roll = rollPct !== undefined ? Math.max(70, Math.min(120, rollPct)) : 95
|
|
const rawTotal = Math.trunc((hp * roll) / 100)
|
|
const fire = Math.trunc((rawTotal * 50) / 100)
|
|
const phys = rawTotal - fire
|
|
|
|
const minTotal = Math.trunc((hp * 70) / 100)
|
|
const minFire = Math.trunc((minTotal * 50) / 100)
|
|
const minPhys = minTotal - minFire
|
|
|
|
const maxTotal = Math.trunc((hp * 120) / 100)
|
|
const maxFire = Math.trunc((maxTotal * 50) / 100)
|
|
const maxPhys = maxTotal - maxFire
|
|
|
|
return {
|
|
phys,
|
|
fire,
|
|
minPhys,
|
|
maxPhys,
|
|
minFire,
|
|
maxFire,
|
|
minTotal,
|
|
maxTotal,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates Corpse Explosion (Skill 74) radius in yards:
|
|
* ln34 (par3=8, par4=1 subtiles -> subtiles * 2/3):
|
|
* slvl 1: 5.33 yards; slvl 10: 11.33 yards; slvl 20: 18.0 yards.
|
|
*/
|
|
export function calculateCorpseExplosionRadiusYards(slvl: number): number {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const subtiles = 8 + (lvl - 1) * 1
|
|
return Math.round((subtiles * (2 / 3)) * 100) / 100
|
|
}
|
|
|
|
/**
|
|
* Calculates Corpse Explosion (Skill 74) radius in pixels on 2D ground plane (40 px / yard).
|
|
*/
|
|
export function calculateCorpseExplosionRadiusPx(slvl: number): number {
|
|
return Math.round(calculateCorpseExplosionRadiusYards(slvl) * 40)
|
|
}
|
|
|
|
/**
|
|
* Calculates Bone Wall (Skill 78) stats:
|
|
* 1.13c ground truth:
|
|
* - Base HP: 19 + (slvl - 1) * 4.6
|
|
* - Synergies: +10% per hard point from Bone Armor (68) and Bone Prison (88)
|
|
* - Duration: fixed 24.0s (600 frames)
|
|
* - Segments: 5 destructible segments
|
|
*/
|
|
export function calculateBoneWallStats(
|
|
slvl: number,
|
|
synergies: {
|
|
readonly boneArmor?: number | undefined
|
|
readonly bonePrison?: number | undefined
|
|
} | number = {},
|
|
): {
|
|
hp: number
|
|
durationFrames: number
|
|
durationSeconds: number
|
|
segmentCount: number
|
|
} {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let syn = 1.0
|
|
if (typeof synergies === 'number') {
|
|
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
} else {
|
|
const points = (synergies.boneArmor ?? 0) + (synergies.bonePrison ?? 0)
|
|
syn = 1.0 + points * 0.10
|
|
}
|
|
const baseHp = 19 + (lvl - 1) * 4.6
|
|
const hp = Math.floor(baseHp * syn)
|
|
|
|
return {
|
|
hp,
|
|
durationFrames: 600,
|
|
durationSeconds: 24.0,
|
|
segmentCount: 5,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates Poison Explosion (Skill 83) synergy multiplier:
|
|
* +15% poison damage per hard point in Poison Dagger (73) and Poison Nova (92).
|
|
*/
|
|
export function calculatePoisonExplosionSynergyMultiplier(synergies: {
|
|
readonly poisonDagger?: number | undefined
|
|
readonly poisonNova?: number | undefined
|
|
} | number = {}): number {
|
|
if (typeof synergies === 'number') {
|
|
return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
}
|
|
const points = (synergies.poisonDagger ?? 0) + (synergies.poisonNova ?? 0)
|
|
return 1.0 + points * 0.15
|
|
}
|
|
|
|
/**
|
|
* Calculates Poison Explosion (Skill 83) poison damage specification:
|
|
* 1.13c ground truth:
|
|
* - eMin: 8, eMax: 24
|
|
* - elev: [2, 4, 6, 8, 10]
|
|
* - HitShift: 4 (rate = base << 4 in 256ths/frame)
|
|
* - Duration: 50 + (slvl - 1) * 10 frames (2.0s to 9.6s)
|
|
* - Synergies: +15% per hard point from Poison Dagger and Poison Nova
|
|
*/
|
|
export function calculatePoisonExplosionDamage(
|
|
slvl: number,
|
|
synergies: {
|
|
readonly poisonDagger?: number | undefined
|
|
readonly poisonNova?: number | undefined
|
|
} | number = {},
|
|
): PoisonDamageSpec {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const syn = calculatePoisonExplosionSynergyMultiplier(synergies)
|
|
|
|
const baseMin = compute5BandScaling(lvl, 8, 2, 4, 6, 8, 10)
|
|
const baseMax = compute5BandScaling(lvl, 24, 2, 4, 6, 8, 10)
|
|
|
|
const minRate = (baseMin << 4)
|
|
const maxRate = (baseMax << 4)
|
|
|
|
const minRateScaled = Math.trunc(minRate * syn)
|
|
const maxRateScaled = Math.trunc(maxRate * syn)
|
|
|
|
const durationFrames = 50 + (lvl - 1) * 10
|
|
const durationSeconds = durationFrames / 25
|
|
|
|
const minDamage = Math.floor((minRateScaled * durationFrames) / 256)
|
|
const maxDamage = Math.floor((maxRateScaled * durationFrames) / 256)
|
|
|
|
return {
|
|
minDamage,
|
|
maxDamage,
|
|
durationFrames,
|
|
durationSeconds,
|
|
minRate: minRateScaled,
|
|
maxRate: maxRateScaled,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates Bone Spear (Skill 84) synergy multiplier:
|
|
* +7% magic damage per hard point in Teeth (67), Bone Wall (78), Bone Prison (88), Bone Spirit (93).
|
|
*/
|
|
export function calculateBoneSpearSynergyMultiplier(synergies: {
|
|
readonly teeth?: number | undefined
|
|
readonly boneWall?: number | undefined
|
|
readonly bonePrison?: number | undefined
|
|
readonly boneSpirit?: number | undefined
|
|
} | number = {}): number {
|
|
if (typeof synergies === 'number') {
|
|
return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
}
|
|
const points =
|
|
(synergies.teeth ?? 0) +
|
|
(synergies.boneWall ?? 0) +
|
|
(synergies.bonePrison ?? 0) +
|
|
(synergies.boneSpirit ?? 0)
|
|
return 1.0 + points * 0.07
|
|
}
|
|
|
|
/**
|
|
* Calculates Bone Spear (Skill 84) magic damage range:
|
|
* 1.13c ground truth:
|
|
* - eMin: 16, eMax: 24
|
|
* - eMinLev: [8, 9, 12, 18, 24]
|
|
* - eMaxLev: [8, 9, 13, 19, 25]
|
|
* - HitShift: 8 (1:1 precision)
|
|
* - Synergies: +7% per hard point from Teeth, Bone Wall, Bone Prison, Bone Spirit.
|
|
* - Kinematic: Piercing magic projectile (collideKill = 0, Vel 24, Range 40).
|
|
*/
|
|
export function calculateBoneSpearDamage(
|
|
slvl: number,
|
|
synergies: {
|
|
readonly teeth?: number | undefined
|
|
readonly boneWall?: number | undefined
|
|
readonly bonePrison?: number | undefined
|
|
readonly boneSpirit?: number | undefined
|
|
} | number = {},
|
|
): DamageRange {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const syn = calculateBoneSpearSynergyMultiplier(synergies)
|
|
|
|
const baseMin = compute5BandScaling(lvl, 16, 8, 9, 12, 18, 24)
|
|
const baseMax = compute5BandScaling(lvl, 24, 8, 9, 13, 19, 25)
|
|
|
|
const min = Math.floor(baseMin * syn)
|
|
const max = Math.floor(baseMax * syn)
|
|
|
|
return { min, max }
|
|
}
|
|
|
|
/**
|
|
* Calculates Bone Prison (Skill 88) stats:
|
|
* 1.13c ground truth:
|
|
* - Base HP: 19 + (slvl - 1) * 4.6
|
|
* - Synergies: +8% per hard point from Bone Armor (68) and Bone Wall (78)
|
|
* - Duration: fixed 24.0s (600 frames)
|
|
* - Structure: Ring of bone segments encircling target unit.
|
|
*/
|
|
export function calculateBonePrisonStats(
|
|
slvl: number,
|
|
synergies: {
|
|
readonly boneArmor?: number | undefined
|
|
readonly boneWall?: number | undefined
|
|
} | number = {},
|
|
): {
|
|
hp: number
|
|
durationFrames: number
|
|
durationSeconds: number
|
|
segmentCount: number
|
|
} {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let syn = 1.0
|
|
if (typeof synergies === 'number') {
|
|
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
} else {
|
|
const points = (synergies.boneArmor ?? 0) + (synergies.boneWall ?? 0)
|
|
syn = 1.0 + points * 0.08
|
|
}
|
|
const baseHp = 19 + (lvl - 1) * 4.6
|
|
const hp = Math.floor(baseHp * syn)
|
|
|
|
return {
|
|
hp,
|
|
durationFrames: 600,
|
|
durationSeconds: 24.0,
|
|
segmentCount: 16,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates Poison Nova (Skill 92) synergy multiplier:
|
|
* +10% poison damage per hard point in Poison Dagger (73) and Poison Explosion (83).
|
|
*/
|
|
export function calculatePoisonNovaSynergyMultiplier(synergies: {
|
|
readonly poisonDagger?: number | undefined
|
|
readonly poisonExplosion?: number | undefined
|
|
} | number = {}): number {
|
|
if (typeof synergies === 'number') {
|
|
return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
}
|
|
const points = (synergies.poisonDagger ?? 0) + (synergies.poisonExplosion ?? 0)
|
|
return 1.0 + points * 0.10
|
|
}
|
|
|
|
/**
|
|
* Calculates Poison Nova (Skill 92) poison damage specification:
|
|
* 1.13c ground truth:
|
|
* - eMin: 16, eMax: 29
|
|
* - elev: [4, 6, 9, 14, 16]
|
|
* - HitShift: 4 (rate = base << 4 in 256ths/frame)
|
|
* - Duration: Strictly FIXED at 50 frames (2.0s) for all levels!
|
|
* - Synergies: +10% per hard point from Poison Dagger and Poison Explosion.
|
|
* - Kinematic: Exactly 64 radial poison missiles (360 degrees).
|
|
*/
|
|
export function calculatePoisonNovaDamage(
|
|
slvl: number,
|
|
synergies: {
|
|
readonly poisonDagger?: number | undefined
|
|
readonly poisonExplosion?: number | undefined
|
|
} | number = {},
|
|
): PoisonDamageSpec {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const syn = calculatePoisonNovaSynergyMultiplier(synergies)
|
|
|
|
const baseMin = compute5BandScaling(lvl, 16, 4, 6, 9, 14, 16)
|
|
const baseMax = compute5BandScaling(lvl, 29, 4, 6, 9, 14, 16)
|
|
|
|
const minRate = (baseMin << 4)
|
|
const maxRate = (baseMax << 4)
|
|
|
|
const minRateScaled = Math.trunc(minRate * syn)
|
|
const maxRateScaled = Math.trunc(maxRate * syn)
|
|
|
|
const durationFrames = 50
|
|
const durationSeconds = 2.0
|
|
|
|
const minDamage = Math.floor((minRateScaled * durationFrames) / 256)
|
|
const maxDamage = Math.floor((maxRateScaled * durationFrames) / 256)
|
|
|
|
return {
|
|
minDamage,
|
|
maxDamage,
|
|
durationFrames,
|
|
durationSeconds,
|
|
minRate: minRateScaled,
|
|
maxRate: maxRateScaled,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates Bone Spirit (Skill 93) synergy multiplier:
|
|
* +6% magic damage per hard point in Teeth (67), Bone Wall (78), Bone Spear (84), Bone Prison (88).
|
|
*/
|
|
export function calculateBoneSpiritSynergyMultiplier(synergies: {
|
|
readonly teeth?: number | undefined
|
|
readonly boneWall?: number | undefined
|
|
readonly boneSpear?: number | undefined
|
|
readonly bonePrison?: number | undefined
|
|
} | number = {}): number {
|
|
if (typeof synergies === 'number') {
|
|
return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
}
|
|
const points =
|
|
(synergies.teeth ?? 0) +
|
|
(synergies.boneWall ?? 0) +
|
|
(synergies.boneSpear ?? 0) +
|
|
(synergies.bonePrison ?? 0)
|
|
return 1.0 + points * 0.06
|
|
}
|
|
|
|
/**
|
|
* Calculates Bone Spirit (Skill 93) magic damage range:
|
|
* 1.13c ground truth:
|
|
* - eMin: 20, eMax: 30
|
|
* - eMinLev: [16, 17, 18, 19, 20]
|
|
* - eMaxLev: [17, 18, 19, 20, 21]
|
|
* - HitShift: 8 (1:1 precision)
|
|
* - Synergies: +6% per hard point from Teeth, Bone Wall, Bone Spear, Bone Prison.
|
|
* - Kinematic: Homing missile (Vel 12, Range 128) tracking target.
|
|
*/
|
|
export function calculateBoneSpiritDamage(
|
|
slvl: number,
|
|
synergies: {
|
|
readonly teeth?: number | undefined
|
|
readonly boneWall?: number | undefined
|
|
readonly boneSpear?: number | undefined
|
|
readonly bonePrison?: number | undefined
|
|
} | number = {},
|
|
): DamageRange {
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
const syn = calculateBoneSpiritSynergyMultiplier(synergies)
|
|
|
|
const baseMin = compute5BandScaling(lvl, 20, 16, 17, 18, 19, 20)
|
|
const baseMax = compute5BandScaling(lvl, 30, 17, 18, 19, 20, 21)
|
|
|
|
const min = Math.floor(baseMin * syn)
|
|
const max = Math.floor(baseMax * syn)
|
|
|
|
return { min, max }
|
|
}
|
|
|