From 1d43a778008aadfbda14a18a992018d8605fd098 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 29 Apr 2026 08:30:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(anim):=20suport=20de=20flip=20H/V=20per=20?= =?UTF-8?q?frame=20i=20per=20animaci=C3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 18 ++ .../core/rendering/sprite/animated_sprite.cpp | 124 ++++------ .../core/rendering/sprite/animated_sprite.hpp | 35 ++- .../sprite/animated_sprite_parser.cpp | 134 +++++++++++ tests/test_animated_sprite_flip.cpp | 214 ++++++++++++++++++ 5 files changed, 438 insertions(+), 87 deletions(-) create mode 100644 source/core/rendering/sprite/animated_sprite_parser.cpp create mode 100644 tests/test_animated_sprite_flip.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e94f498..f7f8298 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,6 +88,7 @@ set(APP_SOURCES source/core/rendering/sprite/sprite.cpp source/core/rendering/sprite/moving_sprite.cpp source/core/rendering/sprite/animated_sprite.cpp + source/core/rendering/sprite/animated_sprite_parser.cpp source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp source/core/rendering/sdl3gpu/viewport.cpp source/core/rendering/sdl3gpu/pipelines/crtpi_pipeline.cpp @@ -444,4 +445,21 @@ if(BUILD_TESTS) add_test(NAME game_fsm COMMAND test_game_fsm WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + + # Smoke del parser d'animacions amb suport de flip H/V. Stand-alone: + # només enllaça `animated_sprite_parser.cpp`, que és la part del motor + # que NO depèn de Resource::Cache ni de Surface — el parser és pure + # YAML-in / dades-out, així que no cal arrossegar tot l'stack. + add_executable(test_animated_sprite_flip + tests/test_animated_sprite_flip.cpp + source/core/rendering/sprite/animated_sprite_parser.cpp + ) + target_include_directories(test_animated_sprite_flip PRIVATE + "${CMAKE_SOURCE_DIR}/source" + ) + target_link_libraries(test_animated_sprite_flip PRIVATE SDL3::SDL3) + target_compile_options(test_animated_sprite_flip PRIVATE -Wall -Wextra -Wpedantic) + add_test(NAME animated_sprite_flip + COMMAND test_animated_sprite_flip + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) endif() diff --git a/source/core/rendering/sprite/animated_sprite.cpp b/source/core/rendering/sprite/animated_sprite.cpp index b50684c..25b07f1 100644 --- a/source/core/rendering/sprite/animated_sprite.cpp +++ b/source/core/rendering/sprite/animated_sprite.cpp @@ -7,36 +7,19 @@ #include "core/rendering/surface.hpp" #include "core/resources/resource_cache.hpp" -#include "external/fkyaml_node.hpp" + +// El parser pure de YAML d'animacions (parseAnimationsFromYaml + helpers) +// viu a `animated_sprite_parser.cpp` perquè és testable sense Surface ni +// Resource::Cache. Aquesta unitat es queda amb el runtime de la classe i +// amb el que sí toca el cache de surfaces (parseSpriteBlock). namespace { - auto convertYamlFramesToRects(const fkyaml::node& frames_node, float frame_width, float frame_height, int frames_per_row, int max_tiles) -> std::vector { - std::vector frames; - SDL_FRect rect = {.x = 0.0F, .y = 0.0F, .w = frame_width, .h = frame_height}; - for (const auto& frame_index_node : frames_node) { - const int NUM_TILE = frame_index_node.get_value(); - if (NUM_TILE <= max_tiles) { - rect.x = static_cast(NUM_TILE % frames_per_row) * frame_width; - rect.y = static_cast(NUM_TILE / frames_per_row) * frame_height; - frames.emplace_back(rect); - } - } - return frames; - } - - struct SheetParams { - float frame_width{0.0F}; - float frame_height{0.0F}; - int frames_per_row{1}; - int max_tiles{1}; - }; - // Llegeix el bloc `sprite: { path, size: [w, h] }` — mateix format que // les plantilles d'entitat. La surface es resol via Resource::Cache amb // el basename del fitxer. - auto parseSpriteBlock(const fkyaml::node& yaml, std::shared_ptr& surface) -> SheetParams { - SheetParams params; + auto parseSpriteBlock(const fkyaml::node& yaml, std::shared_ptr& surface) -> AnimatedSprite::SheetParams { + AnimatedSprite::SheetParams params; if (!yaml.contains("sprite")) { return params; } const auto& s = yaml["sprite"]; if (s.contains("path")) { @@ -57,62 +40,18 @@ namespace { return params; } - // `frame_ms` pot ser un escalar (temps uniforme) o una llista (per-frame). - // Convertim sempre a segons perquè `animate()` treballa amb delta_time - // en segons. Si falta o val 0, `speeds` queda buit i l'animació es - // considera estàtica (no avança). - void parseFrameTimes(const fkyaml::node& anim_node, AnimatedSprite::AnimationData& animation) { - if (!anim_node.contains("frame_ms")) { return; } - const auto& fm = anim_node["frame_ms"]; - if (fm.is_sequence()) { - for (const auto& v : fm) { - animation.speeds.push_back(static_cast(v.get_value()) / 1000.0F); - } - } else { - const float SEC = static_cast(fm.get_value()) / 1000.0F; - if (SEC > 0.0F) { animation.speeds.assign(animation.frames.size(), SEC); } - } - } - - // Ordre de prioritat per a la política de loop: - // 1) `loop_from: N` → rebota al frame N en passar del final (implica loop). - // 2) `loop: false` → animació d'un sol tret (es queda al darrer frame). - // 3) Res → loop complet des del frame 0 (default). - void parseLoopPolicy(const fkyaml::node& anim_node, AnimatedSprite::AnimationData& animation) { - if (anim_node.contains("loop_from")) { - animation.loop_from = anim_node["loop_from"].get_value(); - } else if (anim_node.contains("loop") && !anim_node["loop"].get_value()) { - animation.loop_from = -1; - } else { - animation.loop_from = 0; - } - } - - auto parseAnimations(const fkyaml::node& yaml, const SheetParams& params) -> std::unordered_map { - std::unordered_map animations; - if (!yaml.contains("animations") || !yaml["animations"].is_mapping()) { - return animations; - } - const auto& anims_node = yaml["animations"]; - for (auto it = anims_node.begin(); it != anims_node.end(); ++it) { - const auto NAME = it.key().get_value(); - const auto& anim_node = *it; - - AnimatedSprite::AnimationData animation; - if (anim_node.contains("frames") && anim_node["frames"].is_sequence()) { - animation.frames = convertYamlFramesToRects( - anim_node["frames"], - params.frame_width, - params.frame_height, - params.frames_per_row, - params.max_tiles); - } - parseFrameTimes(anim_node, animation); - parseLoopPolicy(anim_node, animation); - - animations.emplace(NAME, std::move(animation)); - } - return animations; + // Tradueix els flags lògics flip_h/flip_v al `SDL_FlipMode` que admet + // `Surface::render`. Important: a SDL3, `SDL_FlipMode` és un enum + // exclusiu (NONE/HORIZONTAL/VERTICAL), no un bitmask combinable. Per + // tant, V+H simultani al mateix frame no es pot expressar amb una + // única crida — prioritzem H (suficient per als casos pràctics + // d'animació, com `walk_west` o `death_spin`). Si en algun moment fa + // falta combinar, caldrà ampliar `Surface::render` per a doble-flipar + // a baix nivell. + auto toFlipMode(bool flip_h, bool flip_v) -> SDL_FlipMode { + if (flip_h) { return SDL_FLIP_HORIZONTAL; } + if (flip_v) { return SDL_FLIP_VERTICAL; } + return SDL_FLIP_NONE; } } // namespace @@ -122,7 +61,7 @@ AnimatedSprite::AnimatedSprite(const AnimationResource& cached_data) { try { auto yaml = fkyaml::node::deserialize(yaml_content); auto params = parseSpriteBlock(yaml, surface_); - animations_ = parseAnimations(yaml, params); + animations_ = parseAnimationsFromYaml(yaml, params); setWidth(params.frame_width); setHeight(params.frame_height); @@ -132,7 +71,7 @@ AnimatedSprite::AnimatedSprite(const AnimationResource& cached_data) { // abans del primer `update()`, així que el valor real el posa ell. if (!animations_.empty()) { const auto& first = animations_.begin()->second; - if (!first.frames.empty()) { setClip(first.frames[0]); } + if (!first.frames.empty()) { setClip(first.frames[0].rect); } } } catch (const fkyaml::exception& e) { std::cerr << "YAML parsing error in animation " << cached_data.name << ": " << e.what() << '\n'; @@ -154,11 +93,21 @@ void AnimatedSprite::animate(float delta_time) { const float TOTAL = std::accumulate(anim.speeds.begin(), anim.speeds.end(), 0.0F); + // Helper local per a propagar el frame actual (clip + opcionalment flip) + // al sprite. El flip només s'escriu si l'animació declara `controls_flip`, + // per a no trepitjar el flip que el codi del joc encara controla + // externament en animacions sense declaració de flip al YAML. + auto apply_current_frame = [&](int frame_index) { + const auto& frame = anim.frames[frame_index]; + setClip(frame.rect); + if (anim.controls_flip) { setFlip(toFlipMode(frame.flip_h, frame.flip_v)); } + }; + if (anim.accumulated_time >= TOTAL) { if (anim.loop_from < 0) { anim.current_frame = static_cast(anim.frames.size()) - 1; anim.completed = true; - setClip(anim.frames[anim.current_frame]); + apply_current_frame(anim.current_frame); return; } float loop_start = 0.0F; @@ -172,13 +121,13 @@ void AnimatedSprite::animate(float delta_time) { cursor += anim.speeds[i]; if (anim.accumulated_time < cursor) { anim.current_frame = i; - setClip(anim.frames[i]); + apply_current_frame(i); return; } } anim.current_frame = static_cast(anim.frames.size()) - 1; - setClip(anim.frames[anim.current_frame]); + apply_current_frame(anim.current_frame); } auto AnimatedSprite::animationIsCompleted() -> bool { @@ -200,7 +149,10 @@ void AnimatedSprite::setCurrentAnimation(const std::string& name) { anim.current_frame = 0; anim.accumulated_time = 0.0F; anim.completed = false; - if (!anim.frames.empty()) { setClip(anim.frames[0]); } + if (!anim.frames.empty()) { + setClip(anim.frames[0].rect); + if (anim.controls_flip) { setFlip(toFlipMode(anim.frames[0].flip_h, anim.frames[0].flip_v)); } + } } void AnimatedSprite::update(float delta_time) { diff --git a/source/core/rendering/sprite/animated_sprite.hpp b/source/core/rendering/sprite/animated_sprite.hpp index c781aaa..a80969c 100644 --- a/source/core/rendering/sprite/animated_sprite.hpp +++ b/source/core/rendering/sprite/animated_sprite.hpp @@ -8,14 +8,34 @@ #include "core/rendering/sprite/moving_sprite.hpp" #include "core/resources/resource_types.hpp" +#include "external/fkyaml_node.hpp" class AnimatedSprite : public MovingSprite { public: + // Un frame d'animació = rectangle dins el spritesheet + flags de flip + // associats. El flip s'aplica al sprite a `animate()` quan l'animació + // declara que ella controla el flip (vegeu `AnimationData::controls_flip`). + struct Frame { + SDL_FRect rect{.x = 0.0F, .y = 0.0F, .w = 0.0F, .h = 0.0F}; + bool flip_h{false}; + bool flip_v{false}; + }; + + // Paràmetres geomètrics del spritesheet, derivats del bloc `sprite:` + // del YAML. Es passen al parser d'animacions per a calcular els + // rectangles de cada frame a partir dels seus índexs. + struct SheetParams { + float frame_width{0.0F}; + float frame_height{0.0F}; + int frames_per_row{1}; + int max_tiles{1}; + }; + // Dades d'una animació concreta dins d'un spritesheet. Les claus del // mapa (`animations_`) són els noms; aquí només es guarden els fotogrames // i l'estat de reproducció. struct AnimationData { - std::vector frames; + std::vector frames; // Durada de cada frame en segons (convertit des de `frame_ms` al YAML). // Si té un sol valor, val per a tots els frames; altrament ha de tenir // la mateixa mida que `frames`. @@ -24,6 +44,13 @@ class AnimatedSprite : public MovingSprite { // 0 = loop complet des del primer frame. // N>0 = rebota al frame N en passar del final. int loop_from{0}; + // Si true, `animate()` escriu el flip del frame actual al sprite. + // Si false (cas backward-compat per a animacions sense cap declaració + // de flip), `animate()` no toca el flip — el codi del joc el segueix + // controlant externament via `setFlip(...)`. Es marca a true quan + // l'animació declara `flip_h`/`flip_v`, o quan algun frame es declara + // amb la forma mapping `{ index, flip_h?, flip_v? }`. + bool controls_flip{false}; bool completed{false}; int current_frame{0}; float accumulated_time{0.0F}; @@ -40,6 +67,12 @@ class AnimatedSprite : public MovingSprite { void resetAnimation(); void animate(float delta_time); + // Helper de parsing exposat per a testabilitat. NO toca el cache de + // recursos — només el bloc `animations:` del YAML i els paràmetres de + // full ja calculats. El ctor d'`AnimatedSprite` el crida internament. + static auto parseAnimationsFromYaml(const fkyaml::node& yaml, const SheetParams& params) + -> std::unordered_map; + private: std::unordered_map animations_; // Nom de l'animació activa. Buit fins que algú crida `setCurrentAnimation`. diff --git a/source/core/rendering/sprite/animated_sprite_parser.cpp b/source/core/rendering/sprite/animated_sprite_parser.cpp new file mode 100644 index 0000000..3c19721 --- /dev/null +++ b/source/core/rendering/sprite/animated_sprite_parser.cpp @@ -0,0 +1,134 @@ +#include +#include + +#include "core/rendering/sprite/animated_sprite.hpp" + +// Implementació del parser pure de YAML d'animacions. Viu en una unitat +// separada perquè NO depèn de Resource::Cache ni de Surface — només de +// fkyaml — i això permet als tests stand-alone enllaçar només aquest .cpp +// per a verificar el parsing sense haver d'arrossegar tot l'stack de +// recursos i renderitzat. + +namespace { + + // Construeix el rectangle d'un frame a partir del seu índex dins del + // spritesheet. Centralitzat per a no duplicar la fórmula als dos + // formats (escalar / mapping) que admet `frames:` al YAML. + auto rectFromIndex(int index, const AnimatedSprite::SheetParams& params) -> SDL_FRect { + return SDL_FRect{ + .x = static_cast(index % params.frames_per_row) * params.frame_width, + .y = static_cast(index / params.frames_per_row) * params.frame_height, + .w = params.frame_width, + .h = params.frame_height, + }; + } + + // Llig un node `frames` (seqüència) i el converteix a la nova + // representació `vector`. Cada element pot ser: + // - escalar (int) → hereta `default_flip_h`/`default_flip_v` de l'animació. + // - mapping `{ index: N, flip_h?: bool, flip_v?: bool }` → fully + // explícit. Els flags absents defaulten a false (NO hereta de l'anim). + auto convertYamlFrames(const fkyaml::node& frames_node, const AnimatedSprite::SheetParams& params, bool default_flip_h, bool default_flip_v) -> std::vector { + std::vector frames; + for (const auto& frame_node : frames_node) { + if (frame_node.is_mapping()) { + if (!frame_node.contains("index")) { continue; } + const int NUM_TILE = frame_node["index"].get_value(); + if (NUM_TILE > params.max_tiles) { continue; } + AnimatedSprite::Frame frame; + frame.rect = rectFromIndex(NUM_TILE, params); + if (frame_node.contains("flip_h")) { frame.flip_h = frame_node["flip_h"].get_value(); } + if (frame_node.contains("flip_v")) { frame.flip_v = frame_node["flip_v"].get_value(); } + frames.emplace_back(frame); + } else { + const int NUM_TILE = frame_node.get_value(); + if (NUM_TILE > params.max_tiles) { continue; } + frames.emplace_back(AnimatedSprite::Frame{ + .rect = rectFromIndex(NUM_TILE, params), + .flip_h = default_flip_h, + .flip_v = default_flip_v, + }); + } + } + return frames; + } + + // Detecta si dins de `frames_node` hi ha algun element en forma mapping. + // Si n'hi ha (encara que els flags interns siguin false), l'animació + // passa a controlar el flip — el mapping és senyal explícit que l'autor + // del YAML vol governar el flip per frame. + auto framesContainMapping(const fkyaml::node& frames_node) -> bool { + return std::ranges::any_of(frames_node, [](const auto& frame_node) { + return frame_node.is_mapping(); + }); + } + + // `frame_ms` pot ser un escalar (temps uniforme) o una llista (per-frame). + // Convertim sempre a segons perquè `animate()` treballa amb delta_time + // en segons. Si falta o val 0, `speeds` queda buit i l'animació es + // considera estàtica (no avança). + void parseFrameTimes(const fkyaml::node& anim_node, AnimatedSprite::AnimationData& animation) { + if (!anim_node.contains("frame_ms")) { return; } + const auto& fm = anim_node["frame_ms"]; + if (fm.is_sequence()) { + for (const auto& v : fm) { + animation.speeds.push_back(static_cast(v.get_value()) / 1000.0F); + } + } else { + const float SEC = static_cast(fm.get_value()) / 1000.0F; + if (SEC > 0.0F) { animation.speeds.assign(animation.frames.size(), SEC); } + } + } + + // Ordre de prioritat per a la política de loop: + // 1) `loop_from: N` → rebota al frame N en passar del final (implica loop). + // 2) `loop: false` → animació d'un sol tret (es queda al darrer frame). + // 3) Res → loop complet des del frame 0 (default). + void parseLoopPolicy(const fkyaml::node& anim_node, AnimatedSprite::AnimationData& animation) { + if (anim_node.contains("loop_from")) { + animation.loop_from = anim_node["loop_from"].get_value(); + } else if (anim_node.contains("loop") && !anim_node["loop"].get_value()) { + animation.loop_from = -1; + } else { + animation.loop_from = 0; + } + } + +} // namespace + +auto AnimatedSprite::parseAnimationsFromYaml(const fkyaml::node& yaml, const SheetParams& params) + -> std::unordered_map { + std::unordered_map animations; + if (!yaml.contains("animations") || !yaml["animations"].is_mapping()) { + return animations; + } + const auto& anims_node = yaml["animations"]; + for (auto it = anims_node.begin(); it != anims_node.end(); ++it) { + const auto NAME = it.key().get_value(); + const auto& anim_node = *it; + + // Flags de flip a nivell d'animació (defaults a false). Els frames + // declarats com a int els hereten; els declarats com a mapping no. + bool anim_flip_h = false; + bool anim_flip_v = false; + if (anim_node.contains("flip_h")) { anim_flip_h = anim_node["flip_h"].get_value(); } + if (anim_node.contains("flip_v")) { anim_flip_v = anim_node["flip_v"].get_value(); } + + AnimationData animation; + if (anim_node.contains("frames") && anim_node["frames"].is_sequence()) { + const auto& frames_node = anim_node["frames"]; + animation.frames = convertYamlFrames(frames_node, params, anim_flip_h, anim_flip_v); + // L'animació passa a "controlar" el flip si l'autor ha + // declarat qualsevol cosa relacionada amb flip — sigui a + // nivell d'anim o a través d'un frame en forma mapping. + // Sense cap declaració, ens mantenim passius i deixem el + // flip al codi del joc (backward-compat amb sam.yaml). + animation.controls_flip = anim_flip_h || anim_flip_v || framesContainMapping(frames_node); + } + parseFrameTimes(anim_node, animation); + parseLoopPolicy(anim_node, animation); + + animations.emplace(NAME, std::move(animation)); + } + return animations; +} diff --git a/tests/test_animated_sprite_flip.cpp b/tests/test_animated_sprite_flip.cpp new file mode 100644 index 0000000..fd2e0df --- /dev/null +++ b/tests/test_animated_sprite_flip.cpp @@ -0,0 +1,214 @@ +// Smoke test del parser d'animacions amb suport de flip H/V. Stand-alone: +// només enllaça `animated_sprite_parser.cpp` (la part del motor que NO toca +// Resource::Cache ni Surface), perquè la cobertura interessa el contracte +// de càrrega del YAML, no el render real. +// +// Tests coberts: +// T1 backward compat — YAML sense cap flip declarat carrega tots els +// frames amb (flip_h=false, flip_v=false) i sense +// que l'animació prengui control del flip. +// T2 flip a nivell d'animació — `flip_h: true` amb frames `int` → +// tots els frames hereten flip_h=true. +// T3 override per-frame — frame mapping `{ index: N, flip_h: true }` +// en una animació sense flip → només aquest frame +// té el flag. +// T4 mapping no hereta — animació amb `flip_h: true` però un frame +// `{ index: N }` (mapping sense flip) → flip_h=false +// en aquest frame (el mapping declara el seu propi +// estat, sense heretar). +// T5 combinat V+H — frame `{ index, flip_h: true, flip_v: true }` → +// ambdós flags actius. + +#include +#include +#include + +#include "core/rendering/sprite/animated_sprite.hpp" +#include "external/fkyaml_node.hpp" + +namespace { + + int g_failures = 0; + + void check(bool cond, const char* msg, const char* file, int line) { + if (!cond) { + std::cerr << " FAIL: " << msg << " (" << file << ":" << line << ")\n"; + ++g_failures; + } + } + +#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__) + + // Paràmetres comuns per a tots els tests: spritesheet hipotètic 64×64 + // amb tiles de 16×16 → grid 4×4, max_tiles=16. + auto sheetParams() -> AnimatedSprite::SheetParams { + return AnimatedSprite::SheetParams{ + .frame_width = 16.0F, + .frame_height = 16.0F, + .frames_per_row = 4, + .max_tiles = 16, + }; + } + + auto parse(const std::string& yaml_str) { + return AnimatedSprite::parseAnimationsFromYaml( + fkyaml::node::deserialize(yaml_str), sheetParams()); + } + + void testBackwardCompat() { + std::cout << "[T1] backward compat: cap flip declarat\n"; + const std::string YAML = R"( +animations: + walk: { frames: [0, 1, 2, 3], frame_ms: 100 } + stand: { frames: [4] } +)"; + const auto ANIMS = parse(YAML); + REQUIRE(ANIMS.size() == 2); + + const auto WALK_IT = ANIMS.find("walk"); + REQUIRE(WALK_IT != ANIMS.end()); + if (WALK_IT != ANIMS.end()) { + const auto& WALK = WALK_IT->second; + REQUIRE(WALK.frames.size() == 4); + REQUIRE(!WALK.controls_flip); + for (const auto& F : WALK.frames) { + REQUIRE(!F.flip_h); + REQUIRE(!F.flip_v); + } + } + + const auto STAND_IT = ANIMS.find("stand"); + REQUIRE(STAND_IT != ANIMS.end()); + if (STAND_IT != ANIMS.end()) { + const auto& STAND = STAND_IT->second; + REQUIRE(STAND.frames.size() == 1); + REQUIRE(!STAND.controls_flip); + REQUIRE(!STAND.frames[0].flip_h); + REQUIRE(!STAND.frames[0].flip_v); + } + } + + void testFlipAtAnimationLevel() { + std::cout << "[T2] flip a nivell d'anim hereten frames int\n"; + const std::string YAML = R"( +animations: + walk_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true } +)"; + const auto ANIMS = parse(YAML); + const auto IT = ANIMS.find("walk_west"); + REQUIRE(IT != ANIMS.end()); + if (IT != ANIMS.end()) { + const auto& A = IT->second; + REQUIRE(A.frames.size() == 4); + REQUIRE(A.controls_flip); + for (const auto& F : A.frames) { + REQUIRE(F.flip_h); + REQUIRE(!F.flip_v); + } + } + } + + void testOverridePerFrame() { + std::cout << "[T3] override per-frame en animació sense flip\n"; + const std::string YAML = R"( +animations: + death_spin: + frames: + - 0 + - 4 + - 8 + - { index: 8, flip_h: true } + frame_ms: 100 +)"; + const auto ANIMS = parse(YAML); + const auto IT = ANIMS.find("death_spin"); + REQUIRE(IT != ANIMS.end()); + if (IT != ANIMS.end()) { + const auto& A = IT->second; + REQUIRE(A.frames.size() == 4); + // Hi ha un mapping → l'animació pren el control del flip. + REQUIRE(A.controls_flip); + + // Els tres primers (int, sense flip a l'anim) → tot a false. + REQUIRE(!A.frames[0].flip_h); + REQUIRE(!A.frames[0].flip_v); + REQUIRE(!A.frames[1].flip_h); + REQUIRE(!A.frames[1].flip_v); + REQUIRE(!A.frames[2].flip_h); + REQUIRE(!A.frames[2].flip_v); + + // El quart (mapping amb flip_h: true) → només flip_h. + REQUIRE(A.frames[3].flip_h); + REQUIRE(!A.frames[3].flip_v); + } + } + + void testMappingDoesNotInherit() { + std::cout << "[T4] mapping NO hereta el flip de l'animació\n"; + const std::string YAML = R"( +animations: + mixed: + frames: + - 0 + - { index: 4 } + frame_ms: 100 + flip_h: true +)"; + const auto ANIMS = parse(YAML); + const auto IT = ANIMS.find("mixed"); + REQUIRE(IT != ANIMS.end()); + if (IT != ANIMS.end()) { + const auto& A = IT->second; + REQUIRE(A.frames.size() == 2); + REQUIRE(A.controls_flip); + + // Frame int hereta el flip_h true de l'animació. + REQUIRE(A.frames[0].flip_h); + REQUIRE(!A.frames[0].flip_v); + + // Frame mapping declara els seus propis flags (cap declarat + // → false) i NO hereta del flip a nivell d'anim. + REQUIRE(!A.frames[1].flip_h); + REQUIRE(!A.frames[1].flip_v); + } + } + + void testCombinedVerticalAndHorizontal() { + std::cout << "[T5] flip combinat V+H per-frame\n"; + const std::string YAML = R"( +animations: + upside_down: + frames: + - { index: 5, flip_h: true, flip_v: true } + frame_ms: 100 +)"; + const auto ANIMS = parse(YAML); + const auto IT = ANIMS.find("upside_down"); + REQUIRE(IT != ANIMS.end()); + if (IT != ANIMS.end()) { + const auto& A = IT->second; + REQUIRE(A.frames.size() == 1); + REQUIRE(A.controls_flip); + REQUIRE(A.frames[0].flip_h); + REQUIRE(A.frames[0].flip_v); + } + } + +} // namespace + +auto main() -> int { + std::cout << "== smoke tests: animation flip parser ==\n"; + + testBackwardCompat(); + testFlipAtAnimationLevel(); + testOverridePerFrame(); + testMappingDoesNotInherit(); + testCombinedVerticalAndHorizontal(); + + if (g_failures == 0) { + std::cout << "ALL OK\n"; + return 0; + } + std::cerr << g_failures << " smoke check(s) failed\n"; + return 1; +}