diff --git a/README.md b/README.md index 77e1a3f..d7e7bc6 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ npm run size # Enforce bundle size budget - Milestone 0.2 next targets crowd-flow integrations (RVO + flow fields) and behaviour-tree decorators for richer AI control. - Milestone 0.4 plans a procedural + gameplay systems toolkit (Wave Function Collapse, dungeon suite, L-systems, game loop, camera, particles, inventory, combat, save/load, and more). -Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. +Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. ## Contributing 1. Fork the repository. diff --git a/ROADMAP.md b/ROADMAP.md index 7fae198..06cb5e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,7 +61,7 @@ - [x] Shadowcasting field-of-view utilities and minimap helpers - **Systems for gameplay loops** - [x] Inventory system primitives (stacking, filtering, persistence hooks) - - [ ] Combat resolution helpers (cooldowns, damage formulas, status effects) + - [x] Combat resolution helpers (cooldowns, damage formulas, status effects) - [ ] Quest/dialog state machine utilities - [ ] 2D lighting helpers (falloff, blending stubs) - [ ] Wave spawner utilities for encounter pacing diff --git a/docs/index.d.ts b/docs/index.d.ts index 411f88c..378accb 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -1573,6 +1573,128 @@ export interface InventoryController { */ export function createInventory(options: InventoryOptions): InventoryController; +/** + * Combatant statistics used for damage calculations. + * Use for: providing combat state to helpers. + * Import: gameplay/combat.ts + */ +export interface CombatantStats { + health: number; + attack: number; + defense: number; + resistance: number; + critChance?: number; + critMultiplier?: number; +} + +/** + * Supported damage types. + * Use for: distinguishing mitigation paths. + * Import: gameplay/combat.ts + */ +export type DamageType = 'physical' | 'magical' | 'true'; + +/** + * Damage modifier options. + * Use for: tweaking flat bonuses, multipliers, or damage type. + * Import: gameplay/combat.ts + */ +export interface DamageModifiers { + flat?: number; + multiplier?: number; + type?: DamageType; + random?: () => number; +} + +/** + * Damage result payload. + * Use for: applying damage to targets and reporting crits. + * Import: gameplay/combat.ts + */ +export interface DamageResult { + damage: number; + type: DamageType; + isCrit: boolean; +} + +/** + * Calculates combat damage with optional modifiers. + * Use for: resolving attacks in RPG systems. + * Import: gameplay/combat.ts + */ +export function calculateDamage( + attacker: CombatantStats, + defender: CombatantStats, + modifiers?: DamageModifiers +): DamageResult; + +/** + * Applies damage result to a target. + * Use for: clamping health and producing new combat state. + * Import: gameplay/combat.ts + */ +export function applyDamage(target: CombatantStats, result: DamageResult): CombatantStats; + +/** + * Cooldown controller API for abilities. + * Use for: tracking per-ability cooldown timers. + * Import: gameplay/combat.ts + */ +export interface CooldownController { + trigger(id: string, cooldown: number): boolean; + update(delta: number): void; + reset(): void; + getRemaining(id: string): number; +} + +/** + * Creates a cooldown controller. + * Use for: enforcing ability cooldowns. + * Import: gameplay/combat.ts + */ +export function createCooldownController(): CooldownController; + +/** + * Status effect definition. + * Use for: describing duration-based effects. + * Import: gameplay/combat.ts + */ +export interface StatusEffect { + id: string; + duration: number; + tickInterval?: number; + onApply?: (target: CombatantStats) => void; + onTick?: (target: CombatantStats) => void; + onExpire?: (target: CombatantStats) => void; +} + +/** + * Active status effect with runtime timers. + * Use for: advancing effects over time. + * Import: gameplay/combat.ts + */ +export interface ActiveStatusEffect extends StatusEffect { + remaining: number; + tickTimer?: number; +} + +/** + * Creates an active status effect instance with timers. + * Use for: instantiating status effects in controllers. + * Import: gameplay/combat.ts + */ +export function createStatusEffect(effect: StatusEffect): ActiveStatusEffect; + +/** + * Updates active status effects and invokes lifecycle callbacks. + * Use for: applying dots, buffs, and expiry hooks. + * Import: gameplay/combat.ts + */ +export function updateStatusEffects( + target: CombatantStats, + effects: ActiveStatusEffect[], + delta: number +): ActiveStatusEffect[]; /** * Item insertion payload used by the inventory controller. * Use for: adding items with quantity and metadata. diff --git a/examples/combat.ts b/examples/combat.ts new file mode 100644 index 0000000..a032484 --- /dev/null +++ b/examples/combat.ts @@ -0,0 +1,38 @@ +import { + calculateDamage, + applyDamage, + createCooldownController, + createStatusEffect, + updateStatusEffects, +} from '../src/index.js'; + +const attacker = { + health: 120, + attack: 30, + defense: 8, + resistance: 4, + critChance: 0.25, + critMultiplier: 2, +}; + +const defender = { + health: 100, + attack: 15, + defense: 10, + resistance: 6, +}; + +const damage = calculateDamage(attacker, defender, { type: 'physical', flat: 5 }); +const afterHit = applyDamage(defender, damage); +console.log('Damage dealt:', damage.damage, 'Remaining health:', afterHit.health); + +const cooldowns = createCooldownController(); +console.log('Trigger fireball:', cooldowns.trigger('fireball', 3)); +cooldowns.update(1); +console.log('Remaining cooldown:', cooldowns.getRemaining('fireball').toFixed(2)); + +const burn = createStatusEffect({ id: 'burn', duration: 3, tickInterval: 1, onTick: () => console.log('Burn ticks') }); +let effects = [burn]; +effects = updateStatusEffects(attacker, effects, 1); +effects = updateStatusEffects(attacker, effects, 1); +effects = updateStatusEffects(attacker, effects, 1); diff --git a/src/gameplay/combat.ts b/src/gameplay/combat.ts new file mode 100644 index 0000000..e39fdbc --- /dev/null +++ b/src/gameplay/combat.ts @@ -0,0 +1,179 @@ + +export type DamageType = 'physical' | 'magical' | 'true'; + +export interface CombatantStats { + health: number; + attack: number; + defense: number; + resistance: number; + critChance?: number; + critMultiplier?: number; +} + +export interface DamageModifiers { + flat?: number; + multiplier?: number; + type?: DamageType; + random?: () => number; +} + +export interface DamageResult { + damage: number; + type: DamageType; + isCrit: boolean; +} + +export interface CooldownController { + trigger(id: string, cooldown: number): boolean; + update(delta: number): void; + reset(): void; + getRemaining(id: string): number; +} + +export interface StatusEffect { + id: string; + duration: number; + tickInterval?: number; + onApply?: (target: CombatantStats) => void; + onTick?: (target: CombatantStats) => void; + onExpire?: (target: CombatantStats) => void; +} + +export interface ActiveStatusEffect extends StatusEffect { + remaining: number; + tickTimer?: number; +} + +function assertFinite(value: number, label: string): void { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } +} + +function assertNonNegative(value: number, label: string): void { + assertFinite(value, label); + if (value < 0) { + throw new Error(`${label} must be non-negative.`); + } +} + +function resolveMitigation(type: DamageType, defender: CombatantStats): number { + if (type === 'true') { + return 0; + } + return type === 'magical' ? defender.resistance : defender.defense; +} + +/** + * Calculates damage between two combatants with optional modifiers. + */ +export function calculateDamage( + attacker: CombatantStats, + defender: CombatantStats, + modifiers: DamageModifiers = {} +): DamageResult { + assertFinite(attacker.attack, 'attacker.attack'); + assertFinite(defender.health, 'defender.health'); + + const type = modifiers.type ?? 'physical'; + const flat = modifiers.flat ?? 0; + const multiplier = modifiers.multiplier ?? 1; + const random = modifiers.random ?? Math.random; + + assertNonNegative(flat, 'flat'); + assertNonNegative(multiplier, 'multiplier'); + + const mitigation = resolveMitigation(type, defender); + const base = Math.max(attacker.attack * multiplier + flat - mitigation, 0); + const critChance = Math.max(0, attacker.critChance ?? 0); + const critMultiplier = attacker.critMultiplier ?? 1.5; + const isCrit = random() < critChance; + const damage = Math.max(0, Math.round(base * (isCrit ? critMultiplier : 1))); + + return { damage, type, isCrit }; +} + +/** + * Applies damage to a target and returns a new stat snapshot. + */ +export function applyDamage(target: CombatantStats, result: DamageResult): CombatantStats { + const nextHealth = Math.max(0, target.health - result.damage); + return { ...target, health: nextHealth }; +} + +/** + * Creates a cooldown controller for managing ability cooldowns. + */ +export function createCooldownController(): CooldownController { + const timers = new Map(); + + return { + trigger(id: string, cooldown: number): boolean { + assertNonNegative(cooldown, 'cooldown'); + const remaining = timers.get(id) ?? 0; + if (remaining > 0) { + return false; + } + timers.set(id, cooldown); + return true; + }, + update(delta: number): void { + assertNonNegative(delta, 'delta'); + for (const [id, remaining] of timers.entries()) { + const next = Math.max(remaining - delta, 0); + timers.set(id, next); + } + }, + reset(): void { + timers.clear(); + }, + getRemaining(id: string): number { + return timers.get(id) ?? 0; + }, + }; +} + +/** + * Advances active status effects, invoking tick/expire callbacks. + */ +export function updateStatusEffects( + target: CombatantStats, + effects: ActiveStatusEffect[], + delta: number +): ActiveStatusEffect[] { + assertNonNegative(delta, 'delta'); + const nextState = effects.map((effect) => ({ ...effect })); + + for (const effect of nextState) { + if (effect.remaining === effect.duration && effect.onApply) { + effect.onApply(target); + } + + effect.remaining = Math.max(effect.remaining - delta, 0); + if (effect.tickInterval !== undefined) { + effect.tickTimer = (effect.tickTimer ?? effect.tickInterval) - delta; + if (effect.tickTimer <= 0 && effect.remaining > 0) { + effect.onTick?.(target); + effect.tickTimer = effect.tickInterval; + } + } + + if (effect.remaining === 0) { + effect.onExpire?.(target); + } + } + + return nextState.filter((effect) => effect.remaining > 0); +} + +/** + * Creates an active status effect instance with default timers. + */ +export function createStatusEffect(effect: StatusEffect): ActiveStatusEffect { + assertNonNegative(effect.duration, 'duration'); + return { + ...effect, + remaining: effect.duration, + tickTimer: effect.tickInterval, + }; +} diff --git a/src/index.ts b/src/index.ts index e19881e..780232b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,6 +101,8 @@ export const examples = { createTileMapController: 'examples/tileMap.ts', computeFieldOfView: 'examples/shadowcasting.ts', createInventory: 'examples/inventory.ts', + calculateDamage: 'examples/combat.ts', + createCooldownController: 'examples/combat.ts', }, ai: { seek: 'examples/steering.ts', @@ -644,6 +646,29 @@ export type { AddItemOptions, } from './gameplay/inventory.js'; +/** + * Combat helpers for damage calculation, cooldowns, and status effects. + * + * Example file: examples/combat.ts + */ +export { + calculateDamage, + applyDamage, + createCooldownController, + updateStatusEffects, + createStatusEffect, +} from './gameplay/combat.js'; + +export type { + DamageResult, + DamageModifiers, + DamageType, + CombatantStats, + CooldownController, + StatusEffect, + ActiveStatusEffect, +} from './gameplay/combat.js'; + // ============================================================================ // 🔍 SEARCH & STRING UTILITIES diff --git a/tests/combat.test.ts b/tests/combat.test.ts new file mode 100644 index 0000000..962c13a --- /dev/null +++ b/tests/combat.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { + calculateDamage, + applyDamage, + createCooldownController, + createStatusEffect, + updateStatusEffects, +} from '../src/index.js'; + +const baseAttacker = { + health: 100, + attack: 20, + defense: 5, + resistance: 3, + critChance: 0.5, + critMultiplier: 2, +}; + +const baseDefender = { + health: 80, + attack: 10, + defense: 4, + resistance: 6, +}; + +describe('combat helpers', () => { + it('calculates and applies damage with crits and mitigation', () => { + const result = calculateDamage(baseAttacker, baseDefender, { + type: 'physical', + flat: 2, + multiplier: 1, + random: () => 0.1, + }); + expect(result.isCrit).toBe(true); + expect(result.damage).toBeGreaterThan(0); + + const updated = applyDamage(baseDefender, result); + expect(updated.health).toBeLessThan(baseDefender.health); + }); + + it('handles cooldown triggering and updates', () => { + const cooldowns = createCooldownController(); + expect(cooldowns.trigger('fireball', 3)).toBe(true); + expect(cooldowns.trigger('fireball', 3)).toBe(false); + cooldowns.update(1.5); + expect(cooldowns.getRemaining('fireball')).toBeCloseTo(1.5, 5); + cooldowns.update(2); + expect(cooldowns.trigger('fireball', 2)).toBe(true); + }); + + it('applies status effects with ticking and expiry', () => { + let ticks = 0; + let expired = false; + const burn = createStatusEffect({ + id: 'burn', + duration: 3, + tickInterval: 1, + onTick: () => { + ticks += 1; + }, + onExpire: () => { + expired = true; + }, + }); + + let effects = [burn]; + const target = { ...baseDefender }; + effects = updateStatusEffects(target, effects, 1); + effects = updateStatusEffects(target, effects, 1); + expect(ticks).toBe(2); + effects = updateStatusEffects(target, effects, 1); + expect(expired).toBe(true); + expect(effects).toHaveLength(0); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 42e2c38..c350986 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -112,6 +112,8 @@ describe('package entry point', () => { | 'createTileMapController' | 'computeFieldOfView' | 'createInventory' + | 'calculateDamage' + | 'createCooldownController' >(); }); });