Skip to content

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/ecs sub-path

2D Sprite Sheet Animation

Sprite animation is provided by the @web-engine-dev/sprites package via SpriteAnimationController.

Defining Animations

typescript
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.

typescript
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

typescript
// 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

typescript
// 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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

typescript
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:

typescript
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:

typescript
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:

typescript
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

Proprietary software. All rights reserved.