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
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, …)
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.
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).
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.
sprite.anim.define(): this // rebuild native clips after mutating anim.clipsPlay, stop and speed up a clip: anim.play
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.
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:
hero.addEventListener('loopReached', clip => {}) // a looping clip wrapped around (each loop)
hero.addEventListener('completed', clip => {}) // a non-looping clip finishedA perDirection clip reports its expanded name ('die_S', not 'die').
See also
- Sprite — the host node; manual
frame/setFramePx - Sprite sheets — clips by name from a
.sprite.tsfile - Texture2D — loading the sheet
- Node2D —
addEventListener