tiramisu/spritesheet
Spritesheet animation with state machine transitions.
This module provides a complete animation system for 2D sprites with:
- Multiple named animations (idle, walk, jump, etc.)
- Automatic frame advancement with configurable timing
- Loop modes (once, repeat, ping-pong)
- State machine transitions with conditions
- Smooth blending between animations
Creating an Animation Machine
import tiramisu/spritesheet
import gleam/time/duration
let assert Ok(machine) =
spritesheet.new(texture: player_tex, columns: 8, rows: 4)
|> result.map(spritesheet.with_animation(
_,
name: "idle",
frames: [0, 1, 2, 3],
frame_duration: duration.milliseconds(100),
loop: spritesheet.Repeat,
))
|> result.map(spritesheet.with_animation(
_,
name: "walk",
frames: [8, 9, 10, 11, 12, 13],
frame_duration: duration.milliseconds(80),
loop: spritesheet.Repeat,
))
|> result.map(spritesheet.with_transition(
_,
from: "idle",
to: "walk",
condition: spritesheet.custom(fn(ctx) { ctx.is_moving }),
blend_duration: duration.milliseconds(200),
))
|> result.map(spritesheet.build)
Updating and Rendering
fn update(model: Model, msg: Msg, ctx: Context) {
let #(new_machine, _transitioned) =
spritesheet.update(model.machine, model.game_state, ctx.delta_time)
Model(..model, machine: new_machine)
}
fn view(model: Model, _ctx: Context) -> scene.Node {
scene.animated_sprite(
id: "player",
sprite: spritesheet.to_sprite(model.machine),
size: vec2.Vec2(64.0, 64.0),
transform: transform.identity,
physics: option.None,
)
}
Transition Conditions
always(): Immediate transitionafter_duration(duration): After time in current statecustom(fn(ctx) -> Bool): Based on game context
Types
A self-contained animation state machine for spritesheet animations.
Contains everything needed for animated sprites:
- The spritesheet texture and grid configuration
- All registered animations
- Current animation state and frame tracking
- Automatic transitions based on conditions
Create one using the builder pattern:
Example
let assert Ok(machine) =
spritesheet.new(texture: player_texture, columns: 8, rows: 4)
|> result.map(spritesheet.with_animation(
_,
name: "idle",
frames: [0, 1, 2, 3],
frame_duration: duration.milliseconds(100),
loop: spritesheet.Repeat,
))
|> result.map(spritesheet.with_animation(
_,
name: "walk",
frames: [8, 9, 10, 11, 12, 13, 14, 15],
frame_duration: duration.milliseconds(80),
loop: spritesheet.Repeat,
))
|> result.map(spritesheet.with_transition(
_,
from: "idle",
to: "walk",
condition: spritesheet.custom(fn(ctx) { ctx.is_moving }),
blend_duration: duration.milliseconds(200),
))
|> result.map(spritesheet.build)
pub opaque type AnimationMachine(ctx)
Builder for creating an AnimationMachine.
The has_animation phantom type parameter tracks whether at least one
animation has been added. You can only call build() on a builder that
has at least one animation (Builder(HasAnimation, ctx)).
Example
let assert Ok(machine) =
spritesheet.new(texture: tex, columns: 8, rows: 4)
|> result.map(spritesheet.with_animation(
_,
name: "idle",
frames: [0, 1, 2, 3],
frame_duration: duration.milliseconds(100),
loop: spritesheet.Repeat,
))
|> result.map(spritesheet.with_pixel_art(_, True))
|> result.map(spritesheet.build)
pub opaque type Builder(has_animation, ctx)
Condition for transitioning between animations.
Variants
Always: Transition immediately when evaluatedAfterDuration(Duration): Transition after time in current stateCustom(fn(ctx) -> Bool): Transition based on custom context
Example
// Always transition
spritesheet.always()
// After 2 seconds in current animation
spritesheet.after_duration(duration.seconds(2))
// Based on game context
spritesheet.custom(fn(ctx) { ctx.velocity >. 0.1 })
pub type Condition(ctx) =
statemachine.Condition(ctx)
The current frame data from an animation machine.
Either a single animation’s frame, or blending between two animations.
pub type FrameData {
SingleFrame(frame_index: Int)
BlendingFrames(
from_frame_index: Int,
to_frame_index: Int,
blend_factor: Float,
)
}
Constructors
-
SingleFrame(frame_index: Int)Playing a single animation
-
BlendingFrames( from_frame_index: Int, to_frame_index: Int, blend_factor: Float, )Blending between two animations with a factor (0.0 = from, 1.0 = to)
Phantom type indicating at least one animation has been added.
pub type HasAnimation
How the animation should loop.
pub type LoopMode {
Once
Repeat
PingPong
}
Constructors
-
OncePlay once and stop on the last frame
-
RepeatLoop continuously from start to finish
-
PingPongPlay forward then backward (ping-pong)
Phantom type indicating no animation has been added yet.
pub type NoAnimation
Render state for an animated sprite.
This is a non-generic type that contains everything the scene needs
to render an animated sprite. Extract it from an AnimationMachine
using to_sprite.
Example
scene.animated_sprite(
id: "player",
sprite: spritesheet.to_sprite(model.machine),
size: vec2.Vec2(64.0, 64.0),
transform: transform.identity,
physics: option.None,
)
pub opaque type Sprite
Errors that can occur when creating a spritesheet.
pub type SpritesheetError {
InvalidColumns
InvalidRows
InvalidFrameCount
FrameCountExceedsGrid
}
Constructors
-
InvalidColumnsColumns must be at least 1
-
InvalidRowsRows must be at least 1
-
InvalidFrameCountFrame count must be at least 1
-
FrameCountExceedsGridFrame count exceeds grid capacity (columns * rows)
Values
pub fn after_duration(
time: duration.Duration,
) -> statemachine.Condition(ctx)
Transition after spending the specified duration in the current state.
pub fn always() -> statemachine.Condition(ctx)
Always transition immediately when evaluated.
pub fn blend_progress(
machine: AnimationMachine(ctx),
) -> Result(Float, Nil)
Get blend progress as a normalized value (0.0 to 1.0).
Returns Error(Nil) if not currently blending.
pub fn build(
builder: Builder(HasAnimation, ctx),
) -> AnimationMachine(ctx)
Build the animation machine from the builder.
This can only be called on a builder that has at least one animation
(Builder(HasAnimation, ctx)).
Example
let assert Ok(machine) =
spritesheet.new(texture: tex, columns: 8, rows: 4)
|> result.map(spritesheet.with_animation(...))
|> result.map(spritesheet.build)
pub fn current_animation_name(
machine: AnimationMachine(ctx),
) -> String
Get the name of the current animation.
pub fn custom(
check: fn(ctx) -> Bool,
) -> statemachine.Condition(ctx)
Transition based on a custom condition function.
pub fn frame_data(machine: AnimationMachine(ctx)) -> FrameData
Get the current frame data from the animation machine.
Returns either a single frame or blending frame data.
pub fn is_blending(machine: AnimationMachine(ctx)) -> Bool
Check if currently blending between animations.
pub fn is_pixel_art(machine: AnimationMachine(ctx)) -> Bool
Check if pixel art mode is enabled.
pub fn new(
texture texture: savoiardi.Texture,
columns columns: Int,
rows rows: Int,
) -> Result(Builder(NoAnimation, ctx), SpritesheetError)
Create a new animation machine builder from a texture.
This creates the spritesheet configuration. You must add at least one
animation with with_animation before calling build().
Example
let assert Ok(machine) =
spritesheet.new(texture: tex, columns: 8, rows: 4)
|> result.map(spritesheet.with_animation(...))
|> result.map(spritesheet.build)
pub fn new_with_count(
texture texture: savoiardi.Texture,
columns columns: Int,
rows rows: Int,
frame_count frame_count: Int,
) -> Result(Builder(NoAnimation, ctx), SpritesheetError)
Create a new animation machine builder with a specific frame count.
Use this when your sprite atlas has empty cells at the end.
pub fn sprite_frame_index(sprite: Sprite) -> Int
Get the current frame index from a sprite. @internal
pub fn sprite_frame_offset(sprite: Sprite) -> #(Float, Float)
Calculate UV offset for a sprite’s current frame. @internal
pub fn sprite_frame_repeat(sprite: Sprite) -> #(Float, Float)
Calculate UV repeat values for a sprite. @internal
pub fn sprite_pixel_art(sprite: Sprite) -> Bool
Check if pixel art mode is enabled for a sprite. @internal
pub fn sprite_texture(sprite: Sprite) -> savoiardi.Texture
Get the texture from a sprite. @internal
pub fn to_sprite(machine: AnimationMachine(ctx)) -> Sprite
Convert an animation machine to a sprite for rendering.
Call this in your view function to get the current sprite state
that can be passed to scene.animated_sprite.
Example
fn view(model: Model, _ctx: Context) -> scene.Node {
scene.animated_sprite(
id: "player",
sprite: spritesheet.to_sprite(model.machine),
size: vec2.Vec2(64.0, 64.0),
transform: transform.identity,
physics: option.None,
)
}
pub fn transition_to(
machine: AnimationMachine(ctx),
animation_name: String,
) -> AnimationMachine(ctx)
Force a transition to a specific animation by name.
Bypasses all transition conditions and forces an immediate state change. Useful for external events like damage, death, or cutscenes.
Example
// Player got hit, immediately play hurt animation
let machine = spritesheet.transition_to(machine, "hurt")
pub fn transition_to_with_blend(
machine: AnimationMachine(ctx),
animation_name: String,
blend_duration: duration.Duration,
) -> AnimationMachine(ctx)
Force a transition with custom blend duration.
pub fn update(
machine: AnimationMachine(ctx),
context: ctx,
delta_time: duration.Duration,
) -> #(AnimationMachine(ctx), Bool)
Update the animation machine (call every frame).
This advances frame counters for active animations and evaluates transition conditions.
Returns the updated machine and a boolean indicating if a transition occurred this frame.
Example
fn update(model: Model, msg: Msg, ctx: Context) {
case msg {
Tick -> {
let #(new_machine, _transitioned) =
spritesheet.update(model.machine, model.game_state, ctx.delta_time)
Model(..model, machine: new_machine)
}
}
}
pub fn with_animation(
builder: Builder(a, ctx),
name name: String,
frames frames: List(Int),
frame_duration frame_duration: duration.Duration,
loop loop: LoopMode,
) -> Builder(HasAnimation, ctx)
Add an animation to the builder.
The first animation added becomes the initial/default animation.
Adding an animation transitions the builder from NoAnimation to HasAnimation.
Example
builder
|> spritesheet.with_animation(
name: "idle",
frames: [0, 1, 2, 3],
frame_duration: duration.milliseconds(100),
loop: spritesheet.Repeat,
)
|> spritesheet.with_animation(
name: "walk",
frames: [8, 9, 10, 11, 12, 13, 14, 15],
frame_duration: duration.milliseconds(80),
loop: spritesheet.Repeat,
)
pub fn with_default_blend(
builder: Builder(has_animation, ctx),
blend_duration: duration.Duration,
) -> Builder(has_animation, ctx)
Set the default blend duration for manual transitions.
pub fn with_pixel_art(
builder: Builder(has_animation, ctx),
enabled: Bool,
) -> Builder(has_animation, ctx)
Enable pixel art rendering (nearest-neighbor filtering).
When enabled, the texture will use nearest-neighbor filtering instead of linear filtering, preserving crisp pixel edges.
pub fn with_transition(
builder: Builder(HasAnimation, ctx),
from from: String,
to to: String,
condition condition: statemachine.Condition(ctx),
blend_duration blend_duration: duration.Duration,
) -> Builder(HasAnimation, ctx)
Add a transition between two animations by name.
This can only be called on a builder that has at least one animation.
Example
builder
|> spritesheet.with_transition(
from: "idle",
to: "walk",
condition: spritesheet.custom(fn(ctx) { ctx.is_moving }),
blend_duration: duration.milliseconds(200),
)
pub fn with_transition_advanced(
builder: Builder(HasAnimation, ctx),
from from: String,
to to: String,
condition condition: statemachine.Condition(ctx),
blend_duration blend_duration: duration.Duration,
easing easing: option.Option(fn(Float) -> Float),
weight weight: Int,
) -> Builder(HasAnimation, ctx)
Add a transition with advanced options.
Like with_transition but allows specifying easing function and priority weight.