diablo2-web/src/game/combat.ts

777 lines
27 KiB
TypeScript

/**
* Combat sandbox: monsters, melee, resources and experience, at the game's tick
* rate.
*
* The module is deliberately free of rendering and input concerns. It advances
* one 25 Hz tick at a time from a small input record and a collision predicate,
* which is what allows the whole system to be simulated headlessly in a test —
* combat that can only be verified by looking at the screen is combat that will
* be broken most of the time.
*
* Numbers come from data tables (see `tables.ts`), never from constants baked
* into the AI: a monster's health, damage, speed and aggro range are its table
* row, so swapping in the real `MonStats.txt` changes behaviour without touching
* code.
*
* What this is *not*: it is not Diablo II's combat model. Real damage involves
* attack rating versus defence, hit recovery, block, resistances, elemental
* damage and per-skill formulas. This is the skeleton those formulas plug into —
* the states, cooldowns, resource pools and event stream are the parts that need
* to exist first, and they are the parts whose shape is independent of the exact
* arithmetic.
*/
import { numberCell, textCell } from './tables.ts'
import type { DataTable } from './tables.ts'
/** Movement request and intent for one tick. */
export interface CombatInput {
/** Normalized movement, screen axes (y grows downward). */
readonly movement: { readonly x: number; readonly y: number }
/** Whether the attack control is held this tick. */
readonly attack: boolean
}
/**
* What the simulation needs from the world it moves in.
*
* The predicate reports *how many* solid sub-tiles a position overlaps rather
* than a yes/no, because movement needs the count: a body that is already
* overlapping something (a bad spawn, a tile that turned solid under it) must
* still be able to walk out, and the rule that allows it is "never make the
* overlap worse". A boolean cannot express that, and a body stuck forever is a
* far worse failure than one that briefly clips a corner.
*/
export interface CombatTerrain {
/**
* Count solid sub-tiles overlapping a body position.
*
* @param x - body centre x.
* @param y - body centre y.
* @returns the overlap count (0 = clear ground).
*/
readonly overlap: (x: number, y: number) => number
}
/** Tuning that is not per-monster. */
export interface CombatOptions {
/** Player walk speed in pixels per second. */
readonly playerSpeed: number
/** Player melee reach in pixels. */
readonly playerReach: number
/** Ticks between player attacks. */
readonly playerCooldownTicks: number
/** Player damage per hit. */
readonly playerDamage: number
/** Mana spent per attack (0 disables the cost). */
readonly playerManaPerAttack: number
/** Ticks before a dead player is restored, at full resources. */
readonly respawnTicks: number
/** When true, monsters ignore aggro distance and remain standing still (idle). */
readonly disableMonsterAggro?: boolean
}
/** One monster's immutable numbers, read from a table row. */
export interface MonsterStats {
/** Identifier, as written in the table. */
readonly id: string
/** Display name. */
readonly name: string
/** Maximum health. */
readonly hp: number
/** Damage per hit. */
readonly damage: number
/** Ticks between attacks. */
readonly cooldownTicks: number
/** Reach in pixels. */
readonly reach: number
/** Distance at which the monster notices the player, in pixels. */
readonly aggroRadius: number
/** Speed in pixels per second. */
readonly speed: number
/** Experience awarded on death. */
readonly xp: number
}
/** One monster's mutable state. */
export interface Monster {
/** Stable index, for events and debugging. */
readonly index: number
/** Its table numbers. */
readonly stats: MonsterStats
/** World x. */
x: number
/** World y. */
y: number
/** Current health. */
hp: number
/** Ticks until it may attack again. */
cooldown: number
/** What it is doing. */
state: 'idle' | 'chase' | 'attack' | 'dead'
/** Facing (0 = south, turning west), for the renderer. */
facing: number
/** Ticks left of the hit flash, for the renderer. */
hitFlash: number
/** Ticks left of the death animation. */
corpseTicks: number
}
/** The player's combat-relevant state. */
export interface CombatPlayer {
/** World x. */
x: number
/** World y. */
y: number
/** Current health. */
hp: number
/** Maximum health. */
maxHp: number
/** Current mana. */
mana: number
/** Maximum mana. */
maxMana: number
/** Character level. */
level: number
/** Experience accumulated. */
xp: number
/** Ticks until the next attack. */
cooldown: number
/** Facing (0 = south, turning west). */
facing: number
/** Whether the player is alive. */
alive: boolean
/** Ticks left until respawn while dead. */
respawnIn: number
/** Ticks left of the swing animation, for the renderer. */
swingTicks: number
}
/** Something worth telling the player (and the renderer) about. */
export interface CombatEvent {
/** Event kind. */
readonly kind: 'playerHit' | 'monsterHit' | 'kill' | 'levelUp' | 'playerDeath' | 'respawn' | 'noMana'
/** World x the event happened at. */
readonly x: number
/** World y the event happened at. */
readonly y: number
/** Magnitude (damage), when meaningful. */
readonly amount?: number
/** Text to float above the point, when meaningful. */
readonly text?: string
/** Subject identity (a monster's table id on a kill, for quest counters). */
readonly subjectId?: string
}
/** The whole simulation state. */
export interface CombatWorld {
/**
* The local player, for the single-player scene and every existing caller.
*
* In a networked game this is one of {@link CombatWorld.players}, and which one
* is a *view* decision: the simulation must not depend on it, which is why the
* state digest covers `players` in peer order and never this field.
*
* **Invariant:** this object is the same object as one entry of `players`. A
* world assembled by copying fields — a save being loaded, a snapshot being
* restored — breaks that unless {@link rebindPlayer} is called, and the symptom
* is a world that simulates one body while the screen draws another.
*/
player: CombatPlayer
/**
* Every player in the world, in peer order, index 0 first.
*
* Monsters pick the nearest living entry, so two peers whose worlds hold the
* same players in the same order see the same monsters make the same choices.
*/
players: CombatPlayer[]
/** Every monster, dead ones included until their corpse timer expires. */
monsters: Monster[]
/** Ticks simulated. */
tick: number
/** Events produced during the last tick (cleared each tick). */
events: CombatEvent[]
/** Monsters killed. */
kills: number
}
/** Defaults that keep the sandbox playable when a table is thin. */
const DEFAULTS = {
hp: 20,
damage: 3,
cooldownTicks: 25,
reach: 40,
aggroRadius: 220,
speed: 90,
xp: 10,
} as const
/**
* Read one monster's numbers from a table row.
*
* @param row - the record.
* @param rowIndex - row position, used to build a fallback id.
* @returns the stats.
*/
export function monsterStatsFromRow(row: Readonly<Record<string, string>>, rowIndex: number): MonsterStats {
return {
id: textCell(row, 'Id', `monster${String(rowIndex)}`),
name: textCell(row, 'Name', textCell(row, 'Id', `Monster ${String(rowIndex)}`)),
hp: numberCell(row, 'HP', DEFAULTS.hp),
damage: numberCell(row, 'Damage', DEFAULTS.damage),
cooldownTicks: numberCell(row, 'CooldownTicks', DEFAULTS.cooldownTicks),
reach: numberCell(row, 'Reach', DEFAULTS.reach),
aggroRadius: numberCell(row, 'AggroRadius', DEFAULTS.aggroRadius),
speed: numberCell(row, 'Speed', DEFAULTS.speed),
xp: numberCell(row, 'XP', DEFAULTS.xp),
}
}
/**
* Read every monster definition in a table.
*
* @param table - a `MonStats`-shaped table.
* @returns the definitions.
*/
export function monsterStatsFromTable(table: DataTable): MonsterStats[] {
return table.rows.map((row, index) => monsterStatsFromRow(row, index))
}
/**
* Read the experience required per level.
*
* @param table - an `Experience`-shaped table with a `Level` and an `XP` column.
* @returns required cumulative experience by level, index 1 = level 1.
*/
export function experienceTable(table: DataTable): number[] {
const levels: number[] = [0, 0]
for (const row of table.rows) {
const level = numberCell(row, 'Level', 0)
if (level < 1) continue
levels[level] = numberCell(row, 'XP', 0)
}
for (let level = 2; level < levels.length; level += 1) {
levels[level] = Math.max(levels[level] ?? 0, levels[level - 1] ?? 0)
}
return levels
}
/**
* Create a world with a player at a position.
*
* @param x - player x.
* @param y - player y.
* @param maxHp - starting maximum health.
* @param maxMana - starting maximum mana.
* @returns the world.
*/
export function createWorld(x: number, y: number, maxHp = 60, maxMana = 30): CombatWorld {
const player = createPlayer(x, y, maxHp, maxMana)
return {
player,
players: [player],
monsters: [],
tick: 0,
events: [],
kills: 0,
}
}
/**
* Create one player body.
*
* @param x - x.
* @param y - y.
* @param maxHp - starting maximum health.
* @param maxMana - starting maximum mana.
* @returns the player.
*/
export function createPlayer(x: number, y: number, maxHp = 60, maxMana = 30): CombatPlayer {
return {
x, y, hp: maxHp, maxHp, mana: maxMana, maxMana,
level: 1, xp: 0, cooldown: 0, facing: 0, alive: true, respawnIn: 0, swingTicks: 0,
}
}
/**
* Re-establish the invariant that the local player is in the player list.
*
* The combat tick drives `players` while the scene and the renderer read
* `player`; a world built by spreading one object over another ends up with a
* fresh `player` and the *previous* `players` array, so those two would drift
* apart on the next tick. A loaded save is a single-player world by definition,
* so the list becomes exactly this one player.
*
* @param world - the world to repair (mutated in place).
* @returns the same world.
*/
export function rebindPlayer(world: CombatWorld): CombatWorld {
world.players = [world.player]
return world
}
/**
* Add another player to the world, as a joining peer would.
*
* The new body is appended, so its index is its peer index and every peer agrees
* on the order. Nothing about the joining player is derived from the local view.
*
* @param world - the world.
* @param x - spawn x.
* @param y - spawn y.
* @param maxHp - starting maximum health.
* @param maxMana - starting maximum mana.
* @returns the player that was added.
*/
export function addPlayer(world: CombatWorld, x: number, y: number, maxHp = 60, maxMana = 30): CombatPlayer {
const player = createPlayer(x, y, maxHp, maxMana)
world.players.push(player)
return player
}
/**
* The player a monster should go for: the nearest of this tick's targets.
*
* Nearest rather than "player 0" because a monster that ignores the peer standing
* on top of it reads as broken, and because the choice has to be a pure function
* of the world for two peers to agree.
*
* @param monster - the monster choosing.
* @param targets - the players it may attack.
* @returns the target, or null when there is none.
*/
function nearestTarget(monster: Monster, targets: readonly CombatPlayer[]): CombatPlayer | null {
let best: CombatPlayer | null = null
let bestDistance = Number.POSITIVE_INFINITY
for (const candidate of targets) {
const distance = Math.hypot(candidate.x - monster.x, candidate.y - monster.y)
if (distance < bestDistance) {
bestDistance = distance
best = candidate
}
}
return best
}
/**
* Place monsters around a point, skipping unwalkable spots.
*
* Spawns are rejected rather than nudged: dropping a monster into a wall because
* nothing better was found is how units end up permanently stuck, and the caller
* can see the shortfall in the return value.
*
* @param world - the world to populate.
* @param stats - the definitions to spawn from.
* @param count - how many to spawn.
* @param around - spawn centre.
* @param spread - spawn radius in pixels.
* @param terrain - collision predicate.
* @returns how many were actually placed.
*/
export function spawnMonsters(
world: CombatWorld,
stats: readonly MonsterStats[],
count: number,
around: { readonly x: number; readonly y: number },
spread: number,
terrain: CombatTerrain,
): number {
if (stats.length === 0) return 0
let placed = 0
let attempt = 0
while (placed < count && attempt < count * 24) {
attempt += 1
// A deterministic spiral keeps spawns reproducible, which is what makes a
// failing test repeatable.
const angle = attempt * 2.399963
const radius = spread * Math.sqrt(attempt / (count * 24))
const x = around.x + Math.cos(angle) * radius
const y = around.y + Math.sin(angle) * radius
if (terrain.overlap(x, y) > 0) continue
const definition = stats[placed % stats.length]!
world.monsters.push({
index: world.monsters.length,
stats: definition,
x, y,
hp: definition.hp,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
})
placed += 1
}
return placed
}
/** A group of monsters that belongs together on the ground. */
export interface MonsterPack {
/**
* The members, already rolled — one entry per monster.
*
* A pack is a list rather than a definition plus a count because members are
* not interchangeable: health is rolled per monster, and a unique's pack has
* a stronger leader followed by its minions.
*/
readonly members: readonly MonsterStats[]
}
/**
* How far a pack's members sit from their camp centre, in pixels.
*
* Two cells across. A pack has to read as one thing from the player's distance
* — close enough that pulling one pulls the rest, far enough that the sprites
* do not overlap. A cell is 80 wide, and a monster body is about 20.
*/
const PACK_RADIUS_PX = 96
/**
* Place packs, keeping each pack's members together.
*
* {@link spawnMonsters} scatters its monsters on a golden-angle spiral, which
* is the right shape for spreading *unrelated* things evenly and exactly the
* wrong one for a Fallen camp: consecutive spiral points are deliberately far
* apart, so a pack placed that way arrives as a field of loners. Here the
* spiral places the *camps*, and members are clustered inside their own camp.
*
* Both loops reject blocked ground rather than nudging, for the same reason
* {@link spawnMonsters} does: a monster shoved into a wall is stuck forever.
* A camp that cannot fit its whole pack keeps whichever members found room, so
* the caller can see the shortfall in the return value.
*
* @param world - the world to populate.
* @param packs - the packs to place, in order.
* @param around - the centre to spread camps around.
* @param spread - how far camps may be from that centre, in pixels.
* @param terrain - collision predicate.
* @returns how many monsters were actually placed.
*/
export function spawnMonsterPacks(
world: CombatWorld,
packs: readonly MonsterPack[],
around: { readonly x: number; readonly y: number },
spread: number,
terrain: CombatTerrain,
): number {
if (packs.length === 0) return 0
let placed = 0
let campIndex = 0
let attempt = 0
const attemptLimit = packs.length * 24
while (campIndex < packs.length && attempt < attemptLimit) {
attempt += 1
const angle = attempt * 2.399963
const radius = spread * Math.sqrt(attempt / attemptLimit)
const campX = around.x + Math.cos(angle) * radius
const campY = around.y + Math.sin(angle) * radius
if (terrain.overlap(campX, campY) > 0) continue
const pack = packs[campIndex]!
campIndex += 1
let memberAttempt = 0
let memberPlaced = 0
while (memberPlaced < pack.members.length && memberAttempt < pack.members.length * 12) {
// The leader stands on the camp centre; the rest ring it. Offsetting the
// ring by the camp's own angle stops every camp in the level from having
// an identically oriented formation.
const memberAngle = angle + memberAttempt * 2.399963
const memberRadius = memberAttempt === 0
? 0
: PACK_RADIUS_PX * Math.sqrt(memberAttempt / (pack.members.length * 12))
memberAttempt += 1
const x = campX + Math.cos(memberAngle) * memberRadius
const y = campY + Math.sin(memberAngle) * memberRadius
if (terrain.overlap(x, y) > 0) continue
const definition = pack.members[memberPlaced]!
world.monsters.push({
index: world.monsters.length,
stats: definition,
x, y,
hp: definition.hp,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
})
memberPlaced += 1
placed += 1
}
}
return placed
}
/**
* Direction index (0 = south, turning west) for a vector.
*
* @param dx - horizontal component.
* @param dy - vertical component.
* @returns the facing.
*/
export function facingOf(dx: number, dy: number): number {
if (dx === 0 && dy === 0) return 0
const angle = Math.atan2(-dx, dy)
return Math.round((angle / (Math.PI / 4)) + 8) % 8
}
/**
* Move a body one tick, sliding along blocked axes.
*
* @param from - current position.
* @param dx - desired delta x.
* @param dy - desired delta y.
* @param terrain - collision predicate.
* @returns the new position.
*/
function moveWithCollision(
from: { readonly x: number; readonly y: number },
dx: number,
dy: number,
terrain: CombatTerrain,
): { x: number; y: number } {
let { x, y } = from
const current = terrain.overlap(x, y)
// Axis-separated: a blocked axis stops while the other slides along the wall.
if (dx !== 0 && terrain.overlap(x + dx, y) <= current) x += dx
if (dy !== 0 && terrain.overlap(x, y + dy) <= current) y += dy
return { x, y }
}
/**
* Apply damage to one monster from any source (a melee swing, a projectile).
*
* Kill handling lives here rather than at each call site so experience, the kill
* counter, the corpse timer and the event stream behave identically however the
* damage arrived — which is the difference between "the skill works" and "the
* skill works but kills do not count".
*
* @param world - the world.
* @param monsterIndex - which monster.
* @param amount - damage to apply.
* @param attacker - who dealt it; the killer is credited with the experience.
* Defaults to the local player, which is what a single-player caller means.
* @returns true when this damage killed it.
*/
export function damageMonster(world: CombatWorld, monsterIndex: number, amount: number, attacker: CombatPlayer = world.player): boolean {
const monster = world.monsters[monsterIndex]
if (monster === undefined || monster.state === 'dead') return false
monster.hp -= amount
monster.hitFlash = 4
world.events.push({ kind: 'monsterHit', x: monster.x, y: monster.y, amount })
if (monster.hp > 0) return false
monster.state = 'dead'
monster.corpseTicks = 100
world.kills += 1
world.events.push({ kind: 'kill', x: monster.x, y: monster.y, text: monster.stats.name, subjectId: monster.stats.id })
attacker.xp += monster.stats.xp
return true
}
/**
* Advance the simulation one tick for one player.
*
* @param world - the world to advance (mutated in place).
* @param input - this tick's movement and attack intent.
* @param options - non-per-monster tuning.
* @param terrain - collision predicate.
* @param xpTable - cumulative experience required per level, index 1 = level 1.
*/
export function tickCombat(
world: CombatWorld,
input: CombatInput,
options: CombatOptions,
terrain: CombatTerrain,
xpTable: readonly number[],
): void {
tickCombatMulti(world, [input], options, terrain, xpTable)
}
/**
* Advance the simulation one tick for several players at once.
*
* One tick, not one per player: the tick counter, the event stream and the
* monster turn all happen once, while every player's own input is applied before
* the monsters act. Calling {@link tickCombat} once per player instead would run
* the monsters twice in a tick and desync two peers against each other.
*
* `inputs[i]` drives `players[i]`. A peer with no input this tick simply does not
* act — it does not fall back to someone else's controls.
*
* @param world - the world to advance (mutated in place).
* @param inputs - one input per player, in peer order.
* @param options - non-per-monster tuning.
* @param terrain - collision predicate.
* @param xpTable - cumulative experience required per level, index 1 = level 1.
*/
export function tickCombatMulti(
world: CombatWorld,
inputs: readonly CombatInput[],
options: CombatOptions,
terrain: CombatTerrain,
xpTable: readonly number[],
): void {
world.tick += 1
world.events = []
// Who the monsters may attack is decided at the *start* of the tick. A player
// who comes back to life during this tick is therefore safe in it, and a world
// with nobody alive gives the monsters nothing to do: their cooldowns and the
// corpses hold still rather than running down against a player who cannot
// answer. Both rules exist to keep death and respawn reading the way they do in
// single player — you die, the world waits, you come back with a moment to move.
const targets = world.players.filter(player => player.alive)
for (let index = 0; index < inputs.length; index += 1) {
const player = world.players[index]
if (player === undefined) continue
tickPlayer(world, player, inputs[index]!, options, terrain, xpTable)
}
if (targets.length > 0) tickMonsters(world, options, terrain, targets)
}
/**
* Apply one player's turn: respawn, movement, attack.
*
* @param world - the world.
* @param player - the player acting.
* @param input - its input.
* @param options - non-per-monster tuning.
* @param terrain - collision predicate.
* @param xpTable - cumulative experience required per level.
*/
function tickPlayer(
world: CombatWorld,
player: CombatPlayer,
input: CombatInput,
options: CombatOptions,
terrain: CombatTerrain,
xpTable: readonly number[],
): void {
if (!player.alive) {
player.respawnIn -= 1
if (player.respawnIn <= 0) {
player.alive = true
player.hp = player.maxHp
player.mana = player.maxMana
world.events.push({ kind: 'respawn', x: player.x, y: player.y })
}
return
}
if (player.cooldown > 0) player.cooldown -= 1
if (player.swingTicks > 0) player.swingTicks -= 1
// Movement first, so an attack this tick happens from where the player ended up.
const speed = options.playerSpeed / 25
const moved = moveWithCollision(player, input.movement.x * speed, input.movement.y * speed, terrain)
player.x = moved.x
player.y = moved.y
if (input.movement.x !== 0 || input.movement.y !== 0) {
player.facing = facingOf(input.movement.x, input.movement.y)
}
// Player melee: nearest living monster in reach, if the attack is ready.
if (input.attack && player.cooldown === 0) {
const cost = options.playerManaPerAttack
if (cost > 0 && player.mana < cost) {
world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' })
} else {
player.mana = Math.max(0, player.mana - cost)
player.cooldown = options.playerCooldownTicks
player.swingTicks = Math.max(1, Math.floor(options.playerCooldownTicks / 2))
let target: Monster | null = null
let bestDistance = Number.POSITIVE_INFINITY
for (const monster of world.monsters) {
if (monster.state === 'dead') continue
const distance = Math.hypot(monster.x - player.x, monster.y - player.y)
if (distance <= options.playerReach && distance < bestDistance) {
bestDistance = distance
target = monster
}
}
if (target === null) {
world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' })
} else {
player.facing = facingOf(target.x - player.x, target.y - player.y)
target.hp -= options.playerDamage
target.hitFlash = 4
world.events.push({ kind: 'monsterHit', x: target.x, y: target.y, amount: options.playerDamage })
if (target.hp <= 0) {
target.state = 'dead'
target.corpseTicks = 100
world.kills += 1
world.events.push({ kind: 'kill', x: target.x, y: target.y, text: target.stats.name, subjectId: target.stats.id })
player.xp += target.stats.xp
// Level up while the threshold is crossed; the table is cumulative.
while (player.level + 1 < xpTable.length && player.xp >= (xpTable[player.level + 1] ?? Number.POSITIVE_INFINITY)) {
player.level += 1
player.maxHp += 10
player.maxMana += 5
player.hp = player.maxHp
player.mana = player.maxMana
world.events.push({ kind: 'levelUp', x: player.x, y: player.y, text: `level ${String(player.level)}` })
}
}
}
}
}
}
/**
* The monsters' turn: notice, close in, strike. Corpses decay.
*
* @param world - the world.
* @param options - non-per-monster tuning.
* @param terrain - collision predicate.
* @param targets - the players that were alive when the tick began.
*/
function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: CombatTerrain, targets: readonly CombatPlayer[]): void {
for (const monster of world.monsters) {
if (monster.state === 'dead') {
if (monster.corpseTicks > 0) monster.corpseTicks -= 1
continue
}
if (monster.hitFlash > 0) monster.hitFlash -= 1
if (monster.cooldown > 0) monster.cooldown -= 1
if (options.disableMonsterAggro) {
monster.state = 'idle'
continue
}
const player = nearestTarget(monster, targets)
if (player === null) {
monster.state = 'idle'
continue
}
const dx = player.x - monster.x
const dy = player.y - monster.y
const distance = Math.hypot(dx, dy)
if (distance > monster.stats.aggroRadius) {
monster.state = 'idle'
continue
}
monster.facing = facingOf(dx, dy)
if (distance <= monster.stats.reach) {
monster.state = 'attack'
if (monster.cooldown === 0) {
monster.cooldown = monster.stats.cooldownTicks
player.hp -= monster.stats.damage
world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: monster.stats.damage })
if (player.hp <= 0) {
player.hp = 0
player.alive = false
player.respawnIn = options.respawnTicks
world.events.push({ kind: 'playerDeath', x: player.x, y: player.y, text: 'you died' })
}
}
continue
}
monster.state = 'chase'
const step = monster.stats.speed / 25
const movedMonster = moveWithCollision(monster, (dx / distance) * step, (dy / distance) * step, terrain)
monster.x = movedMonster.x
monster.y = movedMonster.y
}
}