Start

Theory

Reference

Examples

← All examples
Rendering · ECS · Assets

Sprite Animation

Spritesheets via a shared frame table

Showcase for components::Animation and systems::AnimationSystem/RenderPipeline: playing back a spritesheet through per-entity frame state, with the frame list itself loaded as a shared asset::FrameTable (walk.toml) rather than embedded per-entity.

Controls

  • Left Click Spawn another animated sprite at the cursor
  • SPACE Play/pause every sprite's animation
  • R Reset
RenderingECSAssets
Sprite Animation screenshot

Run it yourself

build just this example
cmake --build --preset windows-debug --target animation_demo
# binary lands under bin/ (e.g. bin/Debug/animation_demo.exe on Windows)

See Installation for the full build setup.

Source

examples/animation_demo/Game.hpp
#pragma once

/**
 * @brief Showcase for components::Animation and systems::AnimationSystem/
 *        RenderPipeline: playing back a spritesheet through per-entity
 *        frame state, with the frame list itself loaded as a shared
 *        asset::FrameTable rather than embedded per-entity.
 *
 * A 6-frame spritesheet, described by a small `walk.toml` FrameTable
 * meta-file (see assets/), drives a row of pre-spawned sprites, each
 * started on a different frame so they visibly play out of phase with each
 * other -- Animation is per-entity playback state even though every sprite
 * here shares the one loaded FrameTable asset. Left-click spawns another
 * animated sprite at the cursor, with its own randomized frame duration.
 * Space toggles every sprite's Animation between playing and paused
 * (components::PlayAnimation/StopAnimation); R resets back to the initial
 * row.
 *
 * Every Render() call runs asset::AssetManager::ResolveAssets first --
 * deferred-loading any still-unresolved Sprite::m_Texture/Animation::m_Clip
 * in one pass, entities spawned this frame included -- then
 * systems::RenderPipeline (AnimationSystem then RenderSystem). Spawning
 * itself just sets Sprite::m_VirtualPath/Animation::m_ClipPath; unlike
 * earlier examples (see ecs_demo), there's no hand-rolled "is the texture
 * attached yet" bookkeeping to write, since ResolveAssets already re-checks
 * per-entity every call.
 */

#include <ASGE/ASGE.hpp>
#include <random>
#include <vector>

class AnimationDemoState final : public asge::game::state::IGameState<int>
{
    asge::ecs::Registry&             m_Registry;
    asge::game::asset::AssetManager& m_Assets;

    std::vector<asge::ecs::Entity> m_SpriteEntities; // every entity spawned so far, in spawn order

    std::mt19937 m_Rng{ std::random_device{}() };
    bool m_Playing{ true };

    void SpawnInitialRow();
    void SpawnSprite( asge::math::Float2 inPosition, std::size_t inStartFrame );
    void Reset();
    void HandleInput( asge::input::InputState const& inInput );

public:
    AnimationDemoState(asge::ecs::Registry& inRegistry, asge::game::asset::AssetManager& inAssets);

    [[nodiscard]] std::optional<asge::game::state::Transition<int>>
    Update(float inDeltaTime, asge::input::InputState const& inInput) override;
    void Render(asge::video::IRenderer& inRenderer) override;
    [[nodiscard]] asge::graphics::RGBA_Color ClearColor() const noexcept override { return { 15, 15, 20, 255 }; }
    void OnSystemEvent(asge::event::SystemEvent const& inSysEvent) override;
};

class AnimationDemoGame final : public asge::game::Game<int>
{
public:
    explicit AnimationDemoGame(asge::video::IRenderer& inRenderer, asge::audio::AudioDevice& inAudioDev);

protected:
    [[nodiscard]] std::unique_ptr<StateType> CreateState(int inId) override;
};
examples/animation_demo/Game.cpp
#include "Game.hpp"

namespace
{
using asge::game::components::Animation;
using asge::game::components::PlayAnimation;
using asge::game::components::Sprite;
using asge::game::components::StopAnimation;
using asge::game::components::Transform;

constexpr float        kWindowWidth      = 800.0f;
constexpr std::size_t  kFrameCount       = 6;   // must match assets/walk.toml's "count"
constexpr float        kCellSize         = 32.0f;
constexpr float        kSpriteScale      = 2.5f; // draws each 32x32 cell at 80x80 on screen
constexpr float        kRowY             = 250.0f;
constexpr float        kRowSpacing       = kWindowWidth / static_cast<float>(kFrameCount + 1);
constexpr float        kMinFrameDuration = 0.05f;
constexpr float        kMaxFrameDuration = 0.25f;

constexpr char const* kSheetPath = "textures/spritesheet.bmp";
constexpr char const* kClipPath  = "textures/walk.toml"; // asset::FrameTable meta-file -- see assets/
}

AnimationDemoState::AnimationDemoState(
    asge::ecs::Registry& inRegistry, asge::game::asset::AssetManager& inAssets)
