881 lines
30 KiB
TypeScript
881 lines
30 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'
|
|
import {
|
|
allocatePacksToRooms,
|
|
partitionMapIntoRooms,
|
|
spawnMonsterPacksByRooms,
|
|
spawnPacksInRoom,
|
|
PACK_RADIUS_PX,
|
|
} from './monster-rooms.ts'
|
|
import type { MapRoom } from './monster-rooms.ts'
|
|
|
|
export {
|
|
allocatePacksToRooms,
|
|
partitionMapIntoRooms,
|
|
spawnMonsterPacksByRooms,
|
|
spawnPacksInRoom,
|
|
type MapRoom,
|
|
}
|
|
|
|
|
|
/** 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
|
|
/** Elite rank: normal, champion, unique, or minion. */
|
|
readonly rank?: 'normal' | 'champion' | 'unique' | 'minion'
|
|
/** Rolled elite/superunique modifiers from MonUMod.txt. */
|
|
readonly modifiers?: readonly string[]
|
|
/** SuperUnique identifier if this is a fixed SuperUnique boss (e.g. 'Bishibosh'). */
|
|
readonly superUniqueId?: string
|
|
/** Monster level for drops and scaling. */
|
|
readonly level?: 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
|
|
/** Whether the monster is sleeping (inactive off-screen room). */
|
|
sleeping?: boolean | undefined
|
|
}
|
|
|
|
/** 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
|
|
/** Monster level when event is 'kill'. */
|
|
readonly monsterLevel?: number | undefined
|
|
/** Monster rank when event is 'kill'. */
|
|
readonly monsterRank?: ('normal' | 'champion' | 'unique' | 'minion') | undefined
|
|
/** Monster type code (1=normal, 2=champion, 3=unique, 4=boss). */
|
|
readonly monsterType?: number | undefined
|
|
/** SuperUnique ID if applicable. */
|
|
readonly superUniqueId?: string | undefined
|
|
}
|
|
|
|
/** 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 circular exclusion zone where ordinary monster camps must not spawn. */
|
|
export interface SafeZone {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly radius: number
|
|
}
|
|
|
|
/** 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[]
|
|
/** Fixed world pixel coordinates for landmark-bound SuperUnique packs. */
|
|
readonly fixedPosition?: { readonly x: number; readonly y: number }
|
|
/** Landmark tile coordinates (tileX, tileY) if pixel coordinates are resolved at scene load. */
|
|
readonly landmarkTile?: { readonly tileX: number; readonly tileY: number }
|
|
/** SuperUnique boss identifier if this pack belongs to a fixed SuperUnique. */
|
|
readonly superUniqueId?: string
|
|
}
|
|
|
|
|
|
|
|
function isInsideSafeZone(x: number, y: number, safeZones: readonly SafeZone[] | undefined): boolean {
|
|
if (safeZones === undefined || safeZones.length === 0) return false
|
|
for (const sz of safeZones) {
|
|
if (Math.hypot(x - sz.x, y - sz.y) < sz.radius) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @param safeZones - optional safe zones (spawn point, town bridge, waypoints) to exclude ordinary packs from.
|
|
* @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,
|
|
safeZones?: readonly SafeZone[],
|
|
rooms?: readonly MapRoom[],
|
|
): number {
|
|
if (rooms !== undefined && rooms.length > 0) {
|
|
return spawnMonsterPacksByRooms(world, packs, rooms, terrain, safeZones !== undefined ? { safeZones } : undefined)
|
|
}
|
|
if (packs.length === 0) return 0
|
|
let placed = 0
|
|
let campIndex = 0
|
|
let attempt = 0
|
|
const attemptLimit = packs.length * 32
|
|
while (campIndex < packs.length && attempt < attemptLimit) {
|
|
const pack = packs[campIndex]!
|
|
let campX: number
|
|
let campY: number
|
|
let angle: number
|
|
|
|
if (pack.fixedPosition !== undefined) {
|
|
// Fixed landmark SuperUnique pack: anchor camp center to the landmark position.
|
|
campX = pack.fixedPosition.x
|
|
campY = pack.fixedPosition.y
|
|
angle = campIndex * 2.399963
|
|
if (terrain.overlap(campX, campY) > 0) {
|
|
// Search outward from landmark if exact center tile is slightly blocked
|
|
let foundClear = false
|
|
for (let searchStep = 1; searchStep <= 16; searchStep += 1) {
|
|
const sAngle = searchStep * 2.399963
|
|
const sx = campX + Math.cos(sAngle) * (searchStep * 12)
|
|
const sy = campY + Math.sin(sAngle) * (searchStep * 12)
|
|
if (terrain.overlap(sx, sy) === 0) {
|
|
campX = sx
|
|
campY = sy
|
|
foundClear = true
|
|
break
|
|
}
|
|
}
|
|
if (!foundClear) {
|
|
campIndex += 1
|
|
continue
|
|
}
|
|
}
|
|
} else {
|
|
attempt += 1
|
|
angle = attempt * 2.399963
|
|
const radius = spread * Math.sqrt(attempt / (packs.length * 24))
|
|
campX = around.x + Math.cos(angle) * radius
|
|
campY = around.y + Math.sin(angle) * radius
|
|
if (isInsideSafeZone(campX, campY, safeZones)) continue
|
|
if (terrain.overlap(campX, campY) > 0) continue
|
|
}
|
|
|
|
campIndex += 1
|
|
let memberAttempt = 0
|
|
let memberPlaced = 0
|
|
while (memberPlaced < pack.members.length && memberAttempt < pack.members.length * 16) {
|
|
// 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 (pack.fixedPosition === undefined && isInsideSafeZone(x, y, safeZones)) continue
|
|
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.
|
|
*/
|
|
function buildKillEvent(monster: Monster): CombatEvent {
|
|
const rank = monster.stats.rank ?? 'normal'
|
|
const monsterType = rank === 'unique' ? 3 : rank === 'champion' ? 2 : 1
|
|
return {
|
|
kind: 'kill',
|
|
x: monster.x,
|
|
y: monster.y,
|
|
text: monster.stats.name,
|
|
subjectId: monster.stats.id,
|
|
monsterLevel: monster.stats.level,
|
|
monsterRank: monster.stats.rank,
|
|
monsterType,
|
|
superUniqueId: monster.stats.superUniqueId,
|
|
}
|
|
}
|
|
|
|
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(buildKillEvent(monster))
|
|
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(buildKillEvent(target))
|
|
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.sleeping) continue
|
|
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
|
|
}
|
|
}
|