8307 lines
233 KiB
TypeScript
8307 lines
233 KiB
TypeScript
/**
|
|
* Skills: table-driven definitions, casting, and projectiles.
|
|
*
|
|
* Diablo II skills are rows in `Skills.txt`: a mana cost, a cooldown, a range,
|
|
* and damage that scales with the skill's level in bands. This module reads that
|
|
* shape and turns it into something castable, in three parts:
|
|
*
|
|
* - a **definition** read from a table row (never a constant in code, so a real
|
|
* `Skills.txt` changes the game without touching this file);
|
|
* - a **cast** step that checks mana and cooldown and produces either an instant
|
|
* effect or a projectile;
|
|
* - a **projectile** step that flies at the simulation rate, dies on walls,
|
|
* expires on its own timer, and reports what it hit.
|
|
*
|
|
* Damage progression is the one place where a simplification is visible in the
|
|
* code: the real table scales damage through five level bands
|
|
* (`MinLevDam1..5`), and this reads a single `PerLevel` slope instead. The band
|
|
* columns are a mapping-layer problem — they carry no new mechanics, only a
|
|
* different interpolation — so the formula is isolated in {@link skillDamageAt}
|
|
* where the bands can replace it.
|
|
*/
|
|
import { numberCell, resolveText, textCell } from './tables.ts'
|
|
import type { TextSource, DataTable } from './tables.ts'
|
|
import type { D2Table } from './acts.ts'
|
|
import type { Rng } from './rng.ts'
|
|
import type { CharacterClassCode } from './classes.ts'
|
|
import type { CombatPlayer } from './combat.ts'
|
|
import {
|
|
FROZEN_ORB_X_POSITIONS,
|
|
FROZEN_ORB_Y_POSITIONS,
|
|
calculateFrostNovaDamage,
|
|
calculateFrostNovaChillLength,
|
|
calculateFrostNovaSynergyMultiplier,
|
|
spawnFrostNovaRing,
|
|
calculateNovaDamage,
|
|
calculateStaticFieldDamage,
|
|
calculateStaticFieldRadiusYards,
|
|
calculateStaticFieldRadiusPx,
|
|
hasLineOfSight,
|
|
spawnNovaRing,
|
|
executeStaticField,
|
|
calculateLightningDamage,
|
|
calculateLightningSynergyMultiplier,
|
|
calculateChainLightningDamage,
|
|
calculateChainLightningSynergyMultiplier,
|
|
calculateChainLightningJumps,
|
|
type StaticFieldResult,
|
|
type StaticFieldTarget,
|
|
GAT_CHARGED_BOLT_OFFSETS,
|
|
calculateChargedBoltCount,
|
|
calculateChargedBoltSynergyMultiplier,
|
|
calculateChargedBoltDamage,
|
|
calculateEnergyShieldManaRatio,
|
|
calculateEnergyShieldDuration,
|
|
calculateEnergyShieldAbsorbPct,
|
|
calculateThunderStormDuration,
|
|
calculateThunderStormInterval,
|
|
calculateThunderStormDamage,
|
|
calculateLightningMasteryBonusPct,
|
|
calculateTelekinesisDamage,
|
|
isTelekinesisItemAllowed,
|
|
isTelekinesisObjectAllowed,
|
|
calculateFrozenArmorDefense,
|
|
calculateFrozenArmorDuration,
|
|
calculateFrozenArmorFreezeLength,
|
|
calculateShiverArmorDefense,
|
|
calculateShiverArmorDuration,
|
|
calculateShiverArmorDamage,
|
|
calculateShiverArmorChillLength,
|
|
calculateChillingArmorDefense,
|
|
calculateChillingArmorDuration,
|
|
calculateChillingArmorDamage,
|
|
calculateChillingArmorChillLength,
|
|
calculateColdArmorDuration,
|
|
calculateIceBlastDamage,
|
|
calculateIceBlastFreezeLength,
|
|
calculateGlacialSpikeDamage,
|
|
calculateGlacialSpikeFreezeLength,
|
|
calculateColdMasteryPierce,
|
|
calculateFireWallDamage,
|
|
calculateBlazeDamage,
|
|
calculateInfernoDamage,
|
|
calculateBlazeDuration,
|
|
calculateBlazePatchDuration,
|
|
calculateInfernoRange,
|
|
calculateInfernoRangePx,
|
|
calculateInfernoContinuousMana256,
|
|
calculateFireWallPatches,
|
|
calculateEnchantDamage,
|
|
calculateEnchantAttackRating,
|
|
calculateEnchantDuration,
|
|
calculateFireMasteryBonus,
|
|
calculateEnchantAttackDamage,
|
|
calculateTeethDamage,
|
|
calculateTeethSynergyMultiplier,
|
|
calculateTeethCount,
|
|
calculateBoneArmorCapacity,
|
|
calculateBoneArmorSynergyBonus,
|
|
calculatePoisonDaggerDamage,
|
|
calculatePoisonDaggerSynergyMultiplier,
|
|
calculatePoisonDaggerAttackRatingBonus,
|
|
calculateCorpseExplosionDamage,
|
|
calculateCorpseExplosionRadiusYards,
|
|
calculateCorpseExplosionRadiusPx,
|
|
calculateBoneWallStats,
|
|
calculatePoisonExplosionDamage,
|
|
calculatePoisonExplosionSynergyMultiplier,
|
|
calculateBoneSpearDamage,
|
|
calculateBoneSpearSynergyMultiplier,
|
|
calculateBonePrisonStats,
|
|
calculatePoisonNovaDamage,
|
|
calculatePoisonNovaSynergyMultiplier,
|
|
calculateBoneSpiritDamage,
|
|
calculateBoneSpiritSynergyMultiplier,
|
|
spawnPoisonNovaRing,
|
|
calculateCurseRadiusYards,
|
|
calculateCurseRadiusPx,
|
|
calculateCurseDurationFrames,
|
|
calculateAmplifyDamageDebuff,
|
|
calculateWeakenDebuff,
|
|
calculateIronMaidenReturnPct,
|
|
calculateTerrorDistance,
|
|
calculateDecrepifyDebuff,
|
|
calculateLowerResistReduction,
|
|
computeImmunityBreak,
|
|
calculateCurseEffect,
|
|
calculateSkeletonMaxCount,
|
|
calculateSkeletalMageMaxCount,
|
|
calculateReviveMaxCount,
|
|
calculateSkeletonMasteryStats,
|
|
calculateGolemMasteryStats,
|
|
calculateSummonResistPct,
|
|
calculateClayGolemStats,
|
|
calculateBloodGolemStats,
|
|
calculateIronGolemStats,
|
|
calculateFireGolemStats,
|
|
calculateReviveStats,
|
|
type SkeletonMasteryStats,
|
|
type GolemMasteryStats,
|
|
type ClayGolemStats,
|
|
type BloodGolemStats,
|
|
type IronGolemItemProps,
|
|
type IronGolemStats,
|
|
type FireGolemStats,
|
|
type ReviveStats,
|
|
type CurseOpts,
|
|
type CurseSpec,
|
|
type DamageRange,
|
|
type PoisonDamageSpec,
|
|
type SynergyState,
|
|
} from './engine/missile-engine.ts'
|
|
export {
|
|
calculateFrostNovaDamage,
|
|
calculateFrostNovaChillLength,
|
|
calculateFrostNovaSynergyMultiplier,
|
|
spawnFrostNovaRing,
|
|
calculateNovaDamage,
|
|
calculateStaticFieldDamage,
|
|
calculateStaticFieldRadiusYards,
|
|
calculateStaticFieldRadiusPx,
|
|
hasLineOfSight,
|
|
spawnNovaRing,
|
|
executeStaticField,
|
|
calculateLightningDamage,
|
|
calculateLightningSynergyMultiplier,
|
|
calculateChainLightningDamage,
|
|
calculateChainLightningSynergyMultiplier,
|
|
calculateChainLightningJumps,
|
|
type StaticFieldResult,
|
|
type StaticFieldTarget,
|
|
GAT_CHARGED_BOLT_OFFSETS,
|
|
calculateChargedBoltCount,
|
|
calculateChargedBoltSynergyMultiplier,
|
|
calculateChargedBoltDamage,
|
|
calculateEnergyShieldManaRatio,
|
|
calculateEnergyShieldDuration,
|
|
calculateEnergyShieldAbsorbPct,
|
|
calculateThunderStormDuration,
|
|
calculateThunderStormInterval,
|
|
calculateThunderStormDamage,
|
|
calculateLightningMasteryBonusPct,
|
|
calculateTelekinesisDamage,
|
|
isTelekinesisItemAllowed,
|
|
isTelekinesisObjectAllowed,
|
|
calculateFrozenArmorDefense,
|
|
calculateFrozenArmorDuration,
|
|
calculateFrozenArmorFreezeLength,
|
|
calculateShiverArmorDefense,
|
|
calculateShiverArmorDuration,
|
|
calculateShiverArmorDamage,
|
|
calculateShiverArmorChillLength,
|
|
calculateChillingArmorDefense,
|
|
calculateChillingArmorDuration,
|
|
calculateChillingArmorDamage,
|
|
calculateChillingArmorChillLength,
|
|
calculateColdArmorDuration,
|
|
calculateIceBlastDamage,
|
|
calculateIceBlastFreezeLength,
|
|
calculateGlacialSpikeDamage,
|
|
calculateGlacialSpikeFreezeLength,
|
|
calculateColdMasteryPierce,
|
|
calculateFireWallDamage,
|
|
calculateBlazeDamage,
|
|
calculateInfernoDamage,
|
|
calculateBlazeDuration,
|
|
calculateBlazePatchDuration,
|
|
calculateInfernoRange,
|
|
calculateInfernoRangePx,
|
|
calculateInfernoContinuousMana256,
|
|
calculateFireWallPatches,
|
|
calculateEnchantDamage,
|
|
calculateEnchantAttackRating,
|
|
calculateEnchantDuration,
|
|
calculateFireMasteryBonus,
|
|
calculateEnchantAttackDamage,
|
|
calculateTeethDamage,
|
|
calculateTeethSynergyMultiplier,
|
|
calculateTeethCount,
|
|
calculateBoneArmorCapacity,
|
|
calculateBoneArmorSynergyBonus,
|
|
calculatePoisonDaggerDamage,
|
|
calculatePoisonDaggerSynergyMultiplier,
|
|
calculatePoisonDaggerAttackRatingBonus,
|
|
calculateCorpseExplosionDamage,
|
|
calculateCorpseExplosionRadiusYards,
|
|
calculateCorpseExplosionRadiusPx,
|
|
calculateBoneWallStats,
|
|
calculatePoisonExplosionDamage,
|
|
calculatePoisonExplosionSynergyMultiplier,
|
|
calculateBoneSpearDamage,
|
|
calculateBoneSpearSynergyMultiplier,
|
|
calculateBonePrisonStats,
|
|
calculatePoisonNovaDamage,
|
|
calculatePoisonNovaSynergyMultiplier,
|
|
calculateBoneSpiritDamage,
|
|
calculateBoneSpiritSynergyMultiplier,
|
|
spawnPoisonNovaRing,
|
|
calculateCurseRadiusYards,
|
|
calculateCurseRadiusPx,
|
|
calculateCurseDurationFrames,
|
|
calculateAmplifyDamageDebuff,
|
|
calculateWeakenDebuff,
|
|
calculateIronMaidenReturnPct,
|
|
calculateTerrorDistance,
|
|
calculateDecrepifyDebuff,
|
|
calculateLowerResistReduction,
|
|
computeImmunityBreak,
|
|
calculateCurseEffect,
|
|
calculateSkeletonMaxCount,
|
|
calculateSkeletalMageMaxCount,
|
|
calculateReviveMaxCount,
|
|
calculateSkeletonMasteryStats,
|
|
calculateGolemMasteryStats,
|
|
calculateSummonResistPct,
|
|
calculateClayGolemStats,
|
|
calculateBloodGolemStats,
|
|
calculateIronGolemStats,
|
|
calculateFireGolemStats,
|
|
calculateReviveStats,
|
|
type SkeletonMasteryStats,
|
|
type GolemMasteryStats,
|
|
type ClayGolemStats,
|
|
type BloodGolemStats,
|
|
type IronGolemItemProps,
|
|
type IronGolemStats,
|
|
type FireGolemStats,
|
|
type ReviveStats,
|
|
type CurseOpts,
|
|
type CurseSpec,
|
|
type DamageRange,
|
|
type PoisonDamageSpec,
|
|
type SynergyState,
|
|
} from './engine/missile-engine.ts'
|
|
import {
|
|
BLIZZARD_CIRCLE_X,
|
|
BLIZZARD_CIRCLE_Y,
|
|
getBlizzardDropOffset,
|
|
} from './engine/blizzard-table.ts'
|
|
export { BLIZZARD_CIRCLE_X, BLIZZARD_CIRCLE_Y, getBlizzardDropOffset } from './engine/blizzard-table.ts'
|
|
import {
|
|
METEOR_FIRE_SUBTILE_OFFSETS,
|
|
type MeteorFireSubtileOffset,
|
|
} from './engine/meteor-table.ts'
|
|
export {
|
|
METEOR_FIRE_SUBTILE_OFFSETS,
|
|
type MeteorFireSubtileOffset,
|
|
} from './engine/meteor-table.ts'
|
|
import {
|
|
calculateMagicArrowStats,
|
|
calculateFireArrowStats,
|
|
calculateColdArrowStats,
|
|
calculateMultipleShotStats,
|
|
calculateExplodingArrowStats,
|
|
calculateIceArrowStats,
|
|
calculateGuidedArrowStats,
|
|
calculateStrafeStats,
|
|
calculateImmolationArrowStats,
|
|
calculateFreezingArrowStats,
|
|
generateMultipleShotAngles,
|
|
isMultipleShotCenterArrow,
|
|
type MagicArrowStats,
|
|
type FireArrowStats,
|
|
type ColdArrowStats,
|
|
type MultipleShotStats,
|
|
type ExplodingArrowStats,
|
|
type IceArrowStats,
|
|
type GuidedArrowStats,
|
|
type StrafeStats,
|
|
type ImmolationArrowStats,
|
|
type FreezingArrowStats,
|
|
} from './skills/amazon-bow.ts'
|
|
export {
|
|
calculateMagicArrowStats,
|
|
calculateFireArrowStats,
|
|
calculateColdArrowStats,
|
|
calculateMultipleShotStats,
|
|
calculateExplodingArrowStats,
|
|
calculateIceArrowStats,
|
|
calculateGuidedArrowStats,
|
|
calculateStrafeStats,
|
|
calculateImmolationArrowStats,
|
|
calculateFreezingArrowStats,
|
|
generateMultipleShotAngles,
|
|
isMultipleShotCenterArrow,
|
|
type MagicArrowStats,
|
|
type FireArrowStats,
|
|
type ColdArrowStats,
|
|
type MultipleShotStats,
|
|
type ExplodingArrowStats,
|
|
type IceArrowStats,
|
|
type GuidedArrowStats,
|
|
type StrafeStats,
|
|
type ImmolationArrowStats,
|
|
type FreezingArrowStats,
|
|
} from './skills/amazon-bow.ts'
|
|
import {
|
|
calculateInnerSightStats,
|
|
calculateCriticalStrikeStats,
|
|
calculateDodgeStats,
|
|
calculateSlowMissilesStats,
|
|
calculateAvoidStats,
|
|
calculatePenetrateStats,
|
|
calculateDecoyStats,
|
|
calculateEvadeStats,
|
|
calculateValkyrieStats,
|
|
calculatePierceStats,
|
|
type InnerSightStats,
|
|
type CriticalStrikeStats,
|
|
type DodgeStats,
|
|
type SlowMissilesStats,
|
|
type AvoidStats,
|
|
type PenetrateStats,
|
|
type DecoyStats,
|
|
type EvadeStats,
|
|
type ValkyrieStats,
|
|
type ValkyrieEquipmentTier,
|
|
type PierceStats,
|
|
} from './skills/amazon-passive.ts'
|
|
export {
|
|
calculateInnerSightStats,
|
|
calculateCriticalStrikeStats,
|
|
calculateDodgeStats,
|
|
calculateSlowMissilesStats,
|
|
calculateAvoidStats,
|
|
calculatePenetrateStats,
|
|
calculateDecoyStats,
|
|
calculateEvadeStats,
|
|
calculateValkyrieStats,
|
|
calculatePierceStats,
|
|
type InnerSightStats,
|
|
type CriticalStrikeStats,
|
|
type DodgeStats,
|
|
type SlowMissilesStats,
|
|
type AvoidStats,
|
|
type PenetrateStats,
|
|
type DecoyStats,
|
|
type EvadeStats,
|
|
type ValkyrieStats,
|
|
type ValkyrieEquipmentTier,
|
|
} from './skills/amazon-passive.ts'
|
|
import {
|
|
calculateJabStats,
|
|
calculatePowerStrikeStats,
|
|
calculatePoisonJavelinStats,
|
|
calculateImpaleStats,
|
|
calculateLightningBoltStats,
|
|
calculateChargedStrikeStats,
|
|
calculatePlagueJavelinStats,
|
|
calculateFendStats,
|
|
calculateLightningStrikeStats,
|
|
calculateLightningFuryStats,
|
|
type JabStats,
|
|
type PowerStrikeStats,
|
|
type PoisonJavelinStats,
|
|
type ImpaleStats,
|
|
type LightningBoltStats,
|
|
type ChargedStrikeStats,
|
|
type PlagueJavelinStats,
|
|
type FendStats,
|
|
type LightningStrikeStats,
|
|
type LightningFuryStats,
|
|
type PowerStrikeSynergies,
|
|
type PoisonJavelinSynergies,
|
|
type LightningBoltSynergies,
|
|
type ChargedStrikeSynergies,
|
|
type PlagueJavelinSynergies,
|
|
type LightningStrikeSynergies,
|
|
type LightningFurySynergies,
|
|
} from './skills/amazon-javelin-spear.ts'
|
|
export {
|
|
calculateJabStats,
|
|
calculatePowerStrikeStats,
|
|
calculatePoisonJavelinStats,
|
|
calculateImpaleStats,
|
|
calculateLightningBoltStats,
|
|
calculateChargedStrikeStats,
|
|
calculatePlagueJavelinStats,
|
|
calculateFendStats,
|
|
calculateLightningStrikeStats,
|
|
calculateLightningFuryStats,
|
|
type JabStats,
|
|
type PowerStrikeStats,
|
|
type PoisonJavelinStats,
|
|
type ImpaleStats,
|
|
type LightningBoltStats,
|
|
type ChargedStrikeStats,
|
|
type PlagueJavelinStats,
|
|
type FendStats,
|
|
type LightningStrikeStats,
|
|
type LightningFuryStats,
|
|
type PowerStrikeSynergies,
|
|
type PoisonJavelinSynergies,
|
|
type LightningBoltSynergies,
|
|
type ChargedStrikeSynergies,
|
|
type PlagueJavelinSynergies,
|
|
type LightningStrikeSynergies,
|
|
type LightningFurySynergies,
|
|
} from './skills/amazon-javelin-spear.ts'
|
|
|
|
|
|
/** One castable skill, read from a table row. */
|
|
export interface SkillDef {
|
|
/** Table id. */
|
|
readonly id: string
|
|
/** Display name, resolved through the text table when the row names an index. */
|
|
readonly name: string
|
|
/** Mana spent per cast. */
|
|
readonly manaCost: number
|
|
/** Ticks between casts. */
|
|
readonly cooldownTicks: number
|
|
/** Maximum cast distance; also the projectile's lifetime in pixels. */
|
|
readonly range: number
|
|
/** Whether the skill spawns a projectile (as opposed to striking instantly). */
|
|
readonly projectile: boolean
|
|
/** Projectile speed in pixels per second. */
|
|
readonly speed: number
|
|
/** Damage at skill level 1. */
|
|
readonly baseMinDamage: number
|
|
/** Damage at skill level 1. */
|
|
readonly baseMaxDamage: number
|
|
/** Damage added per additional skill level. */
|
|
readonly damagePerLevel: number
|
|
/** Radius of the instant effect, for non-projectile skills. */
|
|
readonly radius: number
|
|
/** Cast overlay identifier from Skills.txt castoverlay column (e.g. 'fire_cast_1'). */
|
|
readonly castOverlay?: string | undefined
|
|
}
|
|
|
|
/** A projectile in flight. */
|
|
export interface Projectile {
|
|
/** Skill it came from. */
|
|
readonly skillId: string
|
|
/** World x. */
|
|
readonly x: number
|
|
/** World y. */
|
|
readonly y: number
|
|
/** Velocity x, pixels per tick. */
|
|
readonly vx: number
|
|
/** Velocity y, pixels per tick. */
|
|
readonly vy: number
|
|
/** Damage on hit. */
|
|
readonly damage: number
|
|
/** Ticks left before it expires. */
|
|
readonly ttl: number
|
|
/** Whether the player fired it (projectiles hit the other side). */
|
|
readonly fromPlayer: boolean
|
|
/** Optional missile type / token (e.g. 'firebolt'). */
|
|
readonly missileType?: string | undefined
|
|
/** Pierces through enemies without expiring until range or wall hit. */
|
|
readonly pierce?: boolean | undefined
|
|
/** Set of monster indices already hit by this projectile (for pierce/nextHit). */
|
|
readonly hitTargets?: Set<number> | undefined
|
|
/** Area of effect splash radius in pixels upon impact. */
|
|
readonly aoeRadius?: number | undefined
|
|
/** Elemental status effect applied on hit: chill, freeze, poison. */
|
|
readonly statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
|
|
/** Duration in ticks of the status effect. */
|
|
readonly statusDuration?: number | undefined
|
|
/** Homing missile: steers towards nearest alive monster. */
|
|
readonly homing?: boolean | undefined
|
|
/** Archimedean spiral trajectory (e.g. Blessed Hammer). */
|
|
readonly spiral?: {
|
|
readonly originX: number
|
|
readonly originY: number
|
|
readonly angle: number
|
|
readonly radius: number
|
|
} | undefined
|
|
/** Random perpendicular trajectory wobble (e.g. Charged Bolt). */
|
|
readonly jitter?: boolean | undefined
|
|
/** PRNG seed for projectile variance (e.g. Charged Bolt). */
|
|
readonly seed?: number | undefined
|
|
/** Current index in the discrete orthogonal jitter table (0-31). */
|
|
readonly jitterIndex?: number | undefined
|
|
/** Target restriction: only hits undead monsters. */
|
|
readonly isUndeadOnly?: boolean | undefined
|
|
/** Sub-missile emission index along the 64-point discrete circle table (e.g. Frozen Orb). */
|
|
readonly subMissileIdx?: number | undefined
|
|
/** Affine curl damping trajectory in last 6 frames (e.g. Frozen Orb Nova). */
|
|
readonly isNovaCurl?: boolean | undefined
|
|
/** Stationary turret emitter (e.g. Hydra, Skill 62). */
|
|
readonly isTurret?: boolean | undefined
|
|
/** Fire cadence interval in ticks for turret emitters (e.g. 25). */
|
|
readonly fireCadenceTicks?: number | undefined
|
|
/** Head index within multi-head cluster (e.g. 0, 1, 2). */
|
|
readonly headIndex?: number | undefined
|
|
/** Pre-configured target world coordinate when targets array is empty. */
|
|
readonly targetX?: number | undefined
|
|
readonly targetY?: number | undefined
|
|
/** Missile type spawned by turret (defaults to 'hydra'). */
|
|
readonly spawnMissileType?: string | undefined
|
|
/** Number of elapsed ticks this projectile has lived. */
|
|
readonly ageTicks?: number | undefined
|
|
/** Altitude above ground in pixels for falling missiles (e.g. Blizzard shards). */
|
|
readonly altitude?: number | undefined
|
|
/** Skill level of the source skill (for dynamic duration/damage scaling). */
|
|
readonly slvl?: number | undefined
|
|
/** Residual ground fire duration in ticks. */
|
|
readonly fireDuration?: number | undefined
|
|
/** Residual ground fire damage per tick. */
|
|
readonly residualDamage?: number | undefined
|
|
/** Authentic 1.13c NextHit flag and NextDelay immunity window in frames. */
|
|
readonly nextHit?: boolean | number | undefined
|
|
readonly nextDelay?: number | undefined
|
|
/** Number of jumps remaining for chaining projectiles (e.g. Chain Lightning). */
|
|
readonly chainRemaining?: number | undefined
|
|
/** Previous target index hit by this chain projectile to avoid immediately retargeting it. */
|
|
readonly lastHitTargetIndex?: number | undefined
|
|
/** Previous position before last tick step (for client-side trail generation). */
|
|
readonly prevX?: number | undefined
|
|
readonly prevY?: number | undefined
|
|
}
|
|
|
|
/**
|
|
* In Diablo II's 2:1 isometric projection (orthogonal diamond grid: 80x40 cells, 16x8 sub-tiles),
|
|
* any horizontal circular curve on the map ground plane projects into scene-pixel coordinates
|
|
* as an ellipse with a 2:1 aspect ratio (vertical scale / horizontal scale = 8 / 16 = 0.5).
|
|
*
|
|
* NOTE: The calculated coordinates (x, y) are physical MAP coordinates on the level plane,
|
|
* NOT the rendered sprite icon offsets. Rendered icons are pre-rendered 2.5D DCC billboards
|
|
* positioned via sprite frame anchors, while map coordinates govern physical trajectory and collision.
|
|
*/
|
|
export const ISO_GROUND_ASPECT_RATIO = 0.5
|
|
|
|
/** What a cast produced. */
|
|
export type CastResult =
|
|
| { readonly kind: 'mana'; readonly cost: number }
|
|
| { readonly kind: 'cooldown'; readonly ticksLeft: number }
|
|
| { readonly kind: 'projectile'; readonly projectile: Projectile }
|
|
| { readonly kind: 'instant'; readonly radius: number; readonly damage: number }
|
|
|
|
/** A caster's position and facing. */
|
|
export interface Caster {
|
|
/** World x. */
|
|
readonly x: number
|
|
/** World y. */
|
|
readonly y: number
|
|
/** Facing index (0 = south, turning west). */
|
|
readonly facing: number
|
|
}
|
|
|
|
/** Direction vectors, in the same order as sprite groups (0 = south, turning west). */
|
|
const FACING_VECTORS: readonly (readonly [number, number])[] = [
|
|
[0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1], [1, 0], [1, 1],
|
|
]
|
|
|
|
/**
|
|
* Data extracted from Diablo II 1.13c Missiles.txt for projectile physics.
|
|
*/
|
|
export interface MissileTxtData {
|
|
readonly name: string
|
|
readonly id: number
|
|
readonly vel: number
|
|
readonly maxVel: number
|
|
readonly range: number
|
|
readonly levRange: number
|
|
readonly celFile: string
|
|
readonly animLen: number
|
|
readonly animSpeed: number
|
|
readonly loopAnim: number
|
|
readonly numDirections: number
|
|
readonly explosionMissile: string
|
|
readonly light: number
|
|
readonly red: number
|
|
readonly green: number
|
|
readonly blue: number
|
|
/** Calculated speed in pixels per second: vel * 25 */
|
|
readonly speedPxPerSec: number
|
|
/** Calculated flight distance in pixels: vel * range */
|
|
readonly distancePx: number
|
|
readonly nextHit?: number | boolean | undefined
|
|
readonly nextDelay?: number | undefined
|
|
readonly trans?: number | undefined
|
|
readonly subLoop?: number | undefined
|
|
readonly subStop?: number | undefined
|
|
}
|
|
|
|
/** Canonical 1.13c Ground Truth Missiles.txt defaults. */
|
|
export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> = Object.freeze({
|
|
lightninghit: {
|
|
name: 'lightninghit',
|
|
id: 99,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 12,
|
|
levRange: 0,
|
|
celFile: 'LightningStrike',
|
|
animLen: 8,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 32,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
trans: 1,
|
|
speedPxPerSec: 0,
|
|
distancePx: 0,
|
|
subLoop: 1,
|
|
subStop: 7,
|
|
},
|
|
lightningbolt: {
|
|
name: 'lightningbolt',
|
|
id: 98,
|
|
vel: 30,
|
|
maxVel: 30,
|
|
range: 25,
|
|
levRange: 0,
|
|
celFile: 'LightningStrike',
|
|
animLen: 16,
|
|
animSpeed: 16,
|
|
loopAnim: 2,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
trans: 1,
|
|
speedPxPerSec: 750,
|
|
distancePx: 750,
|
|
},
|
|
lightning: {
|
|
name: 'lightning',
|
|
id: 98,
|
|
vel: 30,
|
|
maxVel: 30,
|
|
range: 25,
|
|
levRange: 0,
|
|
celFile: 'LightningStrike',
|
|
animLen: 16,
|
|
animSpeed: 16,
|
|
loopAnim: 2,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
trans: 1,
|
|
speedPxPerSec: 750,
|
|
distancePx: 750,
|
|
},
|
|
chainlightning: {
|
|
name: 'chainlightning',
|
|
id: 93,
|
|
vel: 30,
|
|
maxVel: 30,
|
|
range: 25,
|
|
levRange: 0,
|
|
celFile: 'LightningStrike',
|
|
animLen: 16,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 32,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
nextHit: 1,
|
|
nextDelay: 4,
|
|
trans: 1,
|
|
speedPxPerSec: 750,
|
|
distancePx: 750,
|
|
},
|
|
thunderstorm1: {
|
|
name: 'thunderstorm1',
|
|
id: 166,
|
|
vel: 2,
|
|
maxVel: 2,
|
|
range: 9,
|
|
levRange: 0,
|
|
celFile: 'LightningboltBig',
|
|
animLen: 8,
|
|
animSpeed: 8,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
trans: 1,
|
|
speedPxPerSec: 50,
|
|
distancePx: 18,
|
|
},
|
|
thunderstorm: {
|
|
name: 'thunderstorm',
|
|
id: 166,
|
|
vel: 2,
|
|
maxVel: 2,
|
|
range: 9,
|
|
levRange: 0,
|
|
celFile: 'LightningboltBig',
|
|
animLen: 8,
|
|
animSpeed: 8,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
trans: 1,
|
|
speedPxPerSec: 50,
|
|
distancePx: 18,
|
|
},
|
|
nova: {
|
|
name: 'nova',
|
|
id: 90,
|
|
vel: 24,
|
|
maxVel: 24,
|
|
range: 13,
|
|
levRange: 0,
|
|
celFile: 'ElectricNova',
|
|
animLen: 13,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
nextHit: 1,
|
|
nextDelay: 4,
|
|
trans: 1,
|
|
speedPxPerSec: 600,
|
|
distancePx: 312,
|
|
},
|
|
electricnova: {
|
|
name: 'electricnova',
|
|
id: 90,
|
|
vel: 24,
|
|
maxVel: 24,
|
|
range: 13,
|
|
levRange: 0,
|
|
celFile: 'ElectricNova',
|
|
animLen: 13,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
nextHit: 1,
|
|
nextDelay: 4,
|
|
trans: 1,
|
|
speedPxPerSec: 600,
|
|
distancePx: 312,
|
|
},
|
|
frostnova: {
|
|
name: 'frostnova',
|
|
id: 119,
|
|
vel: 24,
|
|
maxVel: 24,
|
|
range: 14,
|
|
levRange: 0,
|
|
celFile: 'FrostNova',
|
|
animLen: 14,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
nextHit: 1,
|
|
nextDelay: 4,
|
|
trans: 1,
|
|
speedPxPerSec: 600,
|
|
distancePx: 336,
|
|
},
|
|
firebolt: {
|
|
name: 'firebolt',
|
|
id: 58,
|
|
vel: 20,
|
|
maxVel: 20,
|
|
range: 50,
|
|
levRange: 0,
|
|
celFile: 'Firebolt',
|
|
animLen: 5,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: 'fireexplode',
|
|
light: 7,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
speedPxPerSec: 500,
|
|
trans: 1,
|
|
distancePx: 1000,
|
|
},
|
|
fireball: {
|
|
name: 'fireball',
|
|
id: 62,
|
|
vel: 20,
|
|
maxVel: 20,
|
|
range: 50,
|
|
levRange: 0,
|
|
celFile: 'Fireball',
|
|
animLen: 5,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: 'explodingarrowexp',
|
|
light: 7,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
speedPxPerSec: 500,
|
|
trans: 1,
|
|
distancePx: 1000,
|
|
},
|
|
fireexplode: {
|
|
name: 'fireexplode',
|
|
id: 29,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 12,
|
|
levRange: 0,
|
|
celFile: 'FireArrowExplode2',
|
|
animLen: 12,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 13,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
explodingarrowexp: {
|
|
name: 'explodingarrowexp',
|
|
id: 42,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 16,
|
|
levRange: 0,
|
|
celFile: 'ExpArrowExplode',
|
|
animLen: 16,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
icebolt: {
|
|
name: 'icebolt',
|
|
id: 59,
|
|
vel: 12,
|
|
maxVel: 12,
|
|
range: 50,
|
|
levRange: 0,
|
|
celFile: 'Icebolt',
|
|
animLen: 6,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: 'iceexplode',
|
|
light: 7,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 300,
|
|
trans: 1,
|
|
distancePx: 600,
|
|
},
|
|
iceblast: {
|
|
name: 'iceblast',
|
|
id: 91,
|
|
vel: 12,
|
|
maxVel: 12,
|
|
range: 50,
|
|
levRange: 0,
|
|
celFile: 'IceBlast',
|
|
animLen: 5,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: 'freezeexplode',
|
|
light: 7,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 300,
|
|
trans: 1,
|
|
distancePx: 600,
|
|
},
|
|
glacialspike: {
|
|
name: 'glacialspike',
|
|
id: 96,
|
|
vel: 16,
|
|
maxVel: 16,
|
|
range: 40,
|
|
levRange: 0,
|
|
celFile: 'GlacialSpike',
|
|
animLen: 6,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: 'freezeexplode',
|
|
light: 5,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 400,
|
|
trans: 1,
|
|
distancePx: 640,
|
|
},
|
|
iceexplode: {
|
|
name: 'iceexplode',
|
|
id: 30,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 16,
|
|
levRange: 0,
|
|
celFile: 'IceArrowExplode',
|
|
animLen: 16,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 11,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
freezeexplode: {
|
|
name: 'freezeexplode',
|
|
id: 88,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 15,
|
|
levRange: 0,
|
|
celFile: 'FreezeExplodeCenter',
|
|
animLen: 15,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 11,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
freezingarrowexp1: {
|
|
name: 'freezingarrowexp1',
|
|
id: 88,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 15,
|
|
levRange: 0,
|
|
celFile: 'FreezeExplodeCenter',
|
|
animLen: 15,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 11,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
glacialspikeejecta: {
|
|
name: 'glacialspikeejecta',
|
|
id: 89,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 15,
|
|
levRange: 0,
|
|
celFile: 'FreezeExplodeEjecta',
|
|
animLen: 15,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 8,
|
|
explosionMissile: '',
|
|
light: 11,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
frozenorb: {
|
|
name: 'frozenorb',
|
|
id: 260,
|
|
vel: 10,
|
|
maxVel: 10,
|
|
range: 30,
|
|
levRange: 0,
|
|
celFile: 'IceOrb',
|
|
animLen: 16,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: 'frozenorbexplode',
|
|
light: 6,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 250,
|
|
trans: 1,
|
|
distancePx: 300,
|
|
},
|
|
frozenorbbolt: {
|
|
name: 'frozenorbbolt',
|
|
id: 261,
|
|
vel: 18,
|
|
maxVel: 18,
|
|
range: 25,
|
|
levRange: 0,
|
|
celFile: 'IceBolt',
|
|
animLen: 6,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: 'iceexplode',
|
|
light: 4,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 450,
|
|
trans: 1,
|
|
distancePx: 450,
|
|
},
|
|
frozenorbnova: {
|
|
name: 'frozenorbnova',
|
|
id: 262,
|
|
vel: 24,
|
|
maxVel: 24,
|
|
range: 25,
|
|
levRange: 0,
|
|
celFile: 'IceBolt',
|
|
animLen: 6,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: 'iceexplode',
|
|
light: 4,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 600,
|
|
trans: 1,
|
|
distancePx: 600,
|
|
},
|
|
frozenorbexplode: {
|
|
name: 'frozenorbexplode',
|
|
id: 263,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 16,
|
|
levRange: 0,
|
|
celFile: 'IceOrbExplode',
|
|
animLen: 16,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 6,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
blizzardcenter: {
|
|
name: 'blizzardcenter',
|
|
id: 158,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 100,
|
|
levRange: 0,
|
|
celFile: '',
|
|
animLen: 2,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
blizzard1: {
|
|
name: 'blizzard1',
|
|
id: 159,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 9,
|
|
levRange: 0,
|
|
celFile: 'icestormfallvar01',
|
|
animLen: 6,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: 'blizzardexplode1',
|
|
light: 4,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
blizzard2: {
|
|
name: 'blizzard2',
|
|
id: 160,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 9,
|
|
levRange: 0,
|
|
celFile: 'icestormfallvar02',
|
|
animLen: 6,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: 'blizzardexplode2',
|
|
light: 4,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
blizzard3: {
|
|
name: 'blizzard3',
|
|
id: 161,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 9,
|
|
levRange: 0,
|
|
celFile: 'Blizzard',
|
|
animLen: 8,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: 'blizzardexplode3',
|
|
light: 4,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
blizzard4: {
|
|
name: 'blizzard4',
|
|
id: 162,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 9,
|
|
levRange: 0,
|
|
celFile: 'Blizzard',
|
|
animLen: 8,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: 'blizzardexplode1',
|
|
light: 4,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
blizzardexplode1: {
|
|
name: 'blizzardexplode1',
|
|
id: 163,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 6,
|
|
levRange: 0,
|
|
celFile: 'icestormimpactvar01',
|
|
animLen: 6,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 6,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
blizzardexplode2: {
|
|
name: 'blizzardexplode2',
|
|
id: 164,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 6,
|
|
levRange: 0,
|
|
celFile: 'icestormimpactvar02',
|
|
animLen: 6,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 6,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
blizzardexplode3: {
|
|
name: 'blizzardexplode3',
|
|
id: 165,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 6,
|
|
levRange: 0,
|
|
celFile: 'icestormimpactvar02',
|
|
animLen: 6,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 6,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
hydra: {
|
|
name: 'hydra',
|
|
id: 247,
|
|
vel: 16,
|
|
maxVel: 16,
|
|
range: 30,
|
|
levRange: 0,
|
|
celFile: 'Firebolt',
|
|
animLen: 5,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: 'fireexplode',
|
|
light: 5,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 400,
|
|
trans: 1,
|
|
distancePx: 480,
|
|
},
|
|
hydrafire: {
|
|
name: 'hydrafire',
|
|
id: 247,
|
|
vel: 16,
|
|
maxVel: 16,
|
|
range: 30,
|
|
levRange: 0,
|
|
celFile: 'Firebolt',
|
|
animLen: 5,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: 'fireexplode',
|
|
light: 5,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 400,
|
|
trans: 1,
|
|
distancePx: 480,
|
|
},
|
|
firewallmaker: {
|
|
name: 'firewallmaker',
|
|
id: 82,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 1,
|
|
levRange: 0,
|
|
celFile: '',
|
|
animLen: 1,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 0,
|
|
green: 0,
|
|
blue: 0,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
firewall: {
|
|
name: 'firewall',
|
|
id: 69,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 90,
|
|
levRange: 0,
|
|
celFile: 'groundFireBig',
|
|
animLen: 37,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 5,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
trans: 1,
|
|
speedPxPerSec: 0,
|
|
distancePx: 0,
|
|
},
|
|
groundfirebig: {
|
|
name: 'groundfirebig',
|
|
id: 83,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 90,
|
|
levRange: 0,
|
|
celFile: 'groundFireBig',
|
|
animLen: 15,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 10,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
trans: 1,
|
|
speedPxPerSec: 0,
|
|
distancePx: 0,
|
|
},
|
|
blaze: {
|
|
name: 'blaze',
|
|
id: 80,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 90,
|
|
levRange: 25,
|
|
celFile: 'Blaze',
|
|
animLen: 14,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 8,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
trans: 1,
|
|
speedPxPerSec: 0,
|
|
distancePx: 0,
|
|
},
|
|
infernoflame1: {
|
|
name: 'infernoflame1',
|
|
id: 72,
|
|
vel: 12,
|
|
maxVel: 12,
|
|
range: 16,
|
|
levRange: 0,
|
|
celFile: 'InfernoFlame1',
|
|
animLen: 8,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 6,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
trans: 1,
|
|
speedPxPerSec: 300,
|
|
distancePx: 192,
|
|
},
|
|
infernoflame2: {
|
|
name: 'infernoflame2',
|
|
id: 73,
|
|
vel: 12,
|
|
maxVel: 12,
|
|
range: 16,
|
|
levRange: 0,
|
|
celFile: 'InfernoFlame2',
|
|
animLen: 8,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 6,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
trans: 1,
|
|
speedPxPerSec: 300,
|
|
distancePx: 192,
|
|
},
|
|
meteor: {
|
|
name: 'meteor',
|
|
id: 100,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 60,
|
|
levRange: 0,
|
|
celFile: 'Meteor',
|
|
animLen: 12,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 1,
|
|
explosionMissile: 'meteorexplode',
|
|
light: 0,
|
|
red: 0,
|
|
green: 0,
|
|
blue: 0,
|
|
speedPxPerSec: 0,
|
|
trans: 0,
|
|
distancePx: 0,
|
|
},
|
|
meteorcenter: {
|
|
name: 'meteorcenter',
|
|
id: 101,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 60,
|
|
levRange: 0,
|
|
celFile: 'MeteorTargetIndicator',
|
|
animLen: 17,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 1,
|
|
explosionMissile: 'meteorexplode',
|
|
light: 4,
|
|
red: 255,
|
|
green: 64,
|
|
blue: 64,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
meteortail: {
|
|
name: 'meteortail',
|
|
id: 102,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 60,
|
|
levRange: 0,
|
|
celFile: 'MeteorTail',
|
|
animLen: 12,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 0,
|
|
green: 0,
|
|
blue: 0,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
meteorexplode: {
|
|
name: 'meteorexplode',
|
|
id: 103,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 16,
|
|
levRange: 0,
|
|
celFile: 'ExpArrowExplode',
|
|
animLen: 16,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 13,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
meteorfire: {
|
|
name: 'meteorfire',
|
|
id: 240,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 30,
|
|
levRange: 15,
|
|
celFile: 'groundFireMedium',
|
|
animLen: 37,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 3,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
chargedbolt: {
|
|
name: 'chargedbolt',
|
|
id: 56,
|
|
vel: 12,
|
|
maxVel: 12,
|
|
range: 98,
|
|
levRange: 0,
|
|
celFile: 'ChargedBolt',
|
|
animLen: 10,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: '',
|
|
light: 3,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 300,
|
|
trans: 1,
|
|
distancePx: 1176,
|
|
},
|
|
teeth: {
|
|
name: 'teeth',
|
|
id: 114,
|
|
vel: 16,
|
|
maxVel: 16,
|
|
range: 50,
|
|
levRange: 0,
|
|
celFile: 'teethMissile',
|
|
animLen: 30,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 32,
|
|
explosionMissile: 'teethexplode',
|
|
light: 3,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 400,
|
|
trans: 1,
|
|
distancePx: 800,
|
|
},
|
|
teethexplode: {
|
|
name: 'teethexplode',
|
|
id: 204,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 13,
|
|
levRange: 0,
|
|
celFile: 'teethexplode',
|
|
animLen: 13,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
bonespear: {
|
|
name: 'bonespear',
|
|
id: 192,
|
|
vel: 24,
|
|
maxVel: 24,
|
|
range: 40,
|
|
levRange: 0,
|
|
celFile: 'BoneSpear',
|
|
animLen: 6,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: 'teethexplode',
|
|
light: 3,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 600,
|
|
trans: 1,
|
|
distancePx: 960,
|
|
},
|
|
bonespearexplode: {
|
|
name: 'bonespearexplode',
|
|
id: 216,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 13,
|
|
levRange: 0,
|
|
celFile: 'teethexplode',
|
|
animLen: 13,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
bonespirit: {
|
|
name: 'bonespirit',
|
|
id: 193,
|
|
vel: 12,
|
|
maxVel: 12,
|
|
range: 128,
|
|
levRange: 0,
|
|
celFile: 'BoneSpirit',
|
|
animLen: 8,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 8,
|
|
explosionMissile: 'bonespiritexplode',
|
|
light: 3,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 300,
|
|
trans: 1,
|
|
distancePx: 1536,
|
|
},
|
|
bonespiritexplode: {
|
|
name: 'bonespiritexplode',
|
|
id: 327,
|
|
vel: 0,
|
|
maxVel: 0,
|
|
range: 13,
|
|
levRange: 0,
|
|
celFile: 'BoneSpiritExplode',
|
|
animLen: 13,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 0,
|
|
trans: 1,
|
|
distancePx: 0,
|
|
},
|
|
poisonnova: {
|
|
name: 'poisonnova',
|
|
id: 118,
|
|
vel: 12,
|
|
maxVel: 12,
|
|
range: 30,
|
|
levRange: 0,
|
|
celFile: 'poisonNova',
|
|
animLen: 30,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 32,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 300,
|
|
trans: 1,
|
|
distancePx: 360,
|
|
},
|
|
holybolt: {
|
|
name: 'holybolt',
|
|
id: 55,
|
|
vel: 20,
|
|
maxVel: 20,
|
|
range: 50,
|
|
levRange: 0,
|
|
celFile: 'HolyBoltMissile',
|
|
animLen: 16,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 1,
|
|
explosionMissile: '',
|
|
light: 7,
|
|
red: 222,
|
|
green: 222,
|
|
blue: 255,
|
|
speedPxPerSec: 500,
|
|
trans: 1,
|
|
distancePx: 1000,
|
|
},
|
|
blessedhammer: {
|
|
name: 'blessedhammer',
|
|
id: 92,
|
|
vel: 18,
|
|
maxVel: 30,
|
|
range: 120,
|
|
levRange: 0,
|
|
celFile: 'blessedhammer',
|
|
animLen: 6,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: '',
|
|
light: 5,
|
|
red: 222,
|
|
green: 222,
|
|
blue: 255,
|
|
speedPxPerSec: 450,
|
|
trans: 1,
|
|
distancePx: 2160,
|
|
},
|
|
magicarrow: {
|
|
name: 'magicarrow',
|
|
id: 27,
|
|
vel: 24,
|
|
maxVel: 24,
|
|
range: 40,
|
|
levRange: 0,
|
|
celFile: 'SafeArrow',
|
|
animLen: 1,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 32,
|
|
explosionMissile: 'teethexplode',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 600,
|
|
trans: 1,
|
|
distancePx: 960,
|
|
},
|
|
firearrow: {
|
|
name: 'firearrow',
|
|
id: 12,
|
|
vel: 24,
|
|
maxVel: 24,
|
|
range: 40,
|
|
levRange: 0,
|
|
celFile: 'FireArrow',
|
|
animLen: 8,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 32,
|
|
explosionMissile: 'fireexplode',
|
|
light: 6,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
speedPxPerSec: 600,
|
|
trans: 1,
|
|
distancePx: 960,
|
|
},
|
|
icearrow: {
|
|
name: 'icearrow',
|
|
id: 28,
|
|
vel: 24,
|
|
maxVel: 24,
|
|
range: 40,
|
|
levRange: 0,
|
|
celFile: 'IceArrow',
|
|
animLen: 8,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 32,
|
|
explosionMissile: 'iceexplode',
|
|
light: 6,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
speedPxPerSec: 600,
|
|
trans: 1,
|
|
distancePx: 960,
|
|
},
|
|
arrow: {
|
|
name: 'arrow',
|
|
id: 0,
|
|
vel: 24,
|
|
maxVel: 24,
|
|
range: 40,
|
|
levRange: 0,
|
|
celFile: 'Arrow',
|
|
animLen: 1,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 32,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 600,
|
|
trans: 0,
|
|
distancePx: 960,
|
|
},
|
|
multipleshotarrow: {
|
|
name: 'multipleshotarrow',
|
|
id: 213,
|
|
vel: 24,
|
|
maxVel: 24,
|
|
range: 50,
|
|
levRange: 0,
|
|
celFile: 'Arrow',
|
|
animLen: 1,
|
|
animSpeed: 16,
|
|
loopAnim: 0,
|
|
numDirections: 32,
|
|
explosionMissile: '',
|
|
light: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 600,
|
|
trans: 0,
|
|
distancePx: 1200,
|
|
},
|
|
lightningjavelin: {
|
|
name: 'lightningjavelin',
|
|
id: 205,
|
|
vel: 30,
|
|
maxVel: 30,
|
|
range: 25,
|
|
levRange: 0,
|
|
celFile: 'LightningJavelin',
|
|
animLen: 5,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: '',
|
|
light: 6,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
speedPxPerSec: 750,
|
|
trans: 1,
|
|
distancePx: 750,
|
|
},
|
|
})
|
|
|
|
/**
|
|
* Safely parse an RGB color channel (0..255) with fallback (default 255).
|
|
* Preserves authentic 0 values while mapping empty/unspecified cells to fallback.
|
|
*/
|
|
export function parseColorChannel(raw: string | undefined, fallback = 255): number {
|
|
if (raw === undefined) return fallback
|
|
const trimmed = raw.trim()
|
|
if (trimmed === '') return fallback
|
|
const n = Number(trimmed)
|
|
return Number.isFinite(n) ? n : fallback
|
|
}
|
|
|
|
/**
|
|
* Parses raw Missiles.txt text into a Map of missile records.
|
|
*/
|
|
export function parseMissilesTxt(content: string): Map<string, MissileTxtData> {
|
|
const map = new Map<string, MissileTxtData>()
|
|
const lines = content.split(/\r?\n/).filter(line => line.trim().length > 0)
|
|
if (lines.length < 2) return map
|
|
const headers = lines[0]!.split('\t').map(h => h.trim())
|
|
const col = (name: string) => headers.indexOf(name)
|
|
|
|
const missileIdx = col('Missile')
|
|
const idIdx = col('Id')
|
|
const velIdx = col('Vel')
|
|
const maxVelIdx = col('MaxVel')
|
|
const rangeIdx = col('Range')
|
|
const levRangeIdx = col('LevRange')
|
|
const celFileIdx = col('CelFile')
|
|
const animLenIdx = col('AnimLen')
|
|
const animSpeedIdx = col('AnimSpeed')
|
|
const loopAnimIdx = col('LoopAnim')
|
|
const numDirIdx = col('NumDirections')
|
|
const explIdx = col('ExplosionMissile')
|
|
const lightIdx = col('Light')
|
|
const redIdx = col('Red')
|
|
const greenIdx = col('Green')
|
|
const blueIdx = col('Blue')
|
|
const nextHitIdx = col('NextHit')
|
|
const nextDelayIdx = col('NextDelay')
|
|
const transIdx = col('Trans')
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const cells = lines[i]!.split('\t')
|
|
const name = (cells[missileIdx] ?? '').trim().toLowerCase()
|
|
if (!name || name === 'expansion') continue
|
|
|
|
const vel = Number(cells[velIdx] ?? 0) || 0
|
|
const maxVel = Number(cells[maxVelIdx] ?? 0) || vel
|
|
const range = Number(cells[rangeIdx] ?? 0) || 0
|
|
const levRange = Number(cells[levRangeIdx] ?? 0) || 0
|
|
const celFile = (cells[celFileIdx] ?? '').trim()
|
|
const animLen = Number(cells[animLenIdx] ?? 0) || 0
|
|
const animSpeed = Number(cells[animSpeedIdx] ?? 0) || 0
|
|
const loopAnim = Number(cells[loopAnimIdx] ?? 0) || 0
|
|
const numDirections = Number(cells[numDirIdx] ?? 0) || 1
|
|
const explosionMissile = (cells[explIdx] ?? '').trim()
|
|
const light = Number(cells[lightIdx] ?? 0) || 0
|
|
const red = parseColorChannel(cells[redIdx])
|
|
const green = parseColorChannel(cells[greenIdx])
|
|
const blue = parseColorChannel(cells[blueIdx])
|
|
const nextHit = Number(cells[nextHitIdx] ?? 0) || 0
|
|
const nextDelay = Number(cells[nextDelayIdx] ?? 0) || 0
|
|
const trans = Number(cells[transIdx] ?? 0) || 0
|
|
|
|
const rec: MissileTxtData = {
|
|
name,
|
|
id: Number(cells[idIdx] ?? 0) || 0,
|
|
vel,
|
|
maxVel,
|
|
range,
|
|
levRange,
|
|
celFile,
|
|
animLen,
|
|
animSpeed,
|
|
loopAnim,
|
|
numDirections,
|
|
explosionMissile,
|
|
light,
|
|
red,
|
|
green,
|
|
blue,
|
|
nextHit,
|
|
nextDelay,
|
|
trans,
|
|
speedPxPerSec: vel * 25,
|
|
distancePx: vel * range,
|
|
}
|
|
map.set(name, rec)
|
|
if (name === 'nova') {
|
|
map.set('electricnova', { ...rec, name: 'electricnova' })
|
|
}
|
|
}
|
|
return map
|
|
}
|
|
|
|
/**
|
|
* Extracts missile speed and flight distance from a Missiles.txt table or parsed records.
|
|
*/
|
|
export function getMissileTxtData(
|
|
missileName: string,
|
|
missilesSource?:
|
|
| Map<string, MissileTxtData>
|
|
| { rows: readonly Readonly<Record<string, string>>[] }
|
|
| { header: readonly string[]; rows: readonly (readonly string[])[] }
|
|
| string
|
|
| undefined,
|
|
): MissileTxtData {
|
|
const key = missileName.trim().toLowerCase()
|
|
if (!missilesSource) {
|
|
return CANONICAL_113C_MISSILES[key] ?? {
|
|
name: key,
|
|
id: 0,
|
|
vel: 20,
|
|
maxVel: 20,
|
|
range: 50,
|
|
levRange: 0,
|
|
celFile: key,
|
|
animLen: 5,
|
|
animSpeed: 16,
|
|
loopAnim: 1,
|
|
numDirections: 16,
|
|
explosionMissile: 'fireexplode',
|
|
light: 7,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
trans: 0,
|
|
speedPxPerSec: 500,
|
|
distancePx: 1000,
|
|
}
|
|
}
|
|
|
|
if (typeof missilesSource === 'string') {
|
|
const parsed = parseMissilesTxt(missilesSource)
|
|
return parsed.get(key) ?? getMissileTxtData(missileName, undefined)
|
|
}
|
|
|
|
if (missilesSource instanceof Map) {
|
|
return missilesSource.get(key) ?? getMissileTxtData(missileName, undefined)
|
|
}
|
|
|
|
// D2Table ({ header, rows })
|
|
if ('header' in missilesSource && Array.isArray((missilesSource as any).header)) {
|
|
const t = missilesSource as { header: readonly string[]; rows: readonly (readonly string[])[] }
|
|
const mIdx = t.header.indexOf('Missile')
|
|
if (mIdx !== -1) {
|
|
const row = t.rows.find(r => (r[mIdx] ?? '').trim().toLowerCase() === key)
|
|
if (row) {
|
|
const vel = Number(row[t.header.indexOf('Vel')] ?? 0) || 0
|
|
const maxVel = Number(row[t.header.indexOf('MaxVel')] ?? 0) || vel
|
|
const range = Number(row[t.header.indexOf('Range')] ?? 0) || 0
|
|
const levRange = Number(row[t.header.indexOf('LevRange')] ?? 0) || 0
|
|
const celFile = (row[t.header.indexOf('CelFile')] ?? '').trim()
|
|
const animLen = Number(row[t.header.indexOf('AnimLen')] ?? 0) || 0
|
|
const animSpeed = Number(row[t.header.indexOf('AnimSpeed')] ?? 0) || 0
|
|
const loopAnim = Number(row[t.header.indexOf('LoopAnim')] ?? 0) || 0
|
|
const numDirections = Number(row[t.header.indexOf('NumDirections')] ?? 0) || 1
|
|
const explosionMissile = (row[t.header.indexOf('ExplosionMissile')] ?? '').trim()
|
|
const light = Number(row[t.header.indexOf('Light')] ?? 0) || 0
|
|
const red = parseColorChannel(row[t.header.indexOf('Red')])
|
|
const green = parseColorChannel(row[t.header.indexOf('Green')])
|
|
const blue = parseColorChannel(row[t.header.indexOf('Blue')])
|
|
const transColIdx = t.header.indexOf('Trans')
|
|
const trans = transColIdx !== -1 ? Number(row[transColIdx] ?? 0) || 0 : 0
|
|
return {
|
|
name: key,
|
|
id: Number(row[t.header.indexOf('Id')] ?? 0) || 0,
|
|
vel,
|
|
maxVel,
|
|
range,
|
|
levRange,
|
|
celFile,
|
|
animLen,
|
|
animSpeed,
|
|
loopAnim,
|
|
numDirections,
|
|
explosionMissile,
|
|
light,
|
|
red,
|
|
green,
|
|
blue,
|
|
trans,
|
|
speedPxPerSec: vel * 25,
|
|
distancePx: vel * range,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// DataTable ({ rows: Record<string, string>[] })
|
|
if ('rows' in missilesSource && !('header' in missilesSource)) {
|
|
const tableRows = missilesSource.rows as readonly Readonly<Record<string, string>>[]
|
|
const row = tableRows.find(
|
|
r => (r['Missile'] ?? r['missile'] ?? '').trim().toLowerCase() === key,
|
|
)
|
|
if (row) {
|
|
const vel = Number(row['Vel'] ?? row['vel'] ?? 0) || 0
|
|
const maxVel = Number(row['MaxVel'] ?? row['maxvel'] ?? 0) || vel
|
|
const range = Number(row['Range'] ?? row['range'] ?? 0) || 0
|
|
const levRange = Number(row['LevRange'] ?? row['levrange'] ?? 0) || 0
|
|
const celFile = (row['CelFile'] ?? row['celfile'] ?? '').trim()
|
|
const animLen = Number(row['AnimLen'] ?? row['animlen'] ?? 0) || 0
|
|
const animSpeed = Number(row['AnimSpeed'] ?? row['animspeed'] ?? 0) || 0
|
|
const loopAnim = Number(row['LoopAnim'] ?? row['loopanim'] ?? 0) || 0
|
|
const numDirections = Number(row['NumDirections'] ?? row['numdirections'] ?? 0) || 1
|
|
const explosionMissile = (row['ExplosionMissile'] ?? row['explosionmissile'] ?? '').trim()
|
|
const light = Number(row['Light'] ?? row['light'] ?? 0) || 0
|
|
const red = parseColorChannel(row['Red'] ?? row['red'])
|
|
const green = parseColorChannel(row['Green'] ?? row['green'])
|
|
const blue = parseColorChannel(row['Blue'] ?? row['blue'])
|
|
const trans = Number(row['Trans'] ?? row['trans'] ?? 0) || 0
|
|
return {
|
|
name: key,
|
|
id: Number(row['Id'] ?? row['id'] ?? 0) || 0,
|
|
vel,
|
|
maxVel,
|
|
range,
|
|
levRange,
|
|
celFile,
|
|
animLen,
|
|
animSpeed,
|
|
loopAnim,
|
|
numDirections,
|
|
explosionMissile,
|
|
light,
|
|
red,
|
|
green,
|
|
blue,
|
|
trans,
|
|
speedPxPerSec: vel * 25,
|
|
distancePx: vel * range,
|
|
}
|
|
}
|
|
}
|
|
|
|
return getMissileTxtData(missileName, undefined)
|
|
}
|
|
|
|
/**
|
|
* Data extracted from Diablo II 1.13c Overlay.txt for visual overlay effects.
|
|
*/
|
|
export interface OverlayTxtData {
|
|
readonly overlay: string
|
|
readonly filename: string
|
|
readonly frames: number
|
|
readonly animRate: number
|
|
readonly trans: number
|
|
readonly preDraw: boolean
|
|
readonly initRadius: number
|
|
readonly radius: number
|
|
readonly red: number
|
|
readonly green: number
|
|
readonly blue: number
|
|
readonly numDirections?: number
|
|
readonly xOffset?: number
|
|
readonly yOffset?: number
|
|
}
|
|
|
|
/** Canonical 1.13c Ground Truth Overlay.txt defaults for elemental sorceress spell overlays. */
|
|
export const CANONICAL_113C_OVERLAYS: Readonly<Record<string, OverlayTxtData>> = Object.freeze({
|
|
fire_cast_1: {
|
|
overlay: 'fire_cast_1',
|
|
filename: 'FireCast_for_Sorceress',
|
|
frames: 14,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
firecast_for_sorceress: {
|
|
overlay: 'fire_cast_1',
|
|
filename: 'FireCast_for_Sorceress',
|
|
frames: 14,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
fire_cast_2: {
|
|
overlay: 'fire_cast_2',
|
|
filename: 'FireCast2',
|
|
frames: 16,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
firecast2: {
|
|
overlay: 'fire_cast_2',
|
|
filename: 'FireCast2',
|
|
frames: 16,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
ice_cast_1: {
|
|
overlay: 'ice_cast_1',
|
|
filename: 'IceCastNew01',
|
|
frames: 15,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
icecastnew01: {
|
|
overlay: 'ice_cast_1',
|
|
filename: 'IceCastNew01',
|
|
frames: 15,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
ice_cast_2: {
|
|
overlay: 'ice_cast_2',
|
|
filename: 'IceCastNew02',
|
|
frames: 15,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
icecastnew02: {
|
|
overlay: 'ice_cast_2',
|
|
filename: 'IceCastNew02',
|
|
frames: 15,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
ice_cast_3: {
|
|
overlay: 'ice_cast_3',
|
|
filename: 'IceCastNew03',
|
|
// Note: Overlay.txt lists 15 frames, but IceCastNew03.dcc contains 16 full frames.
|
|
frames: 16,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
icecastnew03: {
|
|
overlay: 'ice_cast_3',
|
|
filename: 'IceCastNew03',
|
|
// Note: Overlay.txt lists 15 frames, but IceCastNew03.dcc contains 16 full frames.
|
|
frames: 16,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 81,
|
|
green: 81,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
light_cast_1: {
|
|
overlay: 'light_cast_1',
|
|
filename: 'LightningCast',
|
|
frames: 10,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
lightningcast: {
|
|
overlay: 'light_cast_1',
|
|
filename: 'LightningCast',
|
|
frames: 10,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
light_cast_2: {
|
|
overlay: 'light_cast_2',
|
|
filename: 'LightningCastRunesFront',
|
|
frames: 10,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
lightningcastrunesfront: {
|
|
overlay: 'light_cast_2',
|
|
filename: 'LightningCastRunesFront',
|
|
frames: 10,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
teleport: {
|
|
overlay: 'teleport',
|
|
filename: 'Teleport',
|
|
frames: 18,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 5,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 200,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// Authentic 1.13c Sorceress State / Buff Overlays
|
|
frozenarmor: {
|
|
overlay: 'frozenarmor',
|
|
filename: 'FrozenArmor',
|
|
frames: 24,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 13,
|
|
red: 200,
|
|
green: 200,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
shiverarmor: {
|
|
overlay: 'shiverarmor',
|
|
filename: 'FrozenArmor',
|
|
frames: 24,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 13,
|
|
red: 200,
|
|
green: 200,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
chillingarmor: {
|
|
overlay: 'chillingarmor',
|
|
filename: 'FrozenArmor',
|
|
frames: 24,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 13,
|
|
red: 200,
|
|
green: 200,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
chillarmor: {
|
|
overlay: 'chillingarmor',
|
|
filename: 'FrozenArmor',
|
|
frames: 24,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 13,
|
|
red: 200,
|
|
green: 200,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
energyshield: {
|
|
overlay: 'energyshield',
|
|
filename: 'ManaShieldBall',
|
|
frames: 8,
|
|
animRate: 8,
|
|
trans: 5,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
enchant: {
|
|
overlay: 'enchant',
|
|
filename: 'FireEnchant',
|
|
frames: 17,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
thunderstormback: {
|
|
overlay: 'thunderstormback',
|
|
filename: 'ThunderstormCast_operate',
|
|
frames: 19,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 8,
|
|
radius: 8,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// Authentic 1.13c Paladin Aura Overlays (All 33 targets: 13 pairs front/back + 7 full rings)
|
|
// 1. Might (#98)
|
|
might_front: {
|
|
overlay: 'might_front',
|
|
filename: 'AuraMightFront',
|
|
frames: 15,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_might_front: {
|
|
overlay: 'might_front',
|
|
filename: 'AuraMightFront',
|
|
frames: 15,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
might: {
|
|
overlay: 'might_front',
|
|
filename: 'AuraMightFront',
|
|
frames: 15,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
might_back: {
|
|
overlay: 'might_back',
|
|
filename: 'AuraMightBack',
|
|
frames: 15,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_might_back: {
|
|
overlay: 'might_back',
|
|
filename: 'AuraMightBack',
|
|
frames: 15,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 2. Prayer (#99)
|
|
prayer_front: {
|
|
overlay: 'prayer_front',
|
|
filename: 'AuraPrayerFront',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_prayer_front: {
|
|
overlay: 'prayer_front',
|
|
filename: 'AuraPrayerFront',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
prayer: {
|
|
overlay: 'prayer_front',
|
|
filename: 'AuraPrayerFront',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
prayer_back: {
|
|
overlay: 'prayer_back',
|
|
filename: 'AuraPrayerBack',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_prayer_back: {
|
|
overlay: 'prayer_back',
|
|
filename: 'AuraPrayerBack',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 3. Resist Fire (#100) - Full Ring
|
|
resist_fire: {
|
|
overlay: 'resist_fire',
|
|
filename: 'AuraResistFireFront',
|
|
frames: 20,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 6,
|
|
radius: 6,
|
|
red: 255,
|
|
green: 193,
|
|
blue: 103,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_resistfire: {
|
|
overlay: 'resist_fire',
|
|
filename: 'AuraResistFireFront',
|
|
frames: 20,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 6,
|
|
radius: 6,
|
|
red: 255,
|
|
green: 193,
|
|
blue: 103,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 4. Holy Fire (#102)
|
|
holy_fire_front: {
|
|
overlay: 'holy_fire_front',
|
|
filename: 'AuraHolyFireFront',
|
|
frames: 19,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_holyfire_front: {
|
|
overlay: 'holy_fire_front',
|
|
filename: 'AuraHolyFireFront',
|
|
frames: 19,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
holy_fire: {
|
|
overlay: 'holy_fire_front',
|
|
filename: 'AuraHolyFireFront',
|
|
frames: 19,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
holyfire: {
|
|
overlay: 'holy_fire_front',
|
|
filename: 'AuraHolyFireFront',
|
|
frames: 19,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
holy_fire_back: {
|
|
overlay: 'holy_fire_back',
|
|
filename: 'AuraHolyFireBack',
|
|
frames: 19,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_holyfire_back: {
|
|
overlay: 'holy_fire_back',
|
|
filename: 'AuraHolyFireBack',
|
|
frames: 19,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 5. Thorns (#103)
|
|
thorns_front: {
|
|
overlay: 'thorns_front',
|
|
filename: 'AuraThornsFront',
|
|
frames: 18,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_thorns_front: {
|
|
overlay: 'thorns_front',
|
|
filename: 'AuraThornsFront',
|
|
frames: 18,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
thorns: {
|
|
overlay: 'thorns_front',
|
|
filename: 'AuraThornsFront',
|
|
frames: 18,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
thorns_back: {
|
|
overlay: 'thorns_back',
|
|
filename: 'AuraThornsBack',
|
|
frames: 18,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_thorns_back: {
|
|
overlay: 'thorns_back',
|
|
filename: 'AuraThornsBack',
|
|
frames: 18,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 6. Defiance (#104)
|
|
defiance_front: {
|
|
overlay: 'defiance_front',
|
|
filename: 'AuraDefianceFront',
|
|
frames: 15,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_defiance_front: {
|
|
overlay: 'defiance_front',
|
|
filename: 'AuraDefianceFront',
|
|
frames: 15,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
defiance: {
|
|
overlay: 'defiance_front',
|
|
filename: 'AuraDefianceFront',
|
|
frames: 15,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
defiance_back: {
|
|
overlay: 'defiance_back',
|
|
filename: 'AuraDefianceBack',
|
|
frames: 15,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_defiance_back: {
|
|
overlay: 'defiance_back',
|
|
filename: 'AuraDefianceBack',
|
|
frames: 15,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 7. Resist Cold (#105) - Full Ring
|
|
resist_cold: {
|
|
overlay: 'resist_cold',
|
|
filename: 'AuraResistColdBack',
|
|
frames: 20,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 6,
|
|
radius: 6,
|
|
red: 210,
|
|
green: 210,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_resistcold: {
|
|
overlay: 'resist_cold',
|
|
filename: 'AuraResistColdBack',
|
|
frames: 20,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 6,
|
|
radius: 6,
|
|
red: 210,
|
|
green: 210,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 8. Blessed Aim (#108)
|
|
blessed_aim_front: {
|
|
overlay: 'blessed_aim_front',
|
|
filename: 'BlessedAim_front',
|
|
frames: 29,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
blessedaimfront: {
|
|
overlay: 'blessed_aim_front',
|
|
filename: 'BlessedAim_front',
|
|
frames: 29,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
blessed_aim: {
|
|
overlay: 'blessed_aim_front',
|
|
filename: 'BlessedAim_front',
|
|
frames: 29,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
blessedaim: {
|
|
overlay: 'blessed_aim_front',
|
|
filename: 'BlessedAim_front',
|
|
frames: 29,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
blessed_aim_back: {
|
|
overlay: 'blessed_aim_back',
|
|
filename: 'BlessedAim_back',
|
|
frames: 29,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
blessedaimback: {
|
|
overlay: 'blessed_aim_back',
|
|
filename: 'BlessedAim_back',
|
|
frames: 29,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 9. Cleansing (#109)
|
|
cleansing_front: {
|
|
overlay: 'cleansing_front',
|
|
filename: 'Cleansing_front',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
cleansingfront: {
|
|
overlay: 'cleansing_front',
|
|
filename: 'Cleansing_front',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
cleansing: {
|
|
overlay: 'cleansing_front',
|
|
filename: 'Cleansing_front',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
cleansing_back: {
|
|
overlay: 'cleansing_back',
|
|
filename: 'Cleansing_back',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
cleansingback: {
|
|
overlay: 'cleansing_back',
|
|
filename: 'Cleansing_back',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 10. Resist Lightning (#110) - Full Ring
|
|
resist_lightning: {
|
|
overlay: 'resist_lightning',
|
|
filename: 'AuraResistLightningBack',
|
|
frames: 20,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 6,
|
|
radius: 6,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 200,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_resistlight: {
|
|
overlay: 'resist_lightning',
|
|
filename: 'AuraResistLightningBack',
|
|
frames: 20,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 6,
|
|
radius: 6,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 200,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 11. Concentration (#113)
|
|
concentration_front: {
|
|
overlay: 'concentration_front',
|
|
filename: 'Concentration_front',
|
|
frames: 30,
|
|
animRate: 3,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
concentrationfront: {
|
|
overlay: 'concentration_front',
|
|
filename: 'Concentration_front',
|
|
frames: 30,
|
|
animRate: 3,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
concentration: {
|
|
overlay: 'concentration_front',
|
|
filename: 'Concentration_front',
|
|
frames: 30,
|
|
animRate: 3,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
concentration_back: {
|
|
overlay: 'concentration_back',
|
|
filename: 'Concentration_back',
|
|
frames: 30,
|
|
animRate: 3,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
concentrationback: {
|
|
overlay: 'concentration_back',
|
|
filename: 'Concentration_back',
|
|
frames: 30,
|
|
animRate: 3,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 12. Holy Freeze (#114) - Full Ring
|
|
holy_freeze: {
|
|
overlay: 'holyfreeze',
|
|
filename: 'HolyFreeze',
|
|
frames: 15,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
holyfreeze: {
|
|
overlay: 'holyfreeze',
|
|
filename: 'HolyFreeze',
|
|
frames: 15,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 13. Vigor (#115)
|
|
vigor_front: {
|
|
overlay: 'vigor_front',
|
|
filename: 'Stamina_front',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
staminafront: {
|
|
overlay: 'vigor_front',
|
|
filename: 'Stamina_front',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
vigor: {
|
|
overlay: 'vigor_front',
|
|
filename: 'Stamina_front',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
stamina: {
|
|
overlay: 'vigor_front',
|
|
filename: 'Stamina_front',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
vigor_back: {
|
|
overlay: 'vigor_back',
|
|
filename: 'Stamina_back',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
staminaback: {
|
|
overlay: 'vigor_back',
|
|
filename: 'Stamina_back',
|
|
frames: 16,
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 14. Holy Shock (#118)
|
|
holy_shock_front: {
|
|
overlay: 'holy_shock_front',
|
|
filename: 'HolyShock_front',
|
|
frames: 14,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
holyshockfront: {
|
|
overlay: 'holy_shock_front',
|
|
filename: 'HolyShock_front',
|
|
frames: 14,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
holy_shock: {
|
|
overlay: 'holy_shock_front',
|
|
filename: 'HolyShock_front',
|
|
frames: 14,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
holyshock: {
|
|
overlay: 'holy_shock_front',
|
|
filename: 'HolyShock_front',
|
|
frames: 14,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
holy_shock_back: {
|
|
overlay: 'holy_shock_back',
|
|
filename: 'HolyShock_back',
|
|
frames: 14,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
holyshockback: {
|
|
overlay: 'holy_shock_back',
|
|
filename: 'HolyShock_back',
|
|
frames: 14,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 15. Sanctuary (#119)
|
|
sanctuary_front: {
|
|
overlay: 'sanctuary_front',
|
|
filename: 'SanctuaryAura_Front',
|
|
frames: 12,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
sanctuaryfront: {
|
|
overlay: 'sanctuary_front',
|
|
filename: 'SanctuaryAura_Front',
|
|
frames: 12,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
sanctuary: {
|
|
overlay: 'sanctuary_front',
|
|
filename: 'SanctuaryAura_Front',
|
|
frames: 12,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
sanctuary_back: {
|
|
overlay: 'sanctuary_back',
|
|
filename: 'SanctuaryAura_Back',
|
|
frames: 12,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
sanctuaryback: {
|
|
overlay: 'sanctuary_back',
|
|
filename: 'SanctuaryAura_Back',
|
|
frames: 12,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 16. Meditation (#120) - Full Ring
|
|
meditation: {
|
|
overlay: 'meditation',
|
|
filename: 'Meditation',
|
|
frames: 20,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
meditationfront: {
|
|
overlay: 'meditation',
|
|
filename: 'Meditation',
|
|
frames: 20,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 17. Fanaticism (#122)
|
|
fanaticism_front: {
|
|
overlay: 'fanaticism_front',
|
|
filename: 'Fanatacism_Front',
|
|
frames: 30,
|
|
animRate: 6,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
fanaticismfront: {
|
|
overlay: 'fanaticism_front',
|
|
filename: 'Fanatacism_Front',
|
|
frames: 30,
|
|
animRate: 6,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
fanaticism_back: {
|
|
overlay: 'fanaticism_back',
|
|
filename: 'Fanatacism_Back',
|
|
frames: 30,
|
|
animRate: 6,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
fanaticismback: {
|
|
overlay: 'fanaticism_back',
|
|
filename: 'Fanatacism_Back',
|
|
frames: 30,
|
|
animRate: 6,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
fanaticism: {
|
|
overlay: 'fanaticism_back',
|
|
filename: 'Fanatacism_Back',
|
|
frames: 30,
|
|
animRate: 6,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_fanatic: {
|
|
overlay: 'fanaticism_back',
|
|
filename: 'Fanatacism_Back',
|
|
frames: 30,
|
|
animRate: 6,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 18. Conviction (#123) - Full Ring
|
|
conviction: {
|
|
overlay: 'convictionback',
|
|
filename: 'Conviction',
|
|
frames: 21,
|
|
animRate: 6,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
convictionback: {
|
|
overlay: 'convictionback',
|
|
filename: 'Conviction',
|
|
frames: 21,
|
|
animRate: 6,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 19. Redemption (#124) - Full Ring
|
|
redemption: {
|
|
overlay: 'redemptionback',
|
|
filename: 'Redemption',
|
|
frames: 12,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
redemptionback: {
|
|
overlay: 'redemptionback',
|
|
filename: 'Redemption',
|
|
frames: 12,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
// 20. Salvation (#125)
|
|
salvation_front: {
|
|
overlay: 'salvation_front',
|
|
filename: 'AuraResistAllFront',
|
|
frames: 20,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 6,
|
|
radius: 6,
|
|
red: 211,
|
|
green: 148,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_resistall_front: {
|
|
overlay: 'salvation_front',
|
|
filename: 'AuraResistAllFront',
|
|
frames: 20,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 6,
|
|
radius: 6,
|
|
red: 211,
|
|
green: 148,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
salvation: {
|
|
overlay: 'salvation_front',
|
|
filename: 'AuraResistAllFront',
|
|
frames: 20,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 6,
|
|
radius: 6,
|
|
red: 211,
|
|
green: 148,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
salvation_back: {
|
|
overlay: 'salvation_back',
|
|
filename: 'AuraResistAllBack',
|
|
frames: 20,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 6,
|
|
radius: 6,
|
|
red: 211,
|
|
green: 148,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
aura_resistall_back: {
|
|
overlay: 'salvation_back',
|
|
filename: 'AuraResistAllBack',
|
|
frames: 20,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
initRadius: 6,
|
|
radius: 6,
|
|
red: 211,
|
|
green: 148,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
cursecast: {
|
|
overlay: 'cursecast',
|
|
filename: 'CurseCast',
|
|
frames: 25,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 9,
|
|
red: 255,
|
|
green: 128,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
curseamplifydamage: {
|
|
overlay: 'curseamplifydamage',
|
|
filename: 'CurseAmplifyDamageEffect',
|
|
frames: 16,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
cursedimvision: {
|
|
overlay: 'cursedimvision',
|
|
filename: 'CurseDimVisionEffect',
|
|
frames: 16,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
curseweaken: {
|
|
overlay: 'curseweaken',
|
|
filename: 'CurseWeakenEffect',
|
|
frames: 16,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
curseironmaiden: {
|
|
overlay: 'curseironmaiden',
|
|
filename: 'CurseIronMaidenEffect',
|
|
frames: 19,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
curseterror: {
|
|
overlay: 'curseterror',
|
|
filename: 'CurseTerrorEffect',
|
|
frames: 14,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
curseconfuse: {
|
|
overlay: 'curseconfuse',
|
|
filename: 'CurseConfuseEffect',
|
|
frames: 16,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
cursereversevampire: {
|
|
overlay: 'cursereversevampire',
|
|
filename: 'CurseReverseVampireEffect',
|
|
frames: 19,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
steallife: {
|
|
overlay: 'cursereversevampire',
|
|
filename: 'CurseReverseVampireEffect',
|
|
frames: 19,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
curseattract: {
|
|
overlay: 'curseattract',
|
|
filename: 'CurseAttractHarmEffect',
|
|
frames: 16,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
cursedecrepify: {
|
|
overlay: 'cursedecrepify',
|
|
filename: 'CurseDecrepifyEffect',
|
|
frames: 16,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
curselowerresist: {
|
|
overlay: 'curselowerresist',
|
|
filename: 'CurseLowerResistEffect',
|
|
frames: 16,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 0,
|
|
radius: 0,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
numDirections: 1,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
},
|
|
})
|
|
|
|
/**
|
|
* Parses raw Overlay.txt text into a Map of overlay records.
|
|
*/
|
|
export function parseOverlayTxt(content: string): Map<string, OverlayTxtData> {
|
|
const map = new Map<string, OverlayTxtData>()
|
|
const lines = content.split(/\r?\n/).filter(line => line.trim().length > 0)
|
|
if (lines.length < 2) return map
|
|
const headers = lines[0]!.split('\t').map(h => h.trim())
|
|
const col = (name: string) => headers.findIndex(h => h.toLowerCase() === name.toLowerCase())
|
|
|
|
const overlayIdx = col('overlay')
|
|
const filenameIdx = col('Filename')
|
|
const framesIdx = col('Frames')
|
|
const animRateIdx = col('AnimRate')
|
|
const transIdx = col('Trans')
|
|
const preDrawIdx = col('PreDraw')
|
|
const initRadiusIdx = col('InitRadius')
|
|
const radiusIdx = col('Radius')
|
|
const redIdx = col('Red')
|
|
const greenIdx = col('Green')
|
|
const blueIdx = col('Blue')
|
|
const numDirIdx = col('NumDirections')
|
|
const xOffsetIdx = col('Xoffset')
|
|
const yOffsetIdx = col('Yoffset')
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const cells = lines[i]!.split('\t')
|
|
const rawName = (cells[overlayIdx] ?? '').trim()
|
|
const name = rawName.toLowerCase()
|
|
if (!name || name === 'expansion') continue
|
|
|
|
const filename = (cells[filenameIdx] ?? '').trim()
|
|
const frames = Number(cells[framesIdx] ?? 1) || 1
|
|
const animRate = Number(cells[animRateIdx] ?? 16) || 16
|
|
const trans = Number(cells[transIdx] ?? 0) || 0
|
|
const preDraw = Number(cells[preDrawIdx] ?? 0) !== 0
|
|
const initRadius = Number(cells[initRadiusIdx] ?? 0) || 0
|
|
const radius = Number(cells[radiusIdx] ?? 0) || 0
|
|
const red = parseColorChannel(cells[redIdx])
|
|
const green = parseColorChannel(cells[greenIdx])
|
|
const blue = parseColorChannel(cells[blueIdx])
|
|
const numDirections = Number(cells[numDirIdx] ?? 1) || 1
|
|
const xOffset = Number(cells[xOffsetIdx] ?? 0) || 0
|
|
const yOffset = Number(cells[yOffsetIdx] ?? 0) || 0
|
|
|
|
const rec: OverlayTxtData = {
|
|
overlay: rawName,
|
|
filename,
|
|
frames,
|
|
animRate,
|
|
trans,
|
|
preDraw,
|
|
initRadius,
|
|
radius,
|
|
red,
|
|
green,
|
|
blue,
|
|
numDirections,
|
|
xOffset,
|
|
yOffset,
|
|
}
|
|
map.set(name, rec)
|
|
if (filename) {
|
|
map.set(filename.toLowerCase(), rec)
|
|
}
|
|
}
|
|
return map
|
|
}
|
|
|
|
/**
|
|
* Extracts overlay data from an Overlay.txt table or parsed records.
|
|
*/
|
|
export function getOverlayTxtData(
|
|
overlayName: string,
|
|
overlaySource?:
|
|
| Map<string, OverlayTxtData>
|
|
| { rows: readonly Readonly<Record<string, string>>[] }
|
|
| { header: readonly string[]; rows: readonly (readonly string[])[] }
|
|
| string
|
|
| undefined,
|
|
): OverlayTxtData {
|
|
const key = overlayName.trim().toLowerCase()
|
|
if (!overlaySource) {
|
|
return CANONICAL_113C_OVERLAYS[key] ?? {
|
|
overlay: overlayName,
|
|
filename: overlayName,
|
|
frames: 14,
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
initRadius: 1,
|
|
radius: 9,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
}
|
|
}
|
|
|
|
if (typeof overlaySource === 'string') {
|
|
const parsed = parseOverlayTxt(overlaySource)
|
|
return parsed.get(key) ?? getOverlayTxtData(overlayName, undefined)
|
|
}
|
|
|
|
if (overlaySource instanceof Map) {
|
|
return overlaySource.get(key) ?? getOverlayTxtData(overlayName, undefined)
|
|
}
|
|
|
|
// D2Table ({ header, rows })
|
|
if ('header' in overlaySource && Array.isArray((overlaySource as any).header)) {
|
|
const t = overlaySource as { header: readonly string[]; rows: readonly (readonly string[])[] }
|
|
const oIdx = t.header.indexOf('Overlay')
|
|
const fIdx = t.header.indexOf('Filename')
|
|
const row = t.rows.find(
|
|
r => (oIdx !== -1 && (r[oIdx] ?? '').trim().toLowerCase() === key) ||
|
|
(fIdx !== -1 && (r[fIdx] ?? '').trim().toLowerCase() === key),
|
|
)
|
|
if (row) {
|
|
return {
|
|
overlay: oIdx !== -1 ? (row[oIdx] ?? key).trim() : key,
|
|
filename: fIdx !== -1 ? (row[fIdx] ?? '').trim() : '',
|
|
frames: Number(row[t.header.indexOf('Frames')] ?? 1) || 1,
|
|
animRate: Number(row[t.header.indexOf('AnimRate')] ?? 16) || 16,
|
|
trans: Number(row[t.header.indexOf('Trans')] ?? 0) || 0,
|
|
preDraw: Number(row[t.header.indexOf('PreDraw')] ?? 0) !== 0,
|
|
initRadius: Number(row[t.header.indexOf('InitRadius')] ?? 0) || 0,
|
|
radius: Number(row[t.header.indexOf('Radius')] ?? 0) || 0,
|
|
red: parseColorChannel(row[t.header.indexOf('Red')]),
|
|
green: parseColorChannel(row[t.header.indexOf('Green')]),
|
|
blue: parseColorChannel(row[t.header.indexOf('Blue')]),
|
|
numDirections: Number(row[t.header.indexOf('NumDirections')] ?? 1) || 1,
|
|
xOffset: Number(row[t.header.indexOf('Xoffset')] ?? 0) || 0,
|
|
yOffset: Number(row[t.header.indexOf('Yoffset')] ?? 0) || 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
// DataTable ({ rows: Record<string, string>[] })
|
|
if ('rows' in overlaySource && !('header' in overlaySource)) {
|
|
const tableRows = overlaySource.rows as readonly Readonly<Record<string, string>>[]
|
|
const row = tableRows.find(
|
|
r => (r['overlay'] ?? r['Overlay'] ?? '').trim().toLowerCase() === key ||
|
|
(r['Filename'] ?? r['filename'] ?? '').trim().toLowerCase() === key,
|
|
)
|
|
if (row) {
|
|
return {
|
|
overlay: (row['overlay'] ?? row['Overlay'] ?? key).trim(),
|
|
filename: (row['Filename'] ?? row['filename'] ?? '').trim(),
|
|
frames: Number(row['Frames'] ?? row['frames'] ?? 1) || 1,
|
|
animRate: Number(row['AnimRate'] ?? row['animrate'] ?? 16) || 16,
|
|
trans: Number(row['Trans'] ?? row['trans'] ?? 0) || 0,
|
|
preDraw: Number(row['PreDraw'] ?? row['predraw'] ?? 0) !== 0,
|
|
initRadius: Number(row['InitRadius'] ?? row['initradius'] ?? 0) || 0,
|
|
radius: Number(row['Radius'] ?? row['radius'] ?? 0) || 0,
|
|
red: parseColorChannel(row['Red'] ?? row['red']),
|
|
green: parseColorChannel(row['Green'] ?? row['green']),
|
|
blue: parseColorChannel(row['Blue'] ?? row['blue']),
|
|
numDirections: Number(row['NumDirections'] ?? row['numdirections'] ?? 1) || 1,
|
|
xOffset: Number(row['Xoffset'] ?? row['xoffset'] ?? 0) || 0,
|
|
yOffset: Number(row['Yoffset'] ?? row['yoffset'] ?? 0) || 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
return getOverlayTxtData(overlayName, undefined)
|
|
}
|
|
|
|
/**
|
|
* An active unit overlay in the world (e.g. cast overlay, aura, buff visual).
|
|
*/
|
|
export interface ActiveOverlay {
|
|
readonly id: string
|
|
readonly overlayName: string
|
|
readonly casterId: string
|
|
x: number
|
|
y: number
|
|
frame: number
|
|
readonly maxFrames: number
|
|
readonly animRate: number
|
|
readonly preDraw: boolean
|
|
readonly trans: number
|
|
readonly lightRadius: number
|
|
readonly lightColor: readonly [number, number, number]
|
|
expired: boolean
|
|
readonly loop?: boolean | undefined
|
|
readonly targetMonsterIndex?: number | undefined
|
|
readonly isAura?: boolean | undefined
|
|
readonly auraSkillId?: number | undefined
|
|
readonly isCastOverlay?: boolean | undefined
|
|
readonly isStateOverlay?: boolean | undefined
|
|
readonly stateGroup?: number | undefined
|
|
}
|
|
|
|
export interface CreateActiveOverlayOptions {
|
|
readonly loop?: boolean | undefined
|
|
readonly targetMonsterIndex?: number | undefined
|
|
readonly isAura?: boolean | undefined
|
|
readonly auraSkillId?: number | undefined
|
|
readonly isCastOverlay?: boolean | undefined
|
|
readonly isStateOverlay?: boolean | undefined
|
|
readonly stateGroup?: number | undefined
|
|
}
|
|
|
|
/**
|
|
* Creates an ActiveOverlay entity initialized at the caster's coordinates.
|
|
*/
|
|
export function createActiveOverlay(
|
|
overlayName: string,
|
|
caster: { x: number; y: number },
|
|
casterId = 'player',
|
|
overlaySource?: Map<string, OverlayTxtData> | string,
|
|
options?: CreateActiveOverlayOptions,
|
|
): ActiveOverlay {
|
|
const data = getOverlayTxtData(overlayName, overlaySource)
|
|
const cleanName = overlayName.trim().toLowerCase()
|
|
const isColdArmor = ['frozenarmor', 'shiverarmor', 'chillingarmor', 'chillarmor'].includes(cleanName)
|
|
const isKnownState = [
|
|
'frozenarmor',
|
|
'shiverarmor',
|
|
'chillingarmor',
|
|
'chillarmor',
|
|
'energyshield',
|
|
'enchant',
|
|
'thunderstormback',
|
|
].includes(cleanName)
|
|
const isStateOverlay = options?.isStateOverlay ?? isKnownState
|
|
const isCastOverlay = options?.isCastOverlay ?? (!isStateOverlay && !options?.isAura)
|
|
const stateGroup = options?.stateGroup ?? (isColdArmor ? 1 : 0)
|
|
|
|
return {
|
|
id: `overlay_${overlayName}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
overlayName: data.overlay,
|
|
casterId,
|
|
x: caster.x,
|
|
y: caster.y,
|
|
frame: 0,
|
|
maxFrames: data.frames,
|
|
animRate: data.animRate,
|
|
preDraw: data.preDraw,
|
|
trans: data.trans,
|
|
lightRadius: data.radius,
|
|
lightColor: [data.red, data.green, data.blue] as const,
|
|
expired: false,
|
|
loop: options?.loop,
|
|
targetMonsterIndex: options?.targetMonsterIndex,
|
|
isAura: options?.isAura,
|
|
auraSkillId: options?.auraSkillId,
|
|
isCastOverlay,
|
|
isStateOverlay,
|
|
stateGroup,
|
|
}
|
|
}
|
|
|
|
/** Complete canonical mapping for all Paladin aura skills (IDs 98..125) to their 1.13c overlay name. */
|
|
export const PALADIN_AURA_OVERLAYS: Readonly<Record<number, string>> = Object.freeze({
|
|
98: 'might',
|
|
99: 'prayer',
|
|
100: 'resist_fire',
|
|
102: 'holy_fire',
|
|
103: 'thorns',
|
|
104: 'defiance',
|
|
105: 'resist_cold',
|
|
108: 'blessed_aim',
|
|
109: 'cleansing',
|
|
110: 'resist_lightning',
|
|
113: 'concentration',
|
|
114: 'holy_freeze',
|
|
115: 'vigor',
|
|
118: 'holy_shock',
|
|
119: 'sanctuary',
|
|
120: 'meditation',
|
|
122: 'fanaticism',
|
|
123: 'conviction',
|
|
124: 'redemption',
|
|
125: 'salvation',
|
|
})
|
|
|
|
/**
|
|
* Authentic Diablo II v1.13c Paladin Aura Overlay Parts (States.txt + Overlay.txt).
|
|
* - 13 auras have TWO separate parts: front (preDraw=false) + back (preDraw=true).
|
|
* - 7 auras are single full-circle overlays (preDraw=true or false).
|
|
*/
|
|
export const PALADIN_AURA_OVERLAY_PARTS: Readonly<Record<number, readonly string[]>> = Object.freeze({
|
|
98: ['might_front', 'might_back'],
|
|
99: ['prayer_front', 'prayer_back'],
|
|
100: ['resist_fire'],
|
|
102: ['holy_fire_front', 'holy_fire_back'],
|
|
103: ['thorns_front', 'thorns_back'],
|
|
104: ['defiance_front', 'defiance_back'],
|
|
105: ['resist_cold'],
|
|
108: ['blessed_aim_front', 'blessed_aim_back'],
|
|
109: ['cleansing_front', 'cleansing_back'],
|
|
110: ['resist_lightning'],
|
|
113: ['concentration_front', 'concentration_back'],
|
|
114: ['holy_freeze'],
|
|
115: ['vigor_front', 'vigor_back'],
|
|
118: ['holy_shock_front', 'holy_shock_back'],
|
|
119: ['sanctuary_front', 'sanctuary_back'],
|
|
120: ['meditation'],
|
|
122: ['fanaticism_front', 'fanaticism_back'],
|
|
123: ['conviction'],
|
|
124: ['redemption'],
|
|
125: ['salvation_front', 'salvation_back'],
|
|
})
|
|
|
|
export const PALADIN_AURA_SKILL_IDS: ReadonlySet<number> = new Set(
|
|
Object.keys(PALADIN_AURA_OVERLAYS).map(Number),
|
|
)
|
|
|
|
export function isAuraSkill(skillId: number): boolean {
|
|
return PALADIN_AURA_SKILL_IDS.has(skillId)
|
|
}
|
|
|
|
export function getAuraOverlayName(skillId: number): string | null {
|
|
return PALADIN_AURA_OVERLAYS[skillId] ?? null
|
|
}
|
|
|
|
export function getAuraOverlayNames(skillId: number): readonly string[] {
|
|
return PALADIN_AURA_OVERLAY_PARTS[skillId] ?? (PALADIN_AURA_OVERLAYS[skillId] ? [PALADIN_AURA_OVERLAYS[skillId]] : [])
|
|
}
|
|
|
|
/** Complete canonical mapping for all Sorceress skills to their 1.13c castoverlay. */
|
|
export const SORCERESS_SKILL_CAST_OVERLAYS: Readonly<Record<number, string>> = Object.freeze({
|
|
36: 'fire_cast_1', // Fire Bolt
|
|
38: 'light_cast_1', // Charged Bolt
|
|
39: 'ice_cast_1', // Ice Bolt
|
|
40: 'ice_cast_1', // Frozen Armor
|
|
41: 'fire_cast_2', // Inferno
|
|
42: 'light_cast_1', // Static Field
|
|
43: 'light_cast_2', // Telekinesis
|
|
44: 'ice_cast_2', // Frost Nova
|
|
45: 'ice_cast_1', // Ice Blast
|
|
46: 'fire_cast_2', // Blaze
|
|
47: 'fire_cast_2', // Fire Ball
|
|
48: 'light_cast_1', // Nova
|
|
49: 'light_cast_1', // Lightning
|
|
50: 'ice_cast_2', // Shiver Armor
|
|
51: 'fire_cast_2', // Fire Wall
|
|
52: 'fire_cast_2', // Enchant
|
|
53: 'light_cast_1', // Chain Lightning
|
|
54: 'teleport', // Teleport
|
|
55: 'ice_cast_2', // Glacial Spike
|
|
56: 'fire_cast_2', // Meteor
|
|
57: 'light_cast_2', // Thunder Storm
|
|
58: 'light_cast_2', // Energy Shield
|
|
59: 'ice_cast_3', // Blizzard
|
|
60: 'ice_cast_3', // Chilling Armor
|
|
62: 'fire_cast_2', // Hydra
|
|
64: 'ice_cast_3', // Frozen Orb
|
|
})
|
|
|
|
const SORCERESS_SKILL_NAME_TO_OVERLAY: Readonly<Record<string, string>> = Object.freeze({
|
|
firebolt: 'fire_cast_1',
|
|
'fire bolt': 'fire_cast_1',
|
|
chargedbolt: 'light_cast_1',
|
|
'charged bolt': 'light_cast_1',
|
|
icebolt: 'ice_cast_1',
|
|
'ice bolt': 'ice_cast_1',
|
|
frozenarmor: 'ice_cast_1',
|
|
'frozen armor': 'ice_cast_1',
|
|
inferno: 'fire_cast_2',
|
|
staticfield: 'light_cast_1',
|
|
'static field': 'light_cast_1',
|
|
telekinesis: 'light_cast_2',
|
|
frostnova: 'ice_cast_2',
|
|
'frost nova': 'ice_cast_2',
|
|
iceblast: 'ice_cast_1',
|
|
'ice blast': 'ice_cast_1',
|
|
blaze: 'fire_cast_2',
|
|
fireball: 'fire_cast_2',
|
|
'fire ball': 'fire_cast_2',
|
|
nova: 'light_cast_1',
|
|
lightning: 'light_cast_1',
|
|
shiverarmor: 'ice_cast_2',
|
|
'shiver armor': 'ice_cast_2',
|
|
firewall: 'fire_cast_2',
|
|
'fire wall': 'fire_cast_2',
|
|
enchant: 'fire_cast_2',
|
|
chainlightning: 'light_cast_1',
|
|
'chain lightning': 'light_cast_1',
|
|
teleport: 'teleport',
|
|
glacialspike: 'ice_cast_2',
|
|
'glacial spike': 'ice_cast_2',
|
|
meteor: 'fire_cast_2',
|
|
thunderstorm: 'light_cast_2',
|
|
'thunder storm': 'light_cast_2',
|
|
energyshield: 'light_cast_2',
|
|
'energy shield': 'light_cast_2',
|
|
blizzard: 'ice_cast_3',
|
|
chillingarmor: 'ice_cast_3',
|
|
'chilling armor': 'ice_cast_3',
|
|
hydra: 'fire_cast_2',
|
|
frozenorb: 'ice_cast_3',
|
|
'frozen orb': 'ice_cast_3',
|
|
bonearmor: 'bonearmor_cast',
|
|
'bone armor': 'bonearmor_cast',
|
|
bonewall: 'bonecast',
|
|
'bone wall': 'bonecast',
|
|
boneprison: 'bonecast',
|
|
'bone prison': 'bonecast',
|
|
})
|
|
|
|
const NECROMANCER_SKILL_CAST_OVERLAYS: Readonly<Record<number, string>> = Object.freeze({
|
|
68: 'bonearmor_cast', // Bone Armor
|
|
78: 'bonecast', // Bone Wall
|
|
88: 'bonecast', // Bone Prison
|
|
})
|
|
|
|
/**
|
|
* Returns canonical castoverlay name for a skill, if any.
|
|
*/
|
|
export function getSkillCastOverlay(skillId: number | string): string | undefined {
|
|
if (typeof skillId === 'number') {
|
|
return (
|
|
SORCERESS_SKILL_CAST_OVERLAYS[skillId] ??
|
|
NECROMANCER_SKILL_CAST_OVERLAYS[skillId] ??
|
|
BATCH1_SKILLS[skillId]?.castOverlay
|
|
)
|
|
}
|
|
const num = parseInt(skillId, 10)
|
|
if (!Number.isNaN(num)) {
|
|
return (
|
|
SORCERESS_SKILL_CAST_OVERLAYS[num] ??
|
|
NECROMANCER_SKILL_CAST_OVERLAYS[num] ??
|
|
BATCH1_SKILLS[num]?.castOverlay
|
|
)
|
|
}
|
|
const normalized = skillId.toLowerCase().trim()
|
|
const overlay =
|
|
SORCERESS_SKILL_NAME_TO_OVERLAY[normalized] ??
|
|
SORCERESS_SKILL_NAME_TO_OVERLAY[normalized.replace(/[\s_-]/g, '')]
|
|
if (overlay) return overlay
|
|
for (const skill of Object.values(BATCH1_SKILLS)) {
|
|
if (skill.name.toLowerCase() === normalized && skill.castOverlay) {
|
|
return skill.castOverlay
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Read one skill from a table row.
|
|
*
|
|
* @param row - the record.
|
|
* @param rowIndex - position, for a fallback id.
|
|
* @param text - text source for name indices.
|
|
* @param missilesSource - optional Missiles.txt table or parsed map to resolve speed & range.
|
|
* @returns the definition.
|
|
*/
|
|
export function skillFromRow(
|
|
row: Readonly<Record<string, string>>,
|
|
rowIndex: number,
|
|
text: TextSource,
|
|
missilesSource?:
|
|
| Map<string, MissileTxtData>
|
|
| { rows: readonly Readonly<Record<string, string>>[] }
|
|
| { header: readonly string[]; rows: readonly (readonly string[])[] }
|
|
| string
|
|
| undefined,
|
|
): SkillDef {
|
|
const id = textCell(row, 'Id', textCell(row, 'skill', `skill${String(rowIndex)}`))
|
|
const rawName = textCell(row, 'Name', textCell(row, 'skilldesc', id))
|
|
const mslName = textCell(row, 'srvmissile', textCell(row, 'cltmissile'))
|
|
let speed = Math.max(0, numberCell(row, 'Speed', 0))
|
|
let range = Math.max(1, numberCell(row, 'Range', numberCell(row, 'range', 120)))
|
|
if (mslName) {
|
|
const msl = getMissileTxtData(mslName, missilesSource)
|
|
if (msl.speedPxPerSec > 0) speed = msl.speedPxPerSec
|
|
if (msl.distancePx > 0) range = msl.distancePx
|
|
}
|
|
const rawCastOverlay = textCell(row, 'castoverlay', textCell(row, 'castOverlay', ''))
|
|
const castOverlay = rawCastOverlay.length > 0 ? rawCastOverlay.toLowerCase() : undefined
|
|
return {
|
|
id,
|
|
name: resolveText(rawName, text) || id,
|
|
manaCost: Math.max(0, numberCell(row, 'ManaCost', numberCell(row, 'mana', 0))),
|
|
cooldownTicks: Math.max(0, numberCell(row, 'CooldownTicks', numberCell(row, 'delay', 0))),
|
|
range,
|
|
// A projectile skill is one with a missile speed or srvmissile; otherwise it strikes where
|
|
// the caster is facing.
|
|
projectile: speed > 0 || numberCell(row, 'Speed', 0) > 0 || mslName.length > 0,
|
|
speed,
|
|
baseMinDamage: Math.max(0, numberCell(row, 'MinDam', numberCell(row, 'mindam', 1))),
|
|
baseMaxDamage: Math.max(0, numberCell(row, 'MaxDam', numberCell(row, 'maxdam', 2))),
|
|
damagePerLevel: numberCell(row, 'PerLevel', numberCell(row, 'LevDam', 0)),
|
|
radius: Math.max(1, numberCell(row, 'Radius', numberCell(row, 'HitRadius', 24))),
|
|
castOverlay,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read every skill in a table.
|
|
*
|
|
* @param table - a `Skills.txt`-shaped table.
|
|
* @param text - text source for name indices.
|
|
* @param missilesSource - optional Missiles.txt table or parsed map.
|
|
* @returns the definitions, in table order.
|
|
*/
|
|
export function skillsFromTable(
|
|
table: { rows: readonly Readonly<Record<string, string>>[] },
|
|
text: TextSource,
|
|
missilesSource?:
|
|
| Map<string, MissileTxtData>
|
|
| { rows: readonly Readonly<Record<string, string>>[] }
|
|
| { header: readonly string[]; rows: readonly (readonly string[])[] }
|
|
| string
|
|
| undefined,
|
|
): SkillDef[] {
|
|
return table.rows.map((row, index) => skillFromRow(row, index, text, missilesSource))
|
|
}
|
|
|
|
/**
|
|
* Parses raw Skills.txt text into a Map of skill definitions keyed by skill name and ID.
|
|
*/
|
|
export function parseSkillsTxt(
|
|
content: string,
|
|
missilesSource?:
|
|
| Map<string, MissileTxtData>
|
|
| { rows: readonly Readonly<Record<string, string>>[] }
|
|
| { header: readonly string[]; rows: readonly (readonly string[])[] }
|
|
| string
|
|
| undefined,
|
|
): Map<string, SkillDef> {
|
|
const map = new Map<string, SkillDef>()
|
|
const lines = content.split(/\r?\n/).filter(line => line.trim().length > 0)
|
|
if (lines.length < 2) return map
|
|
const headers = lines[0]!.split('\t').map(h => h.trim())
|
|
const rows: Record<string, string>[] = []
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const cells = lines[i]!.split('\t')
|
|
const row: Record<string, string> = {}
|
|
for (let c = 0; c < headers.length; c++) {
|
|
const colName = headers[c]
|
|
if (colName) {
|
|
row[colName] = (cells[c] ?? '').trim()
|
|
}
|
|
}
|
|
rows.push(row)
|
|
}
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const row = rows[i]!
|
|
const skillName = (row['skill'] ?? row['Name'] ?? row['skilldesc'] ?? `skill${i}`).trim()
|
|
if (!skillName || skillName === 'expansion') continue
|
|
const def = skillFromRow(row, i, { get: () => skillName }, missilesSource)
|
|
map.set(skillName.toLowerCase(), def)
|
|
if (def.id) {
|
|
map.set(def.id.toLowerCase(), def)
|
|
}
|
|
}
|
|
return map
|
|
}
|
|
|
|
/**
|
|
* Damage of a skill at a level.
|
|
*
|
|
* @param skill - the definition.
|
|
* @param level - skill level, 1-based.
|
|
* @param rng - random source for the roll inside the damage range.
|
|
* @returns the damage.
|
|
*/
|
|
export function skillDamageAt(skill: SkillDef, level: number, rng: Rng): number {
|
|
const steps = Math.max(0, level - 1)
|
|
const min = skill.baseMinDamage + skill.damagePerLevel * steps
|
|
const max = skill.baseMaxDamage + skill.damagePerLevel * steps
|
|
return rng.int(Math.round(min), Math.round(Math.max(min, max)))
|
|
}
|
|
|
|
/**
|
|
* Cast a skill.
|
|
*
|
|
* Mana and cooldown are checked here rather than by the caller so that every
|
|
* route to casting (a hotkey, an AI, a script) obeys the same rules.
|
|
*
|
|
* @param skill - the definition.
|
|
* @param caster - position and facing.
|
|
* @param level - skill level.
|
|
* @param rng - random source.
|
|
* @param cooldownLeft - ticks of cooldown still owed.
|
|
* @param mana - mana available.
|
|
* @param aim - optional aim point; defaults to straight ahead of the facing.
|
|
* @returns what the cast produced.
|
|
*/
|
|
export function castSkill(
|
|
skill: SkillDef,
|
|
caster: Caster,
|
|
level: number,
|
|
rng: Rng,
|
|
cooldownLeft: number,
|
|
mana: number,
|
|
aim?: { readonly x: number; readonly y: number },
|
|
): CastResult {
|
|
if (cooldownLeft > 0) return { kind: 'cooldown', ticksLeft: cooldownLeft }
|
|
if (mana < skill.manaCost) return { kind: 'mana', cost: skill.manaCost }
|
|
const damage = skillDamageAt(skill, level, rng)
|
|
if (!skill.projectile) return { kind: 'instant', radius: skill.radius, damage }
|
|
|
|
let dx: number
|
|
let dy: number
|
|
if (aim !== undefined) {
|
|
dx = aim.x - caster.x
|
|
dy = aim.y - caster.y
|
|
} else {
|
|
const vector = FACING_VECTORS[caster.facing] ?? FACING_VECTORS[0]!
|
|
dx = vector[0]
|
|
dy = vector[1]
|
|
}
|
|
const length = Math.hypot(dx, dy)
|
|
if (length === 0) return { kind: 'instant', radius: skill.radius, damage }
|
|
const perTick = skill.speed / 25
|
|
const ttl = Math.max(1, Math.round(skill.range / perTick))
|
|
return {
|
|
kind: 'projectile',
|
|
projectile: {
|
|
skillId: skill.id,
|
|
x: caster.x,
|
|
y: caster.y,
|
|
vx: (dx / length) * perTick,
|
|
vy: (dy / length) * perTick,
|
|
damage,
|
|
ttl,
|
|
fromPlayer: true,
|
|
missileType: skill.id === '36' || skill.id === 'firebolt' ? 'firebolt' : undefined,
|
|
},
|
|
}
|
|
}
|
|
|
|
/** A body a projectile can hit. */
|
|
export interface ProjectileTarget {
|
|
/** Stable identity, echoed back on a hit. */
|
|
readonly index: number
|
|
/** World x. */
|
|
readonly x: number
|
|
/** World y. */
|
|
readonly y: number
|
|
/** Radius for hit testing. */
|
|
readonly radius: number
|
|
/** Whether the target is alive; dead targets are not hit. */
|
|
readonly alive: boolean
|
|
}
|
|
|
|
/** What happened to projectiles during a tick. */
|
|
export interface ProjectileOutcome {
|
|
/** Projectiles still in flight. */
|
|
readonly alive: readonly Projectile[]
|
|
/** Hits, one per projectile that struck. */
|
|
readonly hits: readonly {
|
|
readonly targetIndex: number
|
|
readonly damage: number
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly skillId?: string | undefined
|
|
readonly missileType?: string | undefined
|
|
readonly aoeRadius?: number | undefined
|
|
readonly statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
|
|
readonly statusDuration?: number | undefined
|
|
readonly isUndeadOnly?: boolean | undefined
|
|
}[]
|
|
/** Projectiles that died on a wall or expired. */
|
|
readonly expired: number
|
|
/** Projectiles that stopped against terrain. */
|
|
readonly wallHits: number
|
|
/** Terrain wall collision events for visual impacts/explosions. */
|
|
readonly wallHitEvents?: readonly {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly skillId?: string | undefined
|
|
readonly missileType?: string | undefined
|
|
readonly aoeRadius?: number | undefined
|
|
readonly damage?: number | undefined
|
|
readonly statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
|
|
readonly statusDuration?: number | undefined
|
|
}[] | undefined
|
|
/** Newly spawned child projectiles during this tick (e.g. Frozen Orb bolts / nova). */
|
|
readonly spawnedProjectiles?: readonly Projectile[] | undefined
|
|
}
|
|
|
|
function getBlizzardExplosionMissile(missileType: string): string {
|
|
switch (missileType) {
|
|
case 'blizzard1':
|
|
return 'blizzardexplode1'
|
|
case 'blizzard2':
|
|
return 'blizzardexplode2'
|
|
case 'blizzard3':
|
|
return 'blizzardexplode3'
|
|
case 'blizzard4':
|
|
return 'blizzardexplode1'
|
|
default:
|
|
return 'blizzardexplode1'
|
|
}
|
|
}
|
|
|
|
function isBlizzardShard(missileType?: string): boolean {
|
|
return (
|
|
missileType === 'blizzard1' ||
|
|
missileType === 'blizzard2' ||
|
|
missileType === 'blizzard3' ||
|
|
missileType === 'blizzard4'
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Advance every projectile one tick.
|
|
*
|
|
* @param projectiles - projectiles in flight.
|
|
* @param targets - candidate targets.
|
|
* @param options - terrain overlap test and projectile radius.
|
|
* @returns the surviving projectiles and what they hit.
|
|
*/
|
|
export function tickProjectiles(
|
|
projectiles: readonly Projectile[],
|
|
targets: readonly ProjectileTarget[],
|
|
options: {
|
|
readonly overlap: (x: number, y: number) => number
|
|
readonly isMissileBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | undefined
|
|
readonly radius?: number
|
|
readonly nextDelayByTarget?: Map<number | string, number> | Map<number, number> | undefined
|
|
readonly currentTick?: number | undefined
|
|
readonly onMissileStep?: ((missileType: string, prevX: number, prevY: number, curX: number, curY: number, projectile: Projectile) => void) | undefined
|
|
},
|
|
): ProjectileOutcome {
|
|
const radius = options.radius ?? 8
|
|
const alive: Projectile[] = []
|
|
const spawned: Projectile[] = []
|
|
const waveNextDelayHits = new Set<number>()
|
|
const hits: {
|
|
targetIndex: number
|
|
damage: number
|
|
x: number
|
|
y: number
|
|
skillId?: string | undefined
|
|
missileType?: string | undefined
|
|
aoeRadius?: number | undefined
|
|
statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
|
|
statusDuration?: number | undefined
|
|
isUndeadOnly?: boolean | undefined
|
|
}[] = []
|
|
const wallHitEvents: {
|
|
x: number
|
|
y: number
|
|
skillId?: string | undefined
|
|
missileType?: string | undefined
|
|
aoeRadius?: number | undefined
|
|
damage?: number | undefined
|
|
statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
|
|
statusDuration?: number | undefined
|
|
}[] = []
|
|
let expired = 0
|
|
let wallHits = 0
|
|
|
|
for (const projectile of projectiles) {
|
|
const isExpired = projectile.missileType === 'meteorcenter' ? projectile.ttl <= 1 : projectile.ttl <= 0
|
|
if (isExpired) {
|
|
expired += 1
|
|
if (projectile.missileType === 'frozenorb') {
|
|
// Authentic D2 1.13c MISSMODE_SrvHit29_FrozenOrb (0x6FC5CE10):
|
|
// Detonates into 16 frozenorbnova missiles + frozenorbexplode explosion at expiry
|
|
for (let i = 0; i < 64; i += 4) {
|
|
const offX = FROZEN_ORB_X_POSITIONS[i]!
|
|
const offY = FROZEN_ORB_Y_POSITIONS[i]! * ISO_GROUND_ASPECT_RATIO
|
|
const angle = Math.atan2(offY, offX)
|
|
const speed = 24
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x: projectile.x,
|
|
y: projectile.y,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
damage: projectile.damage > 0 ? projectile.damage : 40,
|
|
ttl: 25,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: 'frozenorbnova',
|
|
statusEffect: 'chill',
|
|
statusDuration: 100,
|
|
isNovaCurl: true,
|
|
pierce: false,
|
|
})
|
|
}
|
|
wallHitEvents.push({
|
|
x: projectile.x,
|
|
y: projectile.y,
|
|
skillId: projectile.skillId,
|
|
missileType: 'frozenorb',
|
|
aoeRadius: 24,
|
|
})
|
|
} else if (isBlizzardShard(projectile.missileType)) {
|
|
const expType = getBlizzardExplosionMissile(projectile.missileType!)
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x: projectile.x,
|
|
y: projectile.y,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: projectile.damage,
|
|
ttl: 6,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: expType,
|
|
statusEffect: 'chill',
|
|
statusDuration: 100,
|
|
aoeRadius: 32,
|
|
pierce: false,
|
|
})
|
|
} else if (projectile.missileType === 'fireball') {
|
|
const blastRadius = projectile.aoeRadius ?? 80
|
|
for (const target of targets) {
|
|
if (!target.alive) continue
|
|
const inRange =
|
|
Math.hypot(target.x - projectile.x, (target.y - projectile.y) / ISO_GROUND_ASPECT_RATIO) <= blastRadius
|
|
if (inRange) {
|
|
hits.push({
|
|
targetIndex: target.index,
|
|
damage: projectile.damage,
|
|
x: target.x,
|
|
y: target.y,
|
|
skillId: projectile.skillId,
|
|
missileType: 'fireball',
|
|
aoeRadius: blastRadius,
|
|
})
|
|
}
|
|
}
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x: projectile.x,
|
|
y: projectile.y,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 0,
|
|
ttl: 16,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: 'explodingarrowexp',
|
|
aoeRadius: blastRadius,
|
|
pierce: true,
|
|
})
|
|
wallHitEvents.push({
|
|
x: projectile.x,
|
|
y: projectile.y,
|
|
skillId: projectile.skillId,
|
|
missileType: 'explodingarrowexp',
|
|
aoeRadius: blastRadius,
|
|
})
|
|
} else if (projectile.missileType === 'meteorcenter') {
|
|
// Authentic D2 1.13c MISSMODE_SrvHit14_MeteorCenter (0x6FC5DEF0) & 0x6FC5CEC0:
|
|
// 1. Primary explosion AOE damage (Radius = 6 subtiles = 120px) in 2:1 isometric ground perspective
|
|
const blastRadius = projectile.aoeRadius ?? 120
|
|
for (const target of targets) {
|
|
if (!target.alive) continue
|
|
const inRange =
|
|
Math.hypot(target.x - projectile.x, (target.y - projectile.y) / ISO_GROUND_ASPECT_RATIO) <= blastRadius
|
|
if (inRange) {
|
|
hits.push({
|
|
targetIndex: target.index,
|
|
damage: projectile.damage,
|
|
x: target.x,
|
|
y: target.y,
|
|
skillId: projectile.skillId,
|
|
missileType: 'meteorexplode',
|
|
aoeRadius: blastRadius,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 2. Spawn explosion missile: meteorexplode (ID 103, Range 16)
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x: projectile.x,
|
|
y: projectile.y,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 0,
|
|
ttl: 16,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: 'meteorexplode',
|
|
aoeRadius: blastRadius,
|
|
pierce: true,
|
|
})
|
|
wallHitEvents.push({
|
|
x: projectile.x,
|
|
y: projectile.y,
|
|
skillId: projectile.skillId,
|
|
missileType: 'meteorexplode',
|
|
aoeRadius: blastRadius,
|
|
})
|
|
|
|
// 3. Spawn 18 meteorfire submissiles using 18-offset table
|
|
const slvl = Math.max(1, projectile.slvl ?? 1)
|
|
const fireDuration = projectile.fireDuration ?? (90 + (slvl - 1) * 15)
|
|
const residualDmg = projectile.residualDamage ?? calculateMeteorResidualFireDamage(slvl).min
|
|
for (const off of METEOR_FIRE_SUBTILE_OFFSETS) {
|
|
const fx = projectile.x + off.x * 20
|
|
const fy = projectile.y + off.y * 10
|
|
const blocked =
|
|
options.isMissileBlocked !== undefined
|
|
? options.isMissileBlocked(fx, fy, projectile.x, projectile.y)
|
|
: options.overlap(fx, fy) > 0
|
|
if (!blocked) {
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x: fx,
|
|
y: fy,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: residualDmg,
|
|
ttl: fireDuration,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: 'meteorfire',
|
|
aoeRadius: 16,
|
|
pierce: true,
|
|
slvl,
|
|
fireDuration,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
let vx = projectile.vx
|
|
let vy = projectile.vy
|
|
let x = projectile.x + vx
|
|
let y = projectile.y + vy
|
|
let spiral = projectile.spiral
|
|
let subMissileIdx = projectile.subMissileIdx
|
|
let jitterIndex = projectile.jitterIndex ?? (projectile.seed !== undefined ? Math.abs(projectile.seed) % 32 : undefined)
|
|
|
|
// 0. Frozen Orb Nova affine rotation & damping curl in final frames
|
|
// Authentic D2 1.13c MISSMODE_SrvDo16_FrozenOrbNova (0x6FC58340):
|
|
// In final 6 frames before expiry, every 2 frames apply: x' = (x - y) / 2, y' = (x + y) / 2
|
|
if (
|
|
(projectile.isNovaCurl || projectile.missileType === 'frozenorbnova') &&
|
|
projectile.ttl <= 6 &&
|
|
projectile.ttl % 2 === 0
|
|
) {
|
|
const diff = vx - vy
|
|
const sum = vx + vy
|
|
vx = diff / 2
|
|
vy = sum / 2
|
|
x = projectile.x + vx
|
|
y = projectile.y + vy
|
|
} else if (spiral !== undefined) {
|
|
// 1. Archimedean spiral trajectory (e.g. Blessed Hammer)
|
|
// The hammer traces an Archimedean spiral on the map ground plane. In oblique/isometric
|
|
// perspective (2:1 projection), a circular ground expansion projects to an ellipse with a 2:1 aspect ratio.
|
|
const nextAngle = spiral.angle + 0.22
|
|
const nextRadius = spiral.radius + 3.2
|
|
spiral = { ...spiral, angle: nextAngle, radius: nextRadius }
|
|
x = spiral.originX + Math.cos(nextAngle) * nextRadius
|
|
y = spiral.originY + Math.sin(nextAngle) * (nextRadius * ISO_GROUND_ASPECT_RATIO)
|
|
vx = -Math.sin(nextAngle) * 18
|
|
vy = Math.cos(nextAngle) * (18 * ISO_GROUND_ASPECT_RATIO)
|
|
} else if (projectile.homing) {
|
|
// 2. Homing trajectory (e.g. Bone Spirit) - smoothly steer towards closest living enemy
|
|
let nearestDist = Number.POSITIVE_INFINITY
|
|
let targetX = 0
|
|
let targetY = 0
|
|
let foundTarget = false
|
|
for (const t of targets) {
|
|
if (!t.alive) continue
|
|
const d = Math.hypot(t.x - projectile.x, t.y - projectile.y)
|
|
if (d < nearestDist && d > 4) {
|
|
nearestDist = d
|
|
targetX = t.x
|
|
targetY = t.y
|
|
foundTarget = true
|
|
}
|
|
}
|
|
if (foundTarget) {
|
|
const curAngle = Math.atan2(vy, vx)
|
|
const targetAngle = Math.atan2(targetY - projectile.y, targetX - projectile.x)
|
|
let diff = targetAngle - curAngle
|
|
while (diff > Math.PI) diff -= 2 * Math.PI
|
|
while (diff < -Math.PI) diff += 2 * Math.PI
|
|
const maxTurn = 0.14
|
|
const turn = Math.max(-maxTurn, Math.min(maxTurn, diff))
|
|
const newAngle = curAngle + turn
|
|
const speed = Math.hypot(vx, vy)
|
|
vx = Math.cos(newAngle) * speed
|
|
vy = Math.sin(newAngle) * speed
|
|
x = projectile.x + vx
|
|
y = projectile.y + vy
|
|
}
|
|
} else if (projectile.jitter !== false && (projectile.jitter || projectile.missileType === 'chargedbolt' || projectile.skillId === '38' || projectile.skillId === 'chargedbolt')) {
|
|
// 3. Jitter trajectory (e.g. Charged Bolt) - discrete orthogonal jitter lookup table
|
|
// Authentic D2 1.13c PATH_ComputePathChargedBolt (0x6FD85470):
|
|
// 32-entry lookup table gatChargedBoltOffsets applied perpendicularly to main propagation vector per step.
|
|
let flatVx = vx
|
|
let flatVy = vy / ISO_GROUND_ASPECT_RATIO
|
|
const currentSpeed = Math.hypot(flatVx, flatVy)
|
|
if (currentSpeed > 0) {
|
|
const perpX = -flatVy / currentSpeed
|
|
const perpY = flatVx / currentSpeed
|
|
const idx = Math.abs((jitterIndex ?? (projectile.seed !== undefined ? projectile.seed : projectile.ttl)) % 32)
|
|
const offset = GAT_CHARGED_BOLT_OFFSETS[idx]! * 3
|
|
flatVx += perpX * offset
|
|
flatVy += perpY * offset
|
|
const newSpeed = Math.hypot(flatVx, flatVy)
|
|
if (newSpeed > 0) {
|
|
const scaleDown = currentSpeed / newSpeed
|
|
flatVx *= scaleDown
|
|
flatVy *= scaleDown
|
|
}
|
|
vx = flatVx
|
|
vy = flatVy * ISO_GROUND_ASPECT_RATIO
|
|
x = projectile.x + vx
|
|
y = projectile.y + vy
|
|
jitterIndex = (idx + 1) % 32
|
|
}
|
|
}
|
|
|
|
// 4. Frozen Orb sub-missile emitter (pSrvDoFunc = 15, frequency = 1, step = 19)
|
|
// Authentic D2 1.13c MISSMODE_SrvDo15_FrozenOrb (0x6FC581F0):
|
|
// Emits 1 radial frozenorbbolt along 64-point circle table, advancing by step 19
|
|
if (projectile.missileType === 'frozenorb') {
|
|
const idx = Math.abs((subMissileIdx ?? 0) % 64)
|
|
const offX = FROZEN_ORB_X_POSITIONS[idx]!
|
|
const offY = FROZEN_ORB_Y_POSITIONS[idx]! * ISO_GROUND_ASPECT_RATIO
|
|
const angle = Math.atan2(offY, offX)
|
|
const speed = 18 // vel = 18 in 1.13c Missiles.txt (450 px/s)
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x,
|
|
y,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
damage: projectile.damage > 0 ? projectile.damage : 40,
|
|
ttl: 25,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: 'frozenorbbolt',
|
|
statusEffect: 'chill',
|
|
statusDuration: 100,
|
|
pierce: false,
|
|
})
|
|
subMissileIdx = (idx + 19) % 64
|
|
}
|
|
|
|
// 5. Stationary Turret Emitter (e.g. Hydra, Skill 62)
|
|
const isTurretEmitter =
|
|
projectile.isTurret === true ||
|
|
projectile.missileType === 'hydra_turret' ||
|
|
(projectile.missileType === 'hydra' && projectile.vx === 0 && projectile.vy === 0)
|
|
|
|
if (isTurretEmitter) {
|
|
x = projectile.x
|
|
y = projectile.y
|
|
vx = 0
|
|
vy = 0
|
|
const currentAge = (projectile.ageTicks ?? 0) + 1
|
|
const cadence = projectile.fireCadenceTicks ?? 25
|
|
if (currentAge % cadence === 0) {
|
|
let targetX: number | undefined
|
|
let targetY: number | undefined
|
|
let minDistance = Infinity
|
|
const maxRangePx = 480 // 1.13c Range = 30, Vel = 16 -> 480 px
|
|
|
|
for (const target of targets) {
|
|
if (!target.alive) continue
|
|
const dx = target.x - x
|
|
const dy = (target.y - y) / ISO_GROUND_ASPECT_RATIO
|
|
const dist = Math.hypot(dx, dy)
|
|
if (dist <= maxRangePx && dist < minDistance) {
|
|
minDistance = dist
|
|
targetX = target.x
|
|
targetY = target.y
|
|
}
|
|
}
|
|
|
|
if (targetX !== undefined && targetY !== undefined) {
|
|
const dx = targetX - x
|
|
const dy = targetY - y
|
|
const angle = Math.atan2(dy, dx)
|
|
const speed = 16 // 1.13c Missiles.txt: vel = 16 (400 px/s)
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x,
|
|
y,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
damage: projectile.damage > 0 ? projectile.damage : 28,
|
|
ttl: 30, // 1.13c Missiles.txt: range = 30
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: projectile.spawnMissileType ?? 'hydra',
|
|
statusEffect: projectile.statusEffect,
|
|
statusDuration: projectile.statusDuration,
|
|
pierce: false,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// 6. Blizzard Center Emitter (pSrvDoFunc = 10, Skill 59)
|
|
// Authentic D2 1.13c MISSMODE_SrvDo10_blizzardcenter (0x6FC60CA0):
|
|
// Emits 1 falling shard every 4 ticks (Param3 = 4) across 64-entry circle table (Radius 7)
|
|
// with step 13, for a total of exactly 25 shards over 100 ticks.
|
|
if (projectile.missileType === 'blizzardcenter') {
|
|
const currentAge = (projectile.ageTicks ?? 0) + 1
|
|
if (currentAge % 4 === 0) {
|
|
const idx = Math.abs((subMissileIdx ?? 0) % 64)
|
|
const offset = getBlizzardDropOffset(idx, 7)
|
|
const shardVariants = ['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4'] as const
|
|
const shardType = shardVariants[Math.floor((currentAge / 4) - 1) % shardVariants.length]!
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x: projectile.x + offset.dx,
|
|
y: projectile.y + offset.dy,
|
|
vx: 0,
|
|
vy: 0,
|
|
altitude: 120,
|
|
damage: projectile.damage,
|
|
ttl: 9,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: shardType,
|
|
statusEffect: 'chill',
|
|
statusDuration: 100,
|
|
pierce: false,
|
|
})
|
|
subMissileIdx = (idx + 13) % 64
|
|
}
|
|
}
|
|
|
|
let altitude = projectile.altitude
|
|
if (isBlizzardShard(projectile.missileType)) {
|
|
if (altitude === undefined) altitude = 120
|
|
altitude = Math.max(0, altitude - (120 / 9))
|
|
} else if (projectile.missileType === 'meteor' || projectile.missileType === 'meteortail') {
|
|
if (altitude === undefined) altitude = 60 * 25
|
|
altitude = Math.max(0, altitude - 25)
|
|
}
|
|
|
|
const hitTargets = projectile.hitTargets ?? new Set<number>()
|
|
const currentAge = (projectile.ageTicks ?? 0) + 1
|
|
if (
|
|
(projectile.missileType === 'meteorfire' && currentAge % 5 === 0) ||
|
|
projectile.missileType === 'firewall' ||
|
|
projectile.missileType === 'blaze' ||
|
|
projectile.missileType === 'groundfirebig' ||
|
|
projectile.missileType === 'infernoflame1' ||
|
|
projectile.missileType === 'infernoflame2'
|
|
) {
|
|
hitTargets.clear()
|
|
}
|
|
if (options.onMissileStep !== undefined && (projectile.x !== x || projectile.y !== y)) {
|
|
options.onMissileStep(projectile.missileType ?? '', projectile.x, projectile.y, x, y, projectile)
|
|
}
|
|
const moved: Projectile = {
|
|
...projectile,
|
|
x,
|
|
y,
|
|
vx,
|
|
vy,
|
|
prevX: projectile.x,
|
|
prevY: projectile.y,
|
|
spiral,
|
|
subMissileIdx,
|
|
jitterIndex,
|
|
altitude,
|
|
hitTargets,
|
|
ageTicks: currentAge,
|
|
ttl: projectile.ttl - 1,
|
|
}
|
|
|
|
// Walls first: a projectile dies against missile barriers (solid walls, closed doors)
|
|
const isAirborne =
|
|
(projectile.missileType === 'meteor' || projectile.missileType === 'meteortail') &&
|
|
altitude !== undefined &&
|
|
altitude > 0
|
|
let blockedByTerrain = false
|
|
if (!isAirborne) {
|
|
const moveDist = Math.hypot(x - projectile.x, y - projectile.y)
|
|
const stepSize = 8
|
|
const steps = Math.max(1, Math.ceil(moveDist / stepSize))
|
|
let checkPrevX = projectile.x
|
|
let checkPrevY = projectile.y
|
|
for (let s = 1; s <= steps; s++) {
|
|
const t = s / steps
|
|
const curX = projectile.x + (x - projectile.x) * t
|
|
const curY = projectile.y + (y - projectile.y) * t
|
|
if (options.isMissileBlocked !== undefined) {
|
|
if (options.isMissileBlocked(curX, curY, checkPrevX, checkPrevY)) {
|
|
blockedByTerrain = true
|
|
break
|
|
}
|
|
} else if (options.overlap(curX, curY) > 0) {
|
|
blockedByTerrain = true
|
|
break
|
|
}
|
|
checkPrevX = curX
|
|
checkPrevY = curY
|
|
}
|
|
}
|
|
if (blockedByTerrain) {
|
|
wallHits += 1
|
|
wallHitEvents.push({
|
|
x,
|
|
y,
|
|
skillId: projectile.skillId,
|
|
missileType: projectile.missileType,
|
|
aoeRadius: projectile.aoeRadius,
|
|
damage: projectile.damage,
|
|
statusEffect: projectile.statusEffect,
|
|
statusDuration: projectile.statusDuration,
|
|
})
|
|
if (projectile.missileType === 'frozenorb') {
|
|
for (let i = 0; i < 64; i += 4) {
|
|
const offX = FROZEN_ORB_X_POSITIONS[i]!
|
|
const offY = FROZEN_ORB_Y_POSITIONS[i]! * ISO_GROUND_ASPECT_RATIO
|
|
const angle = Math.atan2(offY, offX)
|
|
const speed = 24
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x,
|
|
y,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
damage: projectile.damage > 0 ? projectile.damage : 40,
|
|
ttl: 25,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: 'frozenorbnova',
|
|
statusEffect: 'chill',
|
|
statusDuration: 100,
|
|
isNovaCurl: true,
|
|
pierce: false,
|
|
})
|
|
}
|
|
} else if (projectile.missileType === 'glacialspike') {
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x,
|
|
y,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 0,
|
|
ttl: 15,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: 'freezeexplode',
|
|
aoeRadius: projectile.aoeRadius ?? 64,
|
|
pierce: true,
|
|
})
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x,
|
|
y,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 0,
|
|
ttl: 15,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: 'glacialspikeejecta',
|
|
aoeRadius: projectile.aoeRadius ?? 64,
|
|
pierce: true,
|
|
})
|
|
} else if (isBlizzardShard(projectile.missileType)) {
|
|
const expType = getBlizzardExplosionMissile(projectile.missileType!)
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x,
|
|
y,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 0,
|
|
ttl: 6,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: expType,
|
|
statusEffect: 'chill',
|
|
statusDuration: 100,
|
|
aoeRadius: 32,
|
|
pierce: false,
|
|
})
|
|
} else if (projectile.missileType === 'fireball') {
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x,
|
|
y,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 0,
|
|
ttl: 16,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: 'explodingarrowexp',
|
|
aoeRadius: projectile.aoeRadius ?? 80,
|
|
pierce: true,
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
|
|
let struck = false
|
|
const isHarmlessEmitter =
|
|
projectile.missileType === 'frozenorb' ||
|
|
projectile.missileType === 'blizzardcenter' ||
|
|
projectile.missileType === 'meteorcenter' ||
|
|
projectile.missileType === 'meteor' ||
|
|
projectile.missileType === 'meteortail' ||
|
|
projectile.missileType === 'firewallmaker' ||
|
|
projectile.missileType === 'freezeexplode' ||
|
|
projectile.missileType === 'glacialspikeejecta' ||
|
|
projectile.missileType === 'explodingarrowexp' ||
|
|
projectile.missileType === 'thunderstorm1' ||
|
|
projectile.missileType === 'thunderstorm' ||
|
|
projectile.missileType === 'lightninghit' ||
|
|
isTurretEmitter
|
|
if (!isHarmlessEmitter) {
|
|
for (const target of targets) {
|
|
if (!target.alive) continue
|
|
if (hitTargets.has(target.index)) continue // already struck by piercing projectile
|
|
if (projectile.missileType === 'blaze' && projectile.fromPlayer && (target as any).isPlayer) {
|
|
continue
|
|
}
|
|
const isGroundPatch =
|
|
projectile.missileType === 'meteorfire' ||
|
|
projectile.missileType === 'firewall' ||
|
|
projectile.missileType === 'blaze' ||
|
|
projectile.missileType === 'groundfirebig'
|
|
const dist = isGroundPatch
|
|
? Math.hypot(target.x - x, (target.y - y) / ISO_GROUND_ASPECT_RATIO)
|
|
: Math.hypot(target.x - x, target.y - y)
|
|
if (dist > target.radius + radius) continue
|
|
|
|
const hasNextDelay =
|
|
(projectile.nextDelay !== undefined && projectile.nextDelay > 0) ||
|
|
projectile.missileType === 'nova' ||
|
|
projectile.missileType === 'electricnova'
|
|
const nextDelayFrames = projectile.nextDelay ?? 4
|
|
|
|
if (hasNextDelay) {
|
|
if (waveNextDelayHits.has(target.index)) continue
|
|
if (options.nextDelayByTarget && options.currentTick !== undefined) {
|
|
const exp = options.nextDelayByTarget.get(target.index)
|
|
if (exp !== undefined && exp > options.currentTick) continue
|
|
}
|
|
}
|
|
|
|
const statusEffect =
|
|
projectile.statusEffect ??
|
|
(isBlizzardShard(projectile.missileType) || projectile.missileType?.startsWith('blizzardexplode')
|
|
? 'chill'
|
|
: undefined)
|
|
const statusDuration =
|
|
projectile.statusDuration ??
|
|
(isBlizzardShard(projectile.missileType) || projectile.missileType?.startsWith('blizzardexplode')
|
|
? 100
|
|
: undefined)
|
|
|
|
hits.push({
|
|
targetIndex: target.index,
|
|
damage: projectile.damage,
|
|
x,
|
|
y,
|
|
skillId: projectile.skillId,
|
|
missileType: projectile.missileType,
|
|
aoeRadius: projectile.aoeRadius,
|
|
statusEffect,
|
|
statusDuration,
|
|
isUndeadOnly: projectile.isUndeadOnly,
|
|
})
|
|
|
|
if (hasNextDelay) {
|
|
waveNextDelayHits.add(target.index)
|
|
if (options.nextDelayByTarget && options.currentTick !== undefined) {
|
|
options.nextDelayByTarget.set(target.index, options.currentTick + nextDelayFrames)
|
|
}
|
|
}
|
|
|
|
hitTargets.add(target.index)
|
|
|
|
// Authentic 1.13c Chain Lightning retargeting / jump
|
|
const isChainLightning =
|
|
projectile.missileType === 'chainlightning' ||
|
|
(projectile.chainRemaining !== undefined && projectile.chainRemaining > 0)
|
|
if (isChainLightning) {
|
|
const jumpsLeft = projectile.chainRemaining !== undefined ? projectile.chainRemaining : 0
|
|
if (jumpsLeft > 0) {
|
|
// Search radius: 20 subtiles (10 yards = 400 px)
|
|
const searchRadiusPx = 400
|
|
let closestCand: ProjectileTarget | null = null
|
|
let closestDist = Infinity
|
|
for (const cand of targets) {
|
|
if (!cand.alive || cand.index === target.index) continue
|
|
// Authentic ping-pong: only exclude immediate prior victim (target.index)
|
|
const d = Math.hypot(cand.x - target.x, cand.y - target.y)
|
|
if (d <= searchRadiusPx && d < closestDist) {
|
|
closestDist = d
|
|
closestCand = cand
|
|
}
|
|
}
|
|
if (closestCand) {
|
|
const dx = closestCand.x - target.x
|
|
const dy = closestCand.y - target.y
|
|
const len = Math.hypot(dx, dy)
|
|
const speed = 30 // 1.13c Missiles.txt vel = 30 (750 px/s)
|
|
const pVx = len > 0 ? (dx / len) * speed : speed
|
|
const pVy = len > 0 ? (dy / len) * speed : 0
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x: target.x,
|
|
y: target.y,
|
|
vx: pVx,
|
|
vy: pVy,
|
|
damage: projectile.damage,
|
|
ttl: 25,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: 'chainlightning',
|
|
chainRemaining: jumpsLeft - 1,
|
|
lastHitTargetIndex: target.index,
|
|
nextHit: 1,
|
|
nextDelay: 4,
|
|
})
|
|
}
|
|
}
|
|
struck = true
|
|
break
|
|
}
|
|
|
|
if (!projectile.pierce) {
|
|
struck = true
|
|
if (projectile.missileType === 'glacialspike') {
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x,
|
|
y,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 0,
|
|
ttl: 15,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: 'freezeexplode',
|
|
aoeRadius: projectile.aoeRadius ?? 64,
|
|
pierce: true,
|
|
})
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x,
|
|
y,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 0,
|
|
ttl: 15,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: 'glacialspikeejecta',
|
|
aoeRadius: projectile.aoeRadius ?? 64,
|
|
pierce: true,
|
|
})
|
|
} else if (isBlizzardShard(projectile.missileType)) {
|
|
const expType = getBlizzardExplosionMissile(projectile.missileType!)
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x,
|
|
y,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 0,
|
|
ttl: 6,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: expType,
|
|
statusEffect: 'chill',
|
|
statusDuration: 100,
|
|
aoeRadius: 32,
|
|
pierce: false,
|
|
})
|
|
} else if (projectile.missileType === 'fireball') {
|
|
spawned.push({
|
|
skillId: projectile.skillId,
|
|
x,
|
|
y,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 0,
|
|
ttl: 16,
|
|
fromPlayer: projectile.fromPlayer,
|
|
missileType: 'explodingarrowexp',
|
|
aoeRadius: projectile.aoeRadius ?? 80,
|
|
pierce: true,
|
|
})
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!struck) {
|
|
alive.push(moved)
|
|
}
|
|
}
|
|
|
|
alive.push(...spawned)
|
|
return { alive, hits, expired, wallHits, wallHitEvents, spawnedProjectiles: spawned }
|
|
}
|
|
|
|
// ============================================================================
|
|
// 1.13c Batch 1 Skills Execution, Synergy Integration, and Combat Calculations
|
|
// ============================================================================
|
|
|
|
export interface SkillExecutionContext {
|
|
readonly caster: CombatPlayer
|
|
readonly target?: { x: number; y: number; id?: string } | undefined
|
|
readonly skillLevel: number // effective level: hardPoints + softPoints
|
|
readonly hardPoints?: number | undefined // base points blvl: 0..20 (for synergy evaluation)
|
|
readonly synergyMultiplier?: number | undefined // computed from skill-tree
|
|
}
|
|
|
|
export type SkillType =
|
|
| 'projectile'
|
|
| 'melee'
|
|
| 'area'
|
|
| 'buff'
|
|
| 'curse'
|
|
| 'movement'
|
|
| 'summon'
|
|
| 'aura'
|
|
| 'passive'
|
|
|
|
export interface Batch1SkillDef {
|
|
readonly id: number
|
|
readonly name: string
|
|
readonly classCode: CharacterClassCode
|
|
readonly charClass: string
|
|
readonly type: SkillType
|
|
readonly mana: number
|
|
readonly lvlmana: number
|
|
readonly minmana: number
|
|
readonly manashift: number
|
|
readonly delay: number
|
|
readonly hitshift: number
|
|
readonly emin: number
|
|
readonly emax: number
|
|
readonly elev1: number
|
|
readonly elev2: number
|
|
readonly elev3: number
|
|
readonly elev4: number
|
|
readonly elev5: number
|
|
readonly emaxlev1?: number
|
|
readonly emaxlev2?: number
|
|
readonly emaxlev3?: number
|
|
readonly emaxlev4?: number
|
|
readonly emaxlev5?: number
|
|
readonly srcDamage: number
|
|
readonly range?: number
|
|
readonly speed?: number
|
|
readonly castOverlay?: string
|
|
readonly srvdofunc?: number | undefined
|
|
readonly castDelay?: number | undefined
|
|
}
|
|
|
|
export const BATCH1_SKILLS: Readonly<Record<number, Batch1SkillDef>> = Object.freeze({
|
|
// Amazon (5 skills)
|
|
6: { id: 6, name: 'Magic Arrow', classCode: 'ama', charClass: 'amazon', type: 'projectile', mana: 12, lvlmana: -1, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128, range: 480, speed: 600 },
|
|
7: { id: 7, name: 'Fire Arrow', classCode: 'ama', charClass: 'amazon', type: 'projectile', mana: 24, lvlmana: 1, minmana: 1, manashift: 5, delay: 0, hitshift: 8, emin: 1, emax: 4, elev1: 2, elev2: 3, elev3: 6, elev4: 12, elev5: 24, emaxlev1: 2, emaxlev2: 3, emaxlev3: 7, emaxlev4: 14, emaxlev5: 27, srcDamage: 128, range: 480, speed: 600 },
|
|
10: { id: 10, name: 'Jab', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 8, lvlmana: 1, minmana: 1, manashift: 6, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
|
|
11: { id: 11, name: 'Cold Arrow', classCode: 'ama', charClass: 'amazon', type: 'projectile', mana: 28, lvlmana: 1, minmana: 1, manashift: 5, delay: 0, hitshift: 7, emin: 6, emax: 8, elev1: 4, elev2: 5, elev3: 8, elev4: 16, elev5: 42, emaxlev1: 4, emaxlev2: 5, emaxlev3: 9, emaxlev4: 17, emaxlev5: 44, srcDamage: 128, range: 480, speed: 600 },
|
|
14: { id: 14, name: 'Power Strike', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 8, lvlmana: 1, minmana: 1, manashift: 6, delay: 0, hitshift: 8, emin: 1, emax: 16, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, emaxlev1: 18, emaxlev2: 36, emaxlev3: 54, emaxlev4: 72, emaxlev5: 90, srcDamage: 128 },
|
|
|
|
// Sorceress (6 skills)
|
|
36: { id: 36, name: 'Fire Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 5, lvlmana: 0, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 6, emax: 12, elev1: 3, elev2: 4, elev3: 8, elev4: 18, elev5: 54, emaxlev1: 3, emaxlev2: 6, emaxlev3: 10, emaxlev4: 20, emaxlev5: 56, srcDamage: 0, range: 1000, speed: 500, castOverlay: 'fire_cast_1' },
|
|
37: { id: 37, name: 'Warmth', classCode: 'sor', charClass: 'sorceress', type: 'passive', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
|
39: { id: 39, name: 'Ice Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 3, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 7, emin: 6, emax: 10, elev1: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, emaxlev1: 3, emaxlev2: 5, emaxlev3: 7, emaxlev4: 9, emaxlev5: 11, srcDamage: 0, range: 400, speed: 450, castOverlay: 'ice_cast_1' },
|
|
44: { id: 44, name: 'Frost Nova', classCode: 'sor', charClass: 'sorceress', type: 'area', mana: 9, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 7, emin: 4, emax: 8, elev1: 4, elev2: 6, elev3: 8, elev4: 10, elev5: 12, emaxlev1: 5, emaxlev2: 7, emaxlev3: 9, emaxlev4: 11, emaxlev5: 13, srcDamage: 0, castOverlay: 'ice_cast_2' },
|
|
47: { id: 47, name: 'Fire Ball', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 10, lvlmana: 1, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 12, emax: 28, elev1: 13, elev2: 23, elev3: 28, elev4: 33, elev5: 38, emaxlev1: 15, emaxlev2: 25, emaxlev3: 30, emaxlev4: 35, emaxlev5: 40, srcDamage: 0, range: 450, speed: 450, castOverlay: 'fire_cast_2' },
|
|
54: { id: 54, name: 'Teleport', classCode: 'sor', charClass: 'sorceress', type: 'movement', mana: 24, lvlmana: -1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0, castOverlay: 'teleport' },
|
|
|
|
// Necromancer (5 skills)
|
|
66: { id: 66, name: 'Amplify Damage', classCode: 'nec', charClass: 'necromancer', type: 'curse', mana: 4, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
|
67: { id: 67, name: 'Teeth', classCode: 'nec', charClass: 'necromancer', type: 'area', mana: 6, lvlmana: 1, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 4, emax: 8, elev1: 2, elev2: 2, elev3: 3, elev4: 4, elev5: 5, emaxlev1: 2, emaxlev2: 3, emaxlev3: 4, emaxlev4: 5, emaxlev5: 6, srcDamage: 0 },
|
|
68: { id: 68, name: 'Bone Armor', classCode: 'nec', charClass: 'necromancer', type: 'buff', mana: 11, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
|
70: { id: 70, name: 'Raise Skeleton', classCode: 'nec', charClass: 'necromancer', type: 'summon', mana: 6, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 1, elev3: 2, elev4: 3, elev5: 4, srcDamage: 0 },
|
|
74: { id: 74, name: 'Corpse Explosion', classCode: 'nec', charClass: 'necromancer', type: 'area', mana: 15, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
|
|
|
// Paladin (6 skills)
|
|
96: { id: 96, name: 'Sacrifice', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128, castOverlay: 'blood1' },
|
|
97: { id: 97, name: 'Smite', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
|
98: { id: 98, name: 'Might', classCode: 'pal', charClass: 'paladin', type: 'aura', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
|
101: { id: 101, name: 'Holy Bolt', classCode: 'pal', charClass: 'paladin', type: 'projectile', mana: 32, lvlmana: 1, minmana: 1, manashift: 4, delay: 0, hitshift: 8, emin: 8, emax: 16, elev1: 8, elev2: 10, elev3: 13, elev4: 16, elev5: 20, emaxlev1: 8, emaxlev2: 11, emaxlev3: 15, emaxlev4: 18, emaxlev5: 23, srcDamage: 0, range: 450, speed: 450 },
|
|
106: { id: 106, name: 'Zeal', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
|
|
112: { id: 112, name: 'Blessed Hammer', classCode: 'pal', charClass: 'paladin', type: 'area', mana: 20, lvlmana: 1, minmana: 1, manashift: 6, delay: 0, hitshift: 8, emin: 12, emax: 16, elev1: 8, elev2: 10, elev3: 12, elev4: 13, elev5: 14, srcDamage: 0 },
|
|
|
|
// Barbarian (6 skills)
|
|
126: { id: 126, name: 'Bash', classCode: 'bar', charClass: 'barbarian', type: 'melee', mana: 2, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
|
|
130: { id: 130, name: 'Howl', classCode: 'bar', charClass: 'barbarian', type: 'curse', mana: 4, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
|
132: { id: 132, name: 'Leap', classCode: 'bar', charClass: 'barbarian', type: 'movement', mana: 2, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
|
133: { id: 133, name: 'Double Swing', classCode: 'bar', charClass: 'barbarian', type: 'melee', mana: 8, lvlmana: -1, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
|
|
138: { id: 138, name: 'Shout', classCode: 'bar', charClass: 'barbarian', type: 'buff', mana: 6, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
|
143: { id: 143, name: 'Leap Attack', classCode: 'bar', charClass: 'barbarian', type: 'melee', mana: 9, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
|
|
|
|
// Druid (5 skills)
|
|
223: { id: 223, name: 'Werewolf', classCode: 'dru', charClass: 'druid', type: 'buff', mana: 15, lvlmana: 0, minmana: 1, manashift: 8, delay: 25, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
|
|
224: { id: 224, name: 'Lycanthropy', classCode: 'dru', charClass: 'druid', type: 'passive', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
|
225: { id: 225, name: 'Firestorm', classCode: 'dru', charClass: 'druid', type: 'area', mana: 4, lvlmana: 0, minmana: 1, manashift: 8, delay: 15, hitshift: 2, emin: 3, emax: 6, elev1: 3, elev2: 5, elev3: 7, elev4: 14, elev5: 21, emaxlev1: 3, emaxlev2: 6, emaxlev3: 8, emaxlev4: 15, emaxlev5: 23, srcDamage: 0 },
|
|
226: { id: 226, name: 'Oak Sage', classCode: 'dru', charClass: 'druid', type: 'summon', mana: 15, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
|
229: { id: 229, name: 'Molten Boulder', classCode: 'dru', charClass: 'druid', type: 'projectile', mana: 20, lvlmana: 1, minmana: 1, manashift: 7, delay: 50, hitshift: 8, emin: 6, emax: 12, elev1: 4, elev2: 7, elev3: 10, elev4: 13, elev5: 16, emaxlev1: 5, emaxlev2: 8, emaxlev3: 11, emaxlev4: 14, emaxlev5: 17, srcDamage: 0, range: 450, speed: 300 },
|
|
|
|
// Assassin (5 skills)
|
|
251: { id: 251, name: 'Fire Blast', classCode: 'ass', charClass: 'assassin', type: 'projectile', mana: 24, lvlmana: 1, minmana: 1, manashift: 5, delay: 0, hitshift: 7, emin: 6, emax: 8, elev1: 3, elev2: 8, elev3: 20, elev4: 38, elev5: 58, emaxlev1: 5, emaxlev2: 11, emaxlev3: 24, emaxlev4: 44, emaxlev5: 66, srcDamage: 0, range: 350, speed: 400 },
|
|
252: { id: 252, name: 'Claw Mastery', classCode: 'ass', charClass: 'assassin', type: 'passive', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
|
254: { id: 254, name: 'Tiger Strike', classCode: 'ass', charClass: 'assassin', type: 'melee', mana: 1, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
|
|
258: { id: 258, name: 'Burst of Speed', classCode: 'ass', charClass: 'assassin', type: 'buff', mana: 10, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
|
261: { id: 261, name: 'Charged Bolt Sentry', classCode: 'ass', charClass: 'assassin', type: 'summon', mana: 13, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 7, emin: 2, emax: 14, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, emaxlev1: 6, emaxlev2: 8, emaxlev3: 12, emaxlev4: 14, emaxlev5: 16, srcDamage: 0 },
|
|
})
|
|
|
|
/**
|
|
* Returns the metadata definition for a Batch 1 combat skill, or undefined if not in Batch 1.
|
|
*/
|
|
export function getBatch1SkillDef(skillId: number): Batch1SkillDef | undefined {
|
|
return BATCH1_SKILLS[skillId]
|
|
}
|
|
|
|
/**
|
|
* Checks whether a skill ID belongs to the canonical Batch 1 essential combat skills.
|
|
*/
|
|
export function isBatch1Skill(skillId: number): boolean {
|
|
return skillId in BATCH1_SKILLS
|
|
}
|
|
|
|
/**
|
|
* Computes 1.13c mana cost with 24.8 fixed-point precision scaling and minmana clamping.
|
|
*
|
|
* Formula: Math.max(minmana, (mana + lvlmana * (effectiveLevel - 1)) * 2^(manashift - 8))
|
|
* - Decreasing mana skills reach 0.0 at high levels (Magic Arrow, Double Swing).
|
|
* - Minimum mana clamping prevents dropping below minmana (Teleport clamps at 1).
|
|
* - Resilience: Invalid / NaN / non-positive levels default to level 1.
|
|
*/
|
|
export function getSkillManaCost(skillId: number, effectiveLevel: number): number {
|
|
if (skillId === 6) {
|
|
return calculateMagicArrowStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 7) {
|
|
return calculateFireArrowStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 8) {
|
|
return calculateInnerSightStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 9) {
|
|
return calculateCriticalStrikeStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 10) {
|
|
return calculateJabStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 11) {
|
|
return calculateColdArrowStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 12) {
|
|
return calculateMultipleShotStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 13) {
|
|
return calculateDodgeStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 14) {
|
|
return calculatePowerStrikeStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 15) {
|
|
return calculatePoisonJavelinStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 16) {
|
|
return calculateExplodingArrowStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 17) {
|
|
return calculateSlowMissilesStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 18) {
|
|
return calculateAvoidStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 19) {
|
|
return calculateImpaleStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 20) {
|
|
return calculateLightningBoltStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 21) {
|
|
return calculateIceArrowStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 22) {
|
|
return calculateGuidedArrowStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 23) {
|
|
return calculatePenetrateStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 24) {
|
|
return calculateChargedStrikeStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 25) {
|
|
return calculatePlagueJavelinStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 26) {
|
|
return calculateStrafeStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 27) {
|
|
return calculateImmolationArrowStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 28) {
|
|
return calculateDecoyStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 29) {
|
|
return calculateEvadeStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 30) {
|
|
return calculateFendStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 31) {
|
|
return calculateFreezingArrowStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 32) {
|
|
return calculateValkyrieStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 33) {
|
|
return calculatePierceStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 34) {
|
|
return calculateLightningStrikeStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 35) {
|
|
return calculateLightningFuryStats(effectiveLevel).manaCost
|
|
}
|
|
if (skillId === 38) {
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
// 1.13c Skills.txt: mana = 24, lvlmana = 4, manashift = 5 (factor = 0.125)
|
|
// Formula: (24 + 4 * (lvl - 1)) * 0.125 = 3 + 0.5 * (lvl - 1)
|
|
return (24 + 4 * (lvl - 1)) * 0.125
|
|
}
|
|
if (skillId === 40) {
|
|
// 1.13c Skills.txt: mana = 7, lvlmana = 0, manashift = 8
|
|
return 7
|
|
}
|
|
if (skillId === 41) {
|
|
// 1.13c Skills.txt: mana = 6, lvlmana = 0, manashift = 8
|
|
return 6
|
|
}
|
|
if (skillId === 46) {
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
// 1.13c Skills.txt: mana = 22, lvlmana = 1, manashift = 7 (factor = 0.5)
|
|
return Math.floor((22 + (lvl - 1)) * 0.5)
|
|
}
|
|
if (skillId === 51) {
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
// 1.13c Skills.txt: mana = 22, lvlmana = 1, manashift = 8 (factor = 1.0)
|
|
return 22 + (lvl - 1)
|
|
}
|
|
if (skillId === 52) {
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
// 1.13c Skills.txt: mana = 25, lvlmana = 1, manashift = 8 (factor = 1.0)
|
|
return 25 + (lvl - 1)
|
|
}
|
|
if (skillId === 43) {
|
|
// 1.13c Skills.txt: mana = 7, lvlmana = 0, manashift = 8
|
|
return 7
|
|
}
|
|
if (skillId === 48) {
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
// 1.13c Skills.txt: mana = 15, lvlmana = 1, manashift = 8
|
|
return 15 + (lvl - 1)
|
|
}
|
|
if (skillId === 42) {
|
|
// 1.13c Skills.txt: mana = 9, lvlmana = 0, manashift = 8
|
|
return 9
|
|
}
|
|
if (skillId === 45) {
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
// 1.13c Skills.txt: mana = 12, lvlmana = 1, manashift = 7 (factor = 0.5)
|
|
return (12 + (lvl - 1)) * 0.5
|
|
}
|
|
if (skillId === 49) {
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
// 1.13c Skills.txt: mana = 16, lvlmana = 1, manashift = 7 (factor = 0.5)
|
|
return (16 + (lvl - 1)) * 0.5
|
|
}
|
|
if (skillId === 50) {
|
|
// 1.13c Skills.txt: mana = 11, lvlmana = 0, manashift = 8
|
|
return 11
|
|
}
|
|
if (skillId === 53) {
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
// 1.13c Skills.txt: mana = 9, lvlmana = 1, manashift = 8 (factor = 1.0)
|
|
return 9 + (lvl - 1)
|
|
}
|
|
if (skillId === 55) {
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
// 1.13c Skills.txt: mana = 20, lvlmana = 1, manashift = 7 (factor = 0.5)
|
|
return (20 + (lvl - 1)) * 0.5
|
|
}
|
|
if (skillId === 56) {
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
// 1.13c Skills.txt: mana = 34, lvlmana = 1, manashift = 7 (factor = 0.5)
|
|
return (34 + (lvl - 1)) * 0.5
|
|
}
|
|
if (skillId === 57) {
|
|
// 1.13c Skills.txt: mana = 19, lvlmana = 0, manashift = 8
|
|
return 19
|
|
}
|
|
if (skillId === 58) {
|
|
// 1.13c Skills.txt: mana = 5, lvlmana = 0, manashift = 8
|
|
return 5
|
|
}
|
|
if (skillId === 59) {
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
// 1.13c Skills.txt: mana = 23, lvlmana = 1, manashift = 8 (factor = 1.0)
|
|
return 23 + (lvl - 1)
|
|
}
|
|
if (skillId === 60) {
|
|
// 1.13c Skills.txt: mana = 17, lvlmana = 0, manashift = 8
|
|
return 17
|
|
}
|
|
if (skillId === 61) {
|
|
// Fire Mastery: passive
|
|
return 0
|
|
}
|
|
if (skillId === 62) {
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
// 1.13c Skills.txt: mana = 40, lvlmana = 1, manashift = 7 (factor = 0.5)
|
|
return (40 + (lvl - 1)) * 0.5
|
|
}
|
|
if (skillId === 63) {
|
|
// Lightning Mastery: passive
|
|
return 0
|
|
}
|
|
if (skillId === 64) {
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
// 1.13c Skills.txt: mana = 30, lvlmana = 1, manashift = 8 (factor = 1.0)
|
|
return 30 + (lvl - 1)
|
|
}
|
|
if (skillId === 65) {
|
|
// Cold Mastery: passive
|
|
return 0
|
|
}
|
|
const def = getBatch1SkillDef(skillId)
|
|
if (!def) return 0
|
|
if (def.mana === 0 && def.lvlmana === 0) return 0
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
|
|
const shiftFactor = 2 ** ((def.manashift ?? 8) - 8)
|
|
const rawCost = (def.mana + def.lvlmana * (lvl - 1)) * shiftFactor
|
|
return Math.max(def.minmana ?? 0, rawCost)
|
|
}
|
|
|
|
/**
|
|
* Returns casting delay cooldown in frames at 25 fps.
|
|
* - Werewolf: 25 frames (1.0s)
|
|
* - Firestorm: 15 frames (0.6s)
|
|
* - Molten Boulder: 50 frames (2.0s)
|
|
* - Meteor: 30 frames (1.2s)
|
|
* - Blizzard: 45 frames (1.8s)
|
|
* - Hydra: 40 frames (1.6s)
|
|
* - Frozen Orb: 25 frames (1.0s)
|
|
* - All other Batch 1 skills: 0 frames
|
|
*/
|
|
export function getSkillCooldownTicks(skillId: number): number {
|
|
if (
|
|
skillId === 6 ||
|
|
skillId === 7 ||
|
|
skillId === 8 ||
|
|
skillId === 9 ||
|
|
skillId === 10 ||
|
|
skillId === 11 ||
|
|
skillId === 12 ||
|
|
skillId === 13 ||
|
|
skillId === 14 ||
|
|
skillId === 16 ||
|
|
skillId === 17 ||
|
|
skillId === 18 ||
|
|
skillId === 19 ||
|
|
skillId === 20 ||
|
|
skillId === 21 ||
|
|
skillId === 22 ||
|
|
skillId === 23 ||
|
|
skillId === 24 ||
|
|
skillId === 26 ||
|
|
skillId === 28 ||
|
|
skillId === 29 ||
|
|
skillId === 30 ||
|
|
skillId === 31 ||
|
|
skillId === 33 ||
|
|
skillId === 34 ||
|
|
skillId === 35 ||
|
|
skillId === 38 ||
|
|
skillId === 40 ||
|
|
skillId === 41 ||
|
|
skillId === 42 ||
|
|
skillId === 43 ||
|
|
skillId === 44 ||
|
|
skillId === 45 ||
|
|
skillId === 46 ||
|
|
skillId === 48 ||
|
|
skillId === 49 ||
|
|
skillId === 50 ||
|
|
skillId === 52 ||
|
|
skillId === 53 ||
|
|
skillId === 55 ||
|
|
skillId === 57 ||
|
|
skillId === 58 ||
|
|
skillId === 60 ||
|
|
skillId === 61 ||
|
|
skillId === 63 ||
|
|
skillId === 65
|
|
) {
|
|
return 0
|
|
}
|
|
if (skillId === 15) {
|
|
return 12 // Poison Javelin: 12 frames (0.5s) casting delay
|
|
}
|
|
if (skillId === 25) {
|
|
return 100 // Plague Javelin: 100 frames (4.0s) casting delay
|
|
}
|
|
if (skillId === 27) {
|
|
return 25 // Immolation Arrow: 25 frames (1.0s)
|
|
}
|
|
if (skillId === 32) {
|
|
return 150 // Valkyrie: 150 frames (6.0s) casting delay
|
|
}
|
|
if (skillId === 51) {
|
|
return 35
|
|
}
|
|
if (skillId === 56) {
|
|
return 30
|
|
}
|
|
if (skillId === 59) {
|
|
return 45
|
|
}
|
|
if (skillId === 62) {
|
|
return 40
|
|
}
|
|
if (skillId === 64) {
|
|
return 25
|
|
}
|
|
if (skillId === 121) {
|
|
return 25
|
|
}
|
|
const def = getBatch1SkillDef(skillId)
|
|
return def?.delay ?? 0
|
|
}
|
|
|
|
/**
|
|
* Computes min/max skill damage based on 1.13c 5-level-band progressions, HitShift precision,
|
|
* hard-point synergy multiplier integration, and weapon damage scaling.
|
|
*
|
|
* @param skillId - Canonical numeric skill ID.
|
|
* @param effectiveLevel - Current effective skill level (hard + soft points).
|
|
* @param synergyMultiplier - Evaluated synergy multiplier: 1.0 + (synergyBonusPct / 100).
|
|
* @param weaponDamage - Optional base weapon damage range { min, max }.
|
|
*/
|
|
export function calculateSkillDamage(
|
|
skillId: number | string,
|
|
slvl: number,
|
|
synergies?: number | {
|
|
readonly iceBolt?: number | undefined
|
|
readonly iceBlast?: number | undefined
|
|
readonly glacialSpike?: number | undefined
|
|
readonly blizzard?: number | undefined
|
|
readonly frozenOrb?: number | undefined
|
|
readonly fireBolt?: number | undefined
|
|
readonly fireBall?: number | undefined
|
|
readonly fireMastery?: number | undefined
|
|
readonly inferno?: number | undefined
|
|
readonly [key: string]: any
|
|
},
|
|
weaponDamage?: { min: number; max: number }
|
|
): { min: number; max: number } {
|
|
if (skillId === 6 || skillId === '6' || skillId === 'magicarrow' || skillId === 'magic arrow') {
|
|
if (!weaponDamage) {
|
|
return { min: 0, max: 0 }
|
|
}
|
|
const stats = calculateMagicArrowStats(slvl)
|
|
const baseMin = weaponDamage.min ?? 0
|
|
const baseMax = weaponDamage.max ?? 0
|
|
return { min: baseMin + stats.flatMagicDamage, max: baseMax + stats.flatMagicDamage }
|
|
}
|
|
if (skillId === 7 || skillId === '7' || skillId === 'firearrow' || skillId === 'fire arrow') {
|
|
const stats = calculateFireArrowStats(slvl, synergies as any)
|
|
const baseMin = weaponDamage?.min ?? 0
|
|
const baseMax = weaponDamage?.max ?? 0
|
|
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
|
}
|
|
if (skillId === 11 || skillId === '11' || skillId === 'coldarrow' || skillId === 'cold arrow') {
|
|
const stats = calculateColdArrowStats(slvl, synergies as any)
|
|
const baseMin = weaponDamage?.min ?? 0
|
|
const baseMax = weaponDamage?.max ?? 0
|
|
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
|
}
|
|
if (skillId === 12 || skillId === '12' || skillId === 'multipleshot' || skillId === 'multiple shot') {
|
|
const stats = calculateMultipleShotStats(slvl)
|
|
const baseMin = weaponDamage?.min ?? 0
|
|
const baseMax = weaponDamage?.max ?? 0
|
|
return { min: Math.floor(baseMin * stats.damageMultiplier), max: Math.floor(baseMax * stats.damageMultiplier) }
|
|
}
|
|
if (skillId === 15 || skillId === '15' || skillId === 'poisonjavelin' || skillId === 'poison javelin') {
|
|
const stats = calculatePoisonJavelinStats(slvl, synergies as any)
|
|
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
|
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
|
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
|
}
|
|
if (skillId === 16 || skillId === '16' || skillId === 'explodingarrow' || skillId === 'exploding arrow') {
|
|
const stats = calculateExplodingArrowStats(slvl, synergies as any)
|
|
const baseMin = weaponDamage?.min ?? 0
|
|
const baseMax = weaponDamage?.max ?? 0
|
|
return { min: baseMin + stats.minBlastDamage, max: baseMax + stats.maxBlastDamage }
|
|
}
|
|
if (skillId === 19 || skillId === '19' || skillId === 'impale') {
|
|
const stats = calculateImpaleStats(slvl)
|
|
const ed = 1 + stats.enhancedDamagePct / 100
|
|
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
|
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
|
return { min: Math.floor(baseMin * ed), max: Math.floor(baseMax * ed) }
|
|
}
|
|
if (skillId === 20 || skillId === '20' || skillId === 'lightningbolt' || skillId === 'lightning bolt') {
|
|
const stats = calculateLightningBoltStats(slvl, synergies as any)
|
|
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
|
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
|
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
|
}
|
|
if (skillId === 21 || skillId === '21' || skillId === 'icearrow' || skillId === 'ice arrow') {
|
|
const stats = calculateIceArrowStats(slvl, synergies as any)
|
|
const baseMin = weaponDamage?.min ?? 0
|
|
const baseMax = weaponDamage?.max ?? 0
|
|
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
|
}
|
|
if (skillId === 22 || skillId === '22' || skillId === 'guidedarrow' || skillId === 'guided arrow') {
|
|
const stats = calculateGuidedArrowStats(slvl)
|
|
const ed = 1 + stats.enhancedDamagePct / 100
|
|
const baseMin = weaponDamage?.min ?? 0
|
|
const baseMax = weaponDamage?.max ?? 0
|
|
return { min: Math.floor(baseMin * ed), max: Math.floor(baseMax * ed) }
|
|
}
|
|
if (skillId === 24 || skillId === '24' || skillId === 'chargedstrike' || skillId === 'charged strike') {
|
|
const stats = calculateChargedStrikeStats(slvl, synergies as any)
|
|
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
|
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
|
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
|
}
|
|
if (skillId === 25 || skillId === '25' || skillId === 'plaguejavelin' || skillId === 'plague javelin') {
|
|
const stats = calculatePlagueJavelinStats(slvl, synergies as any)
|
|
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
|
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
|
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
|
}
|
|
if (skillId === 26 || skillId === '26' || skillId === 'strafe') {
|
|
const stats = calculateStrafeStats(slvl)
|
|
const mult = stats.damageMultiplier * (1 + stats.enhancedDamagePct / 100)
|
|
const baseMin = weaponDamage?.min ?? 0
|
|
const baseMax = weaponDamage?.max ?? 0
|
|
return { min: Math.floor(baseMin * mult), max: Math.floor(baseMax * mult) }
|
|
}
|
|
if (skillId === 27 || skillId === '27' || skillId === 'immolationarrow' || skillId === 'immolation arrow') {
|
|
const stats = calculateImmolationArrowStats(slvl, synergies as any)
|
|
const baseMin = weaponDamage?.min ?? 0
|
|
const baseMax = weaponDamage?.max ?? 0
|
|
return { min: baseMin + stats.minExplosionDamage, max: baseMax + stats.maxExplosionDamage }
|
|
}
|
|
if (skillId === 30 || skillId === '30' || skillId === 'fend') {
|
|
const stats = calculateFendStats(slvl)
|
|
const ed = 1 + stats.enhancedDamagePct / 100
|
|
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
|
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
|
return { min: Math.floor(baseMin * ed), max: Math.floor(baseMax * ed) }
|
|
}
|
|
if (skillId === 31 || skillId === '31' || skillId === 'freezingarrow' || skillId === 'freezing arrow') {
|
|
const stats = calculateFreezingArrowStats(slvl, synergies as any)
|
|
const baseMin = weaponDamage?.min ?? 0
|
|
const baseMax = weaponDamage?.max ?? 0
|
|
return { min: baseMin + stats.minColdDamage, max: baseMax + stats.maxColdDamage }
|
|
}
|
|
if (skillId === 34 || skillId === '34' || skillId === 'lightningstrike' || skillId === 'lightning strike') {
|
|
const stats = calculateLightningStrikeStats(slvl, synergies as any)
|
|
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
|
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
|
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
|
}
|
|
if (skillId === 35 || skillId === '35' || skillId === 'lightningfury' || skillId === 'lightning fury') {
|
|
const stats = calculateLightningFuryStats(slvl, synergies as any)
|
|
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
|
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
|
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
|
}
|
|
if (
|
|
skillId === 8 || skillId === '8' || skillId === 'innersight' || skillId === 'inner sight' ||
|
|
skillId === 9 || skillId === '9' || skillId === 'criticalstrike' || skillId === 'critical strike' ||
|
|
skillId === 13 || skillId === '13' || skillId === 'dodge' ||
|
|
skillId === 17 || skillId === '17' || skillId === 'slowmissiles' || skillId === 'slow missiles' ||
|
|
skillId === 18 || skillId === '18' || skillId === 'avoid' ||
|
|
skillId === 23 || skillId === '23' || skillId === 'penetrate' ||
|
|
skillId === 28 || skillId === '28' || skillId === 'decoy' || skillId === 'dopplezon' ||
|
|
skillId === 29 || skillId === '29' || skillId === 'evade' ||
|
|
skillId === 32 || skillId === '32' || skillId === 'valkyrie' ||
|
|
skillId === 33 || skillId === '33' || skillId === 'pierce'
|
|
) {
|
|
return { min: 0, max: 0 }
|
|
}
|
|
if (skillId === 38 || skillId === '38' || skillId === 'chargedbolt' || skillId === 'charged bolt') {
|
|
return calculateChargedBoltDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 47 || skillId === '47' || skillId === 'fireball' || skillId === 'fire ball') {
|
|
return calculateFireBallDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 41 || skillId === '41' || skillId === 'inferno') {
|
|
return calculateInfernoDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 46 || skillId === '46' || skillId === 'blaze') {
|
|
return calculateBlazeDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 51 || skillId === '51' || skillId === 'firewall' || skillId === 'fire wall') {
|
|
return calculateFireWallDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 52 || skillId === '52' || skillId === 'enchant') {
|
|
return calculateEnchantDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 40 || skillId === '40' || skillId === 'frozenarmor' || skillId === 'frozen armor') {
|
|
return { min: 0, max: 0 }
|
|
}
|
|
if (skillId === 43 || skillId === '43' || skillId === 'telekinesis') {
|
|
return calculateTelekinesisDamage(slvl)
|
|
}
|
|
if (skillId === 44 || skillId === '44' || skillId === 'frostnova' || skillId === 'frost nova') {
|
|
return calculateFrostNovaDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 48 || skillId === '48' || skillId === 'nova') {
|
|
return calculateNovaDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 42 || skillId === '42' || skillId === 'staticfield' || skillId === 'static field') {
|
|
return { min: 0, max: 0 }
|
|
}
|
|
if (skillId === 45 || skillId === '45' || skillId === 'iceblast' || skillId === 'ice blast') {
|
|
return calculateIceBlastDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 50 || skillId === '50' || skillId === 'shiverarmor' || skillId === 'shiver armor') {
|
|
return calculateShiverArmorDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 53 || skillId === '53' || skillId === 'chainlightning' || skillId === 'chain lightning') {
|
|
return calculateChainLightningDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 55 || skillId === '55' || skillId === 'glacialspike' || skillId === 'glacial spike') {
|
|
return calculateGlacialSpikeDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 60 || skillId === '60' || skillId === 'chillingarmor' || skillId === 'chilling armor') {
|
|
return calculateChillingArmorDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 49 || skillId === '49' || skillId === 'lightning') {
|
|
return calculateLightningDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 57 || skillId === '57' || skillId === 'thunderstorm' || skillId === 'thunder storm') {
|
|
return calculateThunderStormDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 58 || skillId === '58' || skillId === 'energyshield' || skillId === 'energy shield') {
|
|
return { min: 0, max: 0 }
|
|
}
|
|
if (skillId === 61 || skillId === '61' || skillId === 'firemastery' || skillId === 'fire mastery') {
|
|
return { min: 0, max: 0 }
|
|
}
|
|
if (skillId === 63 || skillId === '63' || skillId === 'lightningmastery' || skillId === 'lightning mastery') {
|
|
return { min: 0, max: 0 }
|
|
}
|
|
if (skillId === 65 || skillId === '65' || skillId === 'coldmastery' || skillId === 'cold mastery') {
|
|
return { min: 0, max: 0 }
|
|
}
|
|
if (skillId === 59 || skillId === '59' || skillId === 'blizzard') {
|
|
return calculateBlizzardDamage(slvl, synergies)
|
|
}
|
|
if (skillId === 56 || skillId === '56' || skillId === 'meteor') {
|
|
return calculateMeteorDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 62 || skillId === '62' || skillId === 'hydra') {
|
|
return calculateHydraDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 67 || skillId === '67' || skillId === 'teeth') {
|
|
return calculateTeethDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 68 || skillId === '68' || skillId === 'bonearmor' || skillId === 'bone armor') {
|
|
const lvl = Math.max(1, Math.floor(slvl))
|
|
const base = 20 + (lvl - 1) * 10
|
|
let cap = base
|
|
if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) {
|
|
cap = Math.floor(base * synergies)
|
|
} else if (synergies && typeof synergies === 'object') {
|
|
cap = calculateBoneArmorCapacity(slvl, synergies as any)
|
|
}
|
|
return { min: cap, max: cap }
|
|
}
|
|
if (skillId === 70 || skillId === '70' || skillId === 'raiseskeleton' || skillId === 'raise skeleton') {
|
|
return { min: 0, max: 0 }
|
|
}
|
|
if (skillId === 73 || skillId === '73' || skillId === 'poisondagger' || skillId === 'poison dagger') {
|
|
const p = calculatePoisonDaggerDamage(slvl, synergies as any)
|
|
return { min: p.minDamage, max: p.maxDamage }
|
|
}
|
|
if (skillId === 74 || skillId === '74' || skillId === 'corpseexplosion' || skillId === 'corpse explosion') {
|
|
const hp = weaponDamage?.max ?? 1000
|
|
const ce = calculateCorpseExplosionDamage(hp)
|
|
const syn = typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
return { min: Math.floor(ce.minTotal * syn), max: Math.floor(ce.maxTotal * syn) }
|
|
}
|
|
if (skillId === 78 || skillId === '78' || skillId === 'bonewall' || skillId === 'bone wall') {
|
|
const bw = calculateBoneWallStats(slvl, synergies as any)
|
|
return { min: bw.hp, max: bw.hp }
|
|
}
|
|
if (skillId === 83 || skillId === '83' || skillId === 'poisonexplosion' || skillId === 'poison explosion') {
|
|
const pe = calculatePoisonExplosionDamage(slvl, synergies as any)
|
|
return { min: pe.minDamage, max: pe.maxDamage }
|
|
}
|
|
if (skillId === 84 || skillId === '84' || skillId === 'bonespear' || skillId === 'bone spear') {
|
|
return calculateBoneSpearDamage(slvl, synergies as any)
|
|
}
|
|
if (skillId === 88 || skillId === '88' || skillId === 'boneprison' || skillId === 'bone prison') {
|
|
const bp = calculateBonePrisonStats(slvl, synergies as any)
|
|
return { min: bp.hp, max: bp.hp }
|
|
}
|
|
if (skillId === 92 || skillId === '92' || skillId === 'poisonnova' || skillId === 'poison nova') {
|
|
const pn = calculatePoisonNovaDamage(slvl, synergies as any)
|
|
return { min: pn.minDamage, max: pn.maxDamage }
|
|
}
|
|
if (skillId === 93 || skillId === '93' || skillId === 'bonespirit' || skillId === 'bone spirit') {
|
|
return calculateBoneSpiritDamage(slvl, synergies as any)
|
|
}
|
|
|
|
const numericSkillId = typeof skillId === 'number' ? skillId : Number(skillId)
|
|
if ([66, 71, 72, 76, 77, 81, 82, 86, 87, 91].includes(numericSkillId)) {
|
|
return { min: 0, max: 0 }
|
|
}
|
|
const def = Number.isFinite(numericSkillId) ? getBatch1SkillDef(numericSkillId) : undefined
|
|
if (!def) {
|
|
if (weaponDamage) {
|
|
const wMin = Number.isFinite(weaponDamage.min) ? Math.max(0, Math.floor(weaponDamage.min)) : 0
|
|
const wMax = Number.isFinite(weaponDamage.max) ? Math.max(0, Math.floor(weaponDamage.max)) : 0
|
|
return { min: wMin, max: wMax }
|
|
}
|
|
return { min: 0, max: 0 }
|
|
}
|
|
|
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
|
let min = def.emin
|
|
let max = def.emax
|
|
|
|
// Level bands (Level 2..8: lev1, 9..16: lev2, 17..22: lev3, 23..28: lev4, 29+: lev5)
|
|
if (lvl > 1) {
|
|
const maxElev1 = def.emaxlev1 ?? def.elev1
|
|
const maxElev2 = def.emaxlev2 ?? def.elev2
|
|
const maxElev3 = def.emaxlev3 ?? def.elev3
|
|
const maxElev4 = def.emaxlev4 ?? def.elev4
|
|
const maxElev5 = def.emaxlev5 ?? def.elev5
|
|
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
min += b1 * def.elev1
|
|
max += b1 * maxElev1
|
|
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
min += b2 * def.elev2
|
|
max += b2 * maxElev2
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
min += b3 * def.elev3
|
|
max += b3 * maxElev3
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
min += b4 * def.elev4
|
|
max += b4 * maxElev4
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * def.elev5
|
|
max += b5 * maxElev5
|
|
}
|
|
}
|
|
|
|
// Precision shift: HitShift 7 = 0.5 (128/256), HitShift 8 = 1.0 (256/256)
|
|
const hitShift = def.hitshift ?? 8
|
|
const shiftMultiplier = 2 ** (hitShift - 8)
|
|
min *= shiftMultiplier
|
|
max *= shiftMultiplier
|
|
|
|
// Apply synergy multiplier
|
|
const syn = typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
min = Math.floor(min * syn)
|
|
max = Math.floor(max * syn)
|
|
|
|
// Weapon damage scaling via SrcDam (128 = 100% weapon damage)
|
|
if (weaponDamage && def.srcDamage > 0) {
|
|
const wpnMult = def.srcDamage / 128.0
|
|
const wMin = Number.isFinite(weaponDamage.min) ? Math.max(0, Math.floor(weaponDamage.min)) : 0
|
|
const wMax = Number.isFinite(weaponDamage.max) ? Math.max(0, Math.floor(weaponDamage.max)) : 0
|
|
min += Math.floor(wMin * wpnMult)
|
|
max += Math.floor(wMax * wpnMult)
|
|
}
|
|
|
|
return { min, max }
|
|
}
|
|
|
|
/**
|
|
* Calculates Blizzard (Skill 59) synergy multiplier:
|
|
* +5% damage per hard point in Ice Bolt (39), Ice Blast (45), and Glacial Spike (55).
|
|
*/
|
|
export function calculateBlizzardSynergyMultiplier(synergies: {
|
|
readonly iceBolt?: number | undefined
|
|
readonly iceBlast?: number | undefined
|
|
readonly glacialSpike?: number | undefined
|
|
}): number {
|
|
const points = (synergies.iceBolt ?? 0) + (synergies.iceBlast ?? 0) + (synergies.glacialSpike ?? 0)
|
|
return 1.0 + points * 0.05
|
|
}
|
|
|
|
/**
|
|
* Calculates Blizzard (Skill 59) min and max damage with 1.13c 5-band level scaling and synergies.
|
|
*/
|
|
export function calculateBlizzardDamage(
|
|
effectiveLevel: number,
|
|
synergies: {
|
|
readonly iceBolt?: number | undefined
|
|
readonly iceBlast?: number | undefined
|
|
readonly glacialSpike?: number | undefined
|
|
} | number = {},
|
|
): { min: number; max: number } {
|
|
let syn = 1.0
|
|
if (typeof synergies === 'number') {
|
|
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
} else if (synergies && typeof synergies === 'object') {
|
|
syn = calculateBlizzardSynergyMultiplier(synergies)
|
|
}
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
let min = 45
|
|
let max = 75
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
min += b1 * 15
|
|
max += b1 * 16
|
|
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
min += b2 * 30
|
|
max += b2 * 31
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
min += b3 * 45
|
|
max += b3 * 46
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
min += b4 * 55
|
|
max += b4 * 56
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * 65
|
|
max += b5 * 66
|
|
}
|
|
}
|
|
return {
|
|
min: Math.floor(min * syn),
|
|
max: Math.floor(max * syn),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates Meteor (Skill 56) synergy multiplier:
|
|
* +5% fire damage per hard point in Fire Bolt (36) and Fire Ball (47).
|
|
* Fire Mastery (61): +30% base, +7%/lvl if present.
|
|
*/
|
|
export function calculateMeteorSynergyMultiplier(synergies: {
|
|
readonly fireBolt?: number | undefined
|
|
readonly fireBall?: number | undefined
|
|
readonly fireMastery?: number | undefined
|
|
}): number {
|
|
const points = (synergies.fireBolt ?? 0) + (synergies.fireBall ?? 0)
|
|
const synPct = points * 5 // +5% per hard point
|
|
const masteryPct = synergies.fireMastery && synergies.fireMastery > 0
|
|
? 30 + (synergies.fireMastery - 1) * 7 // Fire Mastery: +30% base, +7%/lvl
|
|
: 0
|
|
return (1.0 + synPct / 100) * (1.0 + masteryPct / 100)
|
|
}
|
|
|
|
/**
|
|
* Calculates Meteor (Skill 56) primary detonation fire damage with 1.13c 5-band level scaling and synergies.
|
|
* Base: 80 - 100
|
|
* 5 bands: [23, 39, 79, 81, 83] min / [25, 41, 81, 83, 85] max.
|
|
*/
|
|
export function calculateMeteorDamage(
|
|
effectiveLevel: number,
|
|
synergies: {
|
|
readonly fireBolt?: number | undefined
|
|
readonly fireBall?: number | undefined
|
|
readonly fireMastery?: number | undefined
|
|
} | number = {},
|
|
): { min: number; max: number } {
|
|
let syn = 1.0
|
|
if (typeof synergies === 'number') {
|
|
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
} else if (synergies && typeof synergies === 'object') {
|
|
syn = calculateMeteorSynergyMultiplier(synergies)
|
|
}
|
|
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
let min = 80
|
|
let max = 100
|
|
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
min += b1 * 23
|
|
max += b1 * 25
|
|
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
min += b2 * 39
|
|
max += b2 * 41
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
min += b3 * 79
|
|
max += b3 * 81
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
min += b4 * 81
|
|
max += b4 * 83
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * 83
|
|
max += b5 * 85
|
|
}
|
|
}
|
|
|
|
return {
|
|
min: Math.floor(min * syn),
|
|
max: Math.floor(max * syn),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates Meteor residual ground fire (meteorfire, ID 240) periodic damage.
|
|
* 1.13c Ground Truth:
|
|
* - Base: 15 - 25
|
|
* - 5 bands: [4, 5, 6, 6, 6] min/max
|
|
* - Synergies: +3% per hard point of Inferno (41); Fire Mastery (61) applies.
|
|
* - HitShift: 3 (8/256 internal precision per tick).
|
|
* - Per-second multiplier: 25 * 8 / 256 = 200 / 256 = 0.78125.
|
|
*/
|
|
export function calculateMeteorResidualFireDamage(
|
|
effectiveLevel: number,
|
|
synergies: {
|
|
readonly inferno?: number | undefined
|
|
readonly fireMastery?: number | undefined
|
|
} | number = {},
|
|
): {
|
|
min: number
|
|
max: number
|
|
perSecMin: number
|
|
perSecMax: number
|
|
} {
|
|
let syn = 1.0
|
|
if (typeof synergies === 'number') {
|
|
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
} else if (synergies && typeof synergies === 'object') {
|
|
const infernoPts = synergies.inferno ?? 0
|
|
const infernoPct = infernoPts * 3 // +3% per point
|
|
const masteryPct = synergies.fireMastery && synergies.fireMastery > 0
|
|
? 30 + (synergies.fireMastery - 1) * 7
|
|
: 0
|
|
syn = (1.0 + infernoPct / 100) * (1.0 + masteryPct / 100)
|
|
}
|
|
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
let min = 15
|
|
let max = 25
|
|
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
min += b1 * 4
|
|
max += b1 * 4
|
|
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
min += b2 * 5
|
|
max += b2 * 5
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
min += b3 * 6
|
|
max += b3 * 6
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
min += b4 * 6
|
|
max += b4 * 6
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * 6
|
|
max += b5 * 6
|
|
}
|
|
}
|
|
|
|
const rawMin = Math.floor(min * syn)
|
|
const rawMax = Math.floor(max * syn)
|
|
|
|
// In 1.13c HitShift is 3 (8/256) at 25 fps: per-second multiplier is 200/256 = 0.78125
|
|
const perSecMin = Math.floor((rawMin * 200) / 256)
|
|
const perSecMax = Math.floor((rawMax * 200) / 256)
|
|
|
|
return {
|
|
min: rawMin,
|
|
max: rawMax,
|
|
perSecMin,
|
|
perSecMax,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates Meteor detailed damage profile including impact fire, optional physical split,
|
|
* and residual ground fire damage per second.
|
|
*/
|
|
export function calculateMeteorDetailedDamage(
|
|
effectiveLevel: number,
|
|
synergies: {
|
|
readonly fireBolt?: number | undefined
|
|
readonly fireBall?: number | undefined
|
|
readonly fireMastery?: number | undefined
|
|
readonly inferno?: number | undefined
|
|
} = {},
|
|
options?: { readonly physRatio?: number | undefined }
|
|
): {
|
|
impactFire: { min: number; max: number }
|
|
impactPhys: { min: number; max: number }
|
|
residualFire: { min: number; max: number; perSecMin: number; perSecMax: number }
|
|
} {
|
|
const det = calculateMeteorDamage(effectiveLevel, synergies)
|
|
const physRatio = Math.max(0, Math.min(1, options?.physRatio ?? 0)) // 0 in vanilla 1.13c
|
|
const fireRatio = 1.0 - physRatio
|
|
|
|
const impactFire = {
|
|
min: Math.floor(det.min * fireRatio),
|
|
max: Math.floor(det.max * fireRatio),
|
|
}
|
|
const impactPhys = {
|
|
min: Math.floor(det.min * physRatio),
|
|
max: Math.floor(det.max * physRatio),
|
|
}
|
|
const residualFire = calculateMeteorResidualFireDamage(effectiveLevel, synergies)
|
|
|
|
return {
|
|
impactFire,
|
|
impactPhys,
|
|
residualFire,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates Hydra (Skill 62) synergy multiplier:
|
|
* +3% fire damage per hard point in Fire Bolt (36) and Fire Ball (47).
|
|
*/
|
|
export function calculateHydraSynergyMultiplier(synergies: {
|
|
readonly fireBolt?: number | undefined
|
|
readonly fireBall?: number | undefined
|
|
readonly fireMastery?: number | undefined
|
|
} | number = {}): number {
|
|
if (typeof synergies === 'number') {
|
|
return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
}
|
|
const points = (synergies.fireBolt ?? 0) + (synergies.fireBall ?? 0)
|
|
return 1.0 + points * 0.03
|
|
}
|
|
|
|
/**
|
|
* Calculates Hydra (Skill 62) min and max damage with 1.13c 5-band level scaling and synergies.
|
|
* 1.13c Ground Truth:
|
|
* - EMin: 28, EMax: 39
|
|
* - 5 bands: elev = [11, 15, 19, 23, 27], emaxlev = [13, 17, 21, 25, 29]
|
|
* - HitShift: 7 (factor = 0.5)
|
|
* - Synergies: +3% per hard point of Fire Bolt (36) and Fire Ball (47).
|
|
*/
|
|
export function calculateHydraDamage(
|
|
effectiveLevel: number,
|
|
synergies: {
|
|
readonly fireBolt?: number | undefined
|
|
readonly fireBall?: number | undefined
|
|
readonly fireMastery?: number | undefined
|
|
} | number = {},
|
|
): { min: number; max: number } {
|
|
let syn = 1.0
|
|
if (typeof synergies === 'number') {
|
|
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
} else if (synergies && typeof synergies === 'object') {
|
|
syn = calculateHydraSynergyMultiplier(synergies)
|
|
}
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
let min = 28
|
|
let max = 39
|
|
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
min += b1 * 11
|
|
max += b1 * 13
|
|
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
min += b2 * 15
|
|
max += b2 * 17
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
min += b3 * 19
|
|
max += b3 * 21
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
min += b4 * 23
|
|
max += b4 * 25
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * 27
|
|
max += b5 * 29
|
|
}
|
|
}
|
|
|
|
// HitShift 7 = 0.5 (128/256 precision)
|
|
min *= 0.5
|
|
max *= 0.5
|
|
|
|
return {
|
|
min: Math.floor(min * syn),
|
|
max: Math.floor(max * syn),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates Fire Ball (Skill 47) synergy multiplier:
|
|
* +14% fire damage per hard point in Fire Bolt (36) and Meteor (56).
|
|
* Fire Mastery (61): +30% base, +7%/lvl if present.
|
|
*/
|
|
export function calculateFireBallSynergyMultiplier(synergies: {
|
|
readonly fireBolt?: number | undefined
|
|
readonly meteor?: number | undefined
|
|
readonly fireMastery?: number | undefined
|
|
} | number = {}): number {
|
|
if (typeof synergies === 'number') {
|
|
return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
}
|
|
const points = (synergies.fireBolt ?? 0) + (synergies.meteor ?? 0)
|
|
const synPct = points * 14 // +14% per hard point
|
|
const masteryPct = synergies.fireMastery && synergies.fireMastery > 0
|
|
? 30 + (synergies.fireMastery - 1) * 7 // Fire Mastery: +30% base, +7%/lvl
|
|
: 0
|
|
return (1.0 + synPct / 100) * (1.0 + masteryPct / 100)
|
|
}
|
|
|
|
/**
|
|
* Calculates Fire Ball (Skill 47) min and max damage with 1.13c 5-band level scaling and synergies.
|
|
* 1.13c Ground Truth (`Skills.txt` row 47):
|
|
* - EMin: 12, EMax: 28
|
|
* - 5 bands: elev = [13, 23, 28, 33, 38], emaxlev = [15, 25, 30, 35, 40]
|
|
* - HitShift: 7 (factor = 0.5)
|
|
* - Synergies: +14% per hard point in Fire Bolt (36) and Meteor (56).
|
|
*/
|
|
export function calculateFireBallDamage(
|
|
effectiveLevel: number,
|
|
synergies: {
|
|
readonly fireBolt?: number | undefined
|
|
readonly meteor?: number | undefined
|
|
readonly fireMastery?: number | undefined
|
|
} | number = {},
|
|
): { min: number; max: number } {
|
|
let syn = 1.0
|
|
if (typeof synergies === 'number') {
|
|
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
|
} else if (synergies && typeof synergies === 'object') {
|
|
syn = calculateFireBallSynergyMultiplier(synergies)
|
|
}
|
|
|
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
|
let min = 12
|
|
let max = 28
|
|
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl - 1, 7)
|
|
min += b1 * 13
|
|
max += b1 * 15
|
|
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
min += b2 * 23
|
|
max += b2 * 25
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
min += b3 * 28
|
|
max += b3 * 30
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
min += b4 * 33
|
|
max += b4 * 35
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * 38
|
|
max += b5 * 40
|
|
}
|
|
}
|
|
|
|
min *= 0.5
|
|
max *= 0.5
|
|
|
|
return {
|
|
min: Math.floor(min * syn),
|
|
max: Math.floor(max * syn),
|
|
}
|
|
}
|
|
|
|
/** Result of executing a Batch 1 skill. */
|
|
export type SkillExecutionResult =
|
|
| { readonly kind: 'cooldown'; readonly ticksLeft: number }
|
|
| { readonly kind: 'mana'; readonly cost: number; readonly currentMana: number }
|
|
| {
|
|
readonly kind: 'projectile'
|
|
readonly skillId: number
|
|
readonly skillName: string
|
|
readonly projectile: Projectile
|
|
readonly manaCost: number
|
|
readonly cooldownTicks: number
|
|
readonly damage: { min: number; max: number }
|
|
}
|
|
| {
|
|
readonly kind: 'melee'
|
|
readonly skillId: number
|
|
readonly skillName: string
|
|
readonly damage: { min: number; max: number }
|
|
readonly manaCost: number
|
|
readonly cooldownTicks: number
|
|
readonly hits: number
|
|
readonly knockback: boolean
|
|
readonly autoHit: boolean
|
|
readonly selfDamagePct?: number | undefined
|
|
}
|
|
| {
|
|
readonly kind: 'area'
|
|
readonly skillId: number
|
|
readonly skillName: string
|
|
readonly radius: number
|
|
readonly damage: { min: number; max: number }
|
|
readonly manaCost: number
|
|
readonly cooldownTicks: number
|
|
}
|
|
| {
|
|
readonly kind: 'buff'
|
|
readonly skillId: number
|
|
readonly skillName: string
|
|
readonly durationSeconds: number
|
|
readonly manaCost: number
|
|
readonly cooldownTicks: number
|
|
readonly buffEffect: Readonly<Record<string, number>>
|
|
}
|
|
| {
|
|
readonly kind: 'curse'
|
|
readonly skillId: number
|
|
readonly skillName: string
|
|
readonly radius: number
|
|
readonly durationSeconds: number
|
|
readonly manaCost: number
|
|
readonly cooldownTicks: number
|
|
readonly debuffEffect: Readonly<Record<string, number>>
|
|
}
|
|
| {
|
|
readonly kind: 'movement'
|
|
readonly skillId: number
|
|
readonly skillName: string
|
|
readonly destination: { readonly x: number; readonly y: number }
|
|
readonly manaCost: number
|
|
readonly cooldownTicks: number
|
|
}
|
|
| {
|
|
readonly kind: 'summon'
|
|
readonly skillId: number
|
|
readonly skillName: string
|
|
readonly summonType: string
|
|
readonly manaCost: number
|
|
readonly cooldownTicks: number
|
|
readonly count: number
|
|
}
|
|
| {
|
|
readonly kind: 'aura'
|
|
readonly skillId: number
|
|
readonly skillName: string
|
|
readonly radius: number
|
|
readonly manaCost: number
|
|
readonly cooldownTicks: number
|
|
readonly auraBonusPct: number
|
|
}
|
|
| {
|
|
readonly kind: 'passive'
|
|
readonly skillId: number
|
|
readonly skillName: string
|
|
readonly passiveBonus: Readonly<Record<string, number>>
|
|
}
|
|
|
|
/**
|
|
* Executes combat mechanics for any of the Batch 1 essential combat skills across all 7 classes.
|
|
* Enforces resource checks (cooldown, mana) and produces typed execution outcomes (projectile,
|
|
* melee, area, buff, curse, movement, summon, aura, passive).
|
|
*/
|
|
export function executeSkill(
|
|
skillId: number,
|
|
context: SkillExecutionContext,
|
|
options?: {
|
|
readonly rng?: Rng
|
|
readonly weaponDamage?: { min: number; max: number }
|
|
readonly deductResources?: boolean
|
|
}
|
|
): SkillExecutionResult {
|
|
const def = getBatch1SkillDef(skillId)
|
|
if (!def) {
|
|
return {
|
|
kind: 'melee',
|
|
skillId,
|
|
skillName: 'Unknown',
|
|
damage: options?.weaponDamage
|
|
? { min: Math.max(0, options.weaponDamage.min), max: Math.max(0, options.weaponDamage.max) }
|
|
: { min: 0, max: 0 },
|
|
manaCost: 0,
|
|
cooldownTicks: 0,
|
|
hits: 1,
|
|
knockback: false,
|
|
autoHit: false,
|
|
}
|
|
}
|
|
|
|
const { caster, target, skillLevel } = context
|
|
const synergyMultiplier = context.synergyMultiplier ?? 1.0
|
|
const cooldownLeft = caster.cooldown ?? 0
|
|
if (cooldownLeft > 0 && def.delay > 0) {
|
|
return { kind: 'cooldown', ticksLeft: cooldownLeft }
|
|
}
|
|
|
|
const manaCost = getSkillManaCost(skillId, skillLevel)
|
|
const currentMana = Number.isFinite(caster.mana) ? caster.mana! : 0
|
|
if (currentMana < manaCost) {
|
|
return { kind: 'mana', cost: manaCost, currentMana }
|
|
}
|
|
|
|
const damage = calculateSkillDamage(skillId, skillLevel, synergyMultiplier, options?.weaponDamage)
|
|
const cooldownTicks = def.delay
|
|
|
|
if (options?.deductResources !== false) {
|
|
caster.mana = Math.max(0, currentMana - manaCost)
|
|
if (cooldownTicks > 0) {
|
|
caster.cooldown = Math.max(caster.cooldown ?? 0, cooldownTicks)
|
|
}
|
|
}
|
|
|
|
const facing = caster.facing ?? 0
|
|
const vector = FACING_VECTORS[facing] ?? FACING_VECTORS[0]!
|
|
let dx = vector[0]
|
|
let dy = vector[1]
|
|
if (target !== undefined) {
|
|
const tdx = target.x - caster.x
|
|
const tdy = target.y - caster.y
|
|
const len = Math.hypot(tdx, tdy)
|
|
if (len > 0) {
|
|
dx = tdx / len
|
|
dy = tdy / len
|
|
}
|
|
}
|
|
|
|
switch (def.type) {
|
|
case 'projectile': {
|
|
const speed = def.speed ?? 450
|
|
const range = def.range ?? 400
|
|
const perTick = speed / 25
|
|
const ttl = Math.max(1, Math.round(range / perTick))
|
|
const projDamage = options?.rng
|
|
? options.rng.int(damage.min, Math.max(damage.min, damage.max))
|
|
: Math.round((damage.min + damage.max) / 2)
|
|
const projectile: Projectile = {
|
|
skillId: String(skillId),
|
|
x: caster.x,
|
|
y: caster.y,
|
|
vx: dx * perTick,
|
|
vy: dy * perTick,
|
|
damage: projDamage,
|
|
ttl,
|
|
fromPlayer: true,
|
|
}
|
|
return {
|
|
kind: 'projectile',
|
|
skillId,
|
|
skillName: def.name,
|
|
projectile,
|
|
manaCost,
|
|
cooldownTicks,
|
|
damage,
|
|
}
|
|
}
|
|
|
|
case 'melee': {
|
|
let hits = 1
|
|
let knockback = false
|
|
let autoHit = false
|
|
let selfDamagePct: number | undefined
|
|
|
|
if (skillId === 10) hits = 3 // Jab: 3 strikes
|
|
if (skillId === 106) hits = Math.min(Math.max(1, Math.floor(skillLevel)) + 1, 5) // Zeal: min(lvl+1, 5)
|
|
if (skillId === 133) hits = 2 // Double Swing: 2 hits
|
|
if (skillId === 126) knockback = true // Bash: knockback
|
|
if (skillId === 97) { autoHit = true; knockback = true } // Smite: 100% hit, knockback
|
|
if (skillId === 96) selfDamagePct = 8 // Sacrifice: 8% self-damage
|
|
|
|
return {
|
|
kind: 'melee',
|
|
skillId,
|
|
skillName: def.name,
|
|
damage,
|
|
manaCost,
|
|
cooldownTicks,
|
|
hits,
|
|
knockback,
|
|
autoHit,
|
|
selfDamagePct,
|
|
}
|
|
}
|
|
|
|
case 'area': {
|
|
const radius = skillId === 44 ? 240 : skillId === 74 ? 80 + 20 * (skillLevel - 1) : 120
|
|
return {
|
|
kind: 'area',
|
|
skillId,
|
|
skillName: def.name,
|
|
radius,
|
|
damage,
|
|
manaCost,
|
|
cooldownTicks,
|
|
}
|
|
}
|
|
|
|
case 'buff': {
|
|
const durationSeconds = skillId === 258 ? 120 + 12 * (skillLevel - 1) : skillId === 138 ? 16 + 4 * (skillLevel - 1) : 40
|
|
const buffEffect: Record<string, number> = {}
|
|
if (skillId === 68) buffEffect.damageAbsorption = 20 + 10 * (skillLevel - 1) // Bone Armor
|
|
if (skillId === 138) buffEffect.defensePct = 100 + 10 * (skillLevel - 1) // Shout
|
|
if (skillId === 258) {
|
|
buffEffect.attackSpeedPct = 20 + 2 * (skillLevel - 1) // Burst of Speed
|
|
buffEffect.walkRunSpeedPct = 20 + 3 * (skillLevel - 1)
|
|
}
|
|
if (skillId === 223) {
|
|
buffEffect.werewolfLifePct = 50 + 5 * (skillLevel - 1) // Werewolf
|
|
buffEffect.attackSpeedPct = 20 + 2 * (skillLevel - 1)
|
|
}
|
|
return {
|
|
kind: 'buff',
|
|
skillId,
|
|
skillName: def.name,
|
|
durationSeconds,
|
|
manaCost,
|
|
cooldownTicks,
|
|
buffEffect: Object.freeze(buffEffect),
|
|
}
|
|
}
|
|
|
|
case 'curse': {
|
|
const radius = 80 + 20 * (skillLevel - 1)
|
|
const durationSeconds = skillId === 66 ? 8 + 3 * (skillLevel - 1) : 10
|
|
const debuffEffect: Record<string, number> = {}
|
|
if (skillId === 66) debuffEffect.physicalResistPenaltyPct = 100 // Amplify Damage
|
|
if (skillId === 130) debuffEffect.flee = 1 // Howl
|
|
return {
|
|
kind: 'curse',
|
|
skillId,
|
|
skillName: def.name,
|
|
radius,
|
|
durationSeconds,
|
|
manaCost,
|
|
cooldownTicks,
|
|
debuffEffect: Object.freeze(debuffEffect),
|
|
}
|
|
}
|
|
|
|
case 'movement': {
|
|
const destination = target ? { x: target.x, y: target.y } : { x: caster.x + dx * 200, y: caster.y + dy * 200 }
|
|
return {
|
|
kind: 'movement',
|
|
skillId,
|
|
skillName: def.name,
|
|
destination,
|
|
manaCost,
|
|
cooldownTicks,
|
|
}
|
|
}
|
|
|
|
case 'summon': {
|
|
const summonType = skillId === 70 ? 'skeleton' : skillId === 226 ? 'oak_sage' : 'charged_bolt_sentry'
|
|
return {
|
|
kind: 'summon',
|
|
skillId,
|
|
skillName: def.name,
|
|
summonType,
|
|
manaCost,
|
|
cooldownTicks,
|
|
count: skillId === 70 ? Math.min(1 + Math.floor(skillLevel / 3), 20) : 1,
|
|
}
|
|
}
|
|
|
|
case 'aura': {
|
|
const auraBonusPct = 40 + 10 * (skillLevel - 1) // Might
|
|
return {
|
|
kind: 'aura',
|
|
skillId,
|
|
skillName: def.name,
|
|
radius: 200,
|
|
manaCost: 0,
|
|
cooldownTicks: 0,
|
|
auraBonusPct,
|
|
}
|
|
}
|
|
|
|
case 'passive': {
|
|
const passiveBonus: Record<string, number> = {}
|
|
if (skillId === 37) passiveBonus.manaRegenPct = 30 + 12 * (skillLevel - 1) // Warmth
|
|
if (skillId === 224) passiveBonus.shapeshiftLifePct = 20 + 5 * (skillLevel - 1) // Lycanthropy
|
|
if (skillId === 252) passiveBonus.clawDamagePct = 35 + 8 * (skillLevel - 1) // Claw Mastery
|
|
return {
|
|
kind: 'passive',
|
|
skillId,
|
|
skillName: def.name,
|
|
passiveBonus: Object.freeze(passiveBonus),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Paladin Combat Skills (Tree 1 - 10 Skills) Standalone 1.13c Formulas
|
|
// ============================================================================
|
|
|
|
/**
|
|
* 1. Sacrifice (96)
|
|
* - %ED: 180 + (slvl - 1) * 15%
|
|
* - AR: 20 + (slvl - 1) * 7%
|
|
* - Synergies: Redemption (+15%/lvl), Fanaticism (+5%/lvl)
|
|
* - Self-damage: strictly 8% of damage dealt
|
|
*/
|
|
export function calculateSacrificeStats(
|
|
slvl: number,
|
|
synergies?: { redemption?: number; fanaticism?: number },
|
|
): { toHitBonusPct: number; damagePct: number; selfDamagePct: number } {
|
|
const lvl = Math.max(1, slvl)
|
|
const toHitBonusPct = 20 + (lvl - 1) * 7
|
|
const baseEd = 180 + (lvl - 1) * 15
|
|
const redemptionBlvl = synergies?.redemption ?? 0
|
|
const fanaticismBlvl = synergies?.fanaticism ?? 0
|
|
const synBonus = redemptionBlvl * 15 + fanaticismBlvl * 5
|
|
return {
|
|
toHitBonusPct,
|
|
damagePct: baseEd + synBonus,
|
|
selfDamagePct: 8,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 2. Smite (97)
|
|
* - %ED: 15 + (slvl - 1) * 15%
|
|
* - Stun: Math.min(250, 15 + (slvl - 1) * 5) frames
|
|
* - Damage: shield base min/max + Holy Shield flat bonus
|
|
*/
|
|
export function calculateSmiteStats(
|
|
slvl: number,
|
|
shieldMin: number,
|
|
shieldMax: number,
|
|
holyShieldBonus?: { min: number; max: number },
|
|
): { damagePct: number; stunDurationFrames: number; minDamage: number; maxDamage: number } {
|
|
const lvl = Math.max(1, slvl)
|
|
const damagePct = 15 + (lvl - 1) * 15
|
|
const stunDurationFrames = Math.min(250, 15 + (lvl - 1) * 5)
|
|
const hsMin = holyShieldBonus?.min ?? 0
|
|
const hsMax = holyShieldBonus?.max ?? 0
|
|
const minDamage = shieldMin + hsMin
|
|
const maxDamage = shieldMax + hsMax
|
|
return {
|
|
damagePct,
|
|
stunDurationFrames,
|
|
minDamage,
|
|
maxDamage,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 3. Holy Bolt (101) Magic Damage vs Undead
|
|
* 5-band scaling:
|
|
* EMin: 8, EMinLev: 8, 10, 13, 16, 20
|
|
* EMax: 16, EMaxLev: 8, 11, 15, 18, 23
|
|
* Synergies: Blessed Hammer (+50%/blvl), Fist of the Heavens (+50%/blvl)
|
|
*/
|
|
export function calculateHolyBoltDamage(
|
|
slvl: number,
|
|
synergies?: { blessedHammer?: number; fistOfTheHeavens?: number },
|
|
): { min: number; max: number } {
|
|
const lvl = Math.max(1, slvl)
|
|
let min = 8
|
|
let max = 16
|
|
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl, 8) - 1
|
|
min += b1 * 8
|
|
max += b1 * 8
|
|
}
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl, 16) - 8
|
|
min += b2 * 10
|
|
max += b2 * 11
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl, 22) - 16
|
|
min += b3 * 13
|
|
max += b3 * 15
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl, 28) - 22
|
|
min += b4 * 16
|
|
max += b4 * 18
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * 20
|
|
max += b5 * 23
|
|
}
|
|
|
|
const bhBlvl = synergies?.blessedHammer ?? 0
|
|
const fohBlvl = synergies?.fistOfTheHeavens ?? 0
|
|
const synBonusPct = (bhBlvl + fohBlvl) * 50
|
|
const minFinal = Math.trunc((min * (100 + synBonusPct)) / 100)
|
|
const maxFinal = Math.trunc((max * (100 + synBonusPct)) / 100)
|
|
return { min: minFinal, max: maxFinal }
|
|
}
|
|
|
|
/**
|
|
* 4. Holy Bolt (101) Friendly Heal
|
|
* Min heal: 1 + (slvl - 1) * 2
|
|
* Max heal: 6 + (slvl - 1) * 4
|
|
* Synergy: Prayer (+15%/blvl)
|
|
*/
|
|
export function calculateHolyBoltHeal(
|
|
slvl: number,
|
|
prayerBlvl: number = 0,
|
|
): { min: number; max: number } {
|
|
const lvl = Math.max(1, slvl)
|
|
const rawMin = 1 + (lvl - 1) * 2
|
|
const rawMax = 6 + (lvl - 1) * 4
|
|
const prayerBonus = prayerBlvl * 15
|
|
const min = Math.trunc((rawMin * (100 + prayerBonus)) / 100)
|
|
const max = Math.trunc((rawMax * (100 + prayerBonus)) / 100)
|
|
return { min, max }
|
|
}
|
|
|
|
/**
|
|
* 5. Zeal (106)
|
|
* - Hits: min(slvl + 1, 5) per par5=2, par6=5 -> min(2 + slvl - 1, 5) = min(slvl + 1, 5) (lvl 1 -> 2, lvl 4+ -> 5)
|
|
* - AR: 10 + (slvl - 1) * 10%
|
|
* - %ED: slvl >= 5 ? (slvl - 4) * 6% : 0
|
|
* - Synergy: Sacrifice (+12%/blvl)
|
|
*/
|
|
export function calculateZealStats(
|
|
slvl: number,
|
|
synergies?: { sacrifice?: number },
|
|
): { hits: number; toHitBonusPct: number; damagePct: number } {
|
|
const lvl = Math.max(1, slvl)
|
|
const hits = Math.min(5, Math.max(2, 1 + lvl))
|
|
const toHitBonusPct = 10 + (lvl - 1) * 10
|
|
const baseEd = lvl >= 5 ? (lvl - 4) * 6 : 0
|
|
const sacBlvl = synergies?.sacrifice ?? 0
|
|
const damagePct = baseEd + sacBlvl * 12
|
|
return { hits, toHitBonusPct, damagePct }
|
|
}
|
|
|
|
/**
|
|
* 6. Charge (107)
|
|
* - AR: 50 + (slvl - 1) * 15%
|
|
* - %ED: 100 + (slvl - 1) * 25%
|
|
* - Synergies: Vigor (+20%/blvl), Might (+20%/blvl)
|
|
* - Speed multiplier: 3.0 (300% base run velocity)
|
|
*/
|
|
export function calculateChargeStats(
|
|
slvl: number,
|
|
synergies?: { vigor?: number; might?: number },
|
|
): { toHitBonusPct: number; damagePct: number; speedMultiplier: number } {
|
|
const lvl = Math.max(1, slvl)
|
|
const toHitBonusPct = 50 + (lvl - 1) * 15
|
|
const baseEd = 100 + (lvl - 1) * 25
|
|
const vigorBlvl = synergies?.vigor ?? 0
|
|
const mightBlvl = synergies?.might ?? 0
|
|
const synBonus = (vigorBlvl + mightBlvl) * 20
|
|
return {
|
|
toHitBonusPct,
|
|
damagePct: baseEd + synBonus,
|
|
speedMultiplier: 3.0,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 7. Vengeance (111)
|
|
* - AR: 20 + (slvl - 1) * 10%
|
|
* - Base Elemental %: 70 + (slvl - 1) * 6%
|
|
* - Synergies:
|
|
* - Fire %: + Resist Fire (+10%/blvl) + Salvation (+2%/blvl)
|
|
* - Cold %: + Resist Cold (+10%/blvl) + Salvation (+2%/blvl)
|
|
* - Lightning %: + Resist Lightning (+10%/blvl) + Salvation (+2%/blvl)
|
|
* - Cold Chill Duration: 30 + (slvl - 1) * 15 frames (1.2s + 0.6s/lvl)
|
|
*/
|
|
export function calculateVengeanceStats(
|
|
slvl: number,
|
|
synergies?: {
|
|
resistFire?: number
|
|
resistCold?: number
|
|
resistLightning?: number
|
|
salvation?: number
|
|
},
|
|
): {
|
|
toHitBonusPct: number
|
|
firePct: number
|
|
coldPct: number
|
|
ltngPct: number
|
|
chillDurationFrames: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const toHitBonusPct = 20 + (lvl - 1) * 10
|
|
const baseElemPct = 70 + (lvl - 1) * 6
|
|
const salvBonus = (synergies?.salvation ?? 0) * 2
|
|
const firePct = baseElemPct + (synergies?.resistFire ?? 0) * 10 + salvBonus
|
|
const coldPct = baseElemPct + (synergies?.resistCold ?? 0) * 10 + salvBonus
|
|
const ltngPct = baseElemPct + (synergies?.resistLightning ?? 0) * 10 + salvBonus
|
|
const chillDurationFrames = 30 + (lvl - 1) * 15
|
|
return {
|
|
toHitBonusPct,
|
|
firePct,
|
|
coldPct,
|
|
ltngPct,
|
|
chillDurationFrames,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 8. Blessed Hammer (112) Magic Damage
|
|
* 5-band scaling:
|
|
* EMin: 12, EMinLev: 8, 12, 16, 20, 24
|
|
* EMax: 16, EMaxLev: 8, 12, 16, 20, 24
|
|
* Synergies: Blessed Aim (+14%/blvl), Vigor (+14%/blvl)
|
|
* Concentration: receives 50% of active Concentration %ED
|
|
*/
|
|
export function calculateBlessedHammerDamage(
|
|
slvl: number,
|
|
synergies?: { blessedAim?: number; vigor?: number },
|
|
concentrationEd: number = 0,
|
|
): { min: number; max: number; concentrationBonusPct: number } {
|
|
const lvl = Math.max(1, slvl)
|
|
let min = 12
|
|
let max = 16
|
|
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl, 8) - 1
|
|
min += b1 * 8
|
|
max += b1 * 8
|
|
}
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl, 16) - 8
|
|
min += b2 * 12
|
|
max += b2 * 12
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl, 22) - 16
|
|
min += b3 * 16
|
|
max += b3 * 16
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl, 28) - 22
|
|
min += b4 * 20
|
|
max += b4 * 20
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * 24
|
|
max += b5 * 24
|
|
}
|
|
|
|
const aimBlvl = synergies?.blessedAim ?? 0
|
|
const vigorBlvl = synergies?.vigor ?? 0
|
|
const synBonusPct = (aimBlvl + vigorBlvl) * 14
|
|
const concBonusPct = Math.trunc(concentrationEd / 2)
|
|
const totalMultPct = 100 + synBonusPct
|
|
|
|
let minFinal = Math.trunc((min * totalMultPct) / 100)
|
|
let maxFinal = Math.trunc((max * totalMultPct) / 100)
|
|
|
|
if (concBonusPct > 0) {
|
|
minFinal = Math.trunc((minFinal * (100 + concBonusPct)) / 100)
|
|
maxFinal = Math.trunc((maxFinal * (100 + concBonusPct)) / 100)
|
|
}
|
|
|
|
return { min: minFinal, max: maxFinal, concentrationBonusPct: concBonusPct }
|
|
}
|
|
|
|
/**
|
|
* 9. Conversion (116)
|
|
* - Chance %: dm34 -> Math.floor((slvl * 50) / (slvl + 10))
|
|
* - Duration: 400 frames (16.0s)
|
|
*/
|
|
export function calculateConversionStats(slvl: number): {
|
|
chancePct: number
|
|
durationFrames: number
|
|
durationSeconds: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const chancePct = Math.floor((lvl * 50) / (lvl + 10))
|
|
return {
|
|
chancePct,
|
|
durationFrames: 400,
|
|
durationSeconds: 16,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 10. Holy Shield (117)
|
|
* - Duration: 750 + (slvl - 1) * 625 frames (30s + 25s/lvl)
|
|
* - Defense %: 25 + (slvl - 1) * 15% + Defiance (+15%/blvl)
|
|
* - Blocking %: 10 + Math.floor((slvl * 40) / (slvl + 10))% (dm56)
|
|
* - Flat Smite Damage: 3 + (slvl - 1) * 4 min / 6 + (slvl - 1) * 4 max (per 1.13c compute5BandScaling on Holy Shield min/maxDam)
|
|
*/
|
|
export function calculateHolyShieldStats(
|
|
slvl: number,
|
|
defianceBlvl: number = 0,
|
|
): {
|
|
durationFrames: number
|
|
defensePct: number
|
|
blockChancePct: number
|
|
smiteMinFlat: number
|
|
smiteMaxFlat: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const durationFrames = 750 + (lvl - 1) * 625
|
|
const defensePct = 25 + (lvl - 1) * 15 + defianceBlvl * 15
|
|
const blockChancePct = computeDiminishingReturns(10, 40, lvl)
|
|
|
|
// Holy Shield flat smite min/max damage progression (Skills.txt: minDam=3, maxDam=6, levDam1=2..3 etc.)
|
|
// Let's use exact 5-band scaling: minDam=3, lev=2..3, maxDam=6, lev=2..3
|
|
// From 1.13c Skills.txt: minDam=3, minLevDam1=2, minLevDam2=3, minLevDam3=3, minLevDam4=4, minLevDam5=4
|
|
// maxDam=6, maxLevDam1=2, maxLevDam2=3, maxLevDam3=3, maxLevDam4=4, maxLevDam5=4
|
|
let smiteMinFlat = 3
|
|
let smiteMaxFlat = 6
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl, 8) - 1
|
|
smiteMinFlat += b1 * 2
|
|
smiteMaxFlat += b1 * 2
|
|
}
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl, 16) - 8
|
|
smiteMinFlat += b2 * 3
|
|
smiteMaxFlat += b2 * 3
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl, 22) - 16
|
|
smiteMinFlat += b3 * 4
|
|
smiteMaxFlat += b3 * 4
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl, 28) - 22
|
|
smiteMinFlat += b4 * 4
|
|
smiteMaxFlat += b4 * 4
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
smiteMinFlat += b5 * 4
|
|
smiteMaxFlat += b5 * 4
|
|
}
|
|
|
|
return {
|
|
durationFrames,
|
|
defensePct,
|
|
blockChancePct,
|
|
smiteMinFlat,
|
|
smiteMaxFlat,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 11. Fist of the Heavens (121) Celestial Lightning Damage
|
|
* 5-band scaling:
|
|
* EMin: 150, EMinLev: 15, 30, 45, 55, 65
|
|
* EMax: 200, EMaxLev: 15, 30, 45, 55, 65
|
|
* Synergy: Holy Shock (+7%/blvl)
|
|
*/
|
|
export function calculateFistOfTheHeavensDamage(
|
|
slvl: number,
|
|
synergies?: { holyShock?: number },
|
|
): { lightningMin: number; lightningMax: number } {
|
|
const lvl = Math.max(1, slvl)
|
|
let min = 150
|
|
let max = 200
|
|
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl, 8) - 1
|
|
min += b1 * 15
|
|
max += b1 * 15
|
|
}
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl, 16) - 8
|
|
min += b2 * 30
|
|
max += b2 * 30
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl, 22) - 16
|
|
min += b3 * 45
|
|
max += b3 * 45
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl, 28) - 22
|
|
min += b4 * 55
|
|
max += b4 * 55
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * 65
|
|
max += b5 * 65
|
|
}
|
|
|
|
const holyShockBlvl = synergies?.holyShock ?? 0
|
|
const synBonusPct = holyShockBlvl * 7
|
|
const lightningMin = Math.trunc((min * (100 + synBonusPct)) / 100)
|
|
const lightningMax = Math.trunc((max * (100 + synBonusPct)) / 100)
|
|
return { lightningMin, lightningMax }
|
|
}
|
|
|
|
/**
|
|
* 12. Fist of the Heavens (121) Radial Holy Bolt Damage
|
|
* Base Holy Bolt from FoH:
|
|
* EMin: 8, EMinLev: 8, 10, 13, 16, 20
|
|
* EMax: 16, EMaxLev: 8, 11, 15, 18, 23
|
|
* Synergy: Holy Bolt (+15%/blvl)
|
|
*/
|
|
export function calculateFistOfTheHeavensHolyBoltDamage(
|
|
slvl: number,
|
|
holyBoltBlvl: number = 0,
|
|
): { holyBoltMin: number; holyBoltMax: number } {
|
|
const lvl = Math.max(1, slvl)
|
|
let min = 8
|
|
let max = 16
|
|
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl, 8) - 1
|
|
min += b1 * 8
|
|
max += b1 * 8
|
|
}
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl, 16) - 8
|
|
min += b2 * 10
|
|
max += b2 * 11
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl, 22) - 16
|
|
min += b3 * 13
|
|
max += b3 * 15
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl, 28) - 22
|
|
min += b4 * 16
|
|
max += b4 * 18
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * 20
|
|
max += b5 * 23
|
|
}
|
|
|
|
const synBonusPct = holyBoltBlvl * 15
|
|
const holyBoltMin = Math.trunc((min * (100 + synBonusPct)) / 100)
|
|
const holyBoltMax = Math.trunc((max * (100 + synBonusPct)) / 100)
|
|
return { holyBoltMin, holyBoltMax }
|
|
}
|
|
|
|
/** Helper 5-band scaling matching D2 1.13c Skills.txt (levels 1, 2..8, 9..16, 17..22, 23..28, 29+) */
|
|
function computeBand5(
|
|
lvl: number,
|
|
base: number,
|
|
lev1: number,
|
|
lev2: number,
|
|
lev3: number,
|
|
lev4: number,
|
|
lev5: number,
|
|
): number {
|
|
if (lvl <= 0) return 0
|
|
let total = base
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl, 8) - 1
|
|
total += b1 * lev1
|
|
}
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl, 16) - 8
|
|
total += b2 * lev2
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl, 22) - 16
|
|
total += b3 * lev3
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl, 28) - 22
|
|
total += b4 * lev4
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
total += b5 * lev5
|
|
}
|
|
return total
|
|
}
|
|
|
|
/**
|
|
* Compute `D2Common` diminishing-returns formula `dm(a, b, lvl)`:
|
|
* `floor((110 * lvl * (b - a)) / (100 * (lvl + 6))) + a`
|
|
*/
|
|
function computeDiminishingReturns(a: number, b: number, lvl: number): number {
|
|
if (lvl <= 0) return a
|
|
const num = 110 * lvl * (b - a)
|
|
const den = 100 * (lvl + 6)
|
|
return Math.trunc(num / den) + a
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// PALADIN OFFENSIVE AURAS (10 SKILLS) — 1.13c Ground Truth Calculators
|
|
// ============================================================================
|
|
|
|
/**
|
|
* 1. Might (Skill 98)
|
|
* - Party physical %ED: 40% + (slvl - 1) * 10%
|
|
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2) -> (slvl * 2 + 8) * 2 / 3 yards
|
|
*/
|
|
export function calculateMightStats(slvl: number): {
|
|
damagePercent: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const damagePercent = 40 + (lvl - 1) * 10
|
|
const radiusSubtiles = 16 + (lvl - 1) * 2
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return { damagePercent, radiusSubtiles, radiusYards }
|
|
}
|
|
|
|
/**
|
|
* 2. Holy Fire (Skill 102)
|
|
* - Radial fire pulse (every 50 ticks)
|
|
* - Weapon added fire damage: 6 * pulseMin .. 6 * pulseMax
|
|
* - Synergies: Resist Fire (+18%/blvl), Salvation (+6%/blvl)
|
|
* - 5-band scaling with hitShift = 7: EMin=2, EMax=6, Lev1..5=[1, 2, 3, 5, 7]
|
|
*/
|
|
export function calculateHolyFireStats(
|
|
slvl: number,
|
|
synergies?: { resistFire?: number; salvation?: number },
|
|
): {
|
|
pulseMin: number
|
|
pulseMax: number
|
|
pulseMin256: number
|
|
pulseMax256: number
|
|
weaponFireMin: number
|
|
weaponFireMax: number
|
|
synergyBonusPct: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const baseMin = computeBand5(lvl, 2, 1, 2, 3, 5, 7)
|
|
const baseMax = computeBand5(lvl, 6, 1, 2, 3, 5, 7)
|
|
const rfBlvl = synergies?.resistFire ?? 0
|
|
const salvBlvl = synergies?.salvation ?? 0
|
|
const synergyBonusPct = rfBlvl * 18 + salvBlvl * 6
|
|
|
|
// hitShift = 7 -> 256 fixed point is (base << 7)
|
|
const rawMin256 = baseMin << 7
|
|
const rawMax256 = baseMax << 7
|
|
const pulseMin256 = Math.trunc((rawMin256 * (100 + synergyBonusPct)) / 100)
|
|
const pulseMax256 = Math.trunc((rawMax256 * (100 + synergyBonusPct)) / 100)
|
|
const pulseMin = pulseMin256 >> 8
|
|
const pulseMax = pulseMax256 >> 8
|
|
|
|
// Param5 = 6 (weapon damage multiplier): enms * 6 / 256
|
|
const weaponFireMin = Math.trunc((pulseMin256 * 6) / 256)
|
|
const weaponFireMax = Math.trunc((pulseMax256 * 6) / 256)
|
|
|
|
const radiusSubtiles = 6 + (lvl - 1) * 1
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
|
|
return {
|
|
pulseMin,
|
|
pulseMax,
|
|
pulseMin256,
|
|
pulseMax256,
|
|
weaponFireMin,
|
|
weaponFireMax,
|
|
synergyBonusPct,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 3. Thorns (Skill 103)
|
|
* - Physical melee reflection: 250% + (slvl - 1) * 40%
|
|
* - Radius: 16 + (slvl - 1) * 2 subtiles
|
|
*/
|
|
export function calculateThornsStats(slvl: number): {
|
|
reflectPct: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const reflectPct = 250 + (lvl - 1) * 40
|
|
const radiusSubtiles = 16 + (lvl - 1) * 2
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return { reflectPct, radiusSubtiles, radiusYards }
|
|
}
|
|
|
|
/**
|
|
* 4. Blessed Aim (Skill 108)
|
|
* - Active party %AR: 75% + (slvl - 1) * 15%
|
|
* - Passive %AR: +5% per hard point (blvl * 5%) active even when inactive
|
|
* - Radius: 16 + (slvl - 1) * 2 subtiles
|
|
*/
|
|
export function calculateBlessedAimStats(
|
|
slvl: number,
|
|
blvl?: number,
|
|
): {
|
|
attackRatingPct: number
|
|
passiveArBonusPct: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const attackRatingPct = 75 + (lvl - 1) * 15
|
|
const hardPoints = blvl ?? lvl
|
|
const passiveArBonusPct = hardPoints * 5
|
|
const radiusSubtiles = 16 + (lvl - 1) * 2
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return { attackRatingPct, passiveArBonusPct, radiusSubtiles, radiusYards }
|
|
}
|
|
|
|
/**
|
|
* 5. Concentration (Skill 113)
|
|
* - Party physical %ED: 60% + (slvl - 1) * 15%
|
|
* - Uninterrupted attack chance: 20% (Param1 = 20)
|
|
* - Blessed Hammer synergy: +50% of active Concentration %ED (Math.trunc(damagePercent / 2))
|
|
* - Radius: 16 + (slvl - 1) * 2 subtiles
|
|
*/
|
|
export function calculateConcentrationStats(slvl: number): {
|
|
damagePercent: number
|
|
uninterruptedChancePct: number
|
|
blessedHammerBonusPct: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const damagePercent = 60 + (lvl - 1) * 15
|
|
const uninterruptedChancePct = 20
|
|
const blessedHammerBonusPct = Math.trunc(damagePercent / 2)
|
|
const radiusSubtiles = 16 + (lvl - 1) * 2
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return {
|
|
damagePercent,
|
|
uninterruptedChancePct,
|
|
blessedHammerBonusPct,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 6. Holy Freeze (Skill 114)
|
|
* - Continuous enemy slow: 25%..54% (dm34 / dm56), bypasses Cannot Be Frozen (CBF)
|
|
* - Radial cold pulse (every 50 ticks)
|
|
* - Weapon added cold damage: 5 * pulseMin .. 5 * pulseMax
|
|
* - Added chill length: 30 + (slvl - 1) * 10 frames
|
|
* - Synergies: Resist Cold (+15%/blvl), Salvation (+7%/blvl)
|
|
* - 5-band scaling with hitShift = 8: EMin=2, EMax=3, Lev1..5=[1, 2, 3, 4, 5]
|
|
*/
|
|
export function calculateHolyFreezeStats(
|
|
slvl: number,
|
|
synergies?: { resistCold?: number; salvation?: number },
|
|
): {
|
|
slowPct: number
|
|
pulseMin: number
|
|
pulseMax: number
|
|
pulseMin256: number
|
|
pulseMax256: number
|
|
weaponColdMin: number
|
|
weaponColdMax: number
|
|
chillDurationFrames: number
|
|
chillDurationSeconds: number
|
|
synergyBonusPct: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const slowPct = Math.min(54, 25 + Math.floor((lvl * 35) / (lvl + 6)))
|
|
const baseMin = computeBand5(lvl, 2, 1, 2, 3, 4, 5)
|
|
const baseMax = computeBand5(lvl, 3, 1, 2, 3, 4, 5)
|
|
const rcBlvl = synergies?.resistCold ?? 0
|
|
const salvBlvl = synergies?.salvation ?? 0
|
|
const synergyBonusPct = rcBlvl * 15 + salvBlvl * 7
|
|
|
|
// hitShift = 8
|
|
const rawMin256 = baseMin << 8
|
|
const rawMax256 = baseMax << 8
|
|
const pulseMin256 = Math.trunc((rawMin256 * (100 + synergyBonusPct)) / 100)
|
|
const pulseMax256 = Math.trunc((rawMax256 * (100 + synergyBonusPct)) / 100)
|
|
const pulseMin = pulseMin256 >> 8
|
|
const pulseMax = pulseMax256 >> 8
|
|
|
|
// Param5 = 5 (weapon damage multiplier)
|
|
const weaponColdMin = Math.trunc((pulseMin256 * 5) / 256)
|
|
const weaponColdMax = Math.trunc((pulseMax256 * 5) / 256)
|
|
|
|
const chillDurationFrames = 30 + (lvl - 1) * 10
|
|
const chillDurationSeconds = Number((chillDurationFrames / 25).toFixed(2))
|
|
|
|
const radiusSubtiles = 6 + (lvl - 1) * 1
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
|
|
return {
|
|
slowPct,
|
|
pulseMin,
|
|
pulseMax,
|
|
pulseMin256,
|
|
pulseMax256,
|
|
weaponColdMin,
|
|
weaponColdMax,
|
|
chillDurationFrames,
|
|
chillDurationSeconds,
|
|
synergyBonusPct,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 7. Holy Shock (Skill 118)
|
|
* - Radial lightning pulse (every 50 ticks): min strictly 1, max scales Lev1..5=[6, 8, 10, 12, 15]
|
|
* - Weapon added lightning damage: min 1, max 6 * pulseMax
|
|
* - Synergies: Resist Lightning (+12%/blvl), Salvation (+4%/blvl)
|
|
* - hitShift = 8
|
|
*/
|
|
export function calculateHolyShockStats(
|
|
slvl: number,
|
|
synergies?: { resistLightning?: number; salvation?: number },
|
|
): {
|
|
pulseMin: number
|
|
pulseMax: number
|
|
pulseMin256: number
|
|
pulseMax256: number
|
|
weaponLightningMin: number
|
|
weaponLightningMax: number
|
|
synergyBonusPct: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const baseMin = 1
|
|
const baseMax = computeBand5(lvl, 10, 6, 8, 10, 12, 15)
|
|
const rlBlvl = synergies?.resistLightning ?? 0
|
|
const salvBlvl = synergies?.salvation ?? 0
|
|
const synergyBonusPct = rlBlvl * 12 + salvBlvl * 4
|
|
|
|
// hitShift = 8
|
|
const rawMin256 = baseMin << 8
|
|
const rawMax256 = baseMax << 8
|
|
const pulseMin256 = Math.trunc((rawMin256 * (100 + synergyBonusPct)) / 100)
|
|
const pulseMax256 = Math.trunc((rawMax256 * (100 + synergyBonusPct)) / 100)
|
|
const pulseMin = pulseMin256 >> 8
|
|
const pulseMax = pulseMax256 >> 8
|
|
|
|
// Param5 = 6 (weapon damage multiplier)
|
|
const weaponLightningMin = 1
|
|
const weaponLightningMax = Math.trunc((pulseMax256 * 6) / 256)
|
|
|
|
const radiusSubtiles = 6 + (lvl - 1) * 1
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
|
|
return {
|
|
pulseMin,
|
|
pulseMax,
|
|
pulseMin256,
|
|
pulseMax256,
|
|
weaponLightningMin,
|
|
weaponLightningMax,
|
|
synergyBonusPct,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 8. Sanctuary (Skill 119)
|
|
* - Radial magic pulse + knockback every 50 ticks strictly vs Undead
|
|
* - Direct weapon attacks ignore Undead physical resistance (damageresist = 0%, piercing immunity)
|
|
* - Direct weapon %ED vs Undead: 150% + (slvl - 1) * 30%
|
|
* - Direct weapon AR vs Undead: 100 + (slvl - 1) * 50
|
|
* - Synergy: Cleansing (+7%/blvl magic damage)
|
|
* - hitShift = 8: EMin=8 Lev1..5=[4, 4, 5, 5, 6], EMax=16 Lev1..5=[4, 5, 6, 6, 7]
|
|
*/
|
|
export function calculateSanctuaryStats(
|
|
slvl: number,
|
|
synergies?: { cleansing?: number },
|
|
): {
|
|
pulseMin: number
|
|
pulseMax: number
|
|
pulseMin256: number
|
|
pulseMax256: number
|
|
undeadDamagePercent: number
|
|
undeadAttackRating: number
|
|
synergyBonusPct: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const baseMin = computeBand5(lvl, 8, 4, 4, 5, 5, 6)
|
|
const baseMax = computeBand5(lvl, 16, 4, 5, 6, 6, 7)
|
|
const cleansingBlvl = synergies?.cleansing ?? 0
|
|
const synergyBonusPct = cleansingBlvl * 7
|
|
|
|
// hitShift = 8
|
|
const rawMin256 = baseMin << 8
|
|
const rawMax256 = baseMax << 8
|
|
const pulseMin256 = Math.trunc((rawMin256 * (100 + synergyBonusPct)) / 100)
|
|
const pulseMax256 = Math.trunc((rawMax256 * (100 + synergyBonusPct)) / 100)
|
|
const pulseMin = pulseMin256 >> 8
|
|
const pulseMax = pulseMax256 >> 8
|
|
|
|
const undeadDamagePercent = 150 + (lvl - 1) * 30
|
|
const undeadAttackRating = 100 + (lvl - 1) * 50
|
|
|
|
const radiusSubtiles = 5 + (lvl - 1) * 1
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
|
|
return {
|
|
pulseMin,
|
|
pulseMax,
|
|
pulseMin256,
|
|
pulseMax256,
|
|
undeadDamagePercent,
|
|
undeadAttackRating,
|
|
synergyBonusPct,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 9. Fanaticism (Skill 122)
|
|
* - Caster IAS: 14 + Math.floor((slvl * 25) / (slvl + 7)) % (17%..32%)
|
|
* - Attack Rating: 40% + (slvl - 1) * 5%
|
|
* - Caster physical %ED: 50% + (slvl - 1) * 17%
|
|
* - Party physical %ED: strictly HALF of caster %ED (Math.trunc(casterDamagePercent / 2))
|
|
* - Radius: 11 + (slvl - 1) * 1 subtiles
|
|
*/
|
|
export function calculateFanaticismStats(slvl: number): {
|
|
casterIas: number
|
|
attackRatingPct: number
|
|
casterDamagePercent: number
|
|
partyDamagePercent: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const casterIas = 14 + Math.floor((lvl * 25) / (lvl + 7))
|
|
const attackRatingPct = 40 + (lvl - 1) * 5
|
|
const casterDamagePercent = 50 + (lvl - 1) * 17
|
|
const partyDamagePercent = Math.trunc(casterDamagePercent / 2)
|
|
const radiusSubtiles = 11 + (lvl - 1) * 1
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return {
|
|
casterIas,
|
|
attackRatingPct,
|
|
casterDamagePercent,
|
|
partyDamagePercent,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 10. Conviction (Skill 123)
|
|
* - Defense reduction %: -(30 + (slvl - 1) * 5)%, capped at -90%
|
|
* - Elemental resistance reduction (Fire, Cold, Lightning): -(30 + (slvl - 1) * 5)%, capped at -150%
|
|
* - 1.13c Immunity breaking: applies at 1/5th effectiveness (-6%..-30%) against elemental immunities
|
|
* - Does NOT affect Poison or Magic resistance!
|
|
* - Radius: fixed 20 subtiles
|
|
*/
|
|
export function calculateConvictionStats(slvl: number): {
|
|
defenseReductionPct: number
|
|
resistanceReductionPct: number
|
|
immunityBreakingReductionPct: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const defenseReductionPct = Math.min(90, 30 + (lvl - 1) * 5)
|
|
const resistanceReductionPct = Math.min(150, 30 + (lvl - 1) * 5)
|
|
const immunityBreakingReductionPct = Math.trunc(resistanceReductionPct / 5)
|
|
const radiusSubtiles = 20
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return {
|
|
defenseReductionPct,
|
|
resistanceReductionPct,
|
|
immunityBreakingReductionPct,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// PALADIN DEFENSIVE AURAS (10 SKILLS) — 1.13c Ground Truth Calculators
|
|
// ============================================================================
|
|
|
|
/**
|
|
* 1. Prayer (Skill 99)
|
|
* - Periodic heal pulse (every 50 ticks): edns = computeBand5(slvl, 2, 1, 1, 2, 2, 3) HP
|
|
* (slvl 1: 2 HP, slvl 10: 11 HP, slvl 20: 25 HP)
|
|
* - Mana cost: 16 units of mana256/frame (1/16 mana/frame = 1.5625 mana/second at 25 fps)
|
|
* - Auto-deactivates when mana is depleted (< 16 mana256)
|
|
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
|
|
*/
|
|
export function calculatePrayerStats(slvl: number): {
|
|
healAmount: number
|
|
healAmount256: number
|
|
manaCostPerFrame256: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const healAmount = computeBand5(lvl, 2, 1, 1, 2, 2, 3)
|
|
const healAmount256 = healAmount << 8
|
|
const manaCostPerFrame256 = 16
|
|
const radiusSubtiles = 16 + (lvl - 1) * 2
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return {
|
|
healAmount,
|
|
healAmount256,
|
|
manaCostPerFrame256,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 2. Resist Fire (Skill 100)
|
|
* - Active party fire res: dm34 = floor((110 * slvl * (150 - 35)) / (100 * (slvl + 6))) + 35
|
|
* (+53% at slvl 1, +114% at slvl 10, +132% at slvl 20)
|
|
* - Passive max fire res: +1% per 2 base skill points (hard points) = Math.floor((blvl ?? slvl) / 2)
|
|
* (Active permanently even when aura is off, capped at 95%)
|
|
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
|
|
*/
|
|
export function calculateResistFireStats(
|
|
slvl: number,
|
|
blvl?: number,
|
|
): {
|
|
fireResistPct: number
|
|
maxFireResistBonusPct: number
|
|
passiveMaxFireResistPct: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const fireResistPct = computeDiminishingReturns(35, 150, lvl)
|
|
const basePoints = blvl !== undefined ? Math.max(0, blvl) : lvl
|
|
const passiveMaxFireResistPct = Math.floor(basePoints / 2)
|
|
const maxFireResistBonusPct = passiveMaxFireResistPct
|
|
const radiusSubtiles = 16 + (lvl - 1) * 2
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return {
|
|
fireResistPct,
|
|
maxFireResistBonusPct,
|
|
passiveMaxFireResistPct,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 3. Defiance (Skill 104)
|
|
* - Active party %Defense: ln34 = 70 + (slvl - 1) * 10 (+70% at slvl 1, +160% at slvl 10, +260% at slvl 20)
|
|
* - Synergizes Holy Shield (+15% defense per hard point)
|
|
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
|
|
*/
|
|
export function calculateDefianceStats(slvl: number): {
|
|
defensePercent: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const defensePercent = 70 + (lvl - 1) * 10
|
|
const radiusSubtiles = 16 + (lvl - 1) * 2
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return {
|
|
defensePercent,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 4. Resist Cold (Skill 105)
|
|
* - Active party cold res: dm34 = floor((110 * slvl * (150 - 35)) / (100 * (slvl + 6))) + 35
|
|
* (+53% at slvl 1, +114% at slvl 10, +132% at slvl 20)
|
|
* - Passive max cold res: +1% per 2 base skill points (hard points) = Math.floor((blvl ?? slvl) / 2)
|
|
* (Active permanently even when aura is off, capped at 95%)
|
|
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
|
|
*/
|
|
export function calculateResistColdStats(
|
|
slvl: number,
|
|
blvl?: number,
|
|
): {
|
|
coldResistPct: number
|
|
maxColdResistBonusPct: number
|
|
passiveMaxColdResistPct: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const coldResistPct = computeDiminishingReturns(35, 150, lvl)
|
|
const basePoints = blvl !== undefined ? Math.max(0, blvl) : lvl
|
|
const passiveMaxColdResistPct = Math.floor(basePoints / 2)
|
|
const maxColdResistBonusPct = passiveMaxColdResistPct
|
|
const radiusSubtiles = 16 + (lvl - 1) * 2
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return {
|
|
coldResistPct,
|
|
maxColdResistBonusPct,
|
|
passiveMaxColdResistPct,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 5. Cleansing (Skill 109)
|
|
* - Curse and poison duration reduction: dm34 (from Skills.txt item_poisonlengthresist = 100 - dm34, dm34 is 30..90)
|
|
* dm(30, 90, slvl): 39% at slvl 1, 71% at slvl 10, 80% at slvl 20
|
|
* - Synergy: Free Prayer heal pulse (at Prayer slvl efficiency, 0 mana cost)
|
|
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
|
|
*/
|
|
export function calculateCleansingStats(
|
|
slvl: number,
|
|
prayerBlvl?: number,
|
|
): {
|
|
curseReductionPct: number
|
|
poisonLengthReductionPct: number
|
|
prayerHealAmount: number
|
|
prayerHealAmount256: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const curseReductionPct = computeDiminishingReturns(30, 90, lvl)
|
|
const poisonLengthReductionPct = curseReductionPct
|
|
const pBlvl = prayerBlvl !== undefined ? Math.max(0, prayerBlvl) : 0
|
|
const prayerHealAmount = pBlvl > 0 ? computeBand5(pBlvl, 2, 1, 1, 2, 2, 3) : 0
|
|
const prayerHealAmount256 = prayerHealAmount << 8
|
|
const radiusSubtiles = 16 + (lvl - 1) * 2
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return {
|
|
curseReductionPct,
|
|
poisonLengthReductionPct,
|
|
prayerHealAmount,
|
|
prayerHealAmount256,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 6. Resist Lightning (Skill 110)
|
|
* - Active party lightning res: dm34 = floor((110 * slvl * (150 - 35)) / (100 * (slvl + 6))) + 35
|
|
* (+53% at slvl 1, +114% at slvl 10, +132% at slvl 20)
|
|
* - Passive max lightning res: +1% per 2 base skill points (hard points) = Math.floor((blvl ?? slvl) / 2)
|
|
* (Active permanently even when aura is off, capped at 95%)
|
|
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
|
|
*/
|
|
export function calculateResistLightningStats(
|
|
slvl: number,
|
|
blvl?: number,
|
|
): {
|
|
lightningResistPct: number
|
|
maxLightningResistBonusPct: number
|
|
passiveMaxLightningResistPct: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const lightningResistPct = computeDiminishingReturns(35, 150, lvl)
|
|
const basePoints = blvl !== undefined ? Math.max(0, blvl) : lvl
|
|
const passiveMaxLightningResistPct = Math.floor(basePoints / 2)
|
|
const maxLightningResistBonusPct = passiveMaxLightningResistPct
|
|
const radiusSubtiles = 16 + (lvl - 1) * 2
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return {
|
|
lightningResistPct,
|
|
maxLightningResistBonusPct,
|
|
passiveMaxLightningResistPct,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 7. Vigor (Skill 115)
|
|
* - Faster Run/Walk (velocitypercent): dm56 = floor((110 * slvl * (50 - 7)) / (100 * (slvl + 6))) + 7
|
|
* (+13% at slvl 1, +36% at slvl 10, +43% at slvl 20)
|
|
* - Stamina recovery bonus (staminarecoverybonus): ln34 = 50 + (slvl - 1) * 25
|
|
* (+50% at slvl 1, +275% at slvl 10, +525% at slvl 20)
|
|
* - Max stamina % (skill_staminapercent): ln34 = 50 + (slvl - 1) * 25
|
|
* - Radius: ln12 subtiles (16 + (slvl - 1) * 3)
|
|
*/
|
|
export function calculateVigorStats(slvl: number): {
|
|
velocityPercent: number
|
|
staminaRecoveryBonusPct: number
|
|
maxStaminaPercent: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const velocityPercent = computeDiminishingReturns(7, 50, lvl)
|
|
const staminaRecoveryBonusPct = 50 + (lvl - 1) * 25
|
|
const maxStaminaPercent = 50 + (lvl - 1) * 25
|
|
const radiusSubtiles = 16 + (lvl - 1) * 3
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return {
|
|
velocityPercent,
|
|
staminaRecoveryBonusPct,
|
|
maxStaminaPercent,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 8. Meditation (Skill 120)
|
|
* - Active party mana recovery rate: ln34 = 300 + (slvl - 1) * 25
|
|
* (+300% at slvl 1, +525% at slvl 10, +775% at slvl 20)
|
|
* - Synergy: Free Prayer heal pulse (at Prayer slvl efficiency, 0 mana cost)
|
|
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
|
|
*/
|
|
export function calculateMeditationStats(
|
|
slvl: number,
|
|
prayerBlvl?: number,
|
|
): {
|
|
manaRecoveryBonusPct: number
|
|
prayerHealAmount: number
|
|
prayerHealAmount256: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const manaRecoveryBonusPct = 300 + (lvl - 1) * 25
|
|
const pBlvl = prayerBlvl !== undefined ? Math.max(0, prayerBlvl) : 0
|
|
const prayerHealAmount = pBlvl > 0 ? computeBand5(pBlvl, 2, 1, 1, 2, 2, 3) : 0
|
|
const prayerHealAmount256 = prayerHealAmount << 8
|
|
const radiusSubtiles = 16 + (lvl - 1) * 2
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return {
|
|
manaRecoveryBonusPct,
|
|
prayerHealAmount,
|
|
prayerHealAmount256,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 9. Redemption (Skill 124)
|
|
* - 50-tick cadence, fixed 16 subtiles radius in 2:1 isometric ground space
|
|
* - Redemption chance: dm34 = floor((110 * slvl * (100 - 10)) / (100 * (slvl + 6))) + 10
|
|
* (24% at slvl 1, 71% at slvl 10, 86% at slvl 20)
|
|
* - Life and Mana replenishment: ln56 = 25 + (slvl - 1) * 5 (25 at slvl 1, 70 at slvl 10, 120 at slvl 20)
|
|
* - Consumes valid monster corpses, marks corpse.consumed = true immediately, caster-only restore
|
|
*/
|
|
export function calculateRedemptionStats(slvl: number): {
|
|
redemptionChancePct: number
|
|
lifeRecoverPts: number
|
|
manaRecoverPts: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const redemptionChancePct = computeDiminishingReturns(10, 100, lvl)
|
|
const lifeRecoverPts = 25 + (lvl - 1) * 5
|
|
const manaRecoverPts = lifeRecoverPts
|
|
const radiusSubtiles = 16
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return {
|
|
redemptionChancePct,
|
|
lifeRecoverPts,
|
|
manaRecoverPts,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 10. Salvation (Skill 125)
|
|
* - Active party All Resistances (Fire, Cold, Lightning): dm34 = floor((110 * slvl * (120 - 50)) / (100 * (slvl + 6))) + 50
|
|
* (61% at slvl 1, 98% at slvl 10, 109% at slvl 20)
|
|
* - Strictly zero effect on Poison or Magic resistance!
|
|
* - Synergizes Holy Fire (+6%), Holy Freeze (+7%), Holy Shock (+4%)
|
|
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
|
|
*/
|
|
export function calculateSalvationStats(slvl: number): {
|
|
fireResistPct: number
|
|
coldResistPct: number
|
|
lightningResistPct: number
|
|
radiusSubtiles: number
|
|
radiusYards: number
|
|
} {
|
|
const lvl = Math.max(1, slvl)
|
|
const resPct = computeDiminishingReturns(50, 120, lvl)
|
|
const radiusSubtiles = 16 + (lvl - 1) * 2
|
|
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
|
|
return {
|
|
fireResistPct: resPct,
|
|
coldResistPct: resPct,
|
|
lightningResistPct: resPct,
|
|
radiusSubtiles,
|
|
radiusYards,
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|