Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions docs/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1573,6 +1573,128 @@ export interface InventoryController<TMeta = unknown> {
*/
export function createInventory<TMeta>(options: InventoryOptions<TMeta>): InventoryController<TMeta>;

/**
* 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.
Expand Down
38 changes: 38 additions & 0 deletions examples/combat.ts
Original file line number Diff line number Diff line change
@@ -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);
179 changes: 179 additions & 0 deletions src/gameplay/combat.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();

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,
};
}
Loading