From 7dc9ae7e2ee24034c4573152a19a883d2116d021 Mon Sep 17 00:00:00 2001 From: LyeZinho Date: Fri, 8 May 2026 16:43:48 +0100 Subject: [PATCH] feat(animation): implement AnimationSystem with sprite state machine (RF4.9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AnimationComponents.hpp: FrameRect, AnimationClip, AnimationTransition, AnimationState, Animator component (HashMap state machine, frame events) - Add AnimationSystem.hpp: ECS::ISystem impl with onUpdate() iterating Animator+Sprite, state machine evaluation, frame advance, frame events - Add test_animation.cpp: 22 tests covering defaults, state transitions, frame clamping/looping, pause/resume/setSpeed/play/isPlaying - Update Caffeine.hpp with animation includes - Update tests/CMakeLists.txt to always compile test_animation.cpp - Update docs/fase4/animation.md (status ✅, API rewrite) and README.md --- docs/fase4/README.md | 2 +- docs/fase4/animation.md | 136 ++++----- src/Caffeine.hpp | 6 +- src/animation/AnimationComponents.hpp | 60 ++++ src/animation/AnimationSystem.hpp | 123 ++++++++ tests/CMakeLists.txt | 1 + tests/test_animation.cpp | 416 ++++++++++++++++++++++++++ 7 files changed, 659 insertions(+), 85 deletions(-) create mode 100644 src/animation/AnimationComponents.hpp create mode 100644 src/animation/AnimationSystem.hpp create mode 100644 tests/test_animation.cpp diff --git a/docs/fase4/README.md b/docs/fase4/README.md index b3dea4c..48217ac 100644 --- a/docs/fase4/README.md +++ b/docs/fase4/README.md @@ -16,7 +16,7 @@ Esta fase implementa o **ECS completo** — a arquitetura de dados central que c | **Scene Manager** | [`scene.md`](scene.md) | `Caffeine::Scene` | 4 | ✅ | | **Event Bus** | [`events.md`](events.md) | `Caffeine::Events` | 4 | ✅ | | **Audio System** | [`audio.md`](audio.md) | `Caffeine::Audio` | 4 | ✅ | -| **Animation System** | [`animation.md`](animation.md) | `Caffeine::Animation` | 4 | 📅 | +| **Animation System** | [`animation.md`](animation.md) | `Caffeine::Animation` | 4 | ✅ | | **Physics 2D** | [`physics.md`](physics.md) | `Caffeine::Physics2D` | 4 | ✅ | | **UI System** | [`ui.md`](ui.md) | `Caffeine::UI` | 4 | ✅ | diff --git a/docs/fase4/animation.md b/docs/fase4/animation.md index d2d7d60..7c4ec97 100644 --- a/docs/fase4/animation.md +++ b/docs/fase4/animation.md @@ -3,7 +3,7 @@ > **Fase:** 4 — O Cérebro > **Namespace:** `Caffeine::Animation` > **Arquivo:** `src/animation/AnimationSystem.hpp` -> **Status:** 📅 Planejado +> **Status:** ✅ Implementado > **RF:** RF4.9 --- @@ -16,90 +16,61 @@ Sistema de animação 2D com clipes de sprite e state machine. Cada entidade tem --- -## API Planejada +## API ```cpp namespace Caffeine::Animation { -// ============================================================================ -// @brief Clipe de animação — sequência de frames de sprite sheet. -// ============================================================================ +struct FrameRect { + f32 x = 0.0f; + f32 y = 0.0f; + f32 w = 0.0f; + f32 h = 0.0f; +}; + struct AnimationClip { - FixedString<32> name; - u32 fps = 12; - std::vector frames; // regiões na textura/atlas - bool loop = true; - f32 duration() const { return (f32)frames.size() / (f32)fps; } + FixedString<32> name; + u32 fps = 12; + std::vector frames; + bool loop = true; + f32 duration() const; }; -// ============================================================================ -// @brief Transição entre estados de animação. -// ============================================================================ struct AnimationTransition { FixedString<32> toState; - std::function condition; // quando esta condição é verdade, transiciona - f32 blendTime = 0.1f; // segundos de crossfade - bool hasExitTime = false; // true = espera clip terminar + std::function condition; + f32 blendTime = 0.1f; + bool hasExitTime = false; }; -// ============================================================================ -// @brief Estado de animação (nó na state machine). -// ============================================================================ struct AnimationState { FixedString<32> name; - const AnimationClip* clip = nullptr; - f32 speed = 1.0f; + const AnimationClip* clip = nullptr; + f32 speed = 1.0f; std::vector transitions; }; -// ============================================================================ -// @brief Componente de animação da entidade. -// -// Contém a state machine e estado atual. -// O AnimationSystem itera sobre todas as entidades com este componente. -// ============================================================================ struct Animator { - HashMap, AnimationState> states; - FixedString<32> currentState; - FixedString<32> previousState; - f32 timeInState = 0.0f; - f32 blendWeight = 1.0f; // para crossfade (0 → 1) - f32 playbackScale = 1.0f; // multiplicador de velocidade - bool paused = false; - - // Eventos de animação (frame específico dispara callback) + HashMap, AnimationState> states; + FixedString<32> currentState; + FixedString<32> previousState; + f32 timeInState = 0.0f; + f32 blendWeight = 1.0f; + f32 playbackScale = 1.0f; + bool paused = false; std::vector>> frameEvents; std::function&)> onFrameEvent; }; -// ============================================================================ -// @brief Sistema de animação ECS. -// -// Por frame: -// 1. Verifica condições de transição -// 2. Avança timeInState -// 3. Calcula frame atual do clipe -// 4. Atualiza Sprite.srcRect com o frame correto -// 5. Dispara frameEvents se frame atingido -// ============================================================================ class AnimationSystem : public ECS::ISystem { public: - void update(ECS::World& world, f64 dt) override; - i32 priority() const override { return 200; } - const char* name() const override { return "Animation"; } - - // API imperativa (útil para gameplay code) - void play(ECS::Entity e, const char* stateName, - f32 blendTime = 0.1f); - void pause(ECS::Entity e); - void resume(ECS::Entity e); - void setSpeed(ECS::Entity e, f32 speed); - bool isPlaying(ECS::Entity e, const char* stateName) const; - -private: - void evaluateTransitions(ECS::Entity e, Animator& anim, f64 dt); - void advanceFrame(ECS::Entity e, Animator& anim, Sprite& sprite, f64 dt); - void checkFrameEvents(ECS::Entity e, Animator& anim); + void onUpdate(ECS::World& world, f32 dt) override; + + void play(ECS::World& world, ECS::Entity e, const char* stateName); + void pause(ECS::World& world, ECS::Entity e); + void resume(ECS::World& world, ECS::Entity e); + void setSpeed(ECS::World& world, ECS::Entity e, f32 speed); + bool isPlaying(ECS::World& world, ECS::Entity e, const char* stateName) const; }; } // namespace Caffeine::Animation @@ -129,9 +100,6 @@ idle ──────────────────► jump_rise ## Exemplos de Uso ```cpp -// ── Setup do Animator ───────────────────────────────────────── -Animator anim; - AnimationClip idleClip; idleClip.name = "idle"; idleClip.fps = 8; @@ -144,36 +112,38 @@ walkClip.fps = 12; walkClip.frames = { {128,0,64,64}, {192,0,64,64}, {256,0,64,64}, {320,0,64,64} }; walkClip.loop = true; -AnimationState idleState { "idle", &idleClip }; +AnimationState idleState; +idleState.name = "idle"; +idleState.clip = &idleClip; idleState.transitions.push_back({ - .toState = "walk", - .condition = [&]() { return input.axisValue(Axis::MoveX) != 0.0f; } + FixedString<32>("walk"), + [&]() { return input.axisValue(Axis::MoveX) != 0.0f; } }); -AnimationState walkState { "walk", &walkClip }; +AnimationState walkState; +walkState.name = "walk"; +walkState.clip = &walkClip; walkState.transitions.push_back({ - .toState = "idle", - .condition = [&]() { return input.axisValue(Axis::MoveX) == 0.0f; } + FixedString<32>("idle"), + [&]() { return input.axisValue(Axis::MoveX) == 0.0f; } }); -anim.states["idle"] = idleState; -anim.states["walk"] = walkState; -anim.currentState = "idle"; +Animator anim; +anim.states.set(FixedString<32>("idle"), idleState); +anim.states.set(FixedString<32>("walk"), walkState); +anim.currentState = "idle"; world.add(player, std::move(anim)); -// ── Registrar sistema ───────────────────────────────────────── -world.registerSystem(); +AnimationSystem animSys; +animSys.play(world, player, "attack_1"); -// ── Controle programático ───────────────────────────────────── -auto* animSys = world.getSystem(); -animSys->play(player, "attack_1", 0.0f); // sem blend (combos) - -// ── Frame events ────────────────────────────────────────────── -world.get(player)->frameEvents.push_back({3, "attack_hit"}); +world.get(player)->frameEvents.push_back({3u, FixedString<32>("attack_hit")}); world.get(player)->onFrameEvent = [](const FixedString<32>& evt) { - if (evt == "attack_hit") damageEnemiesInRange(); + if (evt == FixedString<32>("attack_hit")) damageEnemiesInRange(); }; + +animSys.onUpdate(world, dt); ``` --- diff --git a/src/Caffeine.hpp b/src/Caffeine.hpp index e640f5b..7dcebc2 100644 --- a/src/Caffeine.hpp +++ b/src/Caffeine.hpp @@ -71,4 +71,8 @@ #include "audio/AudioComponents.hpp" #ifdef CF_HAS_SDL3 #include "audio/AudioSystem.hpp" -#endif \ No newline at end of file +#endif + +// Animation +#include "animation/AnimationComponents.hpp" +#include "animation/AnimationSystem.hpp" \ No newline at end of file diff --git a/src/animation/AnimationComponents.hpp b/src/animation/AnimationComponents.hpp new file mode 100644 index 0000000..18da157 --- /dev/null +++ b/src/animation/AnimationComponents.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "core/Types.hpp" +#include "containers/FixedString.hpp" +#include "containers/HashMap.hpp" + +#include +#include +#include + +namespace Caffeine::Animation { + +using namespace Caffeine; + +struct FrameRect { + f32 x = 0.0f; + f32 y = 0.0f; + f32 w = 0.0f; + f32 h = 0.0f; +}; + +struct AnimationClip { + FixedString<32> name; + u32 fps = 12; + std::vector frames; + bool loop = true; + + f32 duration() const { + if (fps == 0 || frames.empty()) return 0.0f; + return static_cast(frames.size()) / static_cast(fps); + } +}; + +struct AnimationTransition { + FixedString<32> toState; + std::function condition; + f32 blendTime = 0.1f; + bool hasExitTime = false; +}; + +struct AnimationState { + FixedString<32> name; + const AnimationClip* clip = nullptr; + f32 speed = 1.0f; + std::vector transitions; +}; + +struct Animator { + HashMap, AnimationState> states; + FixedString<32> currentState; + FixedString<32> previousState; + f32 timeInState = 0.0f; + f32 blendWeight = 1.0f; + f32 playbackScale = 1.0f; + bool paused = false; + std::vector>> frameEvents; + std::function&)> onFrameEvent; +}; + +} // namespace Caffeine::Animation diff --git a/src/animation/AnimationSystem.hpp b/src/animation/AnimationSystem.hpp new file mode 100644 index 0000000..7776718 --- /dev/null +++ b/src/animation/AnimationSystem.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include "animation/AnimationComponents.hpp" +#include "ecs/World.hpp" +#include "ecs/Entity.hpp" +#include "ecs/ISystem.hpp" +#include "ecs/Components.hpp" +#include "ecs/ComponentQuery.hpp" + +#include + +namespace Caffeine::Animation { + +using namespace Caffeine; + +class AnimationSystem : public ECS::ISystem { +public: + void onUpdate(ECS::World& world, f32 dt) override { + ECS::ComponentQuery q; + q.with(); + q.with(); + + world.forEach(q, + [dt](ECS::Entity, Animator& anim, ECS::Sprite& sprite) { + if (anim.paused) return; + + evaluateTransitions(anim); + + const AnimationState* state = anim.states.get(anim.currentState); + if (!state || !state->clip || state->clip->frames.empty()) return; + + const AnimationClip* clip = state->clip; + f32 effectiveSpeed = state->speed * anim.playbackScale; + anim.timeInState += dt * effectiveSpeed; + + f32 clipDur = clip->duration(); + if (clipDur > 0.0f) { + if (anim.timeInState >= clipDur) { + if (clip->loop) { + anim.timeInState = std::fmod(anim.timeInState, clipDur); + } else { + anim.timeInState = clipDur; + } + } + } + + u32 frameCount = static_cast(clip->frames.size()); + u32 frame = 0; + if (clip->fps > 0 && frameCount > 0) { + frame = static_cast(anim.timeInState * static_cast(clip->fps)); + if (clip->loop) { + frame = frame % frameCount; + } else { + frame = frame < frameCount ? frame : frameCount - 1u; + } + } + sprite.frameIndex = frame; + + if (anim.onFrameEvent) { + checkFrameEvents(anim, frame); + } + }); + } + + void play(ECS::World& world, ECS::Entity e, const char* stateName) { + Animator* anim = world.get(e); + if (!anim) return; + anim->previousState = anim->currentState; + anim->currentState = stateName; + anim->timeInState = 0.0f; + anim->paused = false; + } + + void pause(ECS::World& world, ECS::Entity e) { + Animator* anim = world.get(e); + if (anim) anim->paused = true; + } + + void resume(ECS::World& world, ECS::Entity e) { + Animator* anim = world.get(e); + if (anim) anim->paused = false; + } + + void setSpeed(ECS::World& world, ECS::Entity e, f32 speed) { + Animator* anim = world.get(e); + if (anim) anim->playbackScale = speed; + } + + bool isPlaying(ECS::World& world, ECS::Entity e, const char* stateName) const { + const Animator* anim = world.get(e); + if (!anim || anim->paused) return false; + return anim->currentState == FixedString<32>(stateName); + } + +private: + static void evaluateTransitions(Animator& anim) { + const AnimationState* state = anim.states.get(anim.currentState); + if (!state) return; + + for (const auto& t : state->transitions) { + if (!t.condition) continue; + if (t.hasExitTime && state->clip) { + if (anim.timeInState < state->clip->duration()) continue; + } + if (t.condition()) { + anim.previousState = anim.currentState; + anim.currentState = t.toState; + anim.timeInState = 0.0f; + return; + } + } + } + + static void checkFrameEvents(Animator& anim, u32 currentFrame) { + for (const auto& [frame, eventName] : anim.frameEvents) { + if (frame == currentFrame) { + anim.onFrameEvent(eventName); + } + } + } +}; + +} // namespace Caffeine::Animation diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8c42d8f..38779a6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -18,6 +18,7 @@ set(CAFFEINE_TEST_SOURCES test_scenemanager.cpp test_physics2d.cpp test_ui.cpp + test_animation.cpp ) if(SDL3_FOUND) diff --git a/tests/test_animation.cpp b/tests/test_animation.cpp new file mode 100644 index 0000000..e1c3267 --- /dev/null +++ b/tests/test_animation.cpp @@ -0,0 +1,416 @@ +#include "catch.hpp" +#include "../src/Caffeine.hpp" +#include "../src/animation/AnimationComponents.hpp" +#include "../src/animation/AnimationSystem.hpp" + +#include + +using namespace Caffeine; +using namespace Caffeine::ECS; +using namespace Caffeine::Animation; + +static constexpr f32 kEps = 0.001f; +static bool approxEq(f32 a, f32 b) { return std::fabs(a - b) < kEps; } + +// ============================================================================ +// FrameRect +// ============================================================================ + +TEST_CASE("FrameRect - default values", "[animation]") { + FrameRect f; + REQUIRE(approxEq(f.x, 0.0f)); + REQUIRE(approxEq(f.y, 0.0f)); + REQUIRE(approxEq(f.w, 0.0f)); + REQUIRE(approxEq(f.h, 0.0f)); +} + +// ============================================================================ +// AnimationClip +// ============================================================================ + +TEST_CASE("AnimationClip - default values", "[animation]") { + AnimationClip clip; + REQUIRE(clip.fps == 12u); + REQUIRE(clip.loop == true); + REQUIRE(clip.frames.empty()); +} + +TEST_CASE("AnimationClip - duration is zero when no frames", "[animation]") { + AnimationClip clip; + REQUIRE(approxEq(clip.duration(), 0.0f)); +} + +TEST_CASE("AnimationClip - duration is zero when fps is zero", "[animation]") { + AnimationClip clip; + clip.fps = 0; + clip.frames.push_back({}); + REQUIRE(approxEq(clip.duration(), 0.0f)); +} + +TEST_CASE("AnimationClip - duration computed correctly", "[animation]") { + AnimationClip clip; + clip.fps = 4; + clip.frames = {{}, {}, {}, {}}; + REQUIRE(approxEq(clip.duration(), 1.0f)); +} + +TEST_CASE("AnimationClip - duration with 3 frames at 12fps", "[animation]") { + AnimationClip clip; + clip.fps = 12; + clip.frames = {{}, {}, {}}; + REQUIRE(approxEq(clip.duration(), 0.25f)); +} + +// ============================================================================ +// AnimationTransition +// ============================================================================ + +TEST_CASE("AnimationTransition - defaults", "[animation]") { + AnimationTransition t; + REQUIRE(approxEq(t.blendTime, 0.1f)); + REQUIRE(t.hasExitTime == false); + REQUIRE(!t.condition); +} + +// ============================================================================ +// AnimationState +// ============================================================================ + +TEST_CASE("AnimationState - defaults", "[animation]") { + AnimationState s; + REQUIRE(s.clip == nullptr); + REQUIRE(approxEq(s.speed, 1.0f)); + REQUIRE(s.transitions.empty()); +} + +// ============================================================================ +// Animator +// ============================================================================ + +TEST_CASE("Animator - defaults", "[animation]") { + Animator anim; + REQUIRE(approxEq(anim.timeInState, 0.0f)); + REQUIRE(approxEq(anim.blendWeight, 1.0f)); + REQUIRE(approxEq(anim.playbackScale, 1.0f)); + REQUIRE(anim.paused == false); + REQUIRE(anim.frameEvents.empty()); + REQUIRE(!anim.onFrameEvent); +} + +// ============================================================================ +// AnimationSystem — logic tests via ECS World +// ============================================================================ + +static AnimationClip makeClip(u32 frameCount, u32 fps, bool loop = true) { + AnimationClip clip; + clip.fps = fps; + clip.loop = loop; + for (u32 i = 0; i < frameCount; ++i) { + clip.frames.push_back({static_cast(i) * 16.0f, 0.0f, 16.0f, 16.0f}); + } + return clip; +} + +TEST_CASE("AnimationSystem - entity with no Sprite is skipped safely", "[animation]") { + World world; + AnimationSystem sys; + Entity e = world.create(); + Animator anim; + world.add(e, anim); + sys.onUpdate(world, 0.016f); + REQUIRE(true); +} + +TEST_CASE("AnimationSystem - entity with no Animator is skipped safely", "[animation]") { + World world; + AnimationSystem sys; + Entity e = world.create(); + world.add(e, Sprite{}); + sys.onUpdate(world, 0.016f); + REQUIRE(true); +} + +TEST_CASE("AnimationSystem - empty Animator does not crash on update", "[animation]") { + World world; + AnimationSystem sys; + Entity e = world.create(); + world.add(e, Animator{}); + world.add(e, Sprite{}); + sys.onUpdate(world, 0.016f); + REQUIRE(true); +} + +TEST_CASE("AnimationSystem - paused Animator does not advance time", "[animation]") { + World world; + AnimationSystem sys; + + AnimationClip clip = makeClip(4, 4); + AnimationState state; + state.name = "idle"; + state.clip = &clip; + + Animator anim; + anim.states.set(FixedString<32>("idle"), state); + anim.currentState = "idle"; + anim.paused = true; + + Entity e = world.create(); + world.add(e, anim); + world.add(e, Sprite{}); + + sys.onUpdate(world, 0.5f); + + const Animator* a = world.get(e); + REQUIRE(approxEq(a->timeInState, 0.0f)); +} + +TEST_CASE("AnimationSystem - advances frameIndex with time", "[animation]") { + World world; + AnimationSystem sys; + + AnimationClip clip = makeClip(4, 4, true); + AnimationState state; + state.name = "idle"; + state.clip = &clip; + + Animator anim; + anim.states.set(FixedString<32>("idle"), state); + anim.currentState = "idle"; + + Entity e = world.create(); + world.add(e, anim); + world.add(e, Sprite{}); + + sys.onUpdate(world, 0.5f); + + const Sprite* sp = world.get(e); + REQUIRE(sp->frameIndex < 4u); +} + +TEST_CASE("AnimationSystem - non-looping clip clamps at last frame", "[animation]") { + World world; + AnimationSystem sys; + + AnimationClip clip = makeClip(4, 4, false); + AnimationState state; + state.name = "attack"; + state.clip = &clip; + + Animator anim; + anim.states.set(FixedString<32>("attack"), state); + anim.currentState = "attack"; + + Entity e = world.create(); + world.add(e, anim); + world.add(e, Sprite{}); + + sys.onUpdate(world, 10.0f); + + const Sprite* sp = world.get(e); + REQUIRE(sp->frameIndex == 3u); +} + +TEST_CASE("AnimationSystem - looping clip wraps frame index", "[animation]") { + World world; + AnimationSystem sys; + + AnimationClip clip = makeClip(4, 4, true); + AnimationState state; + state.name = "walk"; + state.clip = &clip; + + Animator anim; + anim.states.set(FixedString<32>("walk"), state); + anim.currentState = "walk"; + + Entity e = world.create(); + world.add(e, anim); + world.add(e, Sprite{}); + + sys.onUpdate(world, 1.125f); + + const Sprite* sp = world.get(e); + REQUIRE(sp->frameIndex < 4u); +} + +TEST_CASE("AnimationSystem - transition fires when condition is true", "[animation]") { + World world; + AnimationSystem sys; + + AnimationClip idleClip = makeClip(2, 4, true); + AnimationClip walkClip = makeClip(4, 8, true); + + AnimationState idle; + idle.name = "idle"; + idle.clip = &idleClip; + AnimationTransition toWalk; + toWalk.toState = "walk"; + toWalk.condition = []() { return true; }; + idle.transitions.push_back(toWalk); + + AnimationState walk; + walk.name = "walk"; + walk.clip = &walkClip; + + Animator anim; + anim.states.set(FixedString<32>("idle"), idle); + anim.states.set(FixedString<32>("walk"), walk); + anim.currentState = "idle"; + + Entity e = world.create(); + world.add(e, anim); + world.add(e, Sprite{}); + + sys.onUpdate(world, 0.016f); + + const Animator* a = world.get(e); + REQUIRE(a->currentState == FixedString<32>("walk")); +} + +TEST_CASE("AnimationSystem - transition does not fire when condition is false", "[animation]") { + World world; + AnimationSystem sys; + + AnimationClip idleClip = makeClip(2, 4, true); + + AnimationState idle; + idle.name = "idle"; + idle.clip = &idleClip; + AnimationTransition t; + t.toState = "walk"; + t.condition = []() { return false; }; + idle.transitions.push_back(t); + + Animator anim; + anim.states.set(FixedString<32>("idle"), idle); + anim.currentState = "idle"; + + Entity e = world.create(); + world.add(e, anim); + world.add(e, Sprite{}); + + sys.onUpdate(world, 0.016f); + + const Animator* a = world.get(e); + REQUIRE(a->currentState == FixedString<32>("idle")); +} + +TEST_CASE("AnimationSystem - frame event fires at correct frame", "[animation]") { + World world; + AnimationSystem sys; + + AnimationClip clip = makeClip(4, 1000, true); + + AnimationState state; + state.name = "action"; + state.clip = &clip; + + Animator anim; + anim.states.set(FixedString<32>("action"), state); + anim.currentState = "action"; + anim.frameEvents.push_back({0u, FixedString<32>("hit")}); + + FixedString<32> fired; + anim.onFrameEvent = [&](const FixedString<32>& evt) { fired = evt; }; + + Entity e = world.create(); + world.add(e, anim); + world.add(e, Sprite{}); + + sys.onUpdate(world, 0.0f); + + const Animator* a = world.get(e); + REQUIRE(a->currentState == FixedString<32>("action")); +} + +TEST_CASE("AnimationSystem - play() resets state", "[animation]") { + World world; + AnimationSystem sys; + + Entity e = world.create(); + world.add(e, Animator{}); + world.add(e, Sprite{}); + + sys.play(world, e, "walk"); + + const Animator* a = world.get(e); + REQUIRE(a->currentState == FixedString<32>("walk")); + REQUIRE(approxEq(a->timeInState, 0.0f)); + REQUIRE(a->paused == false); +} + +TEST_CASE("AnimationSystem - pause and resume", "[animation]") { + World world; + AnimationSystem sys; + + Entity e = world.create(); + world.add(e, Animator{}); + world.add(e, Sprite{}); + + sys.pause(world, e); + REQUIRE(world.get(e)->paused == true); + + sys.resume(world, e); + REQUIRE(world.get(e)->paused == false); +} + +TEST_CASE("AnimationSystem - setSpeed updates playbackScale", "[animation]") { + World world; + AnimationSystem sys; + + Entity e = world.create(); + world.add(e, Animator{}); + world.add(e, Sprite{}); + + sys.setSpeed(world, e, 2.0f); + REQUIRE(approxEq(world.get(e)->playbackScale, 2.0f)); +} + +TEST_CASE("AnimationSystem - isPlaying returns true for active state", "[animation]") { + World world; + AnimationSystem sys; + + Animator anim; + anim.currentState = "idle"; + anim.paused = false; + + Entity e = world.create(); + world.add(e, anim); + world.add(e, Sprite{}); + + REQUIRE(sys.isPlaying(world, e, "idle") == true); + REQUIRE(sys.isPlaying(world, e, "walk") == false); +} + +TEST_CASE("AnimationSystem - isPlaying returns false when paused", "[animation]") { + World world; + AnimationSystem sys; + + Animator anim; + anim.currentState = "idle"; + anim.paused = true; + + Entity e = world.create(); + world.add(e, anim); + world.add(e, Sprite{}); + + REQUIRE(sys.isPlaying(world, e, "idle") == false); +} + +TEST_CASE("AnimationSystem - play() stores previousState", "[animation]") { + World world; + AnimationSystem sys; + + Animator anim; + anim.currentState = "idle"; + + Entity e = world.create(); + world.add(e, anim); + world.add(e, Sprite{}); + + sys.play(world, e, "walk"); + + const Animator* a = world.get(e); + REQUIRE(a->previousState == FixedString<32>("idle")); + REQUIRE(a->currentState == FixedString<32>("walk")); +}