diablo2-web/src/game/skills.ts

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 }
}