SpriteAnimation

Frame-by-frame sprite-sheet animation, as an aspect on a Sprite (accessor node.anim): a frame cell size plus named clips of frame indices, played by name; playback runs in native code. To keep the rects and clips in a file, use a sprite sheet (.sprite.ts), whose make() attaches this aspect; to tween position, opacity or color, use animateTo.

  • animate a sprite from a sprite sheet
  • play a walk or idle animation by name
  • make a character face the direction it walks
  • play a one-shot animation and react when it ends
  • change the animation speed or stop on the current frame
  • animate from one region of a bigger sheet

Platforms: iOS, Android, desktop and the web player; not on the web (Web Lite); headless tests advance the clips and fire their events but draw nothing.

At a glance

TypeScript
async function main() {
  const scene = new Scene2D()
  const tex = await Texture2D.load(asset('./hero.png'))

  const hero = new Sprite({ texture: tex, anchor: [0.5, 1] })
    .aspect(SpriteAnimation, {
      size: [32, 48],                                // one frame cell, in texture pixels
      fps: 10,
      clips: {
        idle: [0, 1],
        walk: [4, 5, 6, 7],
        die:  { frames: [8, 9, 10], fps: 6, loop: false },
      },
    })

  hero.anim.play('walk')
  hero.addEventListener('completed', clip => { if (clip === 'die') hero.destroy() })
  scene.add(hero)
  scene.open()
}
main()

Define clips from a sheet: aspect(SpriteAnimation, …)

TypeScript
sprite.aspect(SpriteAnimation, {
  size?: [w, h]                    // frame cell size in texture pixels; default = full texture
  cols?: number                    // grid columns override; default floor(texture.width / cellW)
  origin?: [x, y]                  // pixel origin of the grid (a sheet region); default [0, 0]
  fps?: number                     // default fps for clips; default 12
  loop?: boolean                   // default loop for clips; default true
  directions?: string[]            // facing names in texture-row order (see perDirection)
  clips?: Record<string, Clip>     // Clip = number[] | { frames, fps?, loop?, perDirection? }
})

Frame indices count row-major through the sheet's grid: the grid has floor(texture.width / cellW) columns, index 0 is the top-left cell, and indices continue left-to-right, then down. A clip is either a plain index array (inherits fps/loop) or an object with per-clip overrides. origin (and cols) put the grid on one region of a bigger sheet: index 0 is then the cell at origin.

Note

the native clips are sliced from the texture's pixel size, so they're defined when the sprite has a texture at attach time. Without one (a SpriteSheet's lazy load), the define is deferred — play() calls queue by name and start once the texture arrives (unknown names then throw at define time).

Note

passing size also makes the sprite draw one cell at 1 texel = 1 world unit — but sprite.size keeps reading the texture's size, and so does an auto collider (Shape2D {}) measured from it. Give the Sprite the same size when a collider should wrap one cell.

TypeScript
sprite.anim.define(): this    // rebuild native clips after mutating anim.clips

Play, stop and speed up a clip: anim.play

TypeScript
sprite.anim.play(name: string, dir?: string | Vec2Like): this
sprite.anim.stop(): this                // holds the current frame
sprite.anim.speed = 2                   // playback rate multiplier (write-only)
sprite.anim.current                     // name of the playing clip (expanded, e.g. 'walk_SE')
sprite.anim.direction                   // current facing, or null before the first directional play
sprite.anim.frame                       // position in the playing clip's frame list, 0-based (read-only)

Re-playing the already-active clip is a no-op — safe to call play('walk') every frame from input code without restarting the animation. An unknown name throws once the clips are defined.

A non-looping clip stops on its last frame and stays the current clip, so play() with the same name does not restart it. To replay a one-shot, play another clip first: hero.anim.play('idle'); hero.anim.play('attack').

Face the walking direction: perDirection clips

To make a character face the way it walks, draw the sheet with one texture row per facing. Set directions in row order — compass tokens S/SE/E/NE/N/NW/W/SW — and mark clips perDirection: true. Each expands to one native clip per facing (walk_SE, …) with the base row-0 frames shifted down by the row.

play(name, dir) picks the facing: a direction name, or a movement vector (Y-up; the nearest compass row wins — play('walk', inputVector) is the whole walk loop). The last direction sticks, so a later play('idle') keeps facing; a zero vector keeps it too.

TypeScript
async function main() {
  const scene = new Scene2D()
  const tex = await Texture2D.load(asset('./hero-8dir.png'))   // one texture row per facing

  const hero = new Sprite({ texture: tex, anchor: [0.5, 1] })
    .aspect(SpriteAnimation, {
      size: [64, 64],
      directions: ['S', 'SE', 'E', 'NE', 'N', 'NW', 'W', 'SW'],
      clips: { idle: { frames: [0], perDirection: true }, walk: { frames: [1, 2, 3, 4], perDirection: true } },
    })
  scene.add(hero)
  scene.open()

  hero.anim.play('walk', [1, 1])   // the movement vector (Y-up) picks the row: walk_NE
  hero.anim.play('idle')           // still facing NE
}
main()

Know when a clip finished or looped: completed, loopReached

To know when a one-shot sprite animation finished (or a looping one wrapped), listen on the node; the events carry the clip name:

TypeScript
hero.addEventListener('loopReached', clip => {})   // a looping clip wrapped around (each loop)
hero.addEventListener('completed', clip => {})     // a non-looping clip finished

A perDirection clip reports its expanded name ('die_S', not 'die').

See also