diff --git a/data/entities/column/column.yaml b/data/entities/column/column.yaml index 28969c7..0001e81 100644 --- a/data/entities/column/column.yaml +++ b/data/entities/column/column.yaml @@ -19,4 +19,4 @@ flags: collides: true interactable: false animations: - default: { frames: [0], fps: 1, loop: false } + default: { frames: [0], frame_ms: 1000, loop: false } diff --git a/data/entities/door/door.yaml b/data/entities/door/door.yaml index b69bf66..f6ea47b 100644 --- a/data/entities/door/door.yaml +++ b/data/entities/door/door.yaml @@ -21,9 +21,9 @@ flags: collides: true interactable: true animations: - closed: { frames: [1], fps: 1, loop: true } - opening: { frames: [0], fps: 6, loop: false } - open: { frames: [0], fps: 1, loop: true } + closed: { frames: [1], frame_ms: 1000, loop: true } + opening: { frames: [0], frame_ms: 167, loop: false } + open: { frames: [0], frame_ms: 1000, loop: true } behaviour: states: [CLOSED, OPENING, OPEN] initial_state: CLOSED diff --git a/data/entities/dust/dust.yaml b/data/entities/dust/dust.yaml index 612cd5b..693b680 100644 --- a/data/entities/dust/dust.yaml +++ b/data/entities/dust/dust.yaml @@ -16,7 +16,7 @@ flags: # apropiada via `setVariant` abans del primer tick. # jump: frames 0..6 (7 frames) → reproduït al despegue. # land: frames 7..12 (6 frames) → reproduït a l'aterratge. -# fps: 10 = 100 ms per frame. +# frame_ms: 67 ≈ 15 fps (1000 / 67 ≈ 14.9 fps). animations: - jump: { frames: [0, 1, 2, 3, 4, 5, 6], fps: 15, loop: false } - land: { frames: [7, 8, 9, 10, 11, 12], fps: 15, loop: false } + jump: { frames: [0, 1, 2, 3, 4, 5, 6], frame_ms: 67, loop: false } + land: { frames: [7, 8, 9, 10, 11, 12], frame_ms: 67, loop: false } diff --git a/data/entities/floor_trap/floor_trap.yaml b/data/entities/floor_trap/floor_trap.yaml index d99c006..2fd1cb2 100644 --- a/data/entities/floor_trap/floor_trap.yaml +++ b/data/entities/floor_trap/floor_trap.yaml @@ -13,8 +13,8 @@ flags: collides: false interactable: false animations: - inactive: { frames: [0], fps: 1, loop: true } - active: { frames: [0], fps: 6, loop: true } + inactive: { frames: [0], frame_ms: 1000, loop: true } + active: { frames: [0], frame_ms: 167, loop: true } cycle: active_duration: 0.3 inactive_duration: 2.0 diff --git a/data/entities/hole/hole.yaml b/data/entities/hole/hole.yaml index 900f59e..dbcbe6e 100644 --- a/data/entities/hole/hole.yaml +++ b/data/entities/hole/hole.yaml @@ -22,6 +22,6 @@ flags: # lectura genèrica de flags. triggers_on_overlap: true animations: - idle: { frames: [0], fps: 1, loop: true } + idle: { frames: [0], frame_ms: 1000, loop: true } stats: damage: 0 diff --git a/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index c783094..117b7bf 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -32,11 +32,18 @@ flags: # no decideix flip per direcció (només selecciona quina animació mostrar). # La momia no té variants `stand_*` perquè mai no para. animations: - 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 } - raise: { frames: [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27], fps: 8 , loop: false } + walk_south: { frames: [0, 1, 2, 3], frame_ms: 125, loop: true } + walk_north: { frames: [4, 5, 6, 7], frame_ms: 125, loop: true } + walk_east: { frames: [8, 9, 10, 11], frame_ms: 125, loop: true } + walk_west: { frames: [8, 9, 10, 11], frame_ms: 125, loop: true, flip_h: true } + # Alçament: els 15 primers frames passen ràpid (125 ms cadascun) i el + # darrer aguanta 1 s perquè el jugador pugui veure la mòmia ja alçada + # abans que comenci a deambular. `frame_ms` accepta una llista per-frame + # que mapeja 1:1 contra `frames`. + raise: + frames: [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27] + frame_ms: [125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 1000] + loop: false ai: type: corridor_wanderer direction_choice: random diff --git a/data/entities/rolling_rock/rolling_rock.yaml b/data/entities/rolling_rock/rolling_rock.yaml index 95f3745..5d30ec1 100644 --- a/data/entities/rolling_rock/rolling_rock.yaml +++ b/data/entities/rolling_rock/rolling_rock.yaml @@ -13,7 +13,7 @@ flags: collides: true interactable: false animations: - roll: { frames: [0], fps: 10, loop: true } + roll: { frames: [0], frame_ms: 100, loop: true } ai: type: straight_line initial_direction: right diff --git a/data/entities/sarcophagus/sarcophagus.yaml b/data/entities/sarcophagus/sarcophagus.yaml index e6466b3..2884ea0 100644 --- a/data/entities/sarcophagus/sarcophagus.yaml +++ b/data/entities/sarcophagus/sarcophagus.yaml @@ -19,9 +19,9 @@ flags: collides: true interactable: true animations: - closed: { frames: [0], fps: 1, loop: true } - opening: { frames: [1, 2], fps: 6, loop: false } - open: { frames: [3], fps: 1, loop: true } + closed: { frames: [0], frame_ms: 1000, loop: true } + opening: { frames: [1, 2], frame_ms: 167, loop: false } + open: { frames: [3], frame_ms: 1000, loop: true } behaviour: states: [CLOSED, OPENING, OPEN] initial_state: CLOSED diff --git a/data/entities/stone/stone.yaml b/data/entities/stone/stone.yaml index 8017d33..2252111 100644 --- a/data/entities/stone/stone.yaml +++ b/data/entities/stone/stone.yaml @@ -19,4 +19,4 @@ flags: # Animació "default" amb un sol frame: el spawn YAML pot sobreescriure-la # via `frame: N` per fixar la variant escollida al editor. animations: - default: { frames: [0], fps: 1, loop: false } + default: { frames: [0], frame_ms: 1000, loop: false } diff --git a/data/entities/torch/torch.yaml b/data/entities/torch/torch.yaml index 488cdb9..3afa6f4 100644 --- a/data/entities/torch/torch.yaml +++ b/data/entities/torch/torch.yaml @@ -15,7 +15,7 @@ flags: # El torch.gif és un strip vertical 16x64 amb 4 frames apilats. # El render clipa cada frame per `anim.currentFrame()` × 16 píxels en Y. animations: - idle: { frames: [0, 1, 2, 3], fps: 8, loop: true } + idle: { frames: [0, 1, 2, 3], frame_ms: 125, loop: true } # Cercle de llum centrat a l'antorxa. El radi és en píxels del canvas; # el gradient global (data/config/lighting.yaml) hi posa la forma (3 diff --git a/data/entities/wall_trap/wall_trap.yaml b/data/entities/wall_trap/wall_trap.yaml index 39461c1..b385387 100644 --- a/data/entities/wall_trap/wall_trap.yaml +++ b/data/entities/wall_trap/wall_trap.yaml @@ -13,8 +13,8 @@ flags: collides: false interactable: false animations: - inactive: { frames: [0], fps: 1, loop: true } - active: { frames: [0], fps: 6, loop: true } + inactive: { frames: [0], frame_ms: 1000, loop: true } + active: { frames: [0], frame_ms: 167, loop: true } cycle: active_duration: 0.5 inactive_duration: 1.5 diff --git a/data/maps/p1/r1.yaml b/data/maps/p1/r1.yaml index a636d6e..10e1a00 100644 --- a/data/maps/p1/r1.yaml +++ b/data/maps/p1/r1.yaml @@ -15,11 +15,32 @@ tileset: data/tilesets/pyramid_1.gif tile_size: 16 origin: [-8, 0] -# Il·luminació base de la sala (0..1). 1.0 = sala totalment il·luminada -# (com hui, sense efecte visual); 0.0 = completament negra fora del radi -# d'un emissor. El gradient dels cercles d'emissor viu a -# data/config/lighting.yaml. Omet aquesta clau per deixar la sala neta. -light_level: 0.25 +# Il·luminació de la sala. Bloc opcional; si manca, la sala queda +# totalment il·luminada (1.0) i tots els tipus d'entitat emeten amb els +# valors declarats al seu YAML de tipus (comportament històric). +# +# level → atenuació base de la sala (0..1). 1.0 = res +# d'efecte, 0.0 = negre fora del radi dels emissors. +# El gradient dels emissors viu a +# data/config/lighting.yaml. +# emitters. → override per tipus d'entitat. Tres formes: +# false → apaga el tipus +# true → defaults del tipus +# { radius: N, flicker: B } → camps presents +# sobreescriuen el +# default; els +# absents cauen al +# default del tipus. +# Plural per llegir-se natural ("totes les momies de la sala"). Per a +# casos particulars, un spawn pot dur el seu propi `light:` (mateixes +# formes) que té prioritat sobre l'override de sala. La forma antiga +# (`light_level:` plà a l'arrel) també segueix sent vàlida per +# retrocompatibilitat. +lighting: + level: 0.75 + emitters: + players: false + mummies: false # Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl # pintable. Un índex per jugador perquè a multijugador local el recorregut @@ -160,3 +181,10 @@ entities: frame: 18 - type: mummy position: [10, 13] + # Override per-instància: la sala apaga les momies (emitters.mummies: + # false), però aquesta concreta sí emet, amb un radi més curt que el + # default del seu tipus (24 → 16). Bona prova de la tercera capa de + # resolució: tipus → sala → spawn. + light: + radius: 16 + flicker: true diff --git a/source/game/entities/animation_component.cpp b/source/game/entities/animation_component.cpp index e63b62f..2071595 100644 --- a/source/game/entities/animation_component.cpp +++ b/source/game/entities/animation_component.cpp @@ -1,14 +1,87 @@ #include "game/entities/animation_component.hpp" #include +#include #include -AnimationComponent::AnimationComponent(std::vector frames, float fps, bool loop) - : AnimationComponent(std::move(frames), fps, loop, false, false) {} +namespace { -AnimationComponent::AnimationComponent(std::vector frames, float fps, bool loop, bool flip_h, bool flip_v) + // Quan el caller no especifica temps, considerem un frame "estàtic" de + // 1s. El valor és arbitrari: amb 1 frame el component mai no avança, + // així que el speed concret és inert. Amb N frames és el fallback que + // assumeix la factory (mateix valor que el default del format antic). + constexpr float K_DEFAULT_FRAME_SECONDS = 1.0F; + + // Si el YAML declara temps <= 0 per a algun frame, el saturem a aquest + // mínim per a evitar bucles infinits a `tick` (que avancen frame + // mentre `accumulator >= speeds[i]`). 1 ms és prou petit per a no + // alterar mai una temporització real i prou gran per a no col·lapsar. + constexpr float K_MIN_FRAME_SECONDS = 0.001F; + + auto sanitizeSpeed(float seconds) -> float { + return seconds > 0.0F ? seconds : K_MIN_FRAME_SECONDS; + } + + // Expandeix una llista de temps a la mida de `frame_count`: + // - mida = N: 1:1. + // - mida < N: emplena la resta amb l'últim valor (o el default si + // la llista era buida). + // - mida > N: talla a N. + // Tots els valors es passen per `sanitizeSpeed`. + auto fitSpeeds(std::vector speeds, std::size_t frame_count) -> std::vector { + if (frame_count == 0) { return {}; } + if (speeds.empty()) { + return std::vector(frame_count, K_DEFAULT_FRAME_SECONDS); + } + // cppcheck-suppress useStlAlgorithm + for (auto& s : speeds) { s = sanitizeSpeed(s); } + if (speeds.size() == frame_count) { return speeds; } + if (speeds.size() > frame_count) { + speeds.resize(frame_count); + return speeds; + } + const float TAIL = speeds.back(); + speeds.resize(frame_count, TAIL); + return speeds; + } + + // Llig `animations..frames` com a llista d'enters. Garanteix + // almenys 1 frame (fallback a [0]) per a poder construir sempre un + // component vàlid. + auto parseFrames(const fkyaml::node& anim) -> std::vector { + std::vector frames; + for (const auto& f : anim["frames"]) { + // cppcheck-suppress useStlAlgorithm + frames.push_back(f.get_value()); + } + if (frames.empty()) { frames.push_back(0); } + return frames; + } + + // Llig `animations..frame_ms` (escalar o llista) i retorna els + // temps en segons. Si és escalar, retorna una llista uniforme de + // `frame_count` elements; si és llista, copia tal qual (l'ajust de + // mida el farà `fitSpeeds` al constructor). + auto parseFrameMs(const fkyaml::node& anim, std::size_t frame_count) -> std::vector { + std::vector speeds_secs; + const auto& fm = anim["frame_ms"]; + if (fm.is_sequence()) { + for (const auto& v : fm) { + // cppcheck-suppress useStlAlgorithm + speeds_secs.push_back(static_cast(v.get_value()) / 1000.0F); + } + } else { + const float SECS = static_cast(fm.get_value()) / 1000.0F; + speeds_secs.assign(frame_count, SECS); + } + return speeds_secs; + } + +} // namespace + +AnimationComponent::AnimationComponent(std::vector frames, std::vector speeds, bool loop, bool flip_h, bool flip_v) : frames_(std::move(frames)), - fps_(fps > 0.0F ? fps : 1.0F), + speeds_(fitSpeeds(std::move(speeds), frames_.size())), loop_(loop), flip_h_(flip_h), flip_v_(flip_v) { @@ -19,13 +92,52 @@ AnimationComponent::AnimationComponent(std::vector frames, float fps, bool if (frames_.size() <= 1 && !loop_) { completed_ = true; } } +auto AnimationComponent::uniform(std::vector frames, float frame_ms, bool loop) -> AnimationComponent { + return uniform(std::move(frames), frame_ms, loop, false, false); +} + +auto AnimationComponent::uniform(std::vector frames, float frame_ms, bool loop, bool flip_h, bool flip_v) -> AnimationComponent { + const float SECS = sanitizeSpeed(frame_ms / 1000.0F); + std::vector speeds(frames.size(), SECS); + return {std::move(frames), std::move(speeds), loop, flip_h, flip_v}; +} + +auto AnimationComponent::fromYaml(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent { + // Defaults conservadors per a quan el bloc `animations.` no + // existeix: 1 frame loopant a 1s — visualment estàtic, semàntica neutra. + std::vector frames = {0}; + std::vector speeds_secs; // buit ⇒ fitSpeeds emplenarà amb default + bool loop = true; + bool flip_h = false; + bool flip_v = false; + + if (!yaml.contains("animations") || !yaml["animations"].contains(key)) { + return {std::move(frames), std::move(speeds_secs), loop, flip_h, flip_v}; + } + const auto& anim = yaml["animations"][key]; + + // L'antic format `fps: N` ja no és suportat. Si apareix, peguem hard + // perquè la migració sigui visible (no silenciosa). El missatge inclou + // la clau d'animació per a facilitar localitzar el YAML problemàtic. + if (anim.contains("fps")) { + throw std::runtime_error("AnimationComponent: 'fps:' is no longer supported, use 'frame_ms:' instead (animation '" + key + "')"); + } + + if (anim.contains("frames")) { frames = parseFrames(anim); } + if (anim.contains("frame_ms")) { speeds_secs = parseFrameMs(anim, frames.size()); } + 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 {std::move(frames), std::move(speeds_secs), loop, flip_h, flip_v}; +} + void AnimationComponent::tick(float delta_time) { if (frames_.size() <= 1 || completed_) { return; } accumulator_ += delta_time; - const float FRAME_TIME = 1.0F / fps_; - while (accumulator_ >= FRAME_TIME) { - accumulator_ -= FRAME_TIME; + while (accumulator_ >= speeds_[static_cast(current_)]) { + accumulator_ -= speeds_[static_cast(current_)]; ++current_; if (static_cast(current_) >= frames_.size()) { if (loop_) { diff --git a/source/game/entities/animation_component.hpp b/source/game/entities/animation_component.hpp index 1343eef..2095600 100644 --- a/source/game/entities/animation_component.hpp +++ b/source/game/entities/animation_component.hpp @@ -3,8 +3,11 @@ #include #include +#include #include +#include "external/fkyaml_node.hpp" + // Component reutilitzable d'animació: qualsevol entitat que necessiti // reproduir una seqüència de frames l'instancia. Es manté com a camp // opcional dins d'Entity (veure `entity.hpp`) i NO hereta de cap classe @@ -22,16 +25,41 @@ // 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. +// +// El temps es modela com a `speeds_[i]` segons per frame (per-frame), no com +// a `fps` uniforme. Això permet expressar holds al YAML via `frame_ms: [..]` +// (llista) sense haver de repetir el mateix índex de frame. Per a casos +// uniformes la factory omple `speeds_` amb el mateix valor a tot el vector. 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); + + // Factory canònica: llig el bloc `animations.` del YAML. + // + // Camps llegits: + // - `frames` : llista d'índexs sencers (obligatori per animar). + // - `frame_ms` : escalar ms (uniforme) o llista de ms (per-frame). + // Default 1000 ms (animació estàtica). + // - `loop` : bool (default true). + // - `flip_h` : bool (default false). + // - `flip_v` : bool (default false). + // + // Si la clau no existeix retorna un component "estàtic" (1 frame, loop + // true). Si el YAML conté `fps:`, llença `std::runtime_error`: el + // format antic ja no és suportat — cal migrar a `frame_ms:`. + static auto fromYaml(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent; + + // Helper uniforme: construeix un component on tots els frames duren + // `frame_ms` mil·lisegons. Útil per a usos programàtics (tests, + // imposició d'un frame estàtic des del loader de nivell). Per al cas + // típic guiat per YAML, usar `fromYaml`. + static auto uniform(std::vector frames, float frame_ms, bool loop) -> AnimationComponent; + static auto uniform(std::vector frames, float frame_ms, bool loop, bool flip_h, bool flip_v) -> AnimationComponent; // 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; - // en mode no-loop, es queda al darrer frame i `completed()` passa a - // true. + // res. En mode loop, `current_` rebota a 0 en passar del final; en + // mode no-loop, es queda al darrer frame i `completed()` passa a true. + // Cada frame consumeix `speeds_[current_]` segons. void tick(float delta_time); // Torna al primer frame i reinicia l'acumulador. Útil en transicions @@ -39,8 +67,7 @@ class AnimationComponent { void reset(); // Índex actual dins `frames_`. Si `frames_` està buit retorna 0 com - // a fallback tolerant (el loader ja ha emès warning si hi havia - // frames fora de rang; aquí preferim no crashejar). + // a fallback tolerant. [[nodiscard]] auto currentFrame() const -> int; [[nodiscard]] auto frameCount() const -> std::size_t { return frames_.size(); } @@ -55,8 +82,10 @@ class AnimationComponent { [[nodiscard]] auto flipMode() const -> SDL_FlipMode; private: + AnimationComponent(std::vector frames, std::vector speeds, bool loop, bool flip_h, bool flip_v); + std::vector frames_; - float fps_{1.0F}; + std::vector speeds_; // segons per frame; size == frames_.size() bool loop_{true}; bool flip_h_{false}; bool flip_v_{false}; diff --git a/source/game/entities/concrete/door.cpp b/source/game/entities/concrete/door.cpp index d1f9c43..3f9aaaa 100644 --- a/source/game/entities/concrete/door.cpp +++ b/source/game/entities/concrete/door.cpp @@ -1,45 +1,16 @@ #include "game/entities/concrete/door.hpp" #include -#include #include "game/entities/entity_factory.hpp" #include "game/entities/event_bus.hpp" #include "game/entities/player.hpp" #include "game/map/map.hpp" -namespace { - - // Carrega una animació d'un bloc concret (closed/opening/open) amb - // fallbacks tolerants. Si la clau no existeix, retorna una animació - // d'un sol frame loopant: no fa estat invàlid, simplement no hi ha res - // per reproduir. - auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent { - std::vector frames = {0}; - float fps = 1.0F; - bool loop = true; - if (yaml.contains("animations") && yaml["animations"].contains(key)) { - const auto& anim = yaml["animations"][key]; - if (anim.contains("frames")) { - frames.clear(); - for (const auto& f : anim["frames"]) { - // cppcheck-suppress useStlAlgorithm - frames.push_back(f.get_value()); - } - if (frames.empty()) { frames.push_back(0); } - } - if (anim.contains("fps")) { fps = anim["fps"].get_value(); } - if (anim.contains("loop")) { loop = anim["loop"].get_value(); } - } - return {frames, fps, loop}; - } - -} // namespace - Door::Door(const fkyaml::node& yaml) - : anim_closed_(buildAnimation(yaml, "closed")), - anim_opening_(buildAnimation(yaml, "opening")), - anim_open_(buildAnimation(yaml, "open")) { + : anim_closed_(AnimationComponent::fromYaml(yaml, "closed")), + anim_opening_(AnimationComponent::fromYaml(yaml, "opening")), + anim_open_(AnimationComponent::fromYaml(yaml, "open")) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("door"); collides_ = true; interactable_ = true; @@ -99,7 +70,7 @@ void Door::open() { // placeholders defineixen `loop: false` explícitament al YAML, així // que aquest fallback només actua si algú dissenya malament. if (anim_.has_value() && anim_->frameCount() <= 1 && anim_->loops()) { - anim_ = AnimationComponent({0}, 6.0F, false); + anim_ = AnimationComponent::uniform({0}, 167.0F, false); } } diff --git a/source/game/entities/concrete/dust.cpp b/source/game/entities/concrete/dust.cpp index 29aedc5..5797f44 100644 --- a/source/game/entities/concrete/dust.cpp +++ b/source/game/entities/concrete/dust.cpp @@ -1,42 +1,12 @@ #include "game/entities/concrete/dust.hpp" #include -#include #include "game/entities/entity_factory.hpp" -namespace { - - // Llig una animació opcional del bloc `animations` del YAML, amb - // valors per defecte segurs (1 frame, fps=1, sense loop). Si la - // clau no existeix, retornem una animació "ja completada" — així, - // un Dust spawnejat amb una variant inexistent al YAML mor al - // pròxim frame sense petar. - auto buildAnim(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent { - std::vector frames = {0}; - float fps = 1.0F; - bool loop = false; - if (yaml.contains("animations") && yaml["animations"].contains(key)) { - const auto& anim = yaml["animations"][key]; - if (anim.contains("frames")) { - frames.clear(); - for (const auto& f : anim["frames"]) { - // cppcheck-suppress useStlAlgorithm - frames.push_back(f.get_value()); - } - if (frames.empty()) { frames.push_back(0); } - } - if (anim.contains("fps")) { fps = anim["fps"].get_value(); } - if (anim.contains("loop")) { loop = anim["loop"].get_value(); } - } - return {frames, fps, loop}; - } - -} // namespace - Dust::Dust(const fkyaml::node& yaml) - : anim_jump_(buildAnim(yaml, "jump")), - anim_land_(buildAnim(yaml, "land")) { + : anim_jump_(AnimationComponent::fromYaml(yaml, "jump")), + anim_land_(AnimationComponent::fromYaml(yaml, "land")) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("dust"); collides_ = false; interactable_ = false; diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp index 91e9e67..b4faf81 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -29,36 +29,6 @@ namespace { return k_rng_; } - // 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. 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")) { - frames.clear(); - for (const auto& f : anim["frames"]) { - // cppcheck-suppress useStlAlgorithm - frames.push_back(f.get_value()); - } - if (frames.empty()) { frames.push_back(0); } - } - 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, flip_h, flip_v}; - } - // Busca si el segment [prev, prev+delta] travessa una lane del patró // `base + k·pitch` (k ∈ [0..max_idx]). Retorna el delta que cal aplicar // per arribar exactament a la primera línia travessada. Iterem en el @@ -103,11 +73,11 @@ namespace { } // namespace Mummy::Mummy(const fkyaml::node& yaml) - : 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")), - anim_raise_(buildAnimation(yaml, "raise")) { + : anim_walk_south_(AnimationComponent::fromYaml(yaml, "walk_south")), + anim_walk_north_(AnimationComponent::fromYaml(yaml, "walk_north")), + anim_walk_east_(AnimationComponent::fromYaml(yaml, "walk_east")), + anim_walk_west_(AnimationComponent::fromYaml(yaml, "walk_west")), + anim_raise_(AnimationComponent::fromYaml(yaml, "raise")) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("mummy"); collides_ = true; interactable_ = false; diff --git a/source/game/entities/concrete/sarcophagus.cpp b/source/game/entities/concrete/sarcophagus.cpp index 2bea0dd..2fec707 100644 --- a/source/game/entities/concrete/sarcophagus.cpp +++ b/source/game/entities/concrete/sarcophagus.cpp @@ -2,7 +2,6 @@ #include #include -#include #include "core/audio/audio.hpp" #include "core/resources/resource_cache.hpp" @@ -21,26 +20,6 @@ namespace { return k_rng_; } - auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent { - std::vector frames = {0}; - float fps = 1.0F; - bool loop = true; - if (yaml.contains("animations") && yaml["animations"].contains(key)) { - const auto& anim = yaml["animations"][key]; - if (anim.contains("frames")) { - frames.clear(); - for (const auto& f : anim["frames"]) { - // cppcheck-suppress useStlAlgorithm - frames.push_back(f.get_value()); - } - if (frames.empty()) { frames.push_back(0); } - } - if (anim.contains("fps")) { fps = anim["fps"].get_value(); } - if (anim.contains("loop")) { loop = anim["loop"].get_value(); } - } - return {frames, fps, loop}; - } - // Llig `tile_size: [w, h]` del YAML. Fallback [1,1] si no existeix. void readTileSize(const fkyaml::node& yaml, int& w, int& h) { w = 1; @@ -61,9 +40,9 @@ Sarcophagus::Sarcophagus(const fkyaml::node& yaml, int anchor_col, int anchor_ro } Sarcophagus::Sarcophagus(const fkyaml::node& yaml) - : anim_closed_(buildAnimation(yaml, "closed")), - anim_opening_(buildAnimation(yaml, "opening")), - anim_open_(buildAnimation(yaml, "open")) { + : anim_closed_(AnimationComponent::fromYaml(yaml, "closed")), + anim_opening_(AnimationComponent::fromYaml(yaml, "opening")), + anim_open_(AnimationComponent::fromYaml(yaml, "open")) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("sarcophagus"); collides_ = true; interactable_ = true; diff --git a/source/game/entities/concrete/torch.cpp b/source/game/entities/concrete/torch.cpp index 8702cee..1fd4a54 100644 --- a/source/game/entities/concrete/torch.cpp +++ b/source/game/entities/concrete/torch.cpp @@ -1,41 +1,14 @@ #include "game/entities/concrete/torch.hpp" #include -#include #include "game/entities/entity_factory.hpp" -namespace { - - // Ajut: extreu l'animació `idle` del bloc `animations` del YAML si existeix, - // amb valors per defecte que sempre produïxen una animació vàlida (1 frame). - auto buildIdleAnimation(const fkyaml::node& yaml) -> AnimationComponent { - std::vector frames = {0}; - float fps = 1.0F; - bool loop = true; - if (yaml.contains("animations") && yaml["animations"].contains("idle")) { - const auto& anim = yaml["animations"]["idle"]; - if (anim.contains("frames")) { - frames.clear(); - for (const auto& f : anim["frames"]) { - // cppcheck-suppress useStlAlgorithm - frames.push_back(f.get_value()); - } - if (frames.empty()) { frames.push_back(0); } - } - if (anim.contains("fps")) { fps = anim["fps"].get_value(); } - if (anim.contains("loop")) { loop = anim["loop"].get_value(); } - } - return {frames, fps, loop}; - } - -} // namespace - Torch::Torch(const fkyaml::node& yaml) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("torch"); collides_ = false; interactable_ = false; - anim_ = buildIdleAnimation(yaml); + anim_ = AnimationComponent::fromYaml(yaml, "idle"); } REGISTER_ENTITY("torch", Torch) diff --git a/source/game/entities/concrete/whip_attack.cpp b/source/game/entities/concrete/whip_attack.cpp index aa03d57..e39ecc1 100644 --- a/source/game/entities/concrete/whip_attack.cpp +++ b/source/game/entities/concrete/whip_attack.cpp @@ -33,42 +33,18 @@ namespace { return sprite; } - // Construeix l'`AnimationComponent` del swing a partir del bloc - // `animations.swing`. Convenció igual que la resta d'entitats: - // - `frames` : llista d'índexs dins l'spritesheet (obligatori). - // - `frame_ms` : durada uniforme per frame (ms). Alternativament `fps`. - // - `loop` : per defecte true; al látigo el volem false (mor en - // completar). - auto buildSwingAnim(const fkyaml::node& yaml, std::vector& active_out) - -> AnimationComponent { - std::vector frames = {0}; - float fps = 8.0F; - bool loop = false; - if (yaml.contains("animations") && yaml["animations"].contains("swing")) { - const auto& sw = yaml["animations"]["swing"]; - if (sw.contains("frames")) { - frames.clear(); - for (const auto& f : sw["frames"]) { - // cppcheck-suppress useStlAlgorithm - frames.push_back(f.get_value()); - } - if (frames.empty()) { frames.push_back(0); } - } - if (sw.contains("frame_ms")) { - const auto MS = static_cast(sw["frame_ms"].get_value()); - if (MS > 0.0F) { fps = 1000.0F / MS; } - } else if (sw.contains("fps")) { - fps = sw["fps"].get_value(); - } - if (sw.contains("loop")) { loop = sw["loop"].get_value(); } - if (sw.contains("active_frames")) { - for (const auto& f : sw["active_frames"]) { - // cppcheck-suppress useStlAlgorithm - active_out.push_back(f.get_value()); - } - } + // Llig el camp `active_frames` del bloc `animations.swing` — específic + // del látigo: els índexs llistats són els frames on la hitbox aplica + // dany (wind-up i recovery no danyen). La resta de l'animació la + // construeix `AnimationComponent::fromYaml`. + void readActiveFrames(const fkyaml::node& yaml, std::vector& active_out) { + if (!yaml.contains("animations") || !yaml["animations"].contains("swing")) { return; } + const auto& sw = yaml["animations"]["swing"]; + if (!sw.contains("active_frames")) { return; } + for (const auto& f : sw["active_frames"]) { + // cppcheck-suppress useStlAlgorithm + active_out.push_back(f.get_value()); } - return {frames, fps, loop}; } } // namespace @@ -86,7 +62,8 @@ WhipAttack::WhipAttack(const fkyaml::node& yaml) { if (s.contains("west")) { sprite_west_ = readDirSprite(s["west"]); } } - anim_swing_ = buildSwingAnim(yaml, active_frames_); + anim_swing_ = AnimationComponent::fromYaml(yaml, "swing"); + readActiveFrames(yaml, active_frames_); anim_ = anim_swing_; } diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index cde7a88..981bb19 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -174,6 +174,49 @@ namespace { return out; } + // Combina els defaults del tipus amb l'override de sala (si n'hi ha). + // Forma 1: la sala no diu res del tipus → es retornen els defaults + // del tipus tal qual (cas habitual / retrocompat). + // Forma 2: la sala declara `: false` → radi 0 (entitat sense + // llum encara que el seu YAML en declari). + // Forma 3: la sala declara `: true` o `: { ... }` → els + // camps presents a l'override sobreescriuen el default; els absents + // (`std::nullopt`) cauen al default del tipus. + auto resolveLightWithRoom(const std::string& type, LightTemplate type_default) -> LightTemplate { + const auto* room = Map::getRoomEmitterOverride(type); + if (room == nullptr) { return type_default; } + if (!room->enabled) { return {.radius = 0, .flicker = false}; } + LightTemplate out = type_default; + if (room->radius.has_value()) { out.radius = *room->radius; } + if (room->flicker.has_value()) { out.flicker = *room->flicker; } + return out; + } + + // Aplica l'override per-spawn (si existeix) per damunt del que ja + // viu a l'entitat (defaults del tipus + override de sala). Tres formes + // a l'entry del YAML de nivell: + // `light: false` → apaga aquesta instància + // `light: true` → no canvia res; la línia és + // informativa (la llum ja la donen + // les capes inferiors) + // `light: { radius, flicker }` → els camps presents sobreescriuen; + // els absents respecten el que ja + // duia l'entitat + void applySpawnLightOverride(Entity& entity, const fkyaml::node& spawn) { + if (!spawn.contains("light")) { return; } + const auto& l = spawn["light"]; + if (l.is_boolean()) { + if (!l.get_value()) { + entity.setLightRadius(0); + entity.setLightFlicker(false); + } + return; + } + if (!l.is_mapping()) { return; } + if (l.contains("radius")) { entity.setLightRadius(l["radius"].get_value()); } + if (l.contains("flicker")) { entity.setLightFlicker(l["flicker"].get_value()); } + } + // Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }` // de la plantilla d'entitat. // - Sense `base_size` ni `offset`: la caja queda alineada a la vora @@ -296,7 +339,11 @@ namespace { entity->setCollisionBox(*BOX); } entity->setHeight(readHeight(tmpl)); - const auto LIGHT = readLight(tmpl); + // Defaults del tipus → resolució amb override de sala. El layer + // del spawn (per-instància) viu fora de `buildEntity`, perquè + // aquí encara no tenim el node del spawn — el sobreposem a + // `addSpawn` just després. + const auto LIGHT = resolveLightWithRoom(type, readLight(tmpl)); entity->setLightRadius(LIGHT.radius); entity->setLightFlicker(LIGHT.flicker); anchor_out = readAnchor(tmpl); @@ -425,7 +472,7 @@ namespace { // els muros decoratius amb variants (stone, column, ...) l'usen. if (spawn.contains("frame")) { const int FRAME = spawn["frame"].get_value(); - entity->setAnimation(AnimationComponent({FRAME}, 1.0F, false)); + entity->setAnimation(AnimationComponent::uniform({FRAME}, 1000.0F, false)); } manager.add(std::move(entity)); @@ -461,6 +508,10 @@ auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* { std::string anchor; auto entity = buildEntity(TYPE, sprite, anchor); if (entity == nullptr) { return nullptr; } + // Tercera capa de resolució de llum: override per-instància declarat + // al spawn del YAML de la sala. Capes prèvies (tipus, sala) ja viuen + // dins de l'entitat. Aplica abans del std::move per no perdre l'accés. + applySpawnLightOverride(*entity, spawn); Entity* observer = entity.get(); bool ok = false; diff --git a/source/game/map/map.cpp b/source/game/map/map.cpp index 36b6397..9bdcd65 100644 --- a/source/game/map/map.cpp +++ b/source/game/map/map.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "core/resources/resource_helper.hpp" #include "external/fkyaml_node.hpp" @@ -28,12 +29,65 @@ namespace Map { // (debris, columnes). El TU local manté l'estat sense exposar-lo // al header. std::function g_entity_collider; + + // Overrides d'emissor declarats al bloc `lighting.emitters` del YAML + // de la sala. Clau = tipus d'entitat en singular (per concordar amb + // el camp `type:` de cada plantilla). Buit per defecte: una sala + // sense bloc `lighting.emitters` deixa que cada entitat utilitze els + // seus propis defaults del seu YAML de tipus. + std::unordered_map g_room_emitters; + + // Plural → singular per a tipus coneguts. El YAML de sala fa servir + // el plural perquè es llegeix natural ("mummies: false"); el codi + // treballa amb la forma singular que coincideix amb el camp `type:` + // del spawn ("type: mummy"). Si la clau no és reconeguda, passa tal + // qual (suposa que l'autor ja l'ha escrit en singular). + auto normalizeEmitterKey(const std::string& yaml_key) -> std::string { + if (yaml_key == "players") { return "player"; } + if (yaml_key == "mummies") { return "mummy"; } + if (yaml_key == "torches") { return "torch"; } + return yaml_key; + } + + // Llegeix el bloc opcional `lighting:` del YAML d'una sala i + // actualitza `light_level` (si declara `level`) i el mapping + // `g_room_emitters`. Idempotent: cada `Map::loadFrom` neteja el + // mapping abans de poblar-lo, així no s'arrosseguen overrides + // d'una sala anterior. + void loadLightingBlock(const fkyaml::node& yaml) { + g_room_emitters.clear(); + if (!yaml.contains("lighting")) { return; } + const auto& lighting = yaml["lighting"]; + if (lighting.contains("level")) { + light_level = lighting["level"].get_value(); + } + if (!lighting.contains("emitters") || !lighting["emitters"].is_mapping()) { return; } + const auto& em = lighting["emitters"]; + for (auto it = em.begin(); it != em.end(); ++it) { + const auto KEY = normalizeEmitterKey(it.key().get_value()); + const auto& val = it.value(); + RoomEmitterOverride ov; + if (val.is_boolean()) { + ov.enabled = val.get_value(); + } else if (val.is_mapping()) { + ov.enabled = true; + if (val.contains("radius")) { ov.radius = val["radius"].get_value(); } + if (val.contains("flicker")) { ov.flicker = val["flicker"].get_value(); } + } + g_room_emitters[KEY] = ov; + } + } } // namespace void setEntityCollider(std::function fn) { g_entity_collider = std::move(fn); } + auto getRoomEmitterOverride(const std::string& type) -> const RoomEmitterOverride* { + const auto IT = g_room_emitters.find(type); + return IT == g_room_emitters.end() ? nullptr : &IT->second; + } + void loadFrom(const std::string& yaml_path) { auto bytes = Resource::Helper::loadFile(yaml_path); if (bytes.empty()) { @@ -103,9 +157,16 @@ namespace Map { // canviarà el flag a true quan transiti a OPEN. door_open = false; - // Llum base de la sala. La clau és opcional a l'arrel del YAML; - // per defecte 1.0 (sala totalment il·luminada, comportament hui). + // Llum base de la sala. Dues formes acceptades: + // - `light_level: ` a l'arrel (forma antiga, encara + // suportada per retrocompat). + // - `lighting.level: ` dins del bloc nou `lighting:`, + // que també hostatja `lighting.emitters` (overrides per tipus). + // Si manca tot, queda al default 1.0 (sala totalment il·luminada). + // `loadLightingBlock` aplica el `lighting.level` per damunt del + // `light_level:` antic, així el nou bloc guanya si conviuen. light_level = yaml.contains("light_level") ? yaml["light_level"].get_value() : Defaults::Lighting::ROOM_LIGHT_DEFAULT; + loadLightingBlock(yaml); Log::info("Map", "geometria {}x{} sarcs {}x{} pitch {}x{} door_col {} des de {}", grid_cols, grid_rows, sarc_w, sarc_h, pitch_x, pitch_y, door_col, yaml_path); } diff --git a/source/game/map/map.hpp b/source/game/map/map.hpp index 7804086..3e84868 100644 --- a/source/game/map/map.hpp +++ b/source/game/map/map.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -54,9 +55,41 @@ namespace Map { // (com hui, sense efecte visual); 0.0 = completament negra fora del // radi d'un emissor. La clau `light_level:` al YAML de la sala el // sobreescriu — si manca, queda al default 1.0 i la pasada - // d'il·luminació del frame es salta com a no-op. + // d'il·luminació del frame es salta com a no-op. La forma nova és + // declarar-ho dins del bloc `lighting:` (vegeu més avall); `light_level:` + // al root es manté per compatibilitat enrere. inline float light_level = 1.0F; + // Override d'emissor declarat al bloc opcional `lighting.emitters.` + // de la sala. Tres formes acceptades al YAML: + // : false → desactiva la llum per a tot el tipus + // : true → activa amb els defaults del tipus + // : { radius: N, flicker: B } → activa i sobreescriu camps + // Els camps absents (`std::nullopt`) cauen al default del tipus + // d'entitat (radi/flicker del seu YAML). Els tipus no presents al + // mapping conserven el seu comportament per defecte (com avui). + // + // Conversió de clau YAML → tipus d'entitat: el YAML usa plural + // ("players", "mummies", "torches") perquè es llegeix natural per a + // l'autor de la sala; `loadFrom` normalitza a la clau singular + // ("player", "mummy", "torch") que coincideix amb el camp `type:` de + // cada plantilla d'entitat. Tipus no reconeguts s'accepten "tal qual" + // (singular pass-through) per si en el futur s'hi afegeixen emissors + // nous sense tocar el mapatge. + struct RoomEmitterOverride { + bool enabled{true}; + std::optional radius; + std::optional flicker; + }; + + // Consulta l'override de sala per a un tipus d'entitat (clau singular, + // p.ex. "player" / "mummy" / "torch"). Retorna `nullptr` si la sala no + // declara res per aquest tipus — el caller usa els defaults del tipus + // sense modificació. El punter retornat és vàlid fins a la propera + // crida a `Map::loadFrom` (cada càrrega de nivell substitueix el + // mapping intern). + [[nodiscard]] auto getRoomEmitterOverride(const std::string& type) -> const RoomEmitterOverride*; + // Derivats de la geometria base: el pas entre sarcòfags ha de cabre // exactament el jugador, i el mateix passadís s'usa a l'exterior. inline int corridor_w = 0; diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 137d851..1a93382 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -232,6 +232,26 @@ namespace { return out; } + // Override de sala per a la llum del jugador. Si el YAML de la sala + // declara `lighting.emitters.players`, substitueix els valors carregats + // per `Player::loadFrom`. Extret aquí per no inflar la complexitat + // cognitiva del ctor de GameScene (clang-tidy: cognitive-complexity). + // No s'aplica una "tercera capa" per-instància al jugador perquè no + // neix com a spawn del nivell; el dia d'un ítem "linterna" aquell + // sistema mutarà els mateixos camps en runtime — ortogonal a aquest + // override. + void applyRoomLightOverrideToPlayer() { + const auto* room = Map::getRoomEmitterOverride("player"); + if (room == nullptr) { return; } + if (!room->enabled) { + Player::light_radius = 0; + Player::light_flicker = false; + return; + } + if (room->radius.has_value()) { Player::light_radius = *room->radius; } + if (room->flicker.has_value()) { Player::light_flicker = *room->flicker; } + } + } // namespace auto GameScene::defaultConfig() -> Config { @@ -289,6 +309,7 @@ GameScene::GameScene(Config config) // del YAML de P1 és suficient (els dos personatges comparteixen // geometria — morcus és un repaletat de sam). Player::loadFrom(config_.player_yaml); + applyRoomLightOverrideToPlayer(); // Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia, // etc.). Requereix que Map::loadFrom ja s'hagi cridat (ho fa diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index 7b6be0f..7d2e1ce 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -242,11 +242,12 @@ namespace { void testAnimationComponent() { std::cout << "[T6] animation component\n"; - AnimationComponent anim({10, 20, 30}, 10.0F, true); + // 100 ms per frame: a tick(0.15) avança 1 frame; tick(0.10) un altre. + AnimationComponent anim = AnimationComponent::uniform({10, 20, 30}, 100.0F, true); REQUIRE(anim.frameCount() == 3); REQUIRE(anim.currentFrame() == 10); - anim.tick(0.15F); // 1.5 frames de 10 fps → hauria d'avançar a l'índex 1 + anim.tick(0.15F); // 1.5 frames de 100 ms → hauria d'avançar a l'índex 1 REQUIRE(anim.currentFrame() == 20); anim.tick(0.10F); @@ -257,16 +258,101 @@ namespace { REQUIRE(anim.currentFrame() == 10); // Variant no-loop: es para al darrer i queda completed. - AnimationComponent once({1, 2}, 10.0F, false); + AnimationComponent once = AnimationComponent::uniform({1, 2}, 100.0F, false); once.tick(1.0F); // molts frames; ha d'arribar al final i quedar-se. REQUIRE(once.currentFrame() == 2); REQUIRE(once.completed()); // Variant d'1 frame no-loop: completed des del principi. - AnimationComponent single({5}, 1.0F, false); + AnimationComponent single = AnimationComponent::uniform({5}, 1000.0F, false); REQUIRE(single.completed()); } + // Cobertura específica de `AnimationComponent::fromYaml`: format + // `frame_ms` escalar i llista per-frame, gestió de `fps:` (rebutjat), + // i defaults quan la clau no existeix. + void testAnimationFromYaml() { + std::cout << "[T6b] animation fromYaml\n"; + + // 1) Format escalar `frame_ms`: tots els frames duren el mateix. + { + const std::string YAML = R"( +animations: + walk: { frames: [0, 1, 2, 3], frame_ms: 100, loop: true } +)"; + auto node = fkyaml::node::deserialize(YAML); + auto anim = AnimationComponent::fromYaml(node, "walk"); + REQUIRE(anim.frameCount() == 4); + REQUIRE(anim.currentFrame() == 0); + anim.tick(0.10F); + REQUIRE(anim.currentFrame() == 1); + } + + // 2) Format llista `frame_ms`: el darrer frame aguanta més. + { + const std::string YAML = R"( +animations: + raise: + frames: [10, 11, 12] + frame_ms: [100, 100, 500] + loop: false +)"; + auto node = fkyaml::node::deserialize(YAML); + auto anim = AnimationComponent::fromYaml(node, "raise"); + REQUIRE(anim.frameCount() == 3); + anim.tick(0.10F); + REQUIRE(anim.currentFrame() == 11); + anim.tick(0.10F); + REQUIRE(anim.currentFrame() == 12); + // El darrer frame dura 500 ms: tick de 400 ms no completa l'anim. + anim.tick(0.40F); + REQUIRE(!anim.completed()); + // 100 ms més (total 500 ms al frame 12) sí la completa. + anim.tick(0.10F); + REQUIRE(anim.completed()); + } + + // 3) Clau no existent: defaults segurs (1 frame loop, no peta). + { + const std::string YAML = R"( +animations: + walk: { frames: [0], frame_ms: 100, loop: true } +)"; + auto node = fkyaml::node::deserialize(YAML); + auto anim = AnimationComponent::fromYaml(node, "missing"); + REQUIRE(anim.frameCount() == 1); + REQUIRE(anim.loops()); + } + + // 4) `fps:` no és suportat: ha de llençar excepció. + { + const std::string YAML = R"( +animations: + walk: { frames: [0, 1], fps: 8, loop: true } +)"; + auto node = fkyaml::node::deserialize(YAML); + bool threw = false; + try { + (void) AnimationComponent::fromYaml(node, "walk"); + } catch (const std::runtime_error&) { + threw = true; + } + REQUIRE(threw); + } + + // 5) Flip_h al YAML es preserva al component. + { + const std::string YAML = R"( +animations: + west: { frames: [0, 1], frame_ms: 100, loop: true, flip_h: true } +)"; + auto node = fkyaml::node::deserialize(YAML); + auto anim = AnimationComponent::fromYaml(node, "west"); + REQUIRE(anim.flipH()); + REQUIRE(anim.flipMode() == SDL_FLIP_HORIZONTAL); + } + } + void testAllTemplatesLoad() { std::cout << "[T7] plantilles YAML\n"; const std::vector TYPES = { @@ -697,6 +783,7 @@ auto main() -> int { testSarcophagusPerimeter(); testEntityManagerEndToEnd(); testAnimationComponent(); + testAnimationFromYaml(); testAllTemplatesLoad(); testDecorationFlags(); testLoadFromLevel();