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/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index c3743e7..9c00d8e 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -23,18 +23,22 @@ flags: collides: true interactable: false # Strip de 12 frames en graella 4×3 (mateix esquema que el jugador): -# 0..3 → walk_down (cara avall) -# 4..7 → walk_up (cara amunt) -# 8..11 → walk (lateral; flip horitzontal segons el sentit) +# 0..3 → walk_south (cara avall) +# 4..7 → walk_north (cara amunt) +# 8..11 → walk_east (lateral cap a l'est) +# La variant `walk_west` reaprofita els frames de l'est amb `flip_h: true`: +# el motor d'animació aplica el flip al sprite, així el codi de la mòmia +# no decideix flip per direcció (només selecciona quina animació mostrar). # La momia no té variants `stand_*` perquè mai no para. animations: - walk_down: { frames: [0, 1, 2, 3], fps: 8, loop: true } - walk_up: { frames: [4, 5, 6, 7], fps: 8, loop: true } - walk: { frames: [8, 9, 10, 11], fps: 8, loop: true } + walk_south: { frames: [0, 1, 2, 3], fps: 8, loop: true } + walk_north: { frames: [4, 5, 6, 7], fps: 8, loop: true } + walk_east: { frames: [8, 9, 10, 11], fps: 8, loop: true } + walk_west: { frames: [8, 9, 10, 11], fps: 8, loop: true, flip_h: true } ai: type: corridor_wanderer direction_choice: random - initial_direction: right + initial_direction: east stats: speed: 30 damage: 1 diff --git a/data/music/playing.ogg b/data/music/playing.ogg index 5808941..a4d8ca0 100644 Binary files a/data/music/playing.ogg and b/data/music/playing.ogg differ diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index ece6005..6ae578d 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -12,60 +12,43 @@ collision: height: 12 animations: - stand_down: { frames: [0] } - stand_up: { frames: [4] } - stand: { frames: [8] } - jump_down: { frames: [12] } - jump_up: { frames: [13] } - jump: { frames: [14] } - stun_down: { frames: [16] } - stun_up: { frames: [17] } - stun: { frames: [18] } + # Les variants `*_west` reaprofiten els frames de l'est i declaren + # `flip_h: true` perquè el motor d'animació apliqui el flip al sprite — + # el codi del joc no toca mai `flip_` directament. + stand_south: { frames: [0] } + stand_north: { frames: [4] } + stand_east: { frames: [8] } + stand_west: { frames: [8], flip_h: true } - walk_down: - frames: [0, 1, 2, 3] - frame_ms: 100 + jump_south: { frames: [12] } + jump_north: { frames: [13] } + jump_east: { frames: [14] } + jump_west: { frames: [14], flip_h: true } - walk_up: - frames: [4, 5, 6, 7] - frame_ms: 100 + stun_south: { frames: [16] } + stun_north: { frames: [17] } + stun_east: { frames: [18] } + stun_west: { frames: [18], flip_h: true } - walk: - frames: [8, 9, 10, 11] - frame_ms: 100 + walk_south: { frames: [0, 1, 2, 3], frame_ms: 100 } + walk_north: { frames: [4, 5, 6, 7], frame_ms: 100 } + walk_east: { frames: [8, 9, 10, 11], frame_ms: 100 } + walk_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true } - hurt_down: - frames: [0, 1, 2, 3] - frame_ms: 100 + hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 } + hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 } + hurt_east: { frames: [8, 9, 10, 11], frame_ms: 100 } + hurt_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true } - hurt_up: - frames: [4, 5, 6, 7] - frame_ms: 100 + attack_south: { frames: [20, 21, 22, 23], frame_ms: 50 } + attack_north: { frames: [24, 25, 26, 27], frame_ms: 50 } + attack_east: { frames: [28, 29, 30, 31], frame_ms: 50 } + attack_west: { frames: [28, 29, 30, 31], frame_ms: 50, flip_h: true } - hurt: - frames: [8, 9, 10, 11] - frame_ms: 100 - - attack_down: - frames: [20, 21, 22, 23] - frame_ms: 50 - - attack_up: - frames: [24, 25, 26, 27] - frame_ms: 50 - - attack: - frames: [28, 29, 30, 31] - frame_ms: 50 - - recover_down: - frames: [23] - - recover_up: - frames: [27] - - recover: - frames: [31] + recover_south: { frames: [23] } + recover_north: { frames: [27] } + recover_east: { frames: [31] } + recover_west: { frames: [31], flip_h: true } # Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra # tocs curts (passets d'un tile sense rellisquera). @@ -78,8 +61,8 @@ inertia: turn: duration_ms: 80 transitions: - west_to_east: down - east_to_west: up + west_to_east: south + east_to_west: north north_to_south: east south_to_north: west diff --git a/data/player/sam.yaml b/data/player/sam.yaml index e24fd90..f008835 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -12,60 +12,43 @@ collision: height: 12 animations: - stand_down: { frames: [0] } - stand_up: { frames: [4] } - stand: { frames: [8] } - jump_down: { frames: [12] } - jump_up: { frames: [13] } - jump: { frames: [14] } - stun_down: { frames: [16] } - stun_up: { frames: [17] } - stun: { frames: [18] } + # Les variants `*_west` reaprofiten els frames de l'est i declaren + # `flip_h: true` perquè el motor d'animació apliqui el flip al sprite — + # el codi del joc no toca mai `flip_` directament. + stand_south: { frames: [0] } + stand_north: { frames: [4] } + stand_east: { frames: [8] } + stand_west: { frames: [8], flip_h: true } - walk_down: - frames: [0, 1, 2, 3] - frame_ms: 100 + jump_south: { frames: [12] } + jump_north: { frames: [13] } + jump_east: { frames: [14] } + jump_west: { frames: [14], flip_h: true } - walk_up: - frames: [4, 5, 6, 7] - frame_ms: 100 + stun_south: { frames: [16] } + stun_north: { frames: [17] } + stun_east: { frames: [18] } + stun_west: { frames: [18], flip_h: true } - walk: - frames: [8, 9, 10, 11] - frame_ms: 100 + walk_south: { frames: [0, 1, 2, 3], frame_ms: 100 } + walk_north: { frames: [4, 5, 6, 7], frame_ms: 100 } + walk_east: { frames: [8, 9, 10, 11], frame_ms: 100 } + walk_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true } - hurt_down: - frames: [0, 1, 2, 3] - frame_ms: 100 + hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 } + hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 } + hurt_east: { frames: [8, 9, 10, 11], frame_ms: 100 } + hurt_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true } - hurt_up: - frames: [4, 5, 6, 7] - frame_ms: 100 + attack_south: { frames: [20, 21, 22, 23], frame_ms: 50 } + attack_north: { frames: [24, 25, 26, 27], frame_ms: 50 } + attack_east: { frames: [28, 29, 30, 31], frame_ms: 50 } + attack_west: { frames: [28, 29, 30, 31], frame_ms: 50, flip_h: true } - hurt: - frames: [8, 9, 10, 11] - frame_ms: 100 - - attack_down: - frames: [20, 21, 22, 23] - frame_ms: 50 - - attack_up: - frames: [24, 25, 26, 27] - frame_ms: 50 - - attack: - frames: [28, 29, 30, 31] - frame_ms: 50 - - recover_down: - frames: [23] - - recover_up: - frames: [27] - - recover: - frames: [31] + recover_south: { frames: [23] } + recover_north: { frames: [27] } + recover_east: { frames: [31] } + recover_west: { frames: [31], flip_h: true } # Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra # tocs curts (passets d'un tile sense rellisquera). @@ -78,8 +61,8 @@ inertia: turn: duration_ms: 80 transitions: - west_to_east: down - east_to_west: up + west_to_east: south + east_to_west: north north_to_south: east south_to_north: west diff --git a/source/core/rendering/sprite/animated_sprite.cpp b/source/core/rendering/sprite/animated_sprite.cpp index b50684c..4ab8aa1 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,20 @@ 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 + flip) al sprite. + // El motor sempre sobreescriu el flip — si el YAML no declara `flip_h`/ + // `flip_v`, el frame té els flags a false i el resultat és `SDL_FLIP_NONE`. + auto apply_current_frame = [&](int frame_index) { + const auto& frame = anim.frames[frame_index]; + setClip(frame.rect); + 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 +120,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 +148,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); + 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..f11af9a 100644 --- a/source/core/rendering/sprite/animated_sprite.hpp +++ b/source/core/rendering/sprite/animated_sprite.hpp @@ -8,14 +8,41 @@ #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ó. + // + // El flip és sempre propietat del motor: a cada `animate()` s'escriu el + // flip del frame actual al sprite (default `SDL_FLIP_NONE` quan el YAML + // no declara res). El codi del joc no pot — ni ha de — tocar el flip + // manualment; ho expressa al YAML declarant `flip_h: true` a l'animació + // simètrica (p. ex. `walk_west`) o, per a casos puntuals, per frame + // amb la forma mapping `{ index, flip_h?, flip_v? }`. 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`. @@ -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..9604850 --- /dev/null +++ b/source/core/rendering/sprite/animated_sprite_parser.cpp @@ -0,0 +1,114 @@ +#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; + } + + // `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()) { + animation.frames = convertYamlFrames(anim_node["frames"], params, anim_flip_h, anim_flip_v); + } + parseFrameTimes(anim_node, animation); + parseLoopPolicy(anim_node, animation); + + animations.emplace(NAME, std::move(animation)); + } + return animations; +} diff --git a/source/game/entities/animation_component.cpp b/source/game/entities/animation_component.cpp index 65ba3a2..e63b62f 100644 --- a/source/game/entities/animation_component.cpp +++ b/source/game/entities/animation_component.cpp @@ -4,9 +4,14 @@ #include AnimationComponent::AnimationComponent(std::vector frames, float fps, bool loop) + : AnimationComponent(std::move(frames), fps, loop, false, false) {} + +AnimationComponent::AnimationComponent(std::vector frames, float fps, bool loop, bool flip_h, bool flip_v) : frames_(std::move(frames)), fps_(fps > 0.0F ? fps : 1.0F), - loop_(loop) { + loop_(loop), + flip_h_(flip_h), + flip_v_(flip_v) { // Una animació d'1 sol frame amb loop=false és una animació que ja s'ha // acabat de reproduir: no hi ha cap transició per mostrar. Marcant-la // com a completada des de l'inici permet als consumidors (p.ex. Door) @@ -46,3 +51,9 @@ auto AnimationComponent::currentFrame() const -> int { if (frames_.empty()) { return 0; } return frames_[static_cast(current_)]; } + +auto AnimationComponent::flipMode() const -> SDL_FlipMode { + if (flip_h_) { return SDL_FLIP_HORIZONTAL; } + if (flip_v_) { return SDL_FLIP_VERTICAL; } + return SDL_FLIP_NONE; +} diff --git a/source/game/entities/animation_component.hpp b/source/game/entities/animation_component.hpp index 11f851f..1343eef 100644 --- a/source/game/entities/animation_component.hpp +++ b/source/game/entities/animation_component.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include #include @@ -13,10 +15,18 @@ // algun dia aterri l'art definitiu, el renderer consultarà `currentFrame()` // i traduirà l'índex al rectangle del sprite corresponent. De moment els // placeholders tenen un únic frame i `currentFrame()` retorna sempre 0. +// +// El flip (H/V) és propietat de l'animació, no del codi del joc: el YAML +// declara `flip_h`/`flip_v` al bloc d'animació i el component els exposa +// via `flipMode()`. Les entitats apliquen aquest valor al seu `flip_` quan +// canvien d'animació, sense decidir-lo per direcció. Així una mòmia que va +// cap a l'oest reprodueix `walk_west` (frames de l'est + `flip_h: true`) +// sense que el codi de la mòmia toqui flip directament. class AnimationComponent { public: AnimationComponent() = default; AnimationComponent(std::vector frames, float fps, bool loop); + AnimationComponent(std::vector frames, float fps, bool loop, bool flip_h, bool flip_v); // Avanç del temps. Si `frames_` està buit o té un sol element, no fa // res. En mode loop, `current_frame_` rebota a 0 en passar del final; @@ -37,10 +47,19 @@ class AnimationComponent { [[nodiscard]] auto completed() const -> bool { return completed_; } [[nodiscard]] auto loops() const -> bool { return loop_; } + [[nodiscard]] auto flipH() const -> bool { return flip_h_; } + [[nodiscard]] auto flipV() const -> bool { return flip_v_; } + // Combinació dels dos flags al `SDL_FlipMode` que entén el render. + // SDL3 no admet bitmask: si fos VH simultani, prioritzem H (cap cas + // pràctic actual el necessita combinat). + [[nodiscard]] auto flipMode() const -> SDL_FlipMode; + private: std::vector frames_; float fps_{1.0F}; bool loop_{true}; + bool flip_h_{false}; + bool flip_v_{false}; int current_{0}; float accumulator_{0.0F}; bool completed_{false}; diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp index 43977d5..9b1cc0c 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -26,11 +26,15 @@ namespace { // Llegeix un bloc d'animació del YAML amb fallbacks tolerants (mateix // patró que Door/Sarcophagus). Si la clau no apareix, retorna una // animació d'1 frame loopant, neutra — la momia es podrà moure però - // visualment es quedarà estàtica. + // visualment es quedarà estàtica. Llegeix `flip_h`/`flip_v` (default + // false) i els passa al component perquè el motor sàpiga aplicar-los + // al sprite quan aquesta animació estigui activa. auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent { std::vector frames = {0}; float fps = 8.0F; bool loop = true; + bool flip_h = false; + bool flip_v = false; if (yaml.contains("animations") && yaml["animations"].contains(key)) { const auto& anim = yaml["animations"][key]; if (anim.contains("frames")) { @@ -43,8 +47,10 @@ namespace { } if (anim.contains("fps")) { fps = anim["fps"].get_value(); } if (anim.contains("loop")) { loop = anim["loop"].get_value(); } + if (anim.contains("flip_h")) { flip_h = anim["flip_h"].get_value(); } + if (anim.contains("flip_v")) { flip_v = anim["flip_v"].get_value(); } } - return {frames, fps, loop}; + return {frames, fps, loop, flip_h, flip_v}; } // Busca si el segment [prev, prev+delta] travessa una lane del patró @@ -73,9 +79,10 @@ namespace { } // namespace Mummy::Mummy(const fkyaml::node& yaml) - : anim_walk_down_(buildAnimation(yaml, "walk_down")), - anim_walk_up_(buildAnimation(yaml, "walk_up")), - anim_walk_lateral_(buildAnimation(yaml, "walk")) { + : anim_walk_south_(buildAnimation(yaml, "walk_south")), + anim_walk_north_(buildAnimation(yaml, "walk_north")), + anim_walk_east_(buildAnimation(yaml, "walk_east")), + anim_walk_west_(buildAnimation(yaml, "walk_west")) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("mummy"); collides_ = true; interactable_ = false; @@ -87,21 +94,22 @@ Mummy::Mummy(const fkyaml::node& yaml) if (s.contains("damage")) { setDamage(s["damage"].get_value()); } } - // Direcció inicial llegida del bloc `ai:`. Per defecte dreta, per - // coherència amb el que fa RollingRock. Valors acceptats: right, left, - // up, down. Qualsevol altre s'interpreta com a "sense direcció" (la - // momia no es mou fins que step() no triï una direcció). - std::string initial = "right"; + // Direcció inicial llegida del bloc `ai:`. Per defecte est, per + // coherència amb el que fa RollingRock. Valors acceptats: + // east, west, north, south. Qualsevol altre s'interpreta com a + // "sense direcció" (la momia no es mou fins que step() no triï una + // direcció). + std::string initial = "east"; if (yaml.contains("ai") && yaml["ai"].contains("initial_direction")) { initial = yaml["ai"]["initial_direction"].get_value(); } - if (initial == "right") { + if (initial == "east") { changeDirection(1.0F, 0.0F); - } else if (initial == "left") { + } else if (initial == "west") { changeDirection(-1.0F, 0.0F); - } else if (initial == "up") { + } else if (initial == "north") { changeDirection(0.0F, -1.0F); - } else if (initial == "down") { + } else if (initial == "south") { changeDirection(0.0F, 1.0F); } @@ -124,22 +132,26 @@ Mummy::Mummy(const fkyaml::node& yaml) void Mummy::changeDirection(float dx, float dy) { setDirection(dx, dy); - // Prevalença lateral: si hi ha component horitzontal s'usa l'anim - // lateral + flip segons el signe. Només direccions purament verticals - // disparen walk_up/walk_down. Quan la direcció queda (0,0) (cul-de-sac - // amb NONE) no toquem l'animació: la momia es quedarà al frame actual - // fins que step() li torni a assignar direcció. - if (dx != 0.0F) { - anim_ = anim_walk_lateral_; - flip_ = (dx < 0.0F) ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE; + // Una animació per cada cardinal — el flip viu al YAML + // (`walk_west: flip_h: true`) i el codi només selecciona quina + // animació mostrar. Prevalença lateral en diagonals; quan la direcció + // queda (0,0) (cul-de-sac amb NONE) no toquem l'animació: la mòmia es + // quedarà al frame actual fins que step() li torni a assignar direcció. + if (dx > 0.0F) { + anim_ = anim_walk_east_; + } else if (dx < 0.0F) { + anim_ = anim_walk_west_; } else if (dy < 0.0F) { - anim_ = anim_walk_up_; - flip_ = SDL_FLIP_NONE; + anim_ = anim_walk_north_; } else if (dy > 0.0F) { - anim_ = anim_walk_down_; - flip_ = SDL_FLIP_NONE; + anim_ = anim_walk_south_; + } + if (anim_.has_value()) { + anim_->reset(); + // Propaguem el flip que l'animació declara al YAML al camp + // `flip_` que el render llegeix (entity_manager_loadlevel.cpp). + flip_ = anim_->flipMode(); } - if (anim_.has_value()) { anim_->reset(); } } auto Mummy::chooseDirection(const std::vector& allowed) -> Direction { @@ -234,10 +246,10 @@ auto Mummy::detectLaneCrossing(float dx, float dy) const -> std::optional } auto Mummy::currentDirection() const -> Direction { - if (dir_x_ > 0.0F) { return Direction::RIGHT; } - if (dir_x_ < 0.0F) { return Direction::LEFT; } - if (dir_y_ > 0.0F) { return Direction::DOWN; } - if (dir_y_ < 0.0F) { return Direction::UP; } + if (dir_x_ > 0.0F) { return Direction::EAST; } + if (dir_x_ < 0.0F) { return Direction::WEST; } + if (dir_y_ > 0.0F) { return Direction::SOUTH; } + if (dir_y_ < 0.0F) { return Direction::NORTH; } return Direction::NONE; } @@ -268,10 +280,10 @@ auto Mummy::collectDirectionPools(float step) const -> DirectionPools { Direction dir; }; const std::array ALL{{ - {.dx = step, .dy = 0.0F, .dir = Direction::RIGHT}, - {.dx = -step, .dy = 0.0F, .dir = Direction::LEFT}, - {.dx = 0.0F, .dy = -step, .dir = Direction::UP}, - {.dx = 0.0F, .dy = step, .dir = Direction::DOWN}, + {.dx = step, .dy = 0.0F, .dir = Direction::EAST}, + {.dx = -step, .dy = 0.0F, .dir = Direction::WEST}, + {.dx = 0.0F, .dy = -step, .dir = Direction::NORTH}, + {.dx = 0.0F, .dy = step, .dir = Direction::SOUTH}, }}; DirectionPools pools; for (const auto& c : ALL) { @@ -293,16 +305,16 @@ auto Mummy::collectDirectionPools(float step) const -> DirectionPools { void Mummy::applyChoice(Direction dir) { switch (dir) { - case Direction::UP: + case Direction::NORTH: changeDirection(0.0F, -1.0F); break; - case Direction::DOWN: + case Direction::SOUTH: changeDirection(0.0F, 1.0F); break; - case Direction::LEFT: + case Direction::WEST: changeDirection(-1.0F, 0.0F); break; - case Direction::RIGHT: + case Direction::EAST: changeDirection(1.0F, 0.0F); break; case Direction::NONE: @@ -347,16 +359,16 @@ auto Mummy::chooseHurtDirection(Direction preferred) const -> Direction { float dx = 0.0F; float dy = 0.0F; switch (d) { - case Direction::UP: + case Direction::NORTH: dy = -STEP; break; - case Direction::DOWN: + case Direction::SOUTH: dy = STEP; break; - case Direction::LEFT: + case Direction::WEST: dx = -STEP; break; - case Direction::RIGHT: + case Direction::EAST: dx = STEP; break; case Direction::NONE: @@ -373,21 +385,21 @@ auto Mummy::chooseHurtDirection(Direction preferred) const -> Direction { Direction left = Direction::NONE; Direction right = Direction::NONE; switch (preferred) { - case Direction::UP: - left = Direction::LEFT; - right = Direction::RIGHT; + case Direction::NORTH: + left = Direction::WEST; + right = Direction::EAST; break; - case Direction::DOWN: - left = Direction::RIGHT; - right = Direction::LEFT; + case Direction::SOUTH: + left = Direction::EAST; + right = Direction::WEST; break; - case Direction::LEFT: - left = Direction::DOWN; - right = Direction::UP; + case Direction::WEST: + left = Direction::SOUTH; + right = Direction::NORTH; break; - case Direction::RIGHT: - left = Direction::UP; - right = Direction::DOWN; + case Direction::EAST: + left = Direction::NORTH; + right = Direction::SOUTH; break; case Direction::NONE: return Direction::NONE; diff --git a/source/game/entities/concrete/mummy.hpp b/source/game/entities/concrete/mummy.hpp index e7ab31a..c99e0c7 100644 --- a/source/game/entities/concrete/mummy.hpp +++ b/source/game/entities/concrete/mummy.hpp @@ -16,10 +16,10 @@ class Mummy : public DynamicHazard { public: enum class Direction : std::uint8_t { NONE, - UP, - DOWN, - LEFT, - RIGHT }; + NORTH, + SOUTH, + WEST, + EAST }; // Estat d'alt nivell. ROAMING = IA normal (corridor_wanderer); HURT = // post-fuetada, fugint en línia recta durant `hurt_config_.duration_s`. @@ -91,10 +91,11 @@ class Mummy : public DynamicHazard { }; [[nodiscard]] auto collectDirectionPools(float step) const -> DirectionPools; - // Canvi de direcció + aplicació de l'animació i flip corresponents. - // Tots els camins que trien nova direcció (ctor amb `initial_direction`, - // gir després d'un bloqueig) passen per ací per mantenir anim_ i - // flip_ en sincronia amb (dir_x_, dir_y_). + // Canvi de direcció + selecció de l'animació corresponent (un cardinal + // per branch). Tots els camins que trien nova direcció (ctor amb + // `initial_direction`, gir després d'un bloqueig) passen per ací; el + // flip viu al YAML (`walk_west: flip_h: true`) i es propaga des de + // l'animació al `flip_` heretat. void changeDirection(float dx, float dy); // Aplica la direcció triada (wrapper del switch en una sola crida). @@ -128,11 +129,14 @@ class Mummy : public DynamicHazard { // totes ho estan, retorna `NONE` (la mòmia queda quieta en HURT). [[nodiscard]] auto chooseHurtDirection(Direction preferred) const -> Direction; - // Animacions carregades del YAML, una per cada facing. Per a horitzontal - // només en cal una (`walk`) i el flip ja diferencia esquerra/dreta. - AnimationComponent anim_walk_down_; - AnimationComponent anim_walk_up_; - AnimationComponent anim_walk_lateral_; + // Animacions carregades del YAML, una per cada cardinal. La variant + // `walk_west` reaprofita els frames de l'est però amb `flip_h: true` + // declarat al YAML — el motor d'animació aplica el flip al sprite + // automàticament; aquesta classe no toca mai `flip_` directament. + AnimationComponent anim_walk_south_; + AnimationComponent anim_walk_north_; + AnimationComponent anim_walk_east_; + AnimationComponent anim_walk_west_; // Estat HURT i la seua configuració. `hurt_speed_` és l'efectiva // (resultat d'aplicar el `speed` del bloc `hurt:` o, si val 0, la diff --git a/source/game/entities/concrete/whip_attack.cpp b/source/game/entities/concrete/whip_attack.cpp index 0c9c6cd..b638eb0 100644 --- a/source/game/entities/concrete/whip_attack.cpp +++ b/source/game/entities/concrete/whip_attack.cpp @@ -255,13 +255,13 @@ namespace { auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction { switch (d) { case WhipAttack::Direction::NORTH: - return Mummy::Direction::UP; + return Mummy::Direction::NORTH; case WhipAttack::Direction::SOUTH: - return Mummy::Direction::DOWN; + return Mummy::Direction::SOUTH; case WhipAttack::Direction::EAST: - return Mummy::Direction::RIGHT; + return Mummy::Direction::EAST; case WhipAttack::Direction::WEST: - return Mummy::Direction::LEFT; + return Mummy::Direction::WEST; } return Mummy::Direction::NONE; } diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index f7c7709..fb44080 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -155,10 +155,11 @@ class Entity { // el sprite) amb el tile. Nullopt si l'entitat no en té. [[nodiscard]] auto getCollisionBox() const -> const std::optional& { return collision_box_; } - // Flip del sprite. Les entitats estàtiques mai no el toquen (queda a - // SDL_FLIP_NONE); les dinàmiques que miren cap als costats (p. ex. - // Mummy en direcció horitzontal) el canvien quan gira. El manager - // el passa a Surface::render. + // Flip del sprite, propagat des del motor d'animació + // (`AnimationComponent::flipMode()`): cap entitat el decideix per + // direcció — el YAML declara `flip_h: true` a l'animació simètrica + // (p. ex. `walk_west`) i el setter d'animació l'aplica al `flip_`. + // El manager el passa a `Surface::render`. [[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; } void setFlip(SDL_FlipMode flip) { flip_ = flip; } diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index ffa6b5f..01e8861 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -203,22 +203,21 @@ void Player::readWhipBlock(const fkyaml::node& yaml) { } void Player::applyCardinal(Cardinal c) { + // El flip del sprite el governa l'animació al motor: només cal posar + // el `facing_` que tocaria; `animationName()` ja resol el sufix + // (`_west` per Cardinal::WEST) i el YAML hi declara `flip_h: true`. switch (c) { case Cardinal::NORTH: - facing_ = Facing::UP; - setFlip(SDL_FLIP_NONE); + facing_ = Facing::NORTH; break; case Cardinal::SOUTH: - facing_ = Facing::DOWN; - setFlip(SDL_FLIP_NONE); + facing_ = Facing::SOUTH; break; case Cardinal::EAST: - facing_ = Facing::SIDE; - setFlip(SDL_FLIP_NONE); + facing_ = Facing::EAST; break; case Cardinal::WEST: - facing_ = Facing::SIDE; - setFlip(SDL_FLIP_HORIZONTAL); + facing_ = Facing::WEST; break; case Cardinal::NONE: break; @@ -236,10 +235,10 @@ auto Player::cardinalFromInput(float vx, float vy) -> Cardinal { } auto Player::cardinalFromString(const std::string& s) -> Cardinal { - if (s == "north" || s == "up") { return Cardinal::NORTH; } - if (s == "south" || s == "down") { return Cardinal::SOUTH; } - if (s == "east" || s == "right") { return Cardinal::EAST; } - if (s == "west" || s == "left") { return Cardinal::WEST; } + if (s == "north") { return Cardinal::NORTH; } + if (s == "south") { return Cardinal::SOUTH; } + if (s == "east") { return Cardinal::EAST; } + if (s == "west") { return Cardinal::WEST; } throw std::runtime_error("Player: cardinal desconegut '" + s + "' al bloc turn.transitions"); } @@ -313,17 +312,14 @@ void Player::updateFacingAndTurn(float delta_time, float vx, float vy) { turning_remaining_ms_ = static_cast(turn_config.duration_ms); applyCardinal(intermediate_cardinal_); } else if (vx != 0.0F) { - // Tria de facing original: prevalença lateral, flip segons el - // signe de vx; verticals purs van a UP/DOWN amb flip NONE; en - // aturar-se conservem `facing_` per a l'animació stand. - facing_ = Facing::SIDE; - setFlip(vx < 0.0F ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE); + // Tria de facing: prevalença lateral, EAST/WEST segons el signe + // de vx; verticals purs van a NORTH/SOUTH. En aturar-se + // conservem `facing_` per a l'animació stand. + facing_ = (vx < 0.0F) ? Facing::WEST : Facing::EAST; } else if (vy < 0.0F) { - facing_ = Facing::UP; - setFlip(SDL_FLIP_NONE); + facing_ = Facing::NORTH; } else if (vy > 0.0F) { - facing_ = Facing::DOWN; - setFlip(SDL_FLIP_NONE); + facing_ = Facing::SOUTH; } } @@ -342,11 +338,11 @@ Player::Player(const std::string& anim_key, int id, float x, float y, const Cont paint_tile_index_(paint_tile_index) { setPos(x, y); // Postura inicial mirant cap al jugador (cara avall), no de costat: - // forcem facing_ a DOWN i l'animació `stand_down` abans del primer + // forcem facing_ a SOUTH i l'animació `stand_south` abans del primer // frame d'input — el primer `update` la preservarà si l'usuari no // prem cap tecla. - facing_ = Facing::DOWN; - setCurrentAnimation("stand_down"); + facing_ = Facing::SOUTH; + setCurrentAnimation("stand_south"); } void Player::update(float delta_time) { @@ -650,16 +646,16 @@ auto Player::consumeAttackEdge() -> bool { auto Player::currentFacingCardinal() const -> Cardinal { switch (facing_) { - case Facing::UP: + case Facing::NORTH: return Cardinal::NORTH; - case Facing::DOWN: + case Facing::SOUTH: return Cardinal::SOUTH; - case Facing::SIDE: - break; + case Facing::EAST: + return Cardinal::EAST; + case Facing::WEST: + return Cardinal::WEST; } - // Lateral: el flip discrimina E/W. Sense flip = mira a la dreta - // (convenció estable a tots els assets actuals). - return (getFlip() == SDL_FLIP_HORIZONTAL) ? Cardinal::WEST : Cardinal::EAST; + return Cardinal::EAST; } void Player::enterAttacking(Cardinal facing) { @@ -725,7 +721,7 @@ void Player::startJump() { // Un gir de 180° en curs es compromet immediatament: si l'usuari // ha disparat un giro (oest→est, p.ex.) i salta abans que s'esgote // el timer, l'animació del salt ha de mostrar la direcció final - // (jump lateral), no la intermèdia (jump_down). Aplem el pending + // (jump lateral), no la intermèdia (jump_south). Aplem el pending // ara mateix i resetem l'estat del gir. Si no hi havia gir en // curs, és no-op. if (turning_remaining_ms_ > 0.0F) { @@ -1106,91 +1102,50 @@ void Player::renderShadow() const { Screen::get().blendRect(SX, SY, SHADOW_W, SHADOW_H, SHADOW_ARGB, SHADOW_ALPHA); } -auto Player::animationName(bool moving) const -> const char* { - // En l'aire forcem la variant `jump_*` (per direcció), independent de - // si hi ha velocitat horitzontal: per al jugador, l'estat visual del - // salt és per direcció, no per velocitat. - if (state_ == State::AIRBORNE) { - switch (facing_) { - case Facing::UP: - return "jump_up"; - case Facing::DOWN: - return "jump_down"; - case Facing::SIDE: - break; - } - return "jump"; +// Sufix d'animació per cardinal — empat 1:1 amb l'enum `Facing` i el +// sufix declarat als YAMLs de jugador (sam.yaml, morcus.yaml). +auto Player::facingSuffix(Facing facing) -> const char* { + switch (facing) { + case Facing::NORTH: + return "_north"; + case Facing::SOUTH: + return "_south"; + case Facing::EAST: + return "_east"; + case Facing::WEST: + return "_west"; } - // En LANDING_STUN, `stun_*` (per direcció): mostrem el frame - // d'aterratge fins que s'esgota el timer. La direcció ve fixada del - // moment del despegue (no es canvia mentre estem en l'aire). - if (state_ == State::LANDING_STUN) { - switch (facing_) { - case Facing::UP: - return "stun_up"; - case Facing::DOWN: - return "stun_down"; - case Facing::SIDE: - break; - } - return "stun"; - } - // En HURT, `hurt_*` (per direcció): el jugador llisca sense - // control en la direcció triada per `enterHurt`. Mateixa estructura - // que walk per facilitat (ara mateix els frames són clons). - if (state_ == State::HURT) { - switch (facing_) { - case Facing::UP: - return "hurt_up"; - case Facing::DOWN: - return "hurt_down"; - case Facing::SIDE: - break; - } - return "hurt"; - } - // ATTACKING té dues sub-fases: durant `swing_ms` el sprite és - // `attack_*` (animació del braç llançant el látigo); durant - // `cooldown_ms` posterior canviem a `recover_*` per a frames - // pròpis de recuperació. Mentre l'art no estiga dibuixat, els - // YAMLs apunten `recover_*` als mateixos frames que `stand_*` — - // visualment és idèntic, però la separació deixa l'arquitectura - // preparada perquè en afegir frames reals només calga tocar el - // YAML, no el codi. - if (state_ == State::ATTACKING) { - const bool IN_SWING = (attacking_remaining_ms_ > static_cast(whip_config.cooldown_ms)); - if (IN_SWING) { - switch (facing_) { - case Facing::UP: - return "attack_up"; - case Facing::DOWN: - return "attack_down"; - case Facing::SIDE: - break; - } - return "attack"; - } - switch (facing_) { - case Facing::UP: - return "recover_up"; - case Facing::DOWN: - return "recover_down"; - case Facing::SIDE: - break; - } - return "recover"; - } - switch (facing_) { - case Facing::UP: - return moving ? "walk_up" : "stand_up"; - case Facing::DOWN: - return moving ? "walk_down" : "stand_down"; - case Facing::SIDE: + return "_east"; +} + +// Prefix d'animació per estat. ATTACKING té dues sub-fases: durant +// `swing_ms` és `attack_*` (animació del braç llançant el látigo); durant +// `cooldown_ms` posterior, `recover_*`. A AIRBORNE/LANDING_STUN/HURT, la +// variant és per direcció, no per velocitat: l'estat bloqueja el `moving` +// argument i no fa servir walk/stand. +auto Player::statePrefix(State state, bool moving, bool in_attack_swing) -> const char* { + switch (state) { + case State::AIRBORNE: + return "jump"; + case State::LANDING_STUN: + return "stun"; + case State::HURT: + return "hurt"; + case State::ATTACKING: + return in_attack_swing ? "attack" : "recover"; + case State::GROUNDED: + case State::EXITED: + case State::DISABLED: break; } return moving ? "walk" : "stand"; } +auto Player::animationName(bool moving) const -> std::string { + const bool IN_ATTACK_SWING = (attacking_remaining_ms_ > static_cast(whip_config.cooldown_ms)); + return std::string(statePrefix(state_, moving, IN_ATTACK_SWING)) + facingSuffix(facing_); +} + auto Player::getCurrentTile() const -> std::pair { // El grid del tilemap és de `tile_size` píxels i està 16-alineat // respecte a l'origen del tilemap (pot ser negatiu per overscan). El @@ -1249,11 +1204,10 @@ void Player::respawn(float x, float y) { intermediate_cardinal_ = Cardinal::NONE; last_input_cardinal_ = Cardinal::NONE; // Reseteig del facing al d'spawn original (mirant cap a la càmera): - // sense això, un EXITED que entrava per la porta amb facing UP + // sense això, un EXITED que entrava per la porta amb facing NORTH // re-aparix d'esquena al pròxim cicle. Mateixa lògica que el ctor. - facing_ = Facing::DOWN; - setFlip(SDL_FLIP_NONE); - setCurrentAnimation("stand_down"); + facing_ = Facing::SOUTH; + setCurrentAnimation("stand_south"); setPosX(x); setPosY(y); } @@ -1300,10 +1254,10 @@ void Player::spawnFromAir(float x, float y) { intended_vy_ = 0.0F; // Mirant cap avall durant la caiguda — `animationName(AIRBORNE)` amb - // facing DOWN ja resol a `jump_down`. `respawn` ja ha posat facing a - // DOWN; ho deixem explícit ací per claredat de la cinemàtica. - facing_ = Facing::DOWN; - setCurrentAnimation("jump_down"); + // facing SOUTH ja resol a `jump_south`. `respawn` ja ha posat facing a + // SOUTH; ho deixem explícit ací per claredat de la cinemàtica. + facing_ = Facing::SOUTH; + setCurrentAnimation("jump_south"); } auto Player::getBaseBox() const -> SDL_FRect { diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 6f0558e..3d19d7d 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -351,9 +351,12 @@ class Player : public AnimatedSprite { void snapToHorizontalLane(float delta_time); void snapToVerticalLane(float delta_time); - // Nom d'animació per al `facing_` actual. Si `moving` és cert s'usa - // la variant `walk*`, altrament la variant `stand*`. - [[nodiscard]] auto animationName(bool moving) const -> const char*; + // Nom d'animació per al `facing_` actual. Composat com a `prefix + + // suffix` via els dos helpers estàtics (declarats junt amb `Facing` + // més avall) — el prefix depèn de l'estat (walk/stand/jump/stun/hurt/ + // attack/recover), el sufix del cardinal (`_north`/`_south`/`_east`/ + // `_west`). + [[nodiscard]] auto animationName(bool moving) const -> std::string; void snapToLane( float delta_time, float base, @@ -428,19 +431,24 @@ class Player : public AnimatedSprite { // reinstanciar-lo. Map::Info map_; - // Direcció "cap on mira" el jugador. Governa quin conjunt d'animacions - // s'aplica (lateral, amunt, avall) i si el sprite es flipeja o no. Els - // moviments purament horitzontals o diagonals prenen `SIDE` (prevalença - // lateral), els purament verticals fan `UP`/`DOWN`. En aturar-se es - // preserva l'última orientació perquè la versió stand coincideixi. - enum class Facing : std::uint8_t { SIDE, - UP, - DOWN }; - Facing facing_{Facing::SIDE}; + // Direcció "cap on mira" el jugador. Una entrada per cada cardinal: el + // codi del joc no toca mai el flip — l'oest reprodueix `*_west`, que al + // YAML declara `flip_h: true`, i és el motor d'animació qui aplica el + // flip al sprite. Els moviments diagonals prenen el component lateral + // (EAST/WEST); en aturar-se es preserva l'última orientació perquè la + // versió stand coincideixi. + enum class Facing : std::uint8_t { EAST, + WEST, + NORTH, + SOUTH }; + Facing facing_{Facing::EAST}; + + // Helpers de composició de `animationName`. Han de viure ací (després + // de la declaració de `Facing`) perquè la signatura el referencia. + static auto facingSuffix(Facing facing) -> const char*; + static auto statePrefix(State state, bool moving, bool in_attack_swing) -> const char*; // Cardinal complet (N/S/E/W) usat per a la detecció de girs de 180°. - // El parell (Facing, flip) només té 3+1 estats; aquesta enum n'afegeix - // la 4a sense reorganitzar el sistema d'animacions. // Pública perquè el `WhipAttack` la passa a `enterHurt` quan colpeja // un altre jugador (friendly fire). public: @@ -552,9 +560,8 @@ class Player : public AnimatedSprite { // timer `attacking_remaining_ms_` a `swing_ms + cooldown_ms`. void enterAttacking(Cardinal facing); - // Cardinal que correspon al `(facing_, flip_)` actual del sprite — - // la direcció cap on mira el jugador. Si `facing_ == SIDE` el - // resultat depèn del flip; UP/DOWN són trivials. Mai retorna NONE. + // Cardinal corresponent al `facing_` actual del sprite — la direcció + // cap on mira el jugador. Mapeig 1:1 amb `Facing` (mai NONE). [[nodiscard]] auto currentFacingCardinal() const -> Cardinal; // Activa l'estat HURT: el jugador queda projectat amb velocitat diff --git a/tests/test_animated_sprite_flip.cpp b/tests/test_animated_sprite_flip.cpp new file mode 100644 index 0000000..1ec9e1c --- /dev/null +++ b/tests/test_animated_sprite_flip.cpp @@ -0,0 +1,207 @@ +// 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 default — YAML sense cap flip declarat carrega tots els frames +// amb (flip_h=false, flip_v=false). L'aplicació al sprite +// resulta en `SDL_FLIP_NONE`. +// 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 testDefaultNoFlip() { + std::cout << "[T1] default: cap flip declarat → frames sense flip\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); + 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.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); + 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); + + // 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); + + // 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.frames[0].flip_h); + REQUIRE(A.frames[0].flip_v); + } + } + +} // namespace + +auto main() -> int { + std::cout << "== smoke tests: animation flip parser ==\n"; + + testDefaultNoFlip(); + 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; +} diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index 923f4c3..f11190d 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -444,7 +444,7 @@ namespace { // Direcció preferida: dreta. Mòmia ha d'entrar en HURT i avançar // a la dreta (la velocitat de hurt és més alta que la base). - mummy.enterHurt(Mummy::Direction::RIGHT); + mummy.enterHurt(Mummy::Direction::EAST); REQUIRE(mummy.getState() == Mummy::State::HURT); REQUIRE(mummy.getDirX() > 0.0F); @@ -488,7 +488,7 @@ namespace { // timer. Aquesta variant del test NO comprova la sortida // per col·lisió; només verifica que entrar a HURT a la // paret no peta. - mummy.enterHurt(Mummy::Direction::RIGHT); + mummy.enterHurt(Mummy::Direction::EAST); REQUIRE(mummy.getState() == Mummy::State::HURT); // Update llarg → expira el timer i torna a ROAMING. mummy.update(1.0F);