1790 lines
59 KiB
TypeScript
1790 lines
59 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'
|
|
|
|
/** 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
|
|
}
|
|
|
|
/** 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
|
|
/** Target restriction: only hits undead monsters. */
|
|
readonly isUndeadOnly?: boolean | 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
|
|
}
|
|
|
|
/** Canonical 1.13c Ground Truth Missiles.txt defaults. */
|
|
export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> = Object.freeze({
|
|
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,
|
|
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: 'fireexplode',
|
|
light: 7,
|
|
red: 255,
|
|
green: 178,
|
|
blue: 64,
|
|
speedPxPerSec: 500,
|
|
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,
|
|
distancePx: 0,
|
|
},
|
|
explodingarrowexp: {
|
|
name: 'explodingarrowexp',
|
|
id: 47,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
distancePx: 750,
|
|
},
|
|
})
|
|
|
|
/**
|
|
* 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')
|
|
|
|
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 = Number(cells[redIdx] ?? 255) || 255
|
|
const green = Number(cells[greenIdx] ?? 255) || 255
|
|
const blue = Number(cells[blueIdx] ?? 255) || 255
|
|
|
|
const rec: MissileTxtData = {
|
|
name,
|
|
id: Number(cells[idIdx] ?? 0) || 0,
|
|
vel,
|
|
maxVel,
|
|
range,
|
|
levRange,
|
|
celFile,
|
|
animLen,
|
|
animSpeed,
|
|
loopAnim,
|
|
numDirections,
|
|
explosionMissile,
|
|
light,
|
|
red,
|
|
green,
|
|
blue,
|
|
speedPxPerSec: vel * 25,
|
|
distancePx: vel * range,
|
|
}
|
|
map.set(name, rec)
|
|
}
|
|
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,
|
|
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 = Number(row[t.header.indexOf('Red')] ?? 255) || 255
|
|
const green = Number(row[t.header.indexOf('Green')] ?? 255) || 255
|
|
const blue = Number(row[t.header.indexOf('Blue')] ?? 255) || 255
|
|
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,
|
|
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 = Number(row['Red'] ?? row['red'] ?? 255) || 255
|
|
const green = Number(row['Green'] ?? row['green'] ?? 255) || 255
|
|
const blue = Number(row['Blue'] ?? row['blue'] ?? 255) || 255
|
|
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,
|
|
speedPxPerSec: vel * 25,
|
|
distancePx: vel * range,
|
|
}
|
|
}
|
|
}
|
|
|
|
return getMissileTxtData(missileName, 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
|
|
}
|
|
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))),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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))
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}[] | undefined
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
},
|
|
): ProjectileOutcome {
|
|
const radius = options.radius ?? 8
|
|
const alive: Projectile[] = []
|
|
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
|
|
}[] = []
|
|
let expired = 0
|
|
let wallHits = 0
|
|
|
|
for (const projectile of projectiles) {
|
|
if (projectile.ttl <= 0) { expired += 1; continue }
|
|
|
|
let vx = projectile.vx
|
|
let vy = projectile.vy
|
|
let x = projectile.x + vx
|
|
let y = projectile.y + vy
|
|
let spiral = projectile.spiral
|
|
|
|
// 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.
|
|
if (spiral !== undefined) {
|
|
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) {
|
|
// 3. Jitter trajectory (e.g. Charged Bolt) - sinusoidal perpendicular wiggle on the ground plane
|
|
const perpX = -vy
|
|
const perpY = vx
|
|
const speed = Math.hypot(vx, vy)
|
|
if (speed > 0) {
|
|
const wobble = Math.sin(projectile.ttl * 0.8) * 1.5
|
|
x += (perpX / speed) * wobble
|
|
y += (perpY / speed) * (wobble * ISO_GROUND_ASPECT_RATIO)
|
|
}
|
|
}
|
|
|
|
const hitTargets = projectile.hitTargets ?? new Set<number>()
|
|
const moved: Projectile = {
|
|
...projectile,
|
|
x,
|
|
y,
|
|
vx,
|
|
vy,
|
|
spiral,
|
|
hitTargets,
|
|
ttl: projectile.ttl - 1,
|
|
}
|
|
|
|
// Walls first: a projectile dies against missile barriers (solid walls, closed doors)
|
|
const blockedByTerrain =
|
|
options.isMissileBlocked !== undefined
|
|
? options.isMissileBlocked(x, y, projectile.x, projectile.y)
|
|
: options.overlap(x, y) > 0
|
|
if (blockedByTerrain) {
|
|
wallHits += 1
|
|
wallHitEvents.push({
|
|
x,
|
|
y,
|
|
skillId: projectile.skillId,
|
|
missileType: projectile.missileType,
|
|
aoeRadius: projectile.aoeRadius,
|
|
})
|
|
continue
|
|
}
|
|
|
|
let struck = false
|
|
for (const target of targets) {
|
|
if (!target.alive) continue
|
|
if (hitTargets.has(target.index)) continue // already struck by piercing projectile
|
|
if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue
|
|
|
|
hits.push({
|
|
targetIndex: target.index,
|
|
damage: projectile.damage,
|
|
x,
|
|
y,
|
|
skillId: projectile.skillId,
|
|
missileType: projectile.missileType,
|
|
aoeRadius: projectile.aoeRadius,
|
|
statusEffect: projectile.statusEffect,
|
|
statusDuration: projectile.statusDuration,
|
|
isUndeadOnly: projectile.isUndeadOnly,
|
|
})
|
|
|
|
hitTargets.add(target.index)
|
|
if (!projectile.pierce) {
|
|
struck = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!struck) {
|
|
alive.push(moved)
|
|
}
|
|
}
|
|
return { alive, hits, expired, wallHits, wallHitEvents }
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 srcDamage: number
|
|
readonly range?: number
|
|
readonly speed?: number
|
|
}
|
|
|
|
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: 4, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 1, emax: 4, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 128, range: 480, speed: 600 },
|
|
11: { id: 11, name: 'Cold Arrow', classCode: 'ama', charClass: 'amazon', type: 'projectile', mana: 28, lvlmana: 4, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 3, emax: 4, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 128, range: 480, speed: 600 },
|
|
10: { id: 10, name: 'Jab', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 16, lvlmana: 2, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
|
|
14: { id: 14, name: 'Power Strike', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 16, lvlmana: 2, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 1, emax: 16, elev1: 8, elev2: 12, elev3: 16, elev4: 20, elev5: 24, 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: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, srcDamage: 0, range: 1000, speed: 500 },
|
|
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: 6, lvlmana: 0, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 6, emax: 10, elev1: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, srcDamage: 0, range: 400, speed: 450 },
|
|
44: { id: 44, name: 'Frost Nova', classCode: 'sor', charClass: 'sorceress', type: 'area', mana: 9, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 2, emax: 4, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 0 },
|
|
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: 6, elev2: 12, elev3: 18, elev4: 24, elev5: 30, srcDamage: 0, range: 450, speed: 450 },
|
|
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 },
|
|
|
|
// 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: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, 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: 0, elev3: 0, elev4: 0, elev5: 0, 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 },
|
|
97: { id: 97, name: 'Smite', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 2, 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: 16, lvlmana: 1, minmana: 1, manashift: 5, delay: 0, hitshift: 8, emin: 8, emax: 16, elev1: 4, elev2: 8, elev3: 12, elev4: 16, elev5: 20, srcDamage: 0, range: 450, speed: 450 },
|
|
106: { id: 106, name: 'Zeal', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 2, 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: 12, elev3: 16, elev4: 20, elev5: 24, 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 },
|
|
|
|
// Barbarian (6 skills)
|
|
126: { id: 126, name: 'Bash', classCode: 'bar', charClass: 'barbarian', type: 'melee', mana: 2, lvlmana: 0, minmana: 2, 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: 2, 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: 8, emin: 1, emax: 3, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, 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: 3, elev2: 6, elev3: 9, elev4: 12, elev5: 15, srcDamage: 0, range: 450, speed: 300 },
|
|
|
|
// Assassin (5 skills)
|
|
251: { id: 251, name: 'Fire Blast', classCode: 'ass', charClass: 'assassin', type: 'projectile', mana: 24, lvlmana: 4, minmana: 1, manashift: 5, delay: 0, hitshift: 8, emin: 3, emax: 4, elev1: 2, elev2: 3, elev3: 4, elev4: 5, elev5: 6, srcDamage: 0, range: 350, speed: 400 },
|
|
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 },
|
|
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 },
|
|
261: { id: 261, name: 'Charged Bolt Sentry', classCode: 'ass', charClass: 'assassin', type: 'summon', mana: 13, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 1, emax: 7, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 0 },
|
|
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 },
|
|
})
|
|
|
|
/**
|
|
* 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 {
|
|
const def = getBatch1SkillDef(skillId)
|
|
if (!def) 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)
|
|
* - All other Batch 1 skills: 0 frames
|
|
*/
|
|
export function getSkillCooldownTicks(skillId: number): number {
|
|
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,
|
|
effectiveLevel: number,
|
|
synergyMultiplier: number = 1.0,
|
|
weaponDamage?: { min: number; max: number }
|
|
): { min: number; max: number } {
|
|
const def = getBatch1SkillDef(skillId)
|
|
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(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 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 b1 = Math.min(lvl - 1, 7)
|
|
min += b1 * def.elev1
|
|
max += b1 * def.elev1
|
|
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl - 8, 8)
|
|
min += b2 * def.elev2
|
|
max += b2 * def.elev2
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl - 16, 6)
|
|
min += b3 * def.elev3
|
|
max += b3 * def.elev3
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl - 22, 6)
|
|
min += b4 * def.elev4
|
|
max += b4 * def.elev4
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * def.elev5
|
|
max += b5 * def.elev5
|
|
}
|
|
}
|
|
|
|
// 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 = Number.isFinite(synergyMultiplier) && synergyMultiplier > 0 ? synergyMultiplier : 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 }
|
|
}
|
|
|
|
/** 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),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|