tiramisu/spritesheet

Spritesheet animation with state machine transitions.

This module provides a complete animation system for 2D sprites with:

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

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 evaluated
  • AfterDuration(Duration): Transition after time in current state
  • Custom(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

  • Once

    Play once and stop on the last frame

  • Repeat

    Loop continuously from start to finish

  • PingPong

    Play 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

  • InvalidColumns

    Columns must be at least 1

  • InvalidRows

    Rows must be at least 1

  • InvalidFrameCount

    Frame count must be at least 1

  • FrameCountExceedsGrid

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

Search Document