Sprites And Animation¶
spriteSheet({...}) describes a sprite image, its frame grid, and its named
animations. Tua checks the configuration while you type and compiles it to the
bundled anim8 runtime. Each sheet creates
lightweight players that track their own animation, frame, and timing.
local Hero = spriteSheet({
image = "assets/hero.png",
frame = { width = 32, height = 32 },
origin = { x = 16, y = 32 },
animations = {
idle = { from = 0, to = 3, row = 0, fps = 6 },
run = { from = 0, to = 7, row = 1, fps = 12 },
},
})
local hero = Hero:player("idle")
function love.update(dt: number)
if love.keyboard.isDown("right") then
hero:play("run")
else
hero:play("idle")
end
hero:update(dt)
end
function love.draw()
hero:draw(100, 200)
end
Animation names are checked: hero:play("jump") is a type error until the
sheet declares jump, and completion only offers idle and run. Calling
play with the animation that is already playing keeps its place, so calling
it every frame is fine.
Declare A Sheet¶
A sheet names an image, a default frame size, and its animations. Grid rows, columns, and marker frames count from 0 in the configuration; Tua converts them to anim8's one-based selectors when it compiles:
local background = spriteSheet({
image = "assets/images/spritesheet_stall.png",
frame = { width = 256, height = 256, left = 2, top = 4, border = 1 },
origin = { x = 128, y = 256 },
animations = {
orange = { column = 1, row = 0, durations = 1 },
blue = { from = 0, to = 3, row = 1, fps = 8, onLoop = "pauseAtEnd" },
green = {
frames = {
{ from = 0, to = 3, row = 2 },
{ from = 0, to = 1, row = 3 },
},
frame = { width = 128, height = 64, left = 8, top = 16, border = 2 },
origin = { x = 64, y = 64 },
durations = { 0.1, 0.5, 0.1, 0.1, 0.1, 0.1 },
markers = {
{ frame = 1, name = "step" },
},
onLoop = function(animation, loops)
if loops > 1 then
animation:pause()
end
end,
},
},
})
Frame Geometry¶
| Field | Meaning |
|---|---|
frame.width, frame.height |
The default grid cell size, in pixels. |
frame.left, frame.top |
Offset of the grid from the image's top-left corner. |
frame.border |
Spacing between adjacent cells. |
origin.x, origin.y |
The registration point: the pixel inside a frame that lands on the position you draw at. |
An animation may override frame when part of the image uses a different cell
size, and override origin when it needs a different registration point.
Choosing Frames¶
| Selector | Picks |
|---|---|
column = 1 |
One cell. |
from = 0, to = 3 |
An inclusive column range. |
row = 2 |
The row for column or from/to; defaults to 0. |
frames = { {...}, {...} } |
Ordered ranges, for animations that span rows. |
frames can't be combined with the top-level column, from, to, or row
selectors.
Timing And Looping¶
fps = 8is shorthand for a uniform duration of1 / 8seconds.durationsaccepts whatanim8.newAnimationaccepts: one number for every frame, a list such as{ 0.1, 0.5, 0.1 }, or ranges such as{ ['3-5'] = 0.2 }.onLoopruns when the animation wraps. It accepts the name of an animation method, such as"pauseAtEnd", or a function receiving(animation, loops). The olderloop = falseis shorthand foronLoop = "pauseAtEnd"and can't be combined withonLoop.
Tua rejects conflicting selector, timing, marker, and loop settings before it emits anything.
Players¶
sheet:player(name) creates a player and starts it on name; with no name it
starts on the sheet's first animation. Create one player per on-screen sprite.
Each player keeps its own animation, frame, and speed.
| Method | Behavior |
|---|---|
play(name, restart?) |
Play name. If it is already the current animation, it keeps its place unless restart is true. |
stop() |
Pause on the current frame. |
resume() |
Continue after stop. |
gotoAndPlay(name, frame?) |
Switch to name, jump to frame (default 1), and play. |
gotoAndStop(name, frame?) |
Switch to name, jump to frame (default 1), and pause there. |
seekFrame(frame, emitMarkers?) |
Jump to frame in the current animation. |
seek(progress, emitMarkers?) |
Jump to a fraction of the current animation, from 0 (first frame) to 1 (last). |
update(dt) |
Advance playback by dt seconds, scaled by speed, and update nested clips. |
draw(x, y, rotation?, scaleX?, scaleY?, originX?, originY?, shearX?, shearY?) |
Draw the current frame. scaleY defaults to scaleX; the origin defaults to the configured registration point. |
addToBatch(batch, x, y, ...) |
Add the current frame to a SpriteBatch with the same arguments as draw; see Batching. |
on(marker, callback) |
Run callback(player, marker, animation, frame) when playback reaches marker. |
off(marker) |
Remove every callback for marker. |
child(name) |
Return a nested clip's player; see Nested Clips. |
| Field | Meaning |
|---|---|
name |
The current animation's name, or nil. |
playing |
true while the player advances on update. |
speed |
Playback rate: 1 is normal, 2 is double speed, 0 freezes, and negative values play backward. |
Player frame numbers start at 1
The configuration counts rows, columns, and marker frames from 0. The
player's frame arguments (gotoAndPlay, gotoAndStop, seekFrame) and
the frame passed to marker callbacks count from 1, like anim8.
Out-of-range frames are clamped to the animation.
Speed And Manual Seeking¶
Use speed for slow motion, hit-stop, or a per-enemy tempo:
seek lets game state drive the pose instead of elapsed time. For example,
tie a run cycle to the distance travelled so feet don't slide:
local strideLength = 48
local distance = 0
function love.update(dt: number)
local speedX = 120
distance += speedX * dt
hero:seek((distance % strideLength) / strideLength)
end
Markers¶
markers name a frame of an animation, using the configuration's 0-based
frame index. Marker names are checked and completed in on and off:
Normal playback fires every marker it crosses, including across loops and
during one long update, so a slow frame doesn't skip footsteps. seek and
seekFrame are silent unless you pass true as emitMarkers.
Trimmed Frames And Metadata¶
Texture packers often trim empty pixels from each frame. Optional metadata
keeps one record per frame so trimmed frames still line up:
metadata = {
{ origin = { x = 64, y = 64 }, trimX = 0, trimY = 0, sourceWidth = 128, sourceHeight = 64 },
{ origin = { x = 66, y = 62 }, trimX = 2, trimY = 3, sourceWidth = 132, sourceHeight = 68 },
}
originis the frame's registration point in the untrimmed frame.trimXandtrimYplace the trimmed rectangle inside the untrimmed frame.sourceWidthandsourceHeightrecord the untrimmed size.
Players subtract the trim from the draw origin automatically, and the VS Code
preview rebuilds the untrimmed canvas. When metadata is present, it must have
exactly one entry per frame of the animation.
Nested Clips¶
A sheet can include other sheets as named child clips, forming a small display tree, such as a body with a hat:
-- Hat is another spriteSheet declared above.
local Body = spriteSheet({
image = "assets/player.png",
frame = { width = 32, height = 32 },
animations = { idle = { column = 0, fps = 1 } },
children = {
hat = { sheet = Hat, initial = "idle", x = 4, y = -8 },
},
})
local body = Body:player("idle")
body:child("hat"):gotoAndPlay("idle", 1)
Each child sets sheet, an optional initial animation, and an optional
local x, y, rotation, scaleX, and scaleY. Child names are checked and
completed. Children update with their parent and draw inside the parent's
translation, rotation, and scale. Use nested clips for attachments that move
together; keep game logic in ordinary code.
Batch Many Sprites¶
Drawing many sprites that share one image is faster through a LÖVE
SpriteBatch, which sends them to the GPU together. Add each player's current
frame yourself, so draw order stays under your control:
type EnemyDraw = { player: SpritePlayer, x: number, y: number }
local enemies: EnemyDraw[] = {}
local enemyBatch = spriteBatch(Enemy.image, 1000)
function love.draw()
enemyBatch:clear()
for _, enemy in ipairs(enemies) do
enemy.player:addToBatch(enemyBatch, enemy.x, enemy.y)
end
love.graphics.draw(enemyBatch)
end
addToBatch uses the current frame, registration point, trim metadata, flip
state, and transform, and returns the batch entry index, or nil when no
animation is selected.
- The batch must be created from the sheet's image (
Enemy.imageabove). - A player with nested clips raises an error, because a child may use another image. Draw it normally, or batch each leaf player separately.
Sharing And Performance¶
Creating players is cheap because every player shares its sheet's frame, duration, and interval arrays. Only the playback state (timer, current frame, status, and flips) belongs to each player, so spawning hundreds of animated enemies doesn't copy animation data.
Treat those shared arrays as read-only. Editing a player's animation arrays in place would change every player of that sheet:
- Use
player.speedto change one player's timing. - To change an animation's durations, build a new one with
anim8.newAnimation. anim8 only computes frame intervals when an animation is created, so editingdurationsafterward would leave them inconsistent. - If one player really needs its own copy, clone the animation and restart it. A player keeps its current animation until it selects one again, so the restart is what makes it use the copy:
A player draws one image per call. For large crowds, batch leaf players that share an image.
Editor Support¶
- Completion inside the configuration suggests sheet, frame, animation, range,
origin, metadata, and marker fields, inserts their value shapes, and offers
common
onLoopmethod names. - Hover explains each field, including the
loopsparameter of anonLoopcallback. Fixed fields navigate to their schema definitions. background.animations.greenis typed asSpriteAnimation, completes known animation names, and navigates to thegreenentry in the configuration.- The VS Code preview renders the sheet's frames and animations.
Runtime And Overrides¶
Builds write the bundled anim8.lua and tuasprite.lua only when a file uses
spriteSheet, and never overwrite copies that already exist in your project.
If you replace anim8.lua, players use that runtime's public clone() for
each animation, which copies the arrays instead of sharing them.