: m_Registry(inRegistry), m_Assets(inAssets)
{
    SpawnInitialRow();
}

void AnimationDemoState::SpawnInitialRow()
{
    // Every sprite plays the same clip, but each starts on a different
    // frame -- makes it visually obvious that Animation is per-entity
    // playback state, not one shared clock, even though every sprite here
    // shares the one loaded FrameTable asset (see Animation::m_Clip).
    for ( std::size_t ii = 0; ii < kFrameCount; ++ii )
    {
        float const x = kRowSpacing * static_cast<float>(ii + 1) - (kCellSize * kSpriteScale * 0.5f);
        SpawnSprite( asge::math::Float2{ x, kRowY }, ii );
    }
}

void AnimationDemoState::SpawnSprite( asge::math::Float2 inPosition, std::size_t inStartFrame )
{
    auto entity = m_Registry.CreateEntity();
    if ( !entity ) { entity.LogError(); return; }

    m_Registry.AddComponent<Transform>( entity.Value(), Transform{
        .m_LocalCoordinates = inPosition, .m_LocalScale = {kSpriteScale, kSpriteScale},
        .m_WorldCoordinates = inPosition, .m_WorldScale = {kSpriteScale, kSpriteScale}
    } );

    std::uniform_real_distribution<float> durationDist( kMinFrameDuration, kMaxFrameDuration );
    m_Registry.AddComponent<Animation>( entity.Value(), Animation{
        .m_ClipPath = kClipPath,
        .m_FrameDuration = durationDist( m_Rng ),
        .m_CurrentFrame = inStartFrame,
        .m_Playing = m_Playing
    } );

    // m_Texture stays null and m_Clip (Animation, above) stays unresolved
    // until Render()'s AssetManager::ResolveAssets call -- no "is the
    // renderer ready yet" bookkeeping needed here, unlike earlier examples.
    m_Registry.AddComponent<Sprite>( entity.Value(), Sprite{ .m_VirtualPath = kSheetPath } );

    m_SpriteEntities.push_back( entity.Value() );
}

void AnimationDemoState::Reset()
{
    for ( auto entity : m_SpriteEntities )
    {
        if ( auto result = m_Registry.DestroyEntity( entity ); !result ) result.LogError();
    }
    m_SpriteEntities.clear();
    m_Playing = true;
    SpawnInitialRow();
}

void AnimationDemoState::HandleInput(asge::input::InputState const &inInput)
{
    using asge::input::Keycode;
    using asge::input::MouseButton;

    if ( inInput.IsMouseButtonPressed( MouseButton::LEFT ) )
    {
        auto const mouse = inInput.GetMousePosition();
        SpawnSprite(
            asge::math::Float2{ mouse.x() - kCellSize * kSpriteScale * 0.5f,
                                 mouse.y() - kCellSize * kSpriteScale * 0.5f },
            0 );
    }

    if ( inInput.IsKeyPressed( Keycode::SPACE ) )
    {
        m_Playing = !m_Playing;
        for ( auto entity : m_SpriteEntities )
        {
            auto result = m_Registry.GetComponent<Animation>( entity );
            if ( !result ) continue;
            if ( m_Playing ) PlayAnimation( result.Value().get() );
            else StopAnimation( result.Value().get() );
        }
    }

    if ( inInput.IsKeyPressed( Keycode::R ) )
    {
        Reset();
    }
}

std::optional<asge::game::state::Transition<int>>
AnimationDemoState::Update([[maybe_unused]] float inDeltaTime, asge::input::InputState const &inInput)
{
    HandleInput( inInput );
    return std::nullopt;
}

void AnimationDemoState::Render([[maybe_unused]] asge::video::IRenderer &inRenderer)
{
}

void AnimationDemoState::OnSystemEvent([[maybe_unused]] asge::event::SystemEvent const &inSysEvent)
{
    // Everything here is driven by polling InputState in Update() instead.
}

AnimationDemoGame::AnimationDemoGame(asge::video::IRenderer& inRenderer, asge::audio::AudioDevice& inAudioDev)
: Game(inRenderer, inAudioDev)
{
    // ASGE_ANIMATION_DEMO_ASSET_DIR is injected by CMakeLists.txt; mounted
    // once so both the sheet and its FrameTable meta-file are loaded by
    // virtual path (see Render()'s ResolveAssets call) instead of a
    // hardcoded OS path baked into this demo.
    auto mountResult = m_Vfs.Mount("textures", ASGE_ANIMATION_DEMO_ASSET_DIR);
    if ( !mountResult ) mountResult.LogError();

    SetInitialState(0);
}

std::unique_ptr<AnimationDemoGame::StateType> AnimationDemoGame::CreateState([[maybe_unused]] int inId)
{
    return std::make_unique<AnimationDemoState>( m_SceneManager.GetRegistry(), m_Assets );
}

Browse the full example on GitHub →