From 9ad21eb5bbf7de0cd4aa94d59fcf3409a8876bdb Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Mon, 27 Apr 2026 16:59:01 +0200 Subject: [PATCH] feat(attack): esquelet WhipAttack + canal d'spawn via EventBus --- CMakeLists.txt | 1 + data/entities/whip_attack/whip_attack.yaml | 27 ++++ data/entities/whip_attack/whip_east.gif | Bin 0 -> 1069 bytes data/entities/whip_attack/whip_north.gif | Bin 0 -> 1179 bytes data/entities/whip_attack/whip_south.gif | Bin 0 -> 1179 bytes data/entities/whip_attack/whip_west.gif | Bin 0 -> 1069 bytes source/game/entities/attack.hpp | 35 +++++ source/game/entities/concrete/whip_attack.cpp | 127 ++++++++++++++++++ source/game/entities/concrete/whip_attack.hpp | 94 +++++++++++++ source/game/entities/event_bus.hpp | 25 +++- source/game/scenes/game_scene.cpp | 32 +++++ source/game/scenes/game_scene.hpp | 6 + 12 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 data/entities/whip_attack/whip_attack.yaml create mode 100644 data/entities/whip_attack/whip_east.gif create mode 100644 data/entities/whip_attack/whip_north.gif create mode 100644 data/entities/whip_attack/whip_south.gif create mode 100644 data/entities/whip_attack/whip_west.gif create mode 100644 source/game/entities/attack.hpp create mode 100644 source/game/entities/concrete/whip_attack.cpp create mode 100644 source/game/entities/concrete/whip_attack.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e11be9..41f5b7e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,7 @@ set(ENTITY_SOURCES source/game/entities/concrete/wall_trap.cpp source/game/entities/concrete/floor_trap.cpp source/game/entities/concrete/dust.cpp + source/game/entities/concrete/whip_attack.cpp ) set(APP_SOURCES diff --git a/data/entities/whip_attack/whip_attack.yaml b/data/entities/whip_attack/whip_attack.yaml new file mode 100644 index 0000000..e84c1d7 --- /dev/null +++ b/data/entities/whip_attack/whip_attack.yaml @@ -0,0 +1,27 @@ +name: whip_attack + +# Quatre sprites distints, un per cardinal. El sistema d'animació actual +# només permet UNA mida de frame per surface, i les hitboxes horitzontals +# (E/W: 48×16) i verticals (N/S: 16×48) tenen aspect ratios distints; per +# això són 4 fitxers en lloc d'una sola sheet compartida amb flips. +# Tots els .gif viuen al mateix directori que aquest YAML. +sprites: + east: { path: whip_east.gif, size: [48, 16] } + west: { path: whip_west.gif, size: [48, 16] } + north: { path: whip_north.gif, size: [16, 48] } + south: { path: whip_south.gif, size: [16, 48] } + +# Animació compartida entre les 4 direccions: la seqüència de frames i les +# durades són idèntiques; només canvia quina surface/mida es clipa. +# - `frames` : índex de tile dins l'spritesheet (graella deduïda). +# - `frame_ms` : durada uniforme per frame; 50 ms × 4 ≈ 200 ms swing. +# - `loop: false` : el látigo viu una animació i mor (`markDead`). +# - `active_frames` : valors dins de `frames` durant els quals la +# hitbox aplica dany. Wind-up (0) i recovery (3) no +# danyen — només els frames "actius" 1 i 2. +animations: + swing: + frames: [0, 1, 2, 3] + frame_ms: 50 + loop: false + active_frames: [1, 2] diff --git a/data/entities/whip_attack/whip_east.gif b/data/entities/whip_attack/whip_east.gif new file mode 100644 index 0000000000000000000000000000000000000000..eda876892dc37ea276f5d4a98274cba4cc3e9d88 GIT binary patch literal 1069 zcmZ?wbhEHbJis8p(9Xc{pW%N)L&N_LhX2nPY;0^8M!{$ZjHD1y{K>)y@~;kv0Obh> zj{gjt95Nmo794Em5Y~!0v0>rib^&FtITi*DN4q7Av+kVOn9R_~gcthxvv(yIS9B#qB9HdfL+~JGoUxa_!U8_WaG?W<(l?o}0V5PHwMM zN#^A=_T~F-|0>EowLX#E)z*6Di;FV(`sdb4tG>UNRnOeNs>ago(NSMHcfDYXq}R0z c-PJAUTKT-G((K}r_useY=jRvPY%C1c0M~De@&Et; literal 0 HcmV?d00001 diff --git a/data/entities/whip_attack/whip_north.gif b/data/entities/whip_attack/whip_north.gif new file mode 100644 index 0000000000000000000000000000000000000000..9d418e67052145c21f977c4563ba652090af990a GIT binary patch literal 1179 zcmZ?wbhEHb6ks^O(9Xc{pW%N)L&N_LhX2nPY;0^8M!{$ZjHD1y{K>)y@~;kv0Obh> zj{gjt95Nmo793#U5Y`H*C|Ky+!Yi-WqhY9cw2P4^OGjhD;^Y1DtW7d61658=RP%3B zSs?V}*mM@Q6LUN#FFP>HBK_W(&SG`{*@pE@t(gMK&P;B)O1`rSGcR?EF7%r6Yr-n$ z)xvY7j#h1X>a|`qvPpEprR;*ZcY`uS@%5r9IEj*t#d-vOS+xI_ao9`|8;qmgLs>=5JOe>!}KeyQUymsxUl7rVd z)6MtD7M35qc38hc{QjTnqUwTeB+ zV4fECAoG=A$?SaYZ_geSlsZ;~Hw*0wQ_R_J>+!HnGA<*!UFOoWg?Wmf%#@mC)hs4d oRxfOy+kNZciMc)ZE^^$fu$Y$qxY9D{_@Y8)y@~;kv0Obh> zj{gjt95Nmo793#U5Y`H*C|Ky+!Yi-WqhY9cw2P4^OGjhD;^Y1DtW7d61658=RP%3B zSs?V}*mM@Q6LUN#FFP>HBK_W(&SG`{*@pE@t(gMK&P;B)O1`rSGcR?EF7%r6Yr-n$ z)xvY7j#h1X>a|`qvPpEprR;*ZcY`uS@%5r9IEj*t#d-vOS+xI_ao9`|8;qmgLs>=5JOe>!}KeyQUymsxUl7rVd z)6MtD7M35qc38hc{QjTnqUwTeB+ zV4fECAoG=A$?SaYZ_geSlsZ;~Hw*0wQ_R_J>+!HnGA<*!UFOoWg?Wmf%#@mC)hs4d oRxfOy+kNZciMc)ZE^^$fu$Y$qxY9D{_@Y8)y@~;kv0Obh> zj{gjt95Nmo794Em5Y~!0v0>rib^&FtITi*DN4q7Av+kVOn9R_~gcthxvv(yIS9B#qB9HdfL+~JGoUxa_!U8_WaG?W<(l?o}0V5PHwMM zN#^A=_T~F-|0>EowLX#E)z*6Di;FV(`sdb4tG>UNRnOeNs>ago(NSMHcfDYXq}R0z c-PJAUTKT-G((K}r_useY=jRvPY%C1c0M~De@&Et; literal 0 HcmV?d00001 diff --git a/source/game/entities/attack.hpp b/source/game/entities/attack.hpp new file mode 100644 index 0000000..283de36 --- /dev/null +++ b/source/game/entities/attack.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "game/entities/entity.hpp" + +class Player; + +// Base d'entitats d'atac: spawnejades pels jugadors, viuen poc temps i +// poden danyar `Hazard`s i altres `Player`s (mai el seu propi `owner_`). +// +// Branca paral·lela a `Hazard` (no hereta d'ella) perquè la semàntica és +// inversa: un `Hazard` és un perill estàtic/dinàmic que toca el jugador, +// mentre que un `Attack` és el jugador qui colpeja la resta. Mantenir-les +// separades permet, p. ex., que un atac no aparega al test de "què colpeja +// al Player en el seu rect" (`EntityManager::checkPlayerCollisions`) perquè +// el sentit del filtre és un altre. +// +// `Attack` no col·lisiona al test de moviment (els atacs no bloquegen +// passos). El càlcul de quins objectius reben dany el fa cada subclasse +// segons la seua geometria (rectangle direccional per al látigo, cercle +// per a una explosió de bomba en el futur, etc.). +class Attack : public Entity { + public: + Attack() { + collides_ = false; + interactable_ = false; + } + + void setOwner(Player* owner) { owner_ = owner; } + [[nodiscard]] auto getOwner() const -> Player* { return owner_; } + + protected: + // Jugador que ha llançat l'atac. No rep dany del seu propi atac + // (anti-self-friendly-fire). `nullptr` només a tests sintètics. + Player* owner_{nullptr}; +}; diff --git a/source/game/entities/concrete/whip_attack.cpp b/source/game/entities/concrete/whip_attack.cpp new file mode 100644 index 0000000..1f654f7 --- /dev/null +++ b/source/game/entities/concrete/whip_attack.cpp @@ -0,0 +1,127 @@ +#include "game/entities/concrete/whip_attack.hpp" + +#include +#include +#include +#include + +#include "game/entities/entity_factory.hpp" + +namespace { + + // Llig una entrada `sprites.: { path, size: [w, h] }` del YAML. + // Si manquen camps, retorna un DirSprite buit (queda invalidat al + // configure() per `surface_key.empty()`). + auto readDirSprite(const fkyaml::node& s) -> WhipAttack::DirSprite { + WhipAttack::DirSprite sprite; + if (s.contains("path")) { + const auto REL = s["path"].get_value(); + sprite.surface_key = std::filesystem::path(REL).filename().string(); + } + if (s.contains("size")) { + const auto& sz = s["size"]; + if (sz.is_sequence() && sz.size() == 2) { + sprite.frame_w = static_cast(sz[0].get_value()); + sprite.frame_h = static_cast(sz[1].get_value()); + } + } + 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()); + } + } + } + return {frames, fps, loop}; + } + +} // namespace + +WhipAttack::WhipAttack(const fkyaml::node& yaml) { + name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("whip_attack"); + collides_ = false; + interactable_ = false; + + if (yaml.contains("sprites")) { + const auto& s = yaml["sprites"]; + if (s.contains("north")) { sprite_north_ = readDirSprite(s["north"]); } + if (s.contains("south")) { sprite_south_ = readDirSprite(s["south"]); } + if (s.contains("east")) { sprite_east_ = readDirSprite(s["east"]); } + if (s.contains("west")) { sprite_west_ = readDirSprite(s["west"]); } + } + + anim_swing_ = buildSwingAnim(yaml, active_frames_); + anim_ = anim_swing_; +} + +void WhipAttack::configure(Direction dir, float spawn_x, float spawn_y, float range_px) { + direction_ = dir; + range_px_ = range_px; + + const auto& chosen = spriteFor(dir); + setPixelRect({.x = spawn_x, .y = spawn_y, .w = chosen.frame_w, .h = chosen.frame_h}); + + if (anim_.has_value()) { anim_->reset(); } +} + +auto WhipAttack::getSurfaceKey() const -> const std::string& { + return spriteFor(direction_).surface_key; +} + +auto WhipAttack::spriteFor(Direction dir) const -> const DirSprite& { + switch (dir) { + case Direction::NORTH: + return sprite_north_; + case Direction::SOUTH: + return sprite_south_; + case Direction::EAST: + return sprite_east_; + case Direction::WEST: + return sprite_west_; + } + return sprite_east_; +} + +void WhipAttack::update(float delta_time) { + Entity::update(delta_time); + if (anim_.has_value() && anim_->completed()) { markDead(); } +} + +auto WhipAttack::isFrameActive() const -> bool { + if (!anim_.has_value()) { return false; } + const int FRAME = anim_->currentFrame(); + return std::ranges::any_of(active_frames_, [FRAME](int f) { return f == FRAME; }); +} + +REGISTER_ENTITY("whip_attack", WhipAttack) diff --git a/source/game/entities/concrete/whip_attack.hpp b/source/game/entities/concrete/whip_attack.hpp new file mode 100644 index 0000000..3a03105 --- /dev/null +++ b/source/game/entities/concrete/whip_attack.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include +#include +#include +#include + +#include "external/fkyaml_node.hpp" +#include "game/entities/animation_component.hpp" +#include "game/entities/attack.hpp" + +class Entity; + +// Atac de látigo: spawnejat pel jugador en entrar a `ATTACKING`. Viu la +// durada de la seua animació (`swing`) i s'autoelimina al final. +// +// Sprites: 4 fitxers GIF distints, un per cardinal. La mida del frame +// canvia entre direccions (E/W: 48×16, N/S: 16×48), cosa que el sistema +// d'animació actual no permet expressar amb una sola surface (una entitat +// = una graella). Per això el YAML llista les 4 entrades sota `sprites:` +// i `configure()` tria la que toca segons direcció. +// +// Hitbox de dany: el rectangle de dany és el `pixel_rect_` retallat al +// primer bloquejador en la línia d'atac (mur extern del playfield o +// sarcòfag). Aquesta lògica viu a la fase de damage application; en +// aquest skeleton només viu la part visual i de cicle de vida. +class WhipAttack : public Attack { + public: + // Direcció cardinal del swing. Coincideix valors amb + // `Player::Cardinal` (1=N, 2=S, 3=E, 4=W) per facilitar la + // conversió a la frontera de l'event `whip_spawn`. + enum class Direction : std::uint8_t { + NORTH = 1, + SOUTH = 2, + EAST = 3, + WEST = 4, + }; + + // Definició estàtica d'una direcció: clau al `Resource::Cache` i mida + // del frame en píxels. Llegida del YAML una vegada al ctor; usada per + // configure() per omplir surface_ i pixel_rect_. Pública perquè el + // helper d'anàlisi del YAML viu en un namespace anònim al .cpp. + struct DirSprite { + std::string surface_key; + float frame_w{0.0F}; + float frame_h{0.0F}; + }; + + explicit WhipAttack(const fkyaml::node& yaml); + + // Configura l'estat runtime de la instància. Cal cridar-lo abans del + // primer `update`. Selecciona la surface i la mida del pixel_rect_ + // segons direcció, i posa la cantonada superior-esquerra de la + // hitbox a (spawn_x, spawn_y). + void configure(Direction dir, float spawn_x, float spawn_y, float range_px); + + // Avança animació; quan completa, marca dead. + void update(float delta_time) override; + + [[nodiscard]] auto getDirection() const -> Direction { return direction_; } + + // True si el frame actual de l'animació és un dels `active_frames` + // declarats al YAML (els frames durant els quals la hitbox aplica + // dany). Wind-up i recovery retornen false. + [[nodiscard]] auto isFrameActive() const -> bool; + + // Llistat dels frames actius (lectura del YAML, immutable per + // instància). Exposat per als smoke tests. + [[nodiscard]] auto getActiveFrames() const -> const std::vector& { return active_frames_; } + + // Llargada de la hitbox abans de retallar contra murs/sarcs (en píxels). + [[nodiscard]] auto getRangePx() const -> float { return range_px_; } + + // Clau al `Resource::Cache` per a la surface de la direcció actual + // (la que ha triat `configure()`). El caller (típicament `GameScene`) + // resol la surface i la passa via `setSurface()`. Aquesta separació + // manté el codi de l'entitat sense dependència directa al + // `Resource::Cache`, igual que la resta d'entitats del joc. + [[nodiscard]] auto getSurfaceKey() const -> const std::string&; + + private: + [[nodiscard]] auto spriteFor(Direction dir) const -> const DirSprite&; + + Direction direction_{Direction::EAST}; + AnimationComponent anim_swing_; + std::vector active_frames_; + std::set already_hit_; + float range_px_{0.0F}; + + DirSprite sprite_north_; + DirSprite sprite_south_; + DirSprite sprite_east_; + DirSprite sprite_west_; +}; diff --git a/source/game/entities/event_bus.hpp b/source/game/entities/event_bus.hpp index 88b8e64..ee83a23 100644 --- a/source/game/entities/event_bus.hpp +++ b/source/game/entities/event_bus.hpp @@ -12,6 +12,7 @@ // Forward: els events poden dur un punter a `Entity` (p. ex. quin sarcòfag // s'ha obert). No cal incloure el header complet aquí. class Entity; +class Player; // Event pub/sub minimalista. Hi ha un únic `EventBus` per mundus (propietat // de l'`EntityManager`); les entitats que el necessiten en reben un punter @@ -49,9 +50,28 @@ struct WorldPos { float y{0.0F}; }; +// Payload de `whip_spawn`: el `Player` el publica quan entra a ATTACKING +// i el `GameScene` el consumeix per crear un `WhipAttack` via +// `EntityManager::add`. Així el Player no depèn ni de `EntityManager` ni de +// `WhipAttack`, i el contracte queda dins del bus. +// - `owner` : Player que ha llançat l'atac (no rep dany propi). +// - `direction` : Player::Cardinal castat a int (1=N, 2=S, 3=E, 4=W) per +// evitar dependència cíclica al header. +// - `spawn_x/y` : cantonada superior-esquerra de la hitbox màxima +// (sense retallar) en píxels. +// - `range_px` : longitud de la hitbox abans de retallar contra murs i +// sarcòfags. Llegida del YAML del jugador. +struct WhipSpawnPayload { + Player* owner{nullptr}; + int direction{0}; + float spawn_x{0.0F}; + float spawn_y{0.0F}; + float range_px{0.0F}; +}; + struct Event { std::string name; - using Payload = std::variant; + using Payload = std::variant; Payload payload; // Jugador que ha originat (o provocat) l'event. -1 = sense jugador // associat (events del món, com `sarcophagus_opened` des de la pròpia @@ -101,6 +121,9 @@ class EventBus { void publish(const std::string& name, WorldPos pos) { publish(Event{.name = name, .payload = pos}); } + void publish(const std::string& name, const WhipSpawnPayload& whip) { + publish(Event{.name = name, .payload = whip}); + } private: struct Subscription { diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 658e885..3f05172 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -28,7 +28,9 @@ #include "core/ui/ui_config.hpp" #include "external/fkyaml_node.hpp" #include "game/entities/concrete/dust.hpp" +#include "game/entities/concrete/whip_attack.hpp" #include "game/entities/entity.hpp" +#include "game/entities/entity_factory.hpp" #include "game/entities/entity_loader.hpp" #include "game/entities/event_bus.hpp" #include "game/entities/player.hpp" @@ -312,6 +314,18 @@ GameScene::GameScene(Config config) if (pos != nullptr) { spawnDust(pos->x, pos->y, true); } }); + // Whip: precarreguem les 4 surfaces (una per cardinal) abans del + // primer spawn perquè el Player no facte disc al primer atac. + // Subscriu `whip_spawn` per crear el `WhipAttack` corresponent. + Resource::Cache::get().loadSurface("whip_east.gif", "data/entities/whip_attack/whip_east.gif"); + Resource::Cache::get().loadSurface("whip_west.gif", "data/entities/whip_attack/whip_west.gif"); + Resource::Cache::get().loadSurface("whip_north.gif", "data/entities/whip_attack/whip_north.gif"); + Resource::Cache::get().loadSurface("whip_south.gif", "data/entities/whip_attack/whip_south.gif"); + entity_manager_.getBus().subscribe("whip_spawn", [this](const Event& ev) { + const auto* p = std::get_if(&ev.payload); + if (p != nullptr) { spawnWhip(*p); } + }); + // Controls per jugador: P1 amb fletxes, P2 amb WASD. Cada Player // escolta les seues pròpies tecles dins del seu `update`, no hi ha // estat global d'input. @@ -877,6 +891,24 @@ void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) { entity_manager_.add(std::move(dust)); } +void GameScene::spawnWhip(const WhipSpawnPayload& payload) { + // El YAML ja viu cacheat al `EntityLoader`; les 4 surfaces les hem + // precarregat al ctor. `configure()` selecciona la mida segons + // direcció i posa pixel_rect; aquí resolem la surface via + // `Resource::Cache` (mateix patró que `spawnDust`) per evitar que + // l'entitat depengui directament del cache. + auto whip_yaml = EntityLoader::loadTemplate("whip_attack"); + auto whip = std::make_unique(whip_yaml); + whip->setOwner(payload.owner); + whip->configure( + static_cast(payload.direction), + payload.spawn_x, + payload.spawn_y, + payload.range_px); + whip->setSurface(Resource::Cache::get().getSurface(whip->getSurfaceKey())); + entity_manager_.add(std::move(whip)); +} + void GameScene::handlePauseInput() { #ifdef _DEBUG // Mutex amb l'editor: si està actiu, ignorem la tecla de pausa. diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 80614ca..d054eba 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -75,6 +75,12 @@ class GameScene : public Scene { // `is_landing` selecciona la variant de l'animació al `Dust`. void spawnDust(float feet_x_center, float feet_y, bool is_landing); + // Spawna un `WhipAttack` a partir del payload publicat al bus quan + // un Player entra a `ATTACKING`. Llig la plantilla cacheada del YAML + // i delega la mida + surface al `WhipAttack::configure()` segons + // direcció. + void spawnWhip(const struct WhipSpawnPayload& payload); + Config config_; Tilemap tilemap_;