@ferret/combat
Melee fights by rules that the clips set: fighters for the player and the AI, weapon sweeps against hit capsules, blocks, parries, stamina, health, reactions, death.
Read the guide: how to use it →
Classes
BoneGeometry class
class BoneGeometry implements BodyGeometrycapsules and the weapon's striking part on a body's bones as they are posed now (call after the animations of the frame)
constructor(readonly bodies: Record<string, BoneBody>, readonly bodyOf: (f: Fighter) => string = f => f.id) | |
weapon(f: Fighter): [V3, V3] | the striking part of the weapon: from its inner end to its outer end |
capsules(f: Fighter): Capsule[] |
packages/combat/src/bones.ts:17
ClipPoseGeometry class
class ClipPoseGeometry implements BodyGeometryconstructor(readonly specs: Record<string, BodySpec>, readonly clips: Record<string, Record<string, ClipJson>>, readonly bodyOf: (f: Fighter) => string = f => f.id) | |
weapon(f: Fighter): [V3, V3] | the striking part of the weapon: from its inner end to its outer end |
capsules(f: Fighter): Capsule[] |
packages/combat/src/pose.ts:27
Duel class
class Duelt: number | |
phase: 'fight' | 'over' | |
winner?: string | |
overFor: number | |
readonly events: DuelEvent[] | |
readonly o: Required<Omit<DuelOptions, 'restartAfter' | 'parts'>> & Pick<DuelOptions, 'restartAfter'> & { parts: Record<string, number> } | |
constructor(readonly fighters: [Fighter, Fighter], readonly geometry: BodyGeometry, o: DuelOptions) | |
other(f: Fighter): Fighter | |
step(dt: number, commands: [FighterCommand, FighterCommand]): DuelEvent[] | one tick: the commands of both (in the order of fighters); returns what happened in it |
scorch(def: Fighter, damage: number): DuelEvent | undefined | Something burns a fighter (the Dragon's fire): he loses damage and stumbles, or falls when it was the last of his health (the other one has won). A fighter who is already reeling, down or past the fight's end is left alone. Returns the event, or nothing when it did not touch him. |
restart(out?: DuelEvent[]) | back to the start: both in their places, health and stamina full (out: a tick's events, when called from step) |
sideOf(def: Fighter, att: Fighter): 'front' | 'back' | 'left' | 'right' | the side of the defender a blow comes from: by where the attacker stands |
bodiesOverlap(parts: string[] = ['hips', 'chest', 'head']): number | how far the two bodies cut into each other: the largest overlap of their trunk and head capsules (0 when apart) |
packages/combat/src/duel.ts:39
Fighter class
class Fighterpos: V2 | |
yaw: number | |
health: number | |
stamina: number | |
state: FighterState | |
vel: V2 | the world velocity on the ground, and the locomotion clip it shows (free and block states) |
loco: string | |
blockSince: number | when the current block began (for the parry), duel time |
readonly maxHealth: number | |
readonly maxStamina: number | |
constructor(readonly id: string, readonly set: MoveSet, pos: V2, yaw: number) | |
attack(id: string): AttackDef | |
timing(clip: string): ClipTiming | |
windowOf(a: AttackDef): [number, number] | the hit window of an attack, seconds into its clip |
get striking(): boolean | while the weapon can still land a blow |
get alive(): boolean | |
get forward(): V2 | the forward direction on the ground (world x, z) |
get pose(): { clip: string; t: number; additive?: { clip: string; t: number } } | the clip to show and the time in it; a state whose clip is additive shows the guard with that clip on top |
enter(kind: FighterStateKind, clip: string, extra: Partial<FighterState> = {}) | |
spend(n: number) | |
reset(pos: V2, yaw: number) | |
update(dt: number, cmd: FighterCommand, now: number) | one tick of the rules; now is the duel's time |
packages/combat/src/fighter.ts:72
FighterAI class
class FighterAIreadonly tree: BehaviorTree | |
constructor(readonly me: Fighter, readonly duel: Duel, readonly level: AiLevel, readonly random: () => number, readonly reach: (f: Fighter, attack: string) => number) | |
think(dt: number): FighterCommand | the command for this tick |
packages/combat/src/ai.ts:31
FighterBody class
class FighterBodyMoves a body with its fighter: the root where the fighter stands and turned as it faces, and the bones posed by the clips of its state through the animation backend (GroupBackend: the groups are sampled at the times the rules give, never started). A clip that gives way keeps playing while it fades out (fade seconds); an additive clip (a hit reaction of UE's) lies on top of the guard.
readonly backend: GroupBackend | |
constructor(readonly fighter: Fighter, readonly root: BABYLON.TransformNode, readonly groups: Record<string, BABYLON.AnimationGroup>, readonly o: { ground?: number; fade?: number } = {}) | |
snap() | drop the clips that are fading out: the next update shows the fighter's pose alone, at once (for posing a body by hand, as a reach is measured) |
update(dt: number) | one frame: dt the time since the last (the clips that fade out run on by it) |
packages/combat/src/bones.ts:54
Functions
add function
function add: (a: V3, b: V3) => V3packages/combat/src/geometry.ts:10
attackReach function
function attackReach(id: string, set: MoveSet, attack: string, geometry: BodyGeometry, depth = 0.2): numberroot-to-root distance at which an attack of a move set lands on a body depth thick (metres; measured with a fighter standing at the origin facing +z)
packages/combat/src/reach.ts:6
capsuleOverlap function
function capsuleOverlap(x: Capsule, y: Capsule): numberwhether two capsules overlap, and by how much (positive: they cut into each other)
packages/combat/src/geometry.ts:39
dot function
function dot: (a: V3, b: V3) => numberpackages/combat/src/geometry.ts:12
fromScene function
function fromScene: (v: Vector3) => V3a scene point in combat's frame
packages/combat/src/bones.ts:10
groupOf function
function groupOf(groups: Record<string, BABYLON.AnimationGroup>, name: string): BABYLON.AnimationGroup | undefineda clip of a body's bound groups by its own name: the group of that name, or the one that ends in /<name> (a clip set bound with a prefix)
packages/combat/src/bones.ts:45
len function
function len: (a: V3) => numberpackages/combat/src/geometry.ts:13
lerp3 function
function lerp3: (a: V3, b: V3, u: number) => V3packages/combat/src/geometry.ts:14
scale function
function scale: (a: V3, k: number) => V3packages/combat/src/geometry.ts:11
seededRandom function
function seededRandom(seed: number): () => numbera small seeded random source (mulberry32): the same seed, the same fight
packages/combat/src/ai.ts:26
segmentSegment function
function segmentSegment(p0: V3, p1: V3, q0: V3, q1: V3): { d: number; p: V3; q: V3 }the nearest points of two segments p0-p1 and q0-q1, and their distance
packages/combat/src/geometry.ts:17
sub function
function sub: (a: V3, b: V3) => V3packages/combat/src/geometry.ts:9
sweepHit function
function sweepHit(a0: V3, b0: V3, a1: V3, b1: V3, capsules: readonly Capsule[], o: { steps?: number; thickness?: number } = {}): { capsule: Capsule; point: V3; at: number } | nullThe first contact of a striking segment that moves from (a0, b0) to (a1, b1) with any of the capsules: the moving segment is tested at steps moments between (a fast blade crosses a forearm within a frame), the earliest touching one wins. thickness is the half width of the striking part.
packages/combat/src/geometry.ts:47
toScene function
function toScene: (p: V3) => Vector3a combat point in the scene
packages/combat/src/bones.ts:12
toWorld function
function toWorld(f: Fighter, p: V3): V3a point of a fighter's skeleton frame (glTF: y up, faces +z) in the world: turned by its yaw, standing at its position
packages/combat/src/pose.ts:22
Interfaces
AiLevel interface
interface AiLevelreaction: number | seconds before it sees the opponent's attack start |
parry: number | chances, once it has seen an attack coming: parry it, block it, step away (the rest: it does not answer) |
block: number | |
dodge: number | |
aggression: number | attacks it starts per second when in reach and ready, and the chance a light attack is chained / an attack is the heavy one |
combo: number | |
heavy: number | |
tired: number | below this stamina it backs off and waits |
counter: number | the chance it strikes back at once after it has blocked or parried a blow (the attacker is still recovering) |
packages/combat/src/ai.ts:7
AttackDef interface
interface AttackDefid: string | |
clip: string | |
damage: number | |
stamina: number | stamina it costs to start |
window?: [number, number] | when the weapon can hit, seconds into the clip (default: the clip's hit-start .. hit-end notifies) |
chain?: string | the attack a second press chains into, and from when in this one the press is taken |
chainFrom?: number | |
reach?: number | how close the roots must be for it to land, metres (measured from the clip when absent: attackReach) |
track?: number | the body turns toward the opponent this much of its turn rate while the attack winds up (before the window), none after |
packages/combat/src/fighter.ts:13
BodyGeometry interface
interface BodyGeometrywhere a fighter's weapon strikes and what of it can be hit, in the world (right-handed, y up)
weapon(f: Fighter): [V3, V3] | the striking part of the weapon: from its inner end to its outer end |
capsules(f: Fighter): Capsule[] |
packages/combat/src/pose.ts:9
BodySpec interface
interface BodySpecrest: Rest | |
capsules: CapsuleSpec[] | |
weapon: WeaponSpec |
packages/combat/src/pose.ts:19
BoneBody interface
interface BoneBodynodes: Record<string, BABYLON.TransformNode> | |
capsules: CapsuleSpec[] | |
weapon: WeaponSpec |
packages/combat/src/bones.ts:14
Capsule interface
interface Capsulea capsule: the segment a-b grown by r (a body part in space)
packages/combat/src/geometry.ts:7
CapsuleSpec interface
interface CapsuleSpeca capsule between two joints of a body (extend metres past the second: the crown of the head, the fingertips)
name: string | |
from: string | |
to: string | |
extend?: number | |
radius: number |
packages/combat/src/pose.ts:16
ClipTiming interface
interface ClipTimingwhat a clip says about itself: its length and its named moments (hit-start, hit-end, parry-start, parry-end)
duration: number | |
loop?: boolean | |
notifies?: { name: string; time: number }[] | |
additive?: boolean | an additive clip (a hit reaction of UE's): it plays on top of the guard, not instead of it |
packages/combat/src/fighter.ts:7
DuelOptions interface
interface DuelOptionsarena?: number | the radius of the field (metres, centred on the origin) |
keepApart?: number | the roots of the two are kept this far apart (metres) |
parryWindow?: number | a block that began this long before the blow is a parry (seconds; default 0.15) |
blockArc?: number | the side a block covers: blows from within this angle of the facing (degrees) |
parts?: Record<string, number> | how much a part takes of an attack's damage (others: 1) |
guardReach?: number | a defender who guards (blocks facing the blow, or is inside his parry window) meets the blade this far in front of his body: the blade stops on the guard, not inside him (metres; default 0.25) |
blockCost?: number | stamina a blocked blow costs, per point of the attack's damage (default 0.7) |
restartAfter?: number | start again this long after the fight is over (seconds); absent: wait for restart() |
starts: [{ pos: V2; yaw: number }, { pos: V2; yaw: number }] | where the two start: positions and yaws |
packages/combat/src/duel.ts:17
FighterCommand interface
interface FighterCommandwhat drives a fighter each tick: from the player's input or from the AI
move?: V2 | where to go, in the fighter's own frame: [right, forward], each -1..1 |
face?: V2 | a point on the ground (world x, z) to face; the body turns to it at its turn rate |
attack?: boolean | pressed this tick (an edge, not held) |
heavy?: boolean | |
block?: boolean | held |
dodge?: boolean | V2 | pressed this tick: a step away; [right, forward] picks the way (default straight back) |
run?: boolean |
packages/combat/src/fighter.ts:52
FighterState interface
interface FighterStatekind: FighterStateKind | |
clip: string | |
t: number | |
attack?: AttackDef | |
landed?: boolean | |
queued?: boolean | |
dodgeDir?: V2 |
packages/combat/src/fighter.ts:68
MoveSet interface
interface MoveSetclips: Record<string, ClipTiming> | |
guard: string | |
walk: { fwd: string; bwd: string; left: string; right: string } | |
run?: string | |
attacks: AttackDef[] | |
light: string | the first attack of the light chain, and the heavy one (the held attack) |
heavy: string | |
block: string | |
blockImpact: string | |
parry?: string | a parry clip (the move a well-timed block becomes); without one the block impact plays |
hit: { front: string; back: string; left: string; right: string } | |
stumble: { forward: string; backward: string } | |
death: { forward: string; backward: string } | |
dodge: { clip: string; distance: number; time: number; stamina: number } | |
speed: { walk: number; run: number; back: number; strafe: number; turn: number } | metres per second; turn degrees per second |
health?: number | |
stamina?: number | |
stun?: number | how long a blow that lands locks the fighter in its reaction (seconds; default the reaction clip's length) |
packages/combat/src/fighter.ts:29
WeaponSpec interface
interface WeaponSpeca weapon held in a hand: its grip in the hand's frame (a library socket's grip) and the part that strikes, metres along the grip's axis from the grip point
packages/combat/src/pose.ts:18
Types
DuelEvent type
type DuelEvent = | { type: 'hit'; t: number; attacker: string; defender: string; attack: string; part: string; damage: number; point: V3; side: 'front' | 'back' | 'left' | 'right'; clipTime: number; health: number } | { type: 'blocked'; t: number; attacker: string; defender: string; attack: string; point: V3; stamina: number; broken: boolean; clipTime: number } | { type: 'parried'; t: number; attacker: string; defender: string; attack: string; point: V3; clipTime: number } | { type: 'death'; t: number; fighter: string; by: string } | { type: 'scorched'; t: number; defender: string; damage: number; health: number } | { type: 'over'; t: number; winner: string; loser: string } | { type: 'restart'; t: number }packages/combat/src/duel.ts:8
FighterStateKind type
type FighterStateKind = 'free' | 'attack' | 'block' | 'blockImpact' | 'parry' | 'react' | 'stagger' | 'dodge' | 'dead'packages/combat/src/fighter.ts:67
V2 type
type V2 = [number, number]packages/combat/src/fighter.ts:4
V3 type
type V3 = [number, number, number]packages/combat/src/geometry.ts:4
Constants
AI_LEVELS const
const AI_LEVELS: Record<"normal" | "easy" | "hard", AiLevel>packages/combat/src/ai.ts:19
STAMINA_DELAY const
const STAMINA_DELAY: 0.6packages/combat/src/fighter.ts:70
STAMINA_REGEN const
const STAMINA_REGEN: 18packages/combat/src/fighter.ts:70