278 lines
9.6 KiB
TypeScript
278 lines
9.6 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 } from './tables.ts'
|
|
import type { Rng } from './rng.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
|
|
}
|
|
|
|
/** 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],
|
|
]
|
|
|
|
/**
|
|
* 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.
|
|
* @returns the definition.
|
|
*/
|
|
export function skillFromRow(
|
|
row: Readonly<Record<string, string>>,
|
|
rowIndex: number,
|
|
text: TextSource,
|
|
): SkillDef {
|
|
const id = textCell(row, 'Id', textCell(row, 'skill', `skill${String(rowIndex)}`))
|
|
const rawName = textCell(row, 'Name', textCell(row, 'skilldesc', id))
|
|
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: Math.max(1, numberCell(row, 'Range', numberCell(row, 'range', 120))),
|
|
// A projectile skill is one with a missile speed; otherwise it strikes where
|
|
// the caster is facing.
|
|
projectile: numberCell(row, 'Speed', 0) > 0,
|
|
speed: Math.max(0, numberCell(row, 'Speed', 0)),
|
|
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.
|
|
* @returns the definitions, in table order.
|
|
*/
|
|
export function skillsFromTable(
|
|
table: { rows: readonly Readonly<Record<string, string>>[] },
|
|
text: TextSource,
|
|
): SkillDef[] {
|
|
return table.rows.map((row, index) => skillFromRow(row, index, text))
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
},
|
|
}
|
|
}
|
|
|
|
/** 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 }[]
|
|
/** Projectiles that died on a wall or expired. */
|
|
readonly expired: number
|
|
/** Projectiles that stopped against terrain. */
|
|
readonly wallHits: number
|
|
}
|
|
|
|
/**
|
|
* 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 radius?: number },
|
|
): ProjectileOutcome {
|
|
const radius = options.radius ?? 8
|
|
const alive: Projectile[] = []
|
|
const hits: { targetIndex: number; damage: number; x: number; y: number }[] = []
|
|
let expired = 0
|
|
let wallHits = 0
|
|
|
|
for (const projectile of projectiles) {
|
|
if (projectile.ttl <= 0) { expired += 1; continue }
|
|
const x = projectile.x + projectile.vx
|
|
const y = projectile.y + projectile.vy
|
|
const moved: Projectile = { ...projectile, x, y, ttl: projectile.ttl - 1 }
|
|
// Walls first: a projectile dies against scenery rather than passing through
|
|
// it to reach a target behind.
|
|
if (options.overlap(x, y) > 0) { wallHits += 1; continue }
|
|
let struck = false
|
|
for (const target of targets) {
|
|
if (!target.alive) continue
|
|
if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue
|
|
hits.push({ targetIndex: target.index, damage: projectile.damage, x, y })
|
|
struck = true
|
|
break
|
|
}
|
|
if (!struck) alive.push(moved)
|
|
}
|
|
return { alive, hits, expired, wallHits }
|
|
}
|