Animation
This guide covers two complementary packages:
@web-engine-dev/sprites- 2D sprite sheet animation (SpriteAnimationController)@web-engine-dev/animation- skeletal animation, blend trees, state machines, and IK; ECS integration via the@web-engine-dev/animation/ecssub-path
2D Sprite Sheet Animation
Sprite animation is provided by the @web-engine-dev/sprites package via SpriteAnimationController.
Defining Animations
import {
SpriteAnimationController,
createAnimationController,
createAnimationDefinition,
createAnimationId,
createSpriteId,
type AnimationDefinition,
} from '@web-engine-dev/sprites';
// Animation IDs identify each animation
const idle = createAnimationId('idle');
const run = createAnimationId('run');
const jump = createAnimationId('jump');
const attack = createAnimationId('attack');
const fall = createAnimationId('fall');
// Frame IDs correspond to named sprites in your SpriteSheet / SpriteAtlas.
// frameDuration = seconds per frame (1 / fps).
// playMode: 'loop' | 'once' | 'ping-pong'
const idleDef = createAnimationDefinition(
idle,
[0, 1, 2, 3].map((i) => createSpriteId(`hero_${i}`)),
{ frameDuration: 1 / 8, playMode: 'loop' }
);
const runDef = createAnimationDefinition(
run,
[8, 9, 10, 11, 12, 13, 14, 15].map((i) => createSpriteId(`hero_${i}`)),
{ frameDuration: 1 / 12, playMode: 'loop' }
);
const jumpDef = createAnimationDefinition(
jump,
[16, 17, 18].map((i) => createSpriteId(`hero_${i}`)),
{ frameDuration: 1 / 10, playMode: 'once' }
);
const attackDef = createAnimationDefinition(
attack,
[24, 25, 26, 27, 28].map((i) => createSpriteId(`hero_${i}`)),
{ frameDuration: 1 / 15, playMode: 'once' }
);Creating the Controller
SpriteAnimationController is a per-entity, non-ECS object. Create one per animated entity and drive it from your update system.
const animations = new Map<AnimationId, AnimationDefinition>([
[idle, idleDef],
[run, runDef],
[jump, jumpDef],
[attack, attackDef],
]);
const playerAnim = createAnimationController({
animations,
events: {
onComplete: (animId) => console.log(`${animId} finished`),
onLoop: (animId, loopCount) => {
/* optional */
},
onFrameChange: (animId, frameIdx, _frameId) => {
// Trigger hit detection on a specific frame of attack
if (animId === attack && frameIdx === 3) activateHitbox(world, playerEntity);
},
},
});
// Play the default animation
playerAnim.play(idle);Controlling Playback
// SpriteAnimationController is per-entity - not a global resource
const anim = playerAnim; // your SpriteAnimationController for this entity
// Play a clip
anim.play(run);
// Pause / resume
anim.pause();
anim.resume();
// Inspect state
const isAttacking = anim.currentAnimationId === attack;
const currentFrame = anim.currentFrameIndex;
const isPlaying = anim.isPlaying;
// Advance time - call every frame from your update system
anim.update(deltaTime);Animation-Driven Systems
// Map entity ID → SpriteAnimationController, populated when entities spawn
const animControllers = new Map<number, SpriteAnimationController>();
function PlayerAnimationSystem(world: World): void {
const playerAnimQ = world.query().with(Velocity, Player).build();
for (const result of world.run(playerAnimQ)) {
const [vel] = result.components as [{ x: number; y: number }];
const entity = result.entity as number;
const anim = animControllers.get(entity);
if (!anim) continue;
if (anim.currentAnimationId === attack) continue; // don't interrupt attack
const isGrounded = world.has(entity, Grounded);
const speed = Math.abs(vel.x);
if (!isGrounded && vel.y < 0) {
anim.play(jump);
} else if (!isGrounded && vel.y > 0) {
anim.play(fall);
} else if (speed > 10) {
anim.play(run);
} else {
anim.play(idle);
}
anim.update(deltaTime); // advance frame
}
}3D Skeletal Animation
Loading and Playing Clips
Skeletal animations are typically embedded in glTF files. ECS integration is in the @web-engine-dev/animation/ecs sub-path:
import { loadGltf } from '@web-engine-dev/gltf';
import {
AnimationClipPlayer,
AnimationClipRegistry,
AnimationClipRegistryResource,
AnimationGraphSystem,
AnimationEvaluateSystem,
RootMotionSystem,
} from '@web-engine-dev/animation/ecs';
// Register ECS systems in your world setup
world.addSystem(AnimationGraphSystem);
world.addSystem(AnimationEvaluateSystem);
world.addSystem(RootMotionSystem);
// Build a clip registry and insert it as an ECS resource
const clipRegistry = new AnimationClipRegistry();
const idleClipId = clipRegistry.register(idleClip); // AnimationClip instance
const runClipId = clipRegistry.register(runClip);
world.insertResource(AnimationClipRegistryResource, clipRegistry);
// Load the model (embedded clips are parsed in your asset loader)
const heroAsset = await loadGltf('models/hero.glb');
const heroEntity = heroAsset.instantiate(world, {
[Position]: { x: 0, y: 0, z: 0 },
});
// Attach AnimationClipPlayer for simple single-clip playback
world.insert(heroEntity, AnimationClipPlayer, {
clipId: idleClipId,
time: 0,
speed: 1,
looping: 1, // 1 = loop
playing: 1, // 1 = playing
});To switch clips, write the component back:
const player = world.get(heroEntity, AnimationClipPlayer);
world.insert(heroEntity, AnimationClipPlayer, { ...player, clipId: runClipId, time: 0 });Animation State Machine
For state-machine-driven animation, use AnimationPlayer (ECS component) with AnimationGraphRegistry:
import {
AnimationPlayer,
AnimationParameters,
AnimationGraphRegistry,
AnimationGraphRegistryResource,
type AnimationGraphDefinition,
} from '@web-engine-dev/animation/ecs';
// Define the graph
const heroGraph: AnimationGraphDefinition = {
name: 'Hero',
defaultStateIndex: 0,
states: [
{ name: 'idle', clipId: idleClipId, loop: true },
{ name: 'run', clipId: runClipId, loop: true },
{ name: 'jump', clipId: jumpClipId, loop: false },
{ name: 'fall', clipId: fallClipId, loop: true },
{ name: 'attack', clipId: attackClipId, loop: false },
{ name: 'death', clipId: deathClipId, loop: false },
],
// float0 = speed, float1 = velocityY, float2 = isGrounded
transitions: [
{
fromStateIndex: 0,
toStateIndex: 1,
conditions: [{ parameterIndex: 0, operator: 'greater', value: 0.1 }],
duration: 0.1,
},
{
fromStateIndex: 1,
toStateIndex: 0,
conditions: [{ parameterIndex: 0, operator: 'less', value: 0.1 }],
duration: 0.1,
},
{ fromStateIndex: 0, toStateIndex: 2, triggerIndex: 0, duration: 0.05 }, // jump trigger
{ fromStateIndex: 1, toStateIndex: 2, triggerIndex: 0, duration: 0.05 },
{
fromStateIndex: 2,
toStateIndex: 3,
conditions: [{ parameterIndex: 1, operator: 'greater', value: 0 }],
duration: 0.1,
},
{
fromStateIndex: 3,
toStateIndex: 0,
conditions: [{ parameterIndex: 2, operator: 'greaterOrEqual', value: 1 }],
duration: 0.1,
},
],
parameters: [
{ name: 'speed', type: 'float', slotIndex: 0, defaultValue: 0 },
{ name: 'velocityY', type: 'float', slotIndex: 1, defaultValue: 0 },
{ name: 'isGrounded', type: 'bool', slotIndex: 2, defaultValue: 1 },
],
};
// Register graph and insert resource
const graphRegistry = new AnimationGraphRegistry();
const heroGraphId = graphRegistry.register(heroGraph);
world.insertResource(AnimationGraphRegistryResource, graphRegistry);
// Attach AnimationPlayer to the entity
world.insert(heroEntity, AnimationPlayer, {
graphId: heroGraphId,
currentStateIndex: 0,
stateTime: 0,
speed: 1,
paused: 0,
});
// Attach AnimationParameters - float0-7 drive transition conditions
world.insert(heroEntity, AnimationParameters, {
float0: 0, // speed
float1: 0, // velocityY
float2: 1, // isGrounded
float3: 0,
float4: 0,
float5: 0,
float6: 0,
float7: 0,
triggers: 0,
});Standalone State Machine
For non-ECS usage (e.g. inside a script), use the AnimationStateMachine class directly:
import { AnimationStateMachine } from '@web-engine-dev/animation';
const sm = new AnimationStateMachine();
sm.setParameter('speed', 0);
sm.addTransition({
fromState: 'idle',
toState: 'run',
conditions: [{ parameter: 'speed', operator: 'greater', value: 0.1 }],
duration: 0.1,
});
sm.update(deltaTime);Updating State Machine Parameters
function HeroAnimationSystem(world: World): void {
const q = world.query().with(Velocity, AnimationParameters).build();
for (const result of world.run(q)) {
const [vel, params] = result.components as [
{ x: number; y: number; z: number },
{
float0: number;
float1: number;
float2: number;
float3: number;
float4: number;
float5: number;
float6: number;
float7: number;
triggers: number;
},
];
const entity = result.entity as number;
const speed = Math.sqrt(vel.x * vel.x + vel.z * vel.z);
const isGrounded = world.has(entity, Grounded) ? 1 : 0;
world.insert(entity, AnimationParameters, {
...params,
float0: speed, // speed
float1: vel.y, // velocityY
float2: isGrounded, // isGrounded
});
}
}Blend Trees
Blend trees smoothly interpolate between animations based on parameters. Use BlendTree with typed node objects:
import { BlendTree } from '@web-engine-dev/animation';
import { BlendTreeRegistry, BlendTreeRegistryResource } from '@web-engine-dev/animation/ecs';
// 1D blend tree: blend idle → walk → run by speed
const locomotionRoot: Blend1DTreeNode = {
type: 'blend1D',
name: 'locomotion',
parameter: 'speed',
children: [
{ node: idleClip, threshold: 0 },
{ node: walkClip, threshold: 0.5 },
{ node: runClip, threshold: 1.0 },
{ node: sprintClip, threshold: 1.5 },
],
};
// 2D blend tree: directional strafe movement
const strafeRoot: Blend2DTreeNode = {
type: 'blend2D',
name: 'strafe',
parameterX: 'velocityX',
parameterY: 'velocityZ',
blendType: '2DFreeformDirectional',
children: [
{ position: { x: 0, y: 0 }, node: idleClip },
{ position: { x: 0, y: 1 }, node: walkForwardClip },
{ position: { x: 0, y: -1 }, node: walkBackwardClip },
{ position: { x: 1, y: 0 }, node: strafeRightClip },
{ position: { x: -1, y: 0 }, node: strafeLeftClip },
],
};
// For ECS use, register trees in BlendTreeRegistry
const blendTreeRegistry = new BlendTreeRegistry();
const locomotionTreeId = blendTreeRegistry.register(
new BlendTree(locomotionRoot, skeleton.boneCount)
);
world.insertResource(BlendTreeRegistryResource, blendTreeRegistry);Inverse Kinematics (IK)
IK uses standalone solver classes (not ECS components). Solvers operate on Pose objects:
import { TwoBoneSolver, LookAtSolver, IKManager } from '@web-engine-dev/animation';
// Two-bone IK for arm reaching towards a target position
const armSolver = new TwoBoneSolver();
const adjustedPose = armSolver.solve(
currentPose,
{
type: 'twobone',
rootBone: skeleton.getBoneIndex('UpperArm.R'),
midBone: skeleton.getBoneIndex('LowerArm.R'),
endBone: skeleton.getBoneIndex('Hand.R'),
target: { x: targetX, y: targetY, z: targetZ },
pole: { x: poleX, y: poleY, z: poleZ }, // elbow hint
weight: 1.0,
},
skeleton
);
// Look-at IK for head / eyes tracking a target
const lookAtSolver = new LookAtSolver();
const headPose = lookAtSolver.solve(
currentPose,
{
type: 'lookAt',
bone: skeleton.getBoneIndex('Head'),
target: cameraPosition,
weight: 0.7,
forwardAxis: { x: 0, y: 0, z: 1 },
upAxis: { x: 0, y: 1, z: 0 },
},
skeleton
);
// IKManager composes multiple solvers in a defined order
const ikManager = new IKManager();
ikManager.addSolver('arm', armSolver);
ikManager.addSolver('head', lookAtSolver);Animation Events
React to embedded clip events via the ECS event bus or the standalone AnimationPlayerWithEvents:
import { AnimationEventFiredEvent, AnimationCompleteEvent } from '@web-engine-dev/animation/ecs';
import { AnimationPlayerWithEvents, AnimationClipWithEvents } from '@web-engine-dev/animation';
// --- Standalone (non-ECS) approach ---
const attackClipWithEvents = new AnimationClipWithEvents({
name: 'attack',
duration: 1.0,
tracks: [],
loopMode: 'once',
events: [
{ time: 0.2, name: 'windUp' },
{ time: 0.5, name: 'strike', data: { damage: 10 } },
{ time: 0.9, name: 'recover' },
],
});
const attackPlayer = new AnimationPlayerWithEvents(attackClipWithEvents);
attackPlayer.onEvent((event) => {
switch (event.name) {
case 'strike':
activateHitbox(event.data?.damage as number);
break;
case 'recover':
deactivateHitbox();
break;
}
});
attackPlayer.play();
attackPlayer.update(deltaTime);
// --- ECS approach: read from AnimationEventFiredEvent ---
function AnimationEventHandlerSystem(world: World): void {
for (const { entity, eventName } of world.eventReader(AnimationEventFiredEvent).read()) {
if (!world.has(entity, Player)) continue;
switch (eventName) {
case 'hitActive':
activateHitbox(world, entity);
break;
case 'hitInactive':
deactivateHitbox(world, entity);
break;
case 'footstep':
world.eventWriter(PlaySoundEvent).send({ clip: 'footstep', entity });
break;
}
}
}Next Steps
- Visual Effects, particle effects triggered by animation events
- Audio, footstep and attack sounds tied to animation frames
- AI & NPCs, enemy animation state machines