FERRETGame engine over Babylon.js
Ferret

Melee combat (combat)

@ferret/combat runs a fight between two armed bodies by rules that come from their clips. The player and the AI give the same commands to the same Fighter, so the AI fights with exactly the animations and timings the player has, and a role can pass from one to the other at any moment. The rules work on plain numbers (no Babylon), so a whole fight runs in Node in a test; the Babylon side reads the hit shapes from the real bones and poses the bodies.

The sample is examples/fight: a Viking with an axe against a Knight with a sword, both on the AI.

Fighter#

A Fighter is one body's state machine. Its MoveSet names the clips it uses and their timings (ClipTiming: length, loop, notifies, additive); the timings come from the library's clip set items, so the rules always match the clips played.

Commands (FighterCommand), the same for the player and the AI:

CommandEffect
move: [right, forward]walk, back off or strafe; the locomotion clip is picked by the velocity
runrun instead of walk
face: [x, z]turn toward a point at speed.turn
attackthe light attack; a second press during it chains the next one (chain, chainFrom)
heavythe heavy attack
block (held)raise the guard
dodgea quick step back (the dodge clip, its distance and time)

States (FighterStateKind): free, attack, block, blockImpact, parry, react, stagger, dodge, dead. Each plays a clip of the move set; how long it lasts is the clip's length. An attack (AttackDef) can hit only between its clip's hit-start and hit-end notifies (or its own window), it turns toward the opponent only while it winds up (track), and it costs stamina. Stamina is also spent on dodges and blocked blows and comes back after a short rest (STAMINA_REGEN, STAMINA_DELAY).

Duel#

A Duel holds two fighters on a round field. step takes one command per fighter and returns the events of the tick (DuelEvent). In each tick the weapon of a fighter inside its hit window is swept from its place in the frame before to its place now against the opponent's capsules (sweepHit), so a fast blade does not pass through a thin arm between two frames. A defender who guards (blocks facing the blow, or is inside his parry window) meets the blade guardReach (0.25 m) in front of his body, so the blade stops on the guard, not inside him. A blow that meets the body or the guard is:

  • parried when the defender began his block at most parryWindow (0.15 s) before it, or is inside a parry clip's window: the attacker staggers;
  • blocked when the defender blocks and the attacker stands in front of him (within 75 degrees): the defender plays the block impact and pays stamina for the damage, the attacker's blade bounces back to his guard; with no stamina left the guard breaks and he staggers;
  • a hit otherwise: damage by the part struck (head more, hands and forearms less), a reaction by the side it came from, and at no health the death clip that falls away from the blow.

Something outside the duel can hurt a fighter too: duel.scorch(fighter, damage) (the demo's Dragon fire) takes health and staggers him (event scorched), leaves him alone while he is reeling, and the last of his health ends the fight for the other one (death with by: 'fire', then over).

Then the fight is over; with restartAfter (or by restart(), called when the game decides, as the demo does after its ending) both go back to their places with full health. The duel keeps the two bodies apart (their trunk and head capsules only touch; bodiesOverlap() measures it).

Hit geometry#

BodyGeometry gives a fighter's capsules and the striking part of his weapon as he is posed now. Two sources:

  • BoneGeometry reads them from the body's bones in the scene, after the frame's animations and IK;
  • ClipPoseGeometry computes them from clip JSON on the body's rest pose, for Node.

A body is described by a BodySpec: its capsules (CapsuleSpec: from one bone to another, with a radius) and its weapon (WeaponSpec: the bone that holds it, the grip, and the striking part as a range along the grip axis). tools/fight-clips/capsules.mjs measures the capsules from the skinned mesh, so they follow the real silhouette. attackReach measures how far an attack lands from its clip; the AI keeps the distance its weapon wants by it.

Combat's frame is right-handed, y up (glTF's). A Babylon scene is left-handed and its glTF loader mirrors models into it, so a scene point is (-X, Y, Z) in combat (fromScene, toScene) and a yaw is the negative of rotation.y.

Bodies in the scene#

FighterBody moves a body with its fighter each frame: the root where the fighter stands, turned as he faces, and the bones posed by the clips of his state through the animation backend (GroupBackend of animation). Clips are sampled at the time the rules give, never started on their own, with short crossfades between states. A clip marked additive (Unreal's additive hit reactions) lies on top of the guard instead of replacing it.

Call order in a frame: FighterBody.update before the animations, then the animations and IK, then Duel.step reading the bones.

AI#

FighterAI is a behaviour tree of ai over the same commands. Levels (AI_LEVELS: easy, normal, hard, or your own AiLevel) set the reaction time, the chances to parry, block and dodge a seen attack, aggression, combos, heavy attacks, when it tires and how often it strikes back. It closes in, circles, keeps its own weapon's distance, answers a seen attack after its reaction time, chains and backs off when tired. Its random choices come from seededRandom: the same seed gives the same fight.

const duel = new Duel([viking, knight], geometry, { starts, restartAfter: 3 });
const ais = [new FighterAI(viking, duel, AI_LEVELS.normal, seededRandom(1), reach),
             new FighterAI(knight, duel, AI_LEVELS.hard, seededRandom(2), reach)];
scene.onAfterAnimationsObservable.add(() => {
  const dt = scene.getEngine().getDeltaTime() / 1000;
  for (const e of duel.step(dt, [ais[0].think(dt), ais[1].think(dt)])) if (e.type === 'over') console.log(`${e.winner} wins`);
});

A player's fighter takes its command from input instead of think; nothing else changes.

Testing a fight#

Because the rules are plain numbers, a fight can run unattended in Node with ClipPoseGeometry: packages/combat/test/demo-duel.test.ts runs sixteen AI-against-AI fights of the demo and checks that every fight ends and restarts, that blows land only inside the hit windows, that the bodies never overlap, that both sides win and that a hard AI beats an easy one.