⚠️ Beta State

PyBevy is in an early and experimental stage. The API is incomplete, subject to breaking changes without notice, and you should expect bugs. Many features are still under development.

Sprite Animation

Create a looping animation from a sprite sheet.

Introduction

Animating 2D characters is often done using a "sprite sheet" or "texture atlas", which is a single image containing all the frames of an animation.

pybevy provides TextureAtlasLayout and TextureAtlas components to make this easy.

from pybevy.prelude import *
from pybevy.image import TextureAtlasLayout, TextureAtlas, ImagePlugin
from pybevy.math import UVec2

Animation Components

We'll create a custom component, AnimationTimer, to hold a Timer that will control the speed of our animation.

@component
class AnimationTimer(Component):
    def __init__(self, timer: Timer):
        self.timer = timer

Setup

In our setup system, we:

  1. Load the sprite sheet image.
  2. Create a TextureAtlasLayout that describes how the image is divided into frames. Our example sheet has 7 frames in a single row.
  3. Spawn a Sprite with a TextureAtlas component, which links the sprite to the layout and sets the initial frame (index).
def setup(
    commands: Commands,
    asset_server: AssetServer,
    texture_atlas_layouts: ResMut[Assets[TextureAtlasLayout]],
):
    commands.spawn(Camera2d())
 
    texture = asset_server.load_image("textures/rpg/chars/gabe/gabe-idle-run.png")
    layout = TextureAtlasLayout.from_grid(UVec2(24, 24), 7, 1, None, None)
    texture_atlas_layout = texture_atlas_layouts.add(layout)
 
    commands.spawn(
        Sprite(
            image=texture,
            texture_atlas=TextureAtlas(
                layout=texture_atlas_layout,
                index=1, # Start with the first frame of the run animation
            ),
        ),
        Transform.from_scale(Vec3.splat(6.0)),
        AnimationTimer(Timer(0.1, TimerMode.REPEATING)),
    )

Animation System

This system runs every frame to update the animation.

  • It queries for our AnimationTimer and the Sprite's TextureAtlas.
  • It ticks the timer with the time that has passed since the last frame.
  • If the timer has just finished, it advances the texture_atlas.index to the next frame, looping back to the start if necessary.
def animate_sprite_system(
    time: Res[Time],
    query: Query[tuple[Mut[AnimationTimer], Mut[Sprite]]],
):
    for timer, sprite in query:
        timer.timer.tick(time.delta_secs())
        if timer.timer.just_finished():
            # The run animation is in frames 1-6
            if sprite.texture_atlas.index == 6:
                sprite.texture_atlas.index = 1
            else:
                sprite.texture_atlas.index += 1

Running the App

When you run this, you'll see an animated character running in place.

@entrypoint
def main(app: App) -> App:
    return (
        app
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .add_systems(Update, animate_sprite_system)
    )
 
if __name__ == "__main__":
    main().run